evcc-io/soc/server/ui/ui.go

243 lines
5.7 KiB
Go

package ui
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"math/rand"
"net/http"
"os"
"strings"
"time"
"github.com/andig/evcc/soc/server/auth"
"github.com/andig/evcc/util"
"github.com/dgrijalva/jwt-go"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
)
//go:embed index.html
var indexHtml string
//go:embed privacy.html
var privacyHtml string
var (
// login ui and callback
redirectURL = util.Getenv("REDIRECT_URL")
// SSL termination
sslCertDir = util.Getenv("SSL_CERT_DIR", "certs")
// github oauth application
clientID = util.Getenv("GITHUB_CLIENT_ID")
clientSecret = util.Getenv("GITHUB_CLIENT_SECRET")
)
var (
indexTpl, privacyTpl *template.Template
oauthState = randomString()
oauthConfig *oauth2.Config
)
const (
ProfileURL = "https://api.github.com/user"
EmailURL = "https://api.github.com/user/emails"
)
type User struct {
ID int `json:"id"`
Email string `json:"email"`
Bio string `json:"bio"`
Name string `json:"name"`
Login string `json:"login"`
Picture string `json:"avatar_url"`
Location string `json:"location"`
}
func init() {
clientID = util.Getenv("GITHUB_CLIENT_ID")
clientSecret = util.Getenv("GITHUB_CLIENT_SECRET")
oauthConfig = &oauth2.Config{
RedirectURL: redirectURL,
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: []string{},
Endpoint: github.Endpoint,
}
indexTpl = template.Must(template.New("index").Parse(indexHtml))
privacyTpl = template.Must(template.New("privacy").Parse(privacyHtml))
}
func randomString() string {
rand.Seed(time.Now().UnixNano())
chars := []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
length := 16
var b strings.Builder
for i := 0; i < length; i++ {
b.WriteRune(chars[rand.Intn(len(chars))])
}
return b.String()
}
func handleMain(w http.ResponseWriter, r *http.Request) {
_ = indexTpl.Execute(w, nil)
}
func handlePrivacy(w http.ResponseWriter, r *http.Request) {
_ = privacyTpl.Execute(w, nil)
}
func templateError(w http.ResponseWriter, r *http.Request, err string) {
_ = indexTpl.Execute(w, map[string]interface{}{
"Content": template.HTML(`
<h1>Fehler</h1>
<p class="lead">` + err + `</p>`),
})
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
url := oauthConfig.AuthCodeURL(oauthState)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
func handleCallback(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("error") != "" {
err := r.URL.Query().Get("error_description")
templateError(w, r, err)
return
}
user, err := getUserInfo(r.FormValue("state"), r.FormValue("code"))
if err != nil {
templateError(w, r, err.Error())
return
}
authorized, err := auth.IsSponsor(user.Login)
if err != nil {
templateError(w, r, err.Error())
return
}
authMap := map[bool]string{false: "rejected", true: "authorized"}
log.Println(user.Login+":", authMap[authorized])
if !authorized {
_ = indexTpl.Execute(w, map[string]interface{}{
"Content": template.HTML(`
<h1>Inaktiv</h1>
<p class="lead">Github Sponsorship für evcc ist inaktiv. Um erweiterte Funktionen zu nutzen, ist es notwendig, evcc zu unterstützen.</p>
<p class="lead">evcc bei Github <a href="https://github.com/sponsors/andig" class="text-warning">unterstützen</a>.</p>`),
})
return
}
claims := auth.Claims{
Username: user.Name,
StandardClaims: jwt.StandardClaims{
Subject: user.Login,
},
}
jwt, err := auth.AuthorizedToken(claims)
if err != nil {
templateError(w, r, err.Error())
return
}
_ = indexTpl.Execute(w, map[string]interface{}{
"Content": template.HTML(fmt.Sprintf(`
<h1>Aktiv</h1>
<p class="lead">Github Sponsorship für evcc ist aktiv. Das folgende Registrierungstoken kann für den Zugriff auf evcc genutzt werden.</p>
<p class="lead">Der Code ist %d Tage gültig und kann jederzeit neu erzeugt werden.</p>
<p class="lead"><code>`+jwt+`</code></p>`, auth.TokenExpiry),
)})
}
func getUserInfo(state string, code string) (*User, error) {
if state != oauthState {
return nil, fmt.Errorf("invalid oauth state")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
token, err := oauthConfig.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("code exchange failed: %s", err.Error())
}
var user User
req, err := http.NewRequest("GET", ProfileURL, nil)
if err != nil {
return nil, err
}
req.Header.Add("Authorization", "Bearer "+token.AccessToken)
response, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API responded with a %d trying to fetch user information", response.StatusCode)
}
bits, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
err = json.Unmarshal(bits, &user)
if err != nil {
return nil, err
}
return &user, nil
}
func Run() {
mux := &http.ServeMux{}
mux.HandleFunc("/", handleMain)
mux.HandleFunc("/privacy", handlePrivacy)
mux.HandleFunc("/login", handleLogin)
mux.HandleFunc("/callback", handleCallback)
s := &http.Server{
Addr: ":http",
Handler: mux,
}
if sslHosts := strings.TrimSpace(os.Getenv("SSL_HOSTS")); sslHosts != "" {
log.Println("sslHosts:", sslHosts)
log.Println("sslCertDir:", sslCertDir)
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(strings.Split(sslHosts, " ")...),
Cache: autocert.DirCache(sslCertDir),
}
go func() {
log.Fatal(http.ListenAndServe(":http", certManager.HTTPHandler(nil)))
}()
s.Addr = ":https"
s.TLSConfig = certManager.TLSConfig()
log.Fatal(s.ListenAndServeTLS("", ""))
} else {
log.Fatal(s.ListenAndServe())
}
}