// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package admin import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "net/http" "strings" "unicode" "golang.org/x/text/unicode/norm" "sourcedock.dev/petrbalvin/volumen/internal/session" ) // commonPasswords is the blocklist of trivially guessable passwords. // This is the policy every admin password change goes through. var commonPasswords = map[string]bool{ "password": true, "password1": true, "password123": true, "123456": true, "12345678": true, "123456789": true, "qwerty": true, "qwerty123": true, "letmein": true, "iloveyou": true, "admin": true, "admin123": true, "welcome": true, "welcome1": true, "monkey": true, "dragon": true, "football": true, "baseball": true, "sunshine": true, "princess": true, "abc123": true, "111111": true, "123123": true, "1q2w3e4r": true, "passw0rd": true, "trustno1": true, "changeme": true, "secret": true, "secret123": true, "test": true, "test123": true, "guest": true, "master": true, "000000": true, "696969": true, "qwertyuiop": true, "superman": true, "batman": true, "jordan": true, "harley": true, "hunter": true, "hunter2": true, "shadow": true, "michael": true, "jennifer": true, "abcdef": true, "abcdefg": true, } // PasswordError validates a newly chosen password and reports the // first problem as a catalogue key. The key is either a plain sentence or // the id of a plural message whose numeral n is the offending length; // "" with n 0 means accepted. func PasswordError(password string, minLength, maxLength int) (string, int) { if strings.TrimSpace(password) == "" { return "New password cannot be empty.", 0 } length := len([]rune(password)) if length < minLength { return "password.min", minLength } if length > maxLength { return "password.max", maxLength } normalized := strings.ToLower(norm.NFKC.String(password)) if commonPasswords[normalized] { return "This password is too common.", 0 } return "", 0 } // CSRFToken returns (and lazily creates) the CSRF token stored in the // session. func CSRFToken(sess *session.Session) string { token := sess.Get("csrf") if token == "" { token = newTokenHex(32) sess.Set("csrf", token) } return token } // sessionFingerprint derives the value the session carries to bind it to // one password: it changes whenever the account's hash changes, so a // password change or an admin reset retires every cookie issued before // it. It is a digest of the stored hash, never of the password, and // carries too few bits to help anyone invert the hash. func sessionFingerprint(storedHash string) string { sum := sha256.Sum256([]byte("volumen-session-v1:" + storedHash)) return hex.EncodeToString(sum[:8]) } // ValidateCSRF compares the form's _csrf field against the session // token in constant time. func ValidateCSRF(r *http.Request, sess *session.Session) bool { token := r.PostFormValue("_csrf") sessionToken := sess.Get("csrf") if token == "" || sessionToken == "" { return false } return subtle.ConstantTimeCompare([]byte(sessionToken), []byte(token)) == 1 } func newTokenHex(nBytes int) string { buf := make([]byte, nBytes) if _, err := rand.Read(buf); err != nil { return "" } return hex.EncodeToString(buf) } // firstUpper returns the uppercased first rune, or fallback. func firstUpper(s, fallback string) string { for _, r := range s { if unicode.IsSpace(r) { continue } return string(unicode.ToUpper(r)) } return fallback }