110 lines
3.5 KiB
Go
110 lines
3.5 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
|
||
|
|
}
|