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:
parent
c68fd69cdf
commit
b2d81f7015
@ -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
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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'] });
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user