Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions internal/api/2fa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
6 changes: 6 additions & 0 deletions internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export interface CurrentUser {
username: string
role: Role
two_factor_enabled?: boolean
device_trusted?: boolean
}

export interface User {
Expand Down Expand Up @@ -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<Setup2FAResponse>('POST', '/api/auth/2fa/setup'),
enable2FA: (code: string) => req<{ status: string }>('POST', '/api/auth/2fa/enable', { code }),
disable2FA: (password: string, code: string) =>
Expand Down
25 changes: 16 additions & 9 deletions web/src/pages/Login.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 12 additions & 9 deletions web/src/pages/Login.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -42,7 +42,10 @@ export default function Login() {
const [serverHealthy, setServerHealthy] = useState<boolean | null>(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)
Expand Down Expand Up @@ -332,20 +335,20 @@ export default function Login() {
<Icon name="x" size={13} />
</button>
)}
</div>
{!useRecovery && (
<div className="totp-indicator-row" aria-hidden="true">
<div className="totp-slots">
{!useRecovery && (
<div className="totp-slots" aria-hidden="true">
{[0, 1, 2, 3, 4, 5].map(idx => (
<span
key={idx}
className={`totp-slot ${twoFactorCode.length > idx ? 'filled' : ''} ${twoFactorCode.length === idx ? 'active' : ''}`}
/>
))}
</div>
{twoFactorCode.length === 6 && (
<span className="totp-ready-hint">Ready — Press Enter ↵</span>
)}
)}
</div>
{!useRecovery && twoFactorCode.length === 6 && (
<div className="totp-indicator-row" aria-hidden="true">
<span className="totp-ready-hint">Ready — Press Enter ↵</span>
</div>
)}
</label>
Expand Down
44 changes: 44 additions & 0 deletions web/src/pages/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export default function Profile() {
const { user } = useOutletContext<{ user: CurrentUser | null }>()
const [copied, setCopied] = useState(false)
const [twoFactorEnabled, setTwoFactorEnabled] = useState(user?.two_factor_enabled ?? false)
const [deviceTrusted, setDeviceTrusted] = useState(user?.device_trusted ?? false)
const [forgetLoading, setForgetLoading] = useState(false)
const [deviceMsg, setDeviceMsg] = useState('')

// 2FA Modals state
const [modalType, setModalType] = useState<'setup' | 'disable' | 'regen' | null>(null)
Expand All @@ -64,6 +67,21 @@ export default function Profile() {
const [modalError, setModalError] = useState('')
const [codesCopied, setCodesCopied] = useState(false)

const handleForgetDevice = async () => {
setForgetLoading(true)
setDeviceMsg('')
try {
await api.forgetDevice()
setDeviceTrusted(false)
if (user) user.device_trusted = false
setDeviceMsg('This browser has been forgotten. Two-factor authentication will be required next time you log in.')
} catch (err: unknown) {
setDeviceMsg(err instanceof Error ? err.message : 'Failed to forget browser')
} finally {
setForgetLoading(false)
}
}

if (!user) return null

const role = roleContent[user.role]
Expand Down Expand Up @@ -212,6 +230,16 @@ export default function Profile() {
>
View / Regenerate recovery codes
</button>
<button
type="button"
className="ghost"
onClick={handleForgetDevice}
disabled={forgetLoading}
style={{ padding: '8px 14px' }}
title="Revoke the 30-day trust cookie on this browser so a 2FA code is required on next login"
>
{forgetLoading ? 'Forgetting…' : 'Forget this browser'}
</button>
<button
type="button"
className="danger"
Expand All @@ -223,6 +251,22 @@ export default function Profile() {
</>
)}
</div>

{twoFactorEnabled && (
<div style={{ marginTop: '14px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px', fontSize: '12px', color: '#94a3b8' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: deviceTrusted ? '#2dd4bf' : '#64748b', display: 'inline-block' }} />
{deviceTrusted ? 'This browser is remembered (2FA code skipped for 30 days).' : 'This browser is not remembered (2FA code required every login).'}
</span>
</div>
)}

{deviceMsg && (
<div style={{ marginTop: '12px', padding: '8px 12px', background: 'rgba(45,212,191,.1)', border: '1px solid rgba(45,212,191,.25)', borderRadius: '8px', color: '#5eead4', fontSize: '12.5px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Icon name="check" size={14} />
<span>{deviceMsg}</span>
</div>
)}
</section>

<section className="access-card" aria-labelledby="access-title">
Expand Down
Loading