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, 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

View File

@ -60,8 +60,8 @@ export default function LockScreen() {
// ── Registration mode ──
const [mode, setMode] = useState<ScreenMode>('login');
// ── Lockout (HTTP 423) ──
const [lockoutMessage, setLockoutMessage] = useState<string | null>(null);
// ── Inline error (423 lockout, 403 disabled, 401 bad creds) ──
const [loginError, setLoginError] = useState<string | null>(null);
// ── MFA enforcement setup flow ──
const [mfaEnforceStep, setMfaEnforceStep] = useState<MfaEnforceStep>('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() {
</div>
</CardHeader>
<CardContent>
{lockoutMessage && (
{loginError && (
<div
role="alert"
className={cn(
@ -482,8 +485,8 @@ export default function LockScreen() {
'bg-red-500/10 px-3 py-2 mb-4'
)}
>
<Lock className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
<p className="text-xs text-red-400">{lockoutMessage}</p>
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
<p className="text-xs text-red-400">{loginError}</p>
</div>
)}
<form onSubmit={handleCredentialSubmit} className="space-y-4">
@ -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() {
<Button
type="submit"
className="w-full"
disabled={isLoginPending || isSetupPending || !!lockoutMessage}
disabled={isLoginPending || isSetupPending}
>
{isLoginPending || isSetupPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Please wait</>
@ -554,7 +557,7 @@ export default function LockScreen() {
setUsername('');
setPassword('');
setConfirmPassword('');
setLockoutMessage(null);
setLoginError(null);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>

View File

@ -34,6 +34,13 @@ export function useAuth() {
} else {
setMfaToken(null);
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'] });
}
},