diff --git a/internal/api/2fa_test.go b/internal/api/2fa_test.go index 3ed5f8d..8adb7a4 100644 --- a/internal/api/2fa_test.go +++ b/internal/api/2fa_test.go @@ -458,4 +458,22 @@ func Test2FATrustDeviceFlow(t *testing.T) { if !sessionCleared { t.Fatalf("expected apt_session cookie to be cleared on logout") } + + // 7. Test forget-device endpoint clears apt_2fa_trust cookie + forgetReq := newPostRequest("/api/auth/2fa/forget-device", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, forgetReq) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from forget-device, got %d", w.Code) + } + var trustCleared bool + for _, c := range w.Result().Cookies() { + if c.Name == "apt_2fa_trust" && c.MaxAge == -1 { + trustCleared = true + break + } + } + if !trustCleared { + t.Fatalf("expected apt_2fa_trust cookie to be cleared on forget-device") + } } diff --git a/internal/api/auth.go b/internal/api/auth.go index d8fb234..b393e91 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -415,6 +415,7 @@ func (h *Handler) authCheck(w http.ResponseWriter, r *http.Request) { "role": user.Role, "user_id": user.ID, "two_factor_enabled": user.TOTPEnabled, + "device_trusted": h.isDeviceTrusted(r, user.ID), }, http.StatusOK) } @@ -431,6 +432,11 @@ func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) } +func (h *Handler) forgetDevice(w http.ResponseWriter, r *http.Request) { + h.clear2FATrustCookie(w) + jsonOK(w, map[string]string{"status": "ok"}, http.StatusOK) +} + func (h *Handler) issue2FATrustCookie(w http.ResponseWriter, userID string) { claims := jwt.MapClaims{ "sub": userID, diff --git a/internal/api/router.go b/internal/api/router.go index 82f01b5..77ef951 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -60,6 +60,7 @@ func (h *Handler) Router(spa http.Handler) http.Handler { r.With(rl.RateLimit).Post("/api/auth/login", h.login) r.With(rl.RateLimit).Post("/api/auth/2fa/verify", h.verify2FA) r.Post("/api/auth/logout", h.logout) + r.Post("/api/auth/2fa/forget-device", h.forgetDevice) // Admin API — protected by JWT or API key. r.Group(func(r chi.Router) { diff --git a/web/src/api.ts b/web/src/api.ts index 9cdeecb..de4152f 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -100,6 +100,7 @@ export interface CurrentUser { username: string role: Role two_factor_enabled?: boolean + device_trusted?: boolean } export interface User { @@ -144,6 +145,7 @@ export const api = { verify2FA: (preAuthToken: string, code: string, trustDevice: boolean = false) => req<{ status: string }>('POST', '/api/auth/2fa/verify', { pre_auth_token: preAuthToken, code, trust_device: trustDevice }), logout: () => req<{ status: string }>('POST', '/api/auth/logout'), + forgetDevice: () => req<{ status: string }>('POST', '/api/auth/2fa/forget-device'), setup2FA: () => req('POST', '/api/auth/2fa/setup'), enable2FA: (code: string) => req<{ status: string }>('POST', '/api/auth/2fa/enable', { code }), disable2FA: (password: string, code: string) => diff --git a/web/src/pages/Login.css b/web/src/pages/Login.css index 201a8b0..4925622 100644 --- a/web/src/pages/Login.css +++ b/web/src/pages/Login.css @@ -206,13 +206,14 @@ } .login-input-2fa input.input-totp { width:100%; - height:52px; - padding:0 16px; + height:56px; + padding:2px 16px 12px; text-align:center; font-family:var(--mono); font-size:24px; font-weight:750; letter-spacing:.32em; + text-indent:.32em; color:#5eead4; background:#080c11; border:1.5px solid rgba(45,212,191,.35); @@ -273,25 +274,31 @@ .totp-indicator-row { display:flex; align-items:center; - justify-content:space-between; + justify-content:center; margin-top:8px; padding:0 4px; } .totp-slots { + position:absolute; + bottom:9px; + left:50%; + transform:translateX(-50%); display:flex; align-items:center; - gap:6px; + gap:10px; + pointer-events:none; + z-index:2; } .totp-slot { - width:9px; - height:4px; + width:12px; + height:3px; border-radius:999px; - background:rgba(56,68,86,.6); + background:rgba(56,68,86,.65); transition:all .2s cubic-bezier(.16,1,.3,1); } .totp-slot.active { - background:rgba(94,234,212,.4); - transform:scaleX(1.2); + background:rgba(94,234,212,.5); + box-shadow:0 0 6px rgba(94,234,212,.4); } .totp-slot.filled { background:#2dd4bf; diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 1fc5419..c3bcefd 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { useNavigate, useLocation } from 'react-router-dom' +import { useNavigate, useLocation, useSearchParams } from 'react-router-dom' import { api } from '../api' import './Login.css' @@ -42,7 +42,10 @@ export default function Login() { const [serverHealthy, setServerHealthy] = useState(null) const [error, setError] = useState('') const [loading, setLoading] = useState(false) - const [step, setStep] = useState<'credentials' | '2fa'>('credentials') + const [searchParams] = useSearchParams() + const [step, setStep] = useState<'credentials' | '2fa'>( + searchParams.get('step') === '2fa' ? '2fa' : 'credentials' + ) const [preAuthToken, setPreAuthToken] = useState('') const [twoFactorCode, setTwoFactorCode] = useState('') const [useRecovery, setUseRecovery] = useState(false) @@ -332,10 +335,8 @@ export default function Login() { )} - - {!useRecovery && ( - + + {twoFactorEnabled && ( +
+ + + {deviceTrusted ? 'This browser is remembered (2FA code skipped for 30 days).' : 'This browser is not remembered (2FA code required every login).'} + +
+ )} + + {deviceMsg && ( +
+ + {deviceMsg} +
+ )}