evcc-io/util/auth/auth.go
2026-06-06 11:14:46 +02:00

198 lines
5 KiB
Go

package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"time"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/golang-jwt/jwt/v5"
"github.com/sethvargo/go-password/password"
"golang.org/x/crypto/bcrypt"
)
const ApiKeyPrefix = "evcc_"
const admin = "admin"
// Possible authentication modes
type AuthMode int
const (
Enabled AuthMode = iota // normal operation
Disabled // auth checks are skipped (free for all)
Locked // auth features are blocked (demo mode)
)
// Auth is the Auth api
type Auth interface {
RemoveAdminPassword()
SetAdminPassword(string) error
IsAdminPasswordValid(string) bool
GenerateJwtToken(time.Duration) (string, error)
ValidateJwtToken(string) bool
IsAdminPasswordConfigured() bool
SetAuthMode(AuthMode)
GetAuthMode() AuthMode
SetApiKey() (string, error)
IsApiKeyConfigured() bool
ValidateApiKey(string) bool
}
type auth struct {
settings settings.API
authMode AuthMode
}
func New() Auth {
return &auth{settings: new(settings.Settings), authMode: Enabled}
}
func NewMock(settings settings.API) Auth {
return &auth{settings: settings, authMode: Enabled}
}
func (a *auth) hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func (a *auth) getAdminPasswordHash() string {
if pw, err := a.settings.String(keys.AdminPassword); err == nil {
return pw
}
return ""
}
// RemoveAdminPassword resets the admin password. For recovery mode via cli.
func (a *auth) RemoveAdminPassword() {
a.settings.SetString(keys.AdminPassword, "")
a.settings.SetString(keys.JwtSecret, "")
a.settings.SetString(keys.ApiKey, "")
}
// IsAdminPasswordConfigured checks if the admin password is already set
func (a *auth) IsAdminPasswordConfigured() bool {
return a.getAdminPasswordHash() != ""
}
// SetAdminPassword sets the admin password if not already set
func (a *auth) SetAdminPassword(password string) error {
if password == "" {
return errors.New("password cannot be empty")
}
hashed, err := a.hashPassword(password)
if err != nil {
return err
}
a.settings.SetString(keys.AdminPassword, hashed)
return nil
}
// IsAdminPasswordValid checks if the given password matches the admin password
func (a *auth) IsAdminPasswordValid(password string) bool {
adminHash := a.getAdminPasswordHash()
if adminHash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(adminHash), []byte(password)) == nil
}
func (a *auth) generateRandomKey(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// getJwtSecret returns the JWT secret from the settings or generates a new one
func (a *auth) getJwtSecret() ([]byte, error) {
jwtSecret, err := a.settings.String(keys.JwtSecret)
// generate new secret if it doesn't exist yet -> new installation
if err != nil || jwtSecret == "" {
jwtSecret, err = a.generateRandomKey(32)
if err != nil {
return nil, err
}
a.settings.SetString(keys.JwtSecret, jwtSecret)
}
return []byte(jwtSecret), nil
}
// GenerateJwtToken generates an admin user JWT token with the given lifetime
func (a *auth) GenerateJwtToken(lifetime time.Duration) (string, error) {
claims := &jwt.RegisteredClaims{
Subject: admin,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(lifetime)),
}
if jwtSecret, err := a.getJwtSecret(); err != nil {
return "", err
} else {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
}
// ValidateJwtToken validates the given JWT token
func (a *auth) ValidateJwtToken(tokenString string) bool {
jwtSecret, err := a.getJwtSecret()
if err != nil {
return false
}
var claims jwt.RegisteredClaims
_, err = jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (any, error) {
return jwtSecret, nil
}, jwt.WithSubject(admin))
return err == nil
}
func (a *auth) SetAuthMode(authMode AuthMode) {
a.authMode = authMode
}
func (a *auth) GetAuthMode() AuthMode {
return a.authMode
}
// IsApiKeyConfigured reports whether an API key has been generated
func (a *auth) IsApiKeyConfigured() bool {
hash, _ := a.settings.String(keys.ApiKey)
return hash != ""
}
// SetApiKey generates a new API key, stores its hash, and returns the cleartext key
func (a *auth) SetApiKey() (string, error) {
secret, err := password.Generate(30, 6, 0, false, false)
if err != nil {
return "", err
}
key := ApiKeyPrefix + secret
hashed, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
if err != nil {
return "", err
}
a.settings.SetString(keys.ApiKey, string(hashed))
return key, nil
}
// ValidateApiKey returns true if the given token matches the stored key
func (a *auth) ValidateApiKey(token string) bool {
hash, err := a.settings.String(keys.ApiKey)
if err != nil || hash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(token)) == nil
}