fix: auth/2FA — password change requires 2FA gate, admin self-deletion blocked, twofa JSON responses, per-IP email-verify budget, OptionalAuth log sanitised

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent d03ce79c19
commit d90e25d1ff
5 changed files with 139 additions and 28 deletions
+65
View File
@@ -155,8 +155,60 @@ type emailVerifyAttempt struct {
var ( var (
emailVerifyMu sync.Mutex emailVerifyMu sync.Mutex
emailVerifyAttempts = make(map[string]emailVerifyAttempt) emailVerifyAttempts = make(map[string]emailVerifyAttempt)
ipVerifyMu sync.Mutex
ipVerifyAttempts = make(map[string]emailVerifyAttempt)
) )
// ipAttemptsExhausted reports whether the IP's attempt budget is already
// spent, rejecting the request before any DB work. Uses the same window as
// email-verify tracking with a higher cap (50 per IP per window) as a
// secondary per-IP fallback budget.
func ipAttemptsExhausted(ip string) bool {
ipVerifyMu.Lock()
defer ipVerifyMu.Unlock()
evictStaleIPVerifyAttemptsLocked()
a, ok := ipVerifyAttempts[ip]
return ok && a.count >= 50
}
// ipAttemptFailed registers one failed attempt for the IP and reports whether
// the budget for that IP is now exhausted.
func ipAttemptFailed(ip string) bool {
ipVerifyMu.Lock()
defer ipVerifyMu.Unlock()
evictStaleIPVerifyAttemptsLocked()
now := clock.Now()
a := ipVerifyAttempts[ip]
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
a.count = 0
}
a.count++
a.lastAt = now
ipVerifyAttempts[ip] = a
return a.count >= 50
}
// ipAttemptsClear drops the budget for an IP after a successful verify.
func ipAttemptsClear(ip string) {
ipVerifyMu.Lock()
delete(ipVerifyAttempts, ip)
ipVerifyMu.Unlock()
}
// evictStaleIPVerifyAttemptsLocked bounds the IP attempts map. Caller must
// hold ipVerifyMu.
func evictStaleIPVerifyAttemptsLocked() {
if len(ipVerifyAttempts) < emailVerifyMaxTrackedCodes {
return
}
now := clock.Now()
for k, a := range ipVerifyAttempts {
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
delete(ipVerifyAttempts, k)
}
}
}
// emailVerifyAttemptsExhausted reports whether the key's (a user id, or a // emailVerifyAttemptsExhausted reports whether the key's (a user id, or a
// submitted code with no resolvable user) attempt budget is already spent, // submitted code with no resolvable user) attempt budget is already spent,
// rejecting the request before any DB work. // rejecting the request before any DB work.
@@ -1020,6 +1072,16 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Per-IP fallback budget: when no user resolves, the attempt budget is
// keyed on the submitted CODE value — rotating codes gives unlimited
// guesses. The per-IP check prevents a single source from exhausting the
// endpoint regardless of code rotation.
ip := mw.ClientIP(r)
if ipAttemptsExhausted(ip) {
http.Error(w, "too many attempts", http.StatusTooManyRequests)
return
}
codeDigest := twofa.Hash(code) codeDigest := twofa.Hash(code)
var userID string var userID string
@@ -1036,6 +1098,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
// No row with this digest at all — a guess. No user can be // No row with this digest at all — a guess. No user can be
// resolved, so the attempt budget stays keyed per submitted code. // resolved, so the attempt budget stays keyed per submitted code.
ipAttemptFailed(ip)
if emailVerifyAttemptFailed(code) { if emailVerifyAttemptFailed(code) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests) http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return return
@@ -1063,6 +1126,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
} }
// Code exists but expired — count it against the user's budget too. // Code exists but expired — count it against the user's budget too.
if !expiresAt.After(clock.Now()) { if !expiresAt.After(clock.Now()) {
ipAttemptFailed(ip)
if emailVerifyAttemptFailed(userID) { if emailVerifyAttemptFailed(userID) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests) http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return return
@@ -1132,6 +1196,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
// key the submitted code used on earlier guesses). // key the submitted code used on earlier guesses).
emailVerifyAttemptsClear(userID) emailVerifyAttemptsClear(userID)
emailVerifyAttemptsClear(code) emailVerifyAttemptsClear(code)
ipAttemptsClear(ip)
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: message}); err != nil { if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: message}); err != nil {
log.Printf("Failed to encode JSON response: %v", err) log.Printf("Failed to encode JSON response: %v", err)
+20
View File
@@ -338,6 +338,26 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// FIX: admin accounts cannot be self-deleted
if accountRole == "admin" {
http.Error(w, "admin accounts cannot be self-deleted", http.StatusForbidden)
return
}
// FIX: check for active bookings before guest deletion
if accountRole == "guest" {
var bookingCount int
if err := db.Conn.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status IN ('pending', 'confirmed', 'in_progress', 'pending_release')`, userID).Scan(&bookingCount); err != nil {
log.Printf("Failed to check bookings for guest %s: %v", userID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if bookingCount > 0 {
http.Error(w, "cannot delete guest account with active bookings", http.StatusBadRequest)
return
}
}
ctx := r.Context() ctx := r.Context()
// Finding 3: deleting an account is irreversible, so the session token alone // Finding 3: deleting an account is irreversible, so the session token alone
+29 -3
View File
@@ -30,6 +30,7 @@ import (
"crussell/internal/dav" "crussell/internal/dav"
"crussell/internal/images" "crussell/internal/images"
"crussell/internal/s3" "crussell/internal/s3"
"crussell/internal/twofa"
"crussell/internal/validators" "crussell/internal/validators"
"crussell/mw" "crussell/mw"
@@ -659,8 +660,9 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
} }
type ChangePasswordRequest struct { type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" validate:"required,max=72"` CurrentPassword string `json:"current_password" validate:"required,max=72"`
NewPassword string `json:"new_password" validate:"required,min=6,max=72"` NewPassword string `json:"new_password" validate:"required,min=6,max=72"`
VerificationCode string `json:"verification_code"`
} }
func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) { func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
@@ -702,7 +704,8 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
} }
var passwordHash sql.NullString var passwordHash sql.NullString
err := db.Conn.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash) var twoFactorEnabled bool
err := db.Conn.QueryRow(r.Context(), `SELECT password_hash, two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&passwordHash, &twoFactorEnabled)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "user not found", http.StatusNotFound) http.Error(w, "user not found", http.StatusNotFound)
@@ -765,6 +768,29 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
// login-locked-out user can self-recover by changing their password. // login-locked-out user can self-recover by changing their password.
resetCurrentPasswordFailures(r.Context(), userID) resetCurrentPasswordFailures(r.Context(), userID)
if twoFARequired() && twoFactorEnabled {
if req.VerificationCode == "" {
http.Error(w, "a two-factor verification code is required to change the password", http.StatusBadRequest)
return
}
switch err := twofa.VerifyForUser(r.Context(), userID, req.VerificationCode, twofa.ConsumeOnVerify); {
case err == nil:
case errors.Is(err, twofa.ErrIncorrect):
http.Error(w, "incorrect verification code", http.StatusBadRequest)
return
case errors.Is(err, twofa.ErrLockedOut):
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return
case errors.Is(err, twofa.ErrMissingOrExpired):
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
return
default:
log.Printf("Failed to check 2FA pending code for user %s: %v", userID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
log.Printf("Failed to hash new password for user %s: %v", userID, err) log.Printf("Failed to hash new password for user %s: %v", userID, err)
+24 -24
View File
@@ -210,7 +210,7 @@ type TwoFAStatusResponse struct {
func GetTwoFAStatusHandler(w http.ResponseWriter, r *http.Request) { func GetTwoFAStatusHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) mw.RespondError(w, http.StatusUnauthorized, "unauthorized")
return return
} }
@@ -223,7 +223,7 @@ func GetTwoFAStatusHandler(w http.ResponseWriter, r *http.Request) {
`, userID).Scan(&enabled, &method) `, userID).Scan(&enabled, &method)
if err != nil { if err != nil {
log.Printf("failed to fetch 2FA status for user %s: %v", userID, err) log.Printf("failed to fetch 2FA status for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
@@ -254,13 +254,13 @@ type TwoFASetupRequest struct {
func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) { func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) mw.RespondError(w, http.StatusUnauthorized, "unauthorized")
return return
} }
var req TwoFASetupRequest var req TwoFASetupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest) mw.RespondError(w, http.StatusBadRequest, "invalid request")
return return
} }
if req.Method != "email" && req.Method != "sms" { if req.Method != "email" && req.Method != "sms" {
@@ -272,7 +272,7 @@ func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
err := db.Conn.QueryRow(r.Context(), `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled) err := db.Conn.QueryRow(r.Context(), `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
if err != nil { if err != nil {
log.Printf("failed to check 2FA state for user %s: %v", userID, err) log.Printf("failed to check 2FA state for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
if enabled { if enabled {
@@ -315,7 +315,7 @@ func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("failed to store 2FA pending code for user %s: %v", userID, err) log.Printf("failed to store 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
st.SetLastMintAtLocked(now) st.SetLastMintAtLocked(now)
@@ -403,13 +403,13 @@ func VerifyTwoFACodeForUser(ctx context.Context, userID, code string) error {
func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) { func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) mw.RespondError(w, http.StatusUnauthorized, "unauthorized")
return return
} }
var req TwoFAVerifyRequest var req TwoFAVerifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest) mw.RespondError(w, http.StatusBadRequest, "invalid request")
return return
} }
@@ -417,7 +417,7 @@ func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) {
// Dev bypass: no code verification in unenforced environments. // Dev bypass: no code verification in unenforced environments.
if err := enableTwoFA(r, userID); err != nil { if err := enableTwoFA(r, userID); err != nil {
log.Printf("failed to enable 2FA for user %s: %v", userID, err) log.Printf("failed to enable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
writeTwoFAEnabled(w) writeTwoFAEnabled(w)
@@ -435,7 +435,7 @@ func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) {
result, err := checkTwoFACode(r, userID, st, req.Code) result, err := checkTwoFACode(r, userID, st, req.Code)
if err != nil { if err != nil {
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err) log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
switch result { switch result {
@@ -462,7 +462,7 @@ func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) {
if err := enableTwoFA(r, userID); err != nil { if err := enableTwoFA(r, userID); err != nil {
log.Printf("failed to enable 2FA for user %s: %v", userID, err) log.Printf("failed to enable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
writeTwoFAEnabled(w) writeTwoFAEnabled(w)
@@ -528,7 +528,7 @@ type TwoFADisableRequest struct {
func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) { func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) mw.RespondError(w, http.StatusUnauthorized, "unauthorized")
return return
} }
@@ -552,7 +552,7 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err) log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -588,7 +588,7 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) { func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) mw.RespondError(w, http.StatusUnauthorized, "unauthorized")
return return
} }
@@ -596,7 +596,7 @@ func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
err := db.Conn.QueryRow(r.Context(), `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled) err := db.Conn.QueryRow(r.Context(), `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
if err != nil { if err != nil {
log.Printf("failed to check 2FA state for user %s: %v", userID, err) log.Printf("failed to check 2FA state for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
if !enabled { if !enabled {
@@ -625,7 +625,7 @@ func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("failed to prepare 2FA code for saved-card charge for user %s: %v", userID, err) log.Printf("failed to prepare 2FA code for saved-card charge for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
@@ -671,7 +671,7 @@ func AdminSendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("failed to check 2FA state for user %s: %v", targetUserID, err) log.Printf("failed to check 2FA state for user %s: %v", targetUserID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
if !enabled { if !enabled {
@@ -697,7 +697,7 @@ func AdminSendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("failed to prepare 2FA code for user %s: %v", targetUserID, err) log.Printf("failed to prepare 2FA code for user %s: %v", targetUserID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
@@ -742,7 +742,7 @@ func AdminSendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) { func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
if !ok { if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized) mw.RespondError(w, http.StatusUnauthorized, "unauthorized")
return return
} }
@@ -755,7 +755,7 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
// Dev bypass: no re-verification in unenforced environments. // Dev bypass: no re-verification in unenforced environments.
if err := disableTwoFA(r, userID); err != nil { if err := disableTwoFA(r, userID); err != nil {
log.Printf("failed to disable 2FA for user %s: %v", userID, err) log.Printf("failed to disable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -789,14 +789,14 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err) log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
result, err := checkTwoFACode(r, userID, st, req.Code) result, err := checkTwoFACode(r, userID, st, req.Code)
if err != nil { if err != nil {
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err) log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
switch result { switch result {
@@ -814,7 +814,7 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
if err := disableTwoFA(r, userID); err != nil { if err := disableTwoFA(r, userID); err != nil {
log.Printf("failed to disable 2FA for user %s: %v", userID, err) log.Printf("failed to disable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -942,7 +942,7 @@ func AdminRemoveUser2FAHandler(w http.ResponseWriter, r *http.Request) {
affected, err := removeUser2FA(r, userID) affected, err := removeUser2FA(r, userID)
if err != nil { if err != nil {
log.Printf("failed to remove 2FA for user %s: %v", userID, err) log.Printf("failed to remove 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) mw.RespondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
if !affected { if !affected {
+1 -1
View File
@@ -53,7 +53,7 @@ func OptionalAuth(next http.Handler) http.Handler {
userID, role, jti, err := auth.VerifyToken(tokenString, r.Context()) userID, role, jti, err := auth.VerifyToken(tokenString, r.Context())
if err != nil { if err != nil {
log.Printf("OptionalAuth: invalid token: %v", err) log.Printf("OptionalAuth: invalid token ignored")
} else { } else {
ctx := context.WithValue(r.Context(), UserIDKey, userID) ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, UserRoleKey, role) ctx = context.WithValue(ctx, UserRoleKey, role)