Files
flancer/internal/app/auth.go
jbergner 222b5d2413
Some checks failed
release-tag / release-image (push) Has been cancelled
init
2026-08-14 17:42:54 +02:00

205 lines
6.1 KiB
Go

package app
import (
"context"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
const (
passwordIterations = 310_000
passwordSaltBytes = 16
passwordKeyBytes = 32
sessionCookie = "pocketwatch_session"
)
// This is deliberately public and never authenticates an account. It only keeps
// failed-login password work roughly constant when a username does not exist.
const dummyPasswordHash = "pbkdf2-sha256$310000$cG9ja2V0d2F0Y2gtZHVtbXk$y2kfBbJVOW/xpojEt9AAGQfhF+Ul6iGKDKH09PSY7IY"
type session struct {
User User
CSRF string
}
type contextKey int
const sessionKey contextKey = 1
func hashPassword(password string) (string, error) {
if len(password) < 10 {
return "", fmt.Errorf("password must have at least 10 characters")
}
salt := make([]byte, passwordSaltBytes)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := pbkdf2SHA256([]byte(password), salt, passwordIterations, passwordKeyBytes)
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
func verifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
return false
}
iter, err := strconv.Atoi(parts[1])
if err != nil || iter < 100_000 || iter > 2_000_000 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil || len(want) == 0 {
return false
}
got := pbkdf2SHA256([]byte(password), salt, iter, len(want))
return subtle.ConstantTimeCompare(got, want) == 1
}
// pbkdf2SHA256 wraps Go 1.26's standard-library PBKDF2 implementation.
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
key, err := pbkdf2.Key(sha256.New, string(password), salt, iterations, keyLen)
if err != nil {
panic(err) // callers use fixed, validated parameters
}
return key
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func tokenDigest(token string) []byte {
s := sha256.Sum256([]byte(token))
return s[:]
}
func newID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
h := hex.EncodeToString(b)
return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
}
func (a *App) createSession(ctx context.Context, userID string) (token, csrf string, expires time.Time, err error) {
token, err = randomToken(32)
if err != nil {
return
}
csrf, err = randomToken(24)
if err != nil {
return
}
now := time.Now()
expires = now.Add(a.cfg.SessionTTL)
_, err = a.store.db.ExecContext(ctx, `INSERT INTO sessions(token_hash,user_id,csrf_token,created_at_ms,expires_at_ms) VALUES(?,?,?,?,?)`, tokenDigest(token), userID, csrf, now.UnixMilli(), expires.UnixMilli())
return
}
func (a *App) sessionFromRequest(r *http.Request) (*session, error) {
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return nil, sql.ErrNoRows
}
var userID, csrf string
var expires int64
err = a.store.db.QueryRowContext(r.Context(), `SELECT user_id,csrf_token,expires_at_ms FROM sessions WHERE token_hash=?`, tokenDigest(c.Value)).Scan(&userID, &csrf, &expires)
if err != nil {
return nil, err
}
if expires <= time.Now().UnixMilli() {
_, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value))
return nil, sql.ErrNoRows
}
u, err := a.store.userByID(r.Context(), userID)
if err != nil || !u.Active {
return nil, sql.ErrNoRows
}
return &session{User: u, CSRF: csrf}, nil
}
func (a *App) withSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s, err := a.sessionFromRequest(r)
if err != nil {
if strings.HasPrefix(r.URL.Path, "/api/") {
jsonError(w, http.StatusUnauthorized, "not_authenticated", "Bitte anmelden.")
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s)))
})
}
func sessionOf(r *http.Request) *session {
x, _ := r.Context().Value(sessionKey).(*session)
return x
}
func requireCSRF(w http.ResponseWriter, r *http.Request) bool {
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
return true
}
s := sessionOf(r)
if s == nil || subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(s.CSRF)) != 1 {
jsonError(w, http.StatusForbidden, "csrf", "Ungültiges CSRF-Token.")
return false
}
return true
}
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
s := sessionOf(r)
if s == nil || s.User.Role != "admin" {
jsonError(w, http.StatusForbidden, "forbidden", "Admin-Rechte erforderlich.")
return false
}
return true
}
func (a *App) deleteCurrentSession(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil {
_, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value))
}
a.clearSessionCookie(w)
}
func (a *App) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
}
func (a *App) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)})
}
func isUniqueConstraint(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "unique constraint") || strings.Contains(s, "constraint failed")
}