From b2d81f7015686b5b04872fcf7e643acc71846e6f Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Sat, 28 Feb 2026 01:21:06 +0800 Subject: [PATCH] Block inactive user login + fix login flicker + inline error alerts - Backend: reject is_active=False users with HTTP 403 after password verification but before session creation (prevents last_login_at update, lockout reset, and MFA token issuance for disabled accounts) - Frontend: optimistic setQueryData on successful login eliminates the form flash between mutation success and auth query refetch - LockScreen: replace lockoutMessage + toast.error with unified loginError inline alert for 401/403/423 responses Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/auth.py | 11 ++++++++ frontend/src/components/auth/LockScreen.tsx | 29 ++++++++++++--------- frontend/src/hooks/useAuth.ts | 7 +++++ 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0bf3fe0..5a92998 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -304,6 +304,7 @@ async def login( { authenticated: false, mfa_setup_required: true, mfa_token: "..." } — MFA enforcement { authenticated: false, must_change_password: true } — forced password change after admin reset HTTP 401 — wrong credentials + HTTP 403 — account disabled (is_active=False) HTTP 423 — account locked """ client_ip = request.client.host if request.client else "unknown" @@ -335,6 +336,16 @@ async def login( if new_hash: user.password_hash = new_hash + # Block disabled accounts — checked AFTER password verification to avoid + # leaking account-state info, and BEFORE _record_successful_login so + # last_login_at and lockout counters are not reset for inactive users. + if not user.is_active: + await log_audit_event( + db, action="auth.login_blocked_inactive", actor_id=user.id, ip=client_ip, + ) + await db.commit() + raise HTTPException(status_code=403, detail="Account is disabled. Contact an administrator.") + await _record_successful_login(db, user) # SEC-03: MFA enforcement — block login entirely until MFA setup completes diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 12aaa46..aee902d 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -60,8 +60,8 @@ export default function LockScreen() { // ── Registration mode ── const [mode, setMode] = useState('login'); - // ── Lockout (HTTP 423) ── - const [lockoutMessage, setLockoutMessage] = useState(null); + // ── Inline error (423 lockout, 403 disabled, 401 bad creds) ── + const [loginError, setLoginError] = useState(null); // ── MFA enforcement setup flow ── const [mfaEnforceStep, setMfaEnforceStep] = useState('qr'); @@ -98,7 +98,7 @@ export default function LockScreen() { const handleCredentialSubmit = async (e: FormEvent) => { e.preventDefault(); - setLockoutMessage(null); + setLoginError(null); if (isSetup) { const err = validatePassword(password); @@ -120,10 +120,13 @@ export default function LockScreen() { } // mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically } catch (error: any) { - if (error?.response?.status === 423) { - setLockoutMessage(error.response.data?.detail || 'Account locked. Try again later.'); + const status = error?.response?.status; + if (status === 423) { + setLoginError(error.response.data?.detail || 'Account locked. Try again later.'); + } else if (status === 403) { + setLoginError(error.response.data?.detail || 'Account is disabled. Contact an administrator.'); } else { - toast.error(getErrorMessage(error, 'Invalid username or password')); + setLoginError(getErrorMessage(error, 'Invalid username or password')); } } }; @@ -474,7 +477,7 @@ export default function LockScreen() { - {lockoutMessage && ( + {loginError && (
-
)}
@@ -493,7 +496,7 @@ export default function LockScreen() { id="username" type="text" value={username} - onChange={(e) => { setUsername(e.target.value); setLockoutMessage(null); }} + onChange={(e) => { setUsername(e.target.value); setLoginError(null); }} placeholder="Enter username" required autoFocus @@ -506,7 +509,7 @@ export default function LockScreen() { id="password" type="password" value={password} - onChange={(e) => { setPassword(e.target.value); setLockoutMessage(null); }} + onChange={(e) => { setPassword(e.target.value); setLoginError(null); }} placeholder={isSetup ? 'Create a password' : 'Enter password'} required autoComplete={isSetup ? 'new-password' : 'current-password'} @@ -532,7 +535,7 @@ export default function LockScreen() {