281 lines
7.5 KiB
Go
281 lines
7.5 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/evcc-io/evcc/util/auth"
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
const authCookieName = "auth"
|
|
|
|
type updatePasswordRequest struct {
|
|
Current string `json:"current"`
|
|
New string `json:"new"`
|
|
}
|
|
|
|
type loginRequest struct {
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func updatePasswordHandler(authObject auth.Auth) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if authObject.GetAuthMode() == auth.Locked {
|
|
http.Error(w, "Forbidden in demo mode", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req updatePasswordRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// update password
|
|
if authObject.IsAdminPasswordConfigured() {
|
|
if !authObject.IsAdminPasswordValid(req.Current) {
|
|
http.Error(w, "Invalid password", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := authObject.SetAdminPassword(req.New); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
return
|
|
}
|
|
|
|
// create new password
|
|
if err := authObject.SetAdminPassword(req.New); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// auto-login: set auth cookie
|
|
if err := setAuthCookie(authObject, w); err != nil {
|
|
http.Error(w, "Failed to generate JWT token.", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
}
|
|
|
|
// apiKeyFromRequest returns the API key from the Authorization: Bearer header, or "" if absent.
|
|
// A non-Bearer Authorization header (e.g. Basic auth injected by a reverse proxy) is ignored.
|
|
func apiKeyFromRequest(r *http.Request) string {
|
|
token, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !found {
|
|
return ""
|
|
}
|
|
return token
|
|
}
|
|
|
|
// jwtFromCookie returns the session JWT from the auth cookie, or "" if absent
|
|
func jwtFromCookie(r *http.Request) string {
|
|
if cookie, _ := r.Cookie(authCookieName); cookie != nil {
|
|
return cookie.Value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// validateAuth accepts a valid API key from the Authorization header, or a valid session JWT from the auth cookie.
|
|
// Any single valid credential grants access, so evcc coexists with a reverse proxy that injects its own
|
|
// Authorization header or with clients that forward an unrelated bearer token.
|
|
func validateAuth(authObject auth.Auth, r *http.Request) bool {
|
|
if key := apiKeyFromRequest(r); key != "" && authObject.ValidateApiKey(key) {
|
|
return true
|
|
}
|
|
if jwt := jwtFromCookie(r); jwt != "" && authObject.ValidateJwtToken(jwt) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// requireAdminPassword passes when --disable-auth is set or the supplied password matches.
|
|
// Writes 401 and returns false otherwise.
|
|
func requireAdminPassword(w http.ResponseWriter, authObject auth.Auth, password string) bool {
|
|
if authObject.GetAuthMode() == auth.Disabled || authObject.IsAdminPasswordValid(password) {
|
|
return true
|
|
}
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
|
|
// authStatusHandler login status (true/false) based on jwt token. Error if admin password is not configured
|
|
func authStatusHandler(authObject auth.Auth) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if authObject.GetAuthMode() == auth.Disabled {
|
|
w.Write([]byte("true"))
|
|
return
|
|
}
|
|
|
|
if authObject.GetAuthMode() == auth.Locked {
|
|
http.Error(w, "Forbidden in demo mode", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if !authObject.IsAdminPasswordConfigured() {
|
|
http.Error(w, "Not implemented", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if validateAuth(authObject, r) {
|
|
w.Write([]byte("true"))
|
|
} else {
|
|
w.Write([]byte("false"))
|
|
}
|
|
}
|
|
}
|
|
|
|
func setAuthCookie(authObject auth.Auth, w http.ResponseWriter) error {
|
|
lifetime := time.Hour * 24 * 90 // 90 day valid
|
|
tokenString, err := authObject.GenerateJwtToken(lifetime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: authCookieName,
|
|
Value: tokenString,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Expires: time.Now().Add(lifetime),
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func loginHandler(authObject auth.Auth) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if authObject.GetAuthMode() == auth.Locked {
|
|
http.Error(w, "Forbidden in demo mode", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req loginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !authObject.IsAdminPasswordValid(req.Password) {
|
|
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if err := setAuthCookie(authObject, w); err != nil {
|
|
http.Error(w, "Failed to generate JWT token.", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: authCookieName,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
})
|
|
}
|
|
|
|
func ensureAuthHandler(authObject auth.Auth) mux.MiddlewareFunc {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if next == nil {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if authObject.GetAuthMode() == auth.Disabled {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
if authObject.GetAuthMode() == auth.Locked {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if !validateAuth(authObject, r) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func apiKeyStatusHandler(authObject auth.Auth) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
jsonWrite(w, map[string]bool{"configured": authObject.IsApiKeyConfigured()})
|
|
}
|
|
}
|
|
|
|
// regenerateApiKeyHandler creates or rotates the API key. Requires the admin password (a leaked API key cannot self-rotate)
|
|
func regenerateApiKeyHandler(authObject auth.Auth) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req loginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !requireAdminPassword(w, authObject, req.Password) {
|
|
return
|
|
}
|
|
|
|
key, err := authObject.SetApiKey()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
jsonWrite(w, map[string]string{"key": key})
|
|
}
|
|
}
|
|
|
|
// ensureDbAuth guards /db/ endpoints: API key Bearer passes directly;
|
|
// session users must also supply the admin password in X-Admin-Password header.
|
|
func ensureDbAuth(authObject auth.Auth) mux.MiddlewareFunc {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if authObject.GetAuthMode() == auth.Disabled {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
if authObject.GetAuthMode() == auth.Locked {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if key := apiKeyFromRequest(r); key != "" {
|
|
if authObject.ValidateApiKey(key) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if !authObject.ValidateJwtToken(jwtFromCookie(r)) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if !authObject.IsAdminPasswordValid(r.Header.Get("X-Admin-Password")) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|