Latest Updates done, before integrating
This commit is contained in:
221
internal/auth/auth.go
Normal file
221
internal/auth/auth.go
Normal file
@@ -0,0 +1,221 @@
|
||||
// Package auth provides session-based authentication and CSRF protection
|
||||
// for the EMS web UI. Both the session token and CSRF token are persisted
|
||||
// to disk so they survive EMS restarts without logging out open browser tabs.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "ems_session"
|
||||
csrfField = "csrf_token"
|
||||
sessionFile = "session.token"
|
||||
csrfFile = "csrf.token"
|
||||
)
|
||||
|
||||
// Manager handles login, session cookies, and CSRF tokens.
|
||||
type Manager struct {
|
||||
username string
|
||||
password string
|
||||
tokenDir string
|
||||
mu sync.RWMutex
|
||||
session string
|
||||
csrf string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Manager and loads or generates persistent tokens from tokenDir.
|
||||
// If password is empty, authentication is disabled (open access).
|
||||
func New(username, password, tokenDir string, logger *slog.Logger) *Manager {
|
||||
m := &Manager{
|
||||
username: username,
|
||||
password: password,
|
||||
tokenDir: tokenDir,
|
||||
logger: logger,
|
||||
}
|
||||
m.session = m.loadOrGenerate(sessionFile)
|
||||
m.csrf = m.loadOrGenerate(csrfFile)
|
||||
return m
|
||||
}
|
||||
|
||||
// Enabled reports whether authentication is active.
|
||||
func (m *Manager) Enabled() bool { return m.password != "" }
|
||||
|
||||
// CSRFToken returns the persistent CSRF token to embed in HTML forms.
|
||||
func (m *Manager) CSRFToken() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.csrf
|
||||
}
|
||||
|
||||
// RequireAuth is middleware that redirects unauthenticated requests to /login.
|
||||
// Exempt paths: /login, /health (health checks don't need auth).
|
||||
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.Enabled() || r.URL.Path == "/login" || r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil || !m.validSession(cookie.Value) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyCSRF is middleware that checks the csrf_token field on POST requests.
|
||||
// On failure it redirects to / so the user gets a fresh form — avoids a
|
||||
// confusing error page after an EMS restart regenerates the token.
|
||||
func (m *Manager) VerifyCSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if m.Enabled() && r.Method == http.MethodPost && r.URL.Path != "/login" && r.URL.Path != "/health" {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
got := r.FormValue(csrfField)
|
||||
m.mu.RLock()
|
||||
ok := subtle.ConstantTimeCompare([]byte(got), []byte(m.csrf)) == 1
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
// Redirect to status page so the user gets a fresh form.
|
||||
m.logger.Warn("CSRF token mismatch — redirecting to status page", "path", r.URL.Path)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// LoginHandler serves GET /login (form) and POST /login (credential check).
|
||||
func (m *Manager) LoginHandler() http.HandlerFunc {
|
||||
tmpl := template.Must(template.New("login").Parse(loginTemplate))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
tmpl.Execute(w, nil) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
user := r.FormValue("username")
|
||||
pass := r.FormValue("password")
|
||||
|
||||
uOK := subtle.ConstantTimeCompare([]byte(user), []byte(m.username)) == 1
|
||||
pOK := subtle.ConstantTimeCompare([]byte(pass), []byte(m.password)) == 1
|
||||
if !uOK || !pOK {
|
||||
m.logger.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
|
||||
tmpl.Execute(w, "Ungültige Zugangsdaten") //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
session := m.session
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Set Secure flag when arriving via HTTPS reverse proxy.
|
||||
secure := r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: session,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
m.logger.Info("web UI login", "remote_addr", r.RemoteAddr)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// LogoutHandler clears the session cookie and redirects to /login.
|
||||
func (m *Manager) LogoutHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
m.logger.Info("web UI logout", "remote_addr", r.RemoteAddr)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) validSession(token string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return subtle.ConstantTimeCompare([]byte(token), []byte(m.session)) == 1
|
||||
}
|
||||
|
||||
func (m *Manager) loadOrGenerate(filename string) string {
|
||||
path := filepath.Join(m.tokenDir, filename)
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if t := strings.TrimSpace(string(data)); len(t) == 64 {
|
||||
return t
|
||||
}
|
||||
}
|
||||
token := newToken()
|
||||
if err := os.MkdirAll(m.tokenDir, 0o700); err == nil {
|
||||
_ = os.WriteFile(path, []byte(token), 0o600)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func newToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("auth: cannot generate random token: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
const loginTemplate = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>EMS · Anmelden</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui,sans-serif;background:#f1f5f9;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
||||
.card{background:#fff;border-radius:1rem;box-shadow:0 2px 12px rgba(0,0,0,.1);padding:2rem;width:100%;max-width:360px}
|
||||
h1{font-size:1.4rem;margin-bottom:1.5rem;color:#0f172a;text-align:center}
|
||||
label{display:block;font-size:.85rem;color:#64748b;margin-bottom:.3rem;margin-top:1rem}
|
||||
input{width:100%;padding:.65rem .8rem;border:1px solid #cbd5e1;border-radius:.5rem;font-size:1rem;outline:none}
|
||||
input:focus{border-color:#3b82f6;box-shadow:0 0 0 2px #bfdbfe}
|
||||
button{width:100%;margin-top:1.5rem;padding:.75rem;background:#2563eb;color:#fff;border:none;border-radius:.5rem;font-size:1rem;cursor:pointer}
|
||||
button:hover{background:#1d4ed8}
|
||||
.error{background:#fee2e2;color:#b91c1c;border-radius:.5rem;padding:.6rem .9rem;margin-top:1rem;font-size:.9rem;text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>☀️ Solar EMS</h1>
|
||||
<form method="post" action="/login">
|
||||
<label for="u">Benutzer</label>
|
||||
<input type="text" id="u" name="username" autocomplete="username" required autofocus>
|
||||
<label for="p">Passwort</label>
|
||||
<input type="password" id="p" name="password" autocomplete="current-password" required>
|
||||
{{if .}}<div class="error">{{.}}</div>{{end}}
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
Reference in New Issue
Block a user