Add role based login and user management
This commit is contained in:
parent
f67bbdce78
commit
353dafc30d
4 changed files with 681 additions and 34 deletions
|
|
@ -1,28 +1,257 @@
|
|||
package backend
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
sessionCookieName = "mail_graveyard_session"
|
||||
currentUserKey = contextKey("currentUser")
|
||||
roleUser = "user"
|
||||
roleManager = "verwalter"
|
||||
roleAdmin = "admin"
|
||||
passwordHashIterations = 120000
|
||||
defaultSessionValidDays = 7
|
||||
)
|
||||
|
||||
// InitAuth bereitet die Session-/Login-Verwaltung vor.
|
||||
//
|
||||
// Sicherheitslage: Dieses Tool haelt IMAP-Zugangsdaten fremder Postfaecher und
|
||||
// kann per SMTP Mails verschicken. Es darf NIE offen im Netz stehen.
|
||||
// - Default: bind 127.0.0.1 (nur lokal), Login mit admin_user/admin_pass.
|
||||
// - Wenn spaeter remote erreichbar (hinter Caddy): /vadmin-Mail-2FA-Muster
|
||||
// aus dem Web-Deploy-Kit vorschalten.
|
||||
// - Wenn remote erreichbar (hinter Caddy): HTTPS und restriktive Caddy-Regeln
|
||||
// beibehalten. Rollen prueft die App serverseitig.
|
||||
func InitAuth() error {
|
||||
// TODO Codex: Session-Cookie-Store initialisieren.
|
||||
return nil
|
||||
if err := CleanupSessions(); err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := CountAppUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(Cfg.AdminUser) == "" || Cfg.AdminPass == "" {
|
||||
return fmt.Errorf("admin_user/admin_pass fehlen fuer initialen Login-Benutzer")
|
||||
}
|
||||
hash, err := HashPassword(Cfg.AdminPass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return SaveAppUser(strings.TrimSpace(Cfg.AdminUser), hash, roleAdmin, true)
|
||||
}
|
||||
|
||||
// AuthMiddleware schuetzt alle Routen ausser /login und /static.
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO Codex: /login + /static durchlassen, sonst Session pruefen,
|
||||
// sonst Redirect /login.
|
||||
next.ServeHTTP(w, r)
|
||||
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/login" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || strings.TrimSpace(cookie.Value) == "" {
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
user, err := SessionUser(cookie.Value)
|
||||
if err != nil {
|
||||
clearSessionCookie(w)
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), currentUserKey, user)))
|
||||
})
|
||||
}
|
||||
|
||||
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO Codex: Formular, Abgleich mit Cfg.AdminUser/AdminPass, Cookie setzen.
|
||||
func CurrentUser(r *http.Request) AppUser {
|
||||
if u, ok := r.Context().Value(currentUserKey).(AppUser); ok {
|
||||
return u
|
||||
}
|
||||
return AppUser{}
|
||||
}
|
||||
|
||||
func CanManageAccounts(r *http.Request) bool {
|
||||
return hasRole(CurrentUser(r), roleManager)
|
||||
}
|
||||
|
||||
func CanManageUsers(r *http.Request) bool {
|
||||
return hasRole(CurrentUser(r), roleManager)
|
||||
}
|
||||
|
||||
func IsAdmin(r *http.Request) bool {
|
||||
return CurrentUser(r).Role == roleAdmin
|
||||
}
|
||||
|
||||
func requireManager(w http.ResponseWriter, r *http.Request) bool {
|
||||
if CanManageAccounts(r) {
|
||||
return true
|
||||
}
|
||||
http.Error(w, "Keine Berechtigung.", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if IsAdmin(r) {
|
||||
return true
|
||||
}
|
||||
http.Error(w, "Nur Admins duerfen diese Aktion ausfuehren.", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
func hasRole(user AppUser, needed string) bool {
|
||||
rank := map[string]int{roleUser: 1, roleManager: 2, roleAdmin: 3}
|
||||
return rank[user.Role] >= rank[needed]
|
||||
}
|
||||
|
||||
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
renderLoginPage(w, r.URL.Query().Get("err"))
|
||||
case http.MethodPost:
|
||||
if err := r.ParseForm(); err != nil {
|
||||
renderLoginPage(w, err.Error())
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
user, err := GetAppUser(username)
|
||||
if err != nil || !user.Active || !CheckPassword(password, user.PasswordHash) {
|
||||
renderLoginPage(w, "Login fehlgeschlagen.")
|
||||
return
|
||||
}
|
||||
token, err := randomToken(32)
|
||||
if err != nil {
|
||||
renderLoginPage(w, err.Error())
|
||||
return
|
||||
}
|
||||
expires := time.Now().Add(defaultSessionValidDays * 24 * time.Hour)
|
||||
if err := CreateSession(token, user.ID, expires.UTC().Format("2006-01-02 15:04:05")); err != nil {
|
||||
renderLoginPage(w, err.Error())
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: expires,
|
||||
MaxAge: int(time.Until(expires).Seconds()),
|
||||
})
|
||||
http.Redirect(w, r, "/view", http.StatusSeeOther)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
_ = DeleteSession(cookie.Value)
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func renderLoginPage(w http.ResponseWriter, errMsg string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Login - Mail-Graveyard</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260712-15"></head>
|
||||
<body class="ol2013 login-body">
|
||||
<main class="login-card">
|
||||
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
|
||||
<form method="post" action="/login" class="login-form">
|
||||
<label class="label">Benutzer<input class="input" name="username" autocomplete="username" autofocus required></label>
|
||||
<label class="label">Passwort<input class="input" name="password" type="password" autocomplete="current-password" required></label>
|
||||
%s
|
||||
<button class="btn" type="submit">Anmelden</button>
|
||||
</form>
|
||||
</main>
|
||||
</body></html>`, loginErrorHTML(errMsg))
|
||||
}
|
||||
|
||||
func loginErrorHTML(errMsg string) string {
|
||||
if errMsg == "" {
|
||||
return ""
|
||||
}
|
||||
return `<div class="notice bad login-error">` + html.EscapeString(errMsg) + `</div>`
|
||||
}
|
||||
|
||||
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt, err := randomToken(18)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := passwordDigest(password, salt, passwordHashIterations)
|
||||
return fmt.Sprintf("sha256:%d:%s:%s", passwordHashIterations, salt, base64.RawStdEncoding.EncodeToString(sum)), nil
|
||||
}
|
||||
|
||||
func CheckPassword(password, encoded string) bool {
|
||||
parts := strings.Split(encoded, ":")
|
||||
if len(parts) != 4 || parts[0] != "sha256" {
|
||||
return false
|
||||
}
|
||||
var iterations int
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &iterations); err != nil || iterations <= 0 {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got := passwordDigest(password, parts[2], iterations)
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
func passwordDigest(password, salt string, iterations int) []byte {
|
||||
data := []byte(salt + ":" + password)
|
||||
sum := sha256.Sum256(data)
|
||||
out := sum[:]
|
||||
for i := 1; i < iterations; i++ {
|
||||
next := sha256.Sum256(out)
|
||||
out = next[:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func randomToken(size int) (string, error) {
|
||||
b := make([]byte, size)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue