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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-28 01:21:06 +08:00
parent c68fd69cdf
commit b2d81f7015
3 changed files with 34 additions and 13 deletions

View File

@ -304,6 +304,7 @@ async def login(
{ authenticated: false, mfa_setup_required: true, mfa_token: "..." } MFA enforcement { authenticated: false, mfa_setup_required: true, mfa_token: "..." } MFA enforcement
{ authenticated: false, must_change_password: true } forced password change after admin reset { authenticated: false, must_change_password: true } forced password change after admin reset
HTTP 401 wrong credentials HTTP 401 wrong credentials
HTTP 403 account disabled (is_active=False)
HTTP 423 account locked HTTP 423 account locked
""" """
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
@ -335,6 +336,16 @@ async def login(
if new_hash: if new_hash:
user.password_hash = 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) await _record_successful_login(db, user)
# SEC-03: MFA enforcement — block login entirely until MFA setup completes # SEC-03: MFA enforcement — block login entirely until MFA setup completes

View File

@ -60,8 +60,8 @@ export default function LockScreen() {
// ── Registration mode ── // ── Registration mode ──
const [mode, setMode] = useState<ScreenMode>('login'); const [mode, setMode] = useState<ScreenMode>('login');
// ── Lockout (HTTP 423) ── // ── Inline error (423 lockout, 403 disabled, 401 bad creds) ──
const [lockoutMessage, setLockoutMessage] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
// ── MFA enforcement setup flow ── // ── MFA enforcement setup flow ──
const [mfaEnforceStep, setMfaEnforceStep] = useState<MfaEnforceStep>('qr'); const [mfaEnforceStep, setMfaEnforceStep] = useState<MfaEnforceStep>('qr');
@ -98,7 +98,7 @@ export default function LockScreen() {
const handleCredentialSubmit = async (e: FormEvent) => { const handleCredentialSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setLockoutMessage(null); setLoginError(null);
if (isSetup) { if (isSetup) {
const err = validatePassword(password); const err = validatePassword(password);
@ -120,10 +120,13 @@ export default function LockScreen() {
} }
// mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically // mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically
} catch (error: any) { } catch (error: any) {
if (error?.response?.status === 423) { const status = error?.response?.status;
setLockoutMessage(error.response.data?.detail || 'Account locked. Try again later.'); 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 { } else {
toast.error(getErrorMessage(error, 'Invalid username or password')); setLoginError(getErrorMessage(error, 'Invalid username or password'));
} }
} }
}; };
@ -474,7 +477,7 @@ export default function LockScreen() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{lockoutMessage && ( {loginError && (
<div <div
role="alert" role="alert"
className={cn( className={cn(
@ -482,8 +485,8 @@ export default function LockScreen() {
'bg-red-500/10 px-3 py-2 mb-4' 'bg-red-500/10 px-3 py-2 mb-4'
)} )}
> >
<Lock className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" /> <AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
<p className="text-xs text-red-400">{lockoutMessage}</p> <p className="text-xs text-red-400">{loginError}</p>
</div> </div>
)} )}
<form onSubmit={handleCredentialSubmit} className="space-y-4"> <form onSubmit={handleCredentialSubmit} className="space-y-4">
@ -493,7 +496,7 @@ export default function LockScreen() {
id="username" id="username"
type="text" type="text"
value={username} value={username}
onChange={(e) => { setUsername(e.target.value); setLockoutMessage(null); }} onChange={(e) => { setUsername(e.target.value); setLoginError(null); }}
placeholder="Enter username" placeholder="Enter username"
required required
autoFocus autoFocus
@ -506,7 +509,7 @@ export default function LockScreen() {
id="password" id="password"
type="password" type="password"
value={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'} placeholder={isSetup ? 'Create a password' : 'Enter password'}
required required
autoComplete={isSetup ? 'new-password' : 'current-password'} autoComplete={isSetup ? 'new-password' : 'current-password'}
@ -532,7 +535,7 @@ export default function LockScreen() {
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
disabled={isLoginPending || isSetupPending || !!lockoutMessage} disabled={isLoginPending || isSetupPending}
> >
{isLoginPending || isSetupPending ? ( {isLoginPending || isSetupPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Please wait</> <><Loader2 className="h-4 w-4 animate-spin" />Please wait</>
@ -554,7 +557,7 @@ export default function LockScreen() {
setUsername(''); setUsername('');
setPassword(''); setPassword('');
setConfirmPassword(''); setConfirmPassword('');
setLockoutMessage(null); setLoginError(null);
}} }}
className="text-xs text-muted-foreground hover:text-foreground transition-colors" className="text-xs text-muted-foreground hover:text-foreground transition-colors"
> >

View File

@ -34,6 +34,13 @@ export function useAuth() {
} else { } else {
setMfaToken(null); setMfaToken(null);
setMfaSetupRequired(false); setMfaSetupRequired(false);
// Optimistically mark authenticated to prevent form flash during refetch
if ('authenticated' in data && data.authenticated && !('must_change_password' in data && data.must_change_password)) {
queryClient.setQueryData(['auth'], (old: AuthStatus | undefined) => ({
...old!,
authenticated: true,
}));
}
queryClient.invalidateQueries({ queryKey: ['auth'] }); queryClient.invalidateQueries({ queryKey: ['auth'] });
} }
}, },