Add rules language support and improve password validation across the app

This commit is contained in:
Jan Meinl
2026-05-17 05:57:20 +02:00
parent 68034dea7d
commit 570272a777
16 changed files with 330 additions and 77 deletions
+39 -5
View File
@@ -8,6 +8,7 @@ import (
"errors"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -25,8 +26,21 @@ const sessionDuration = 24 * time.Hour * 14
const maxUsernameLen = 64
const maxDisplayNameLen = 128
const maxPasswordLen = 256
const minPasswordLen = 8
const maxFieldLen = 2000
// validatePassword returns "" if the password meets the policy, or a short
// error code suitable for the JSON `error` field if it doesn't.
func validatePassword(p string) string {
if len(p) < minPasswordLen {
return "password_too_short"
}
if len(p) > maxPasswordLen {
return "too_long"
}
return ""
}
func registerAuthRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/login", loginRateLimit(handleLogin))
mux.HandleFunc("POST /api/logout", handleLogout)
@@ -87,7 +101,9 @@ func clientIP(r *http.Request) string {
return host
}
func loginAllowed(ip string) bool {
// loginStatus returns (remaining, retryAfterSeconds). When remaining == 0 the
// caller must reject the request and use retryAfterSeconds for headers.
func loginStatus(ip string) (int, int) {
loginMu.Lock()
defer loginMu.Unlock()
a, ok := loginAttempts_[ip]
@@ -103,7 +119,20 @@ func loginAllowed(ip string) bool {
}
}
a.times = kept
return len(a.times) < loginMaxAttempts
remaining := loginMaxAttempts - len(a.times)
if remaining < 0 {
remaining = 0
}
retryAfter := 0
if remaining == 0 && len(a.times) > 0 {
// Time until the oldest attempt falls out of the window.
next := a.times[0].Add(loginWindow).Sub(time.Now())
retryAfter = int(next.Seconds()) + 1
if retryAfter < 1 {
retryAfter = 1
}
}
return remaining, retryAfter
}
func loginRecord(ip string) {
@@ -120,7 +149,12 @@ func loginRecord(ip string) {
func loginRateLimit(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if !loginAllowed(ip) {
remaining, retryAfter := loginStatus(ip)
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(loginMaxAttempts))
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
if remaining == 0 {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.Header().Set("X-RateLimit-Reset", strconv.Itoa(retryAfter))
writeError(w, http.StatusTooManyRequests, "too_many_attempts")
return
}
@@ -245,8 +279,8 @@ func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
}
}
if req.Password != nil && *req.Password != "" {
if len(*req.Password) > maxPasswordLen {
writeError(w, http.StatusBadRequest, "too_long")
if msg := validatePassword(*req.Password); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)