🆕 Token login added

Backend (AuthHttpHandler):
- New users_remember_tokens table stores sha256 hex of the raw token
  so the DB never holds a usable credential. Seed file adds the table
  and a login.remember.duration.days setting (default 30).
- /api/auth/login accepts "remember": true. On success, issues a fresh
  32-byte base64url token, stores the hash, returns the raw token.
- New POST /api/auth/remember: accepts the raw token, looks up by hash,
  on a valid hit mints a fresh SSO ticket, rotates the token (deletes
  the consumed one and issues a new one), returns both to the client.
  No Turnstile - it's an automated trusted-device flow.
- /api/auth/logout also accepts rememberToken and deletes that single
  row so other devices keep their tokens.

Frontend:
- LoginView: "Remember me" checkbox (key login.remember_me already in
  ExternalTexts). Enabling it persists the returned rememberToken in
  localStorage.nitro.remember.token.
- App.tsx: before deciding to show the login screen, try a silent POST
  to /api/auth/remember with the stored token. On 200, inject the
  returned ssoTicket into window.NitroConfig and proceed to the
  authenticated flow; on 401, forget the token and show login.
- PurseView logout: sends the stored rememberToken in the body so the
  server can delete it, and clears localStorage before reload.
This commit is contained in:
duckietm
2026-04-23 10:16:32 +02:00
parent 2ff37c22d2
commit 969f4a07d2
5 changed files with 96 additions and 7 deletions
+15 -1
View File
@@ -64,6 +64,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
const [ mode, setMode ] = useState<DialogMode>('login');
const [ username, setUsername ] = useState('');
const [ password, setPassword ] = useState('');
const [ rememberMe, setRememberMe ] = useState(false);
const [ error, setError ] = useState<string | null>(null);
const [ info, setInfo ] = useState<string | null>(null);
const [ submitting, setSubmitting ] = useState(false);
@@ -255,6 +256,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
const { ok, payload } = await postJson(loginUrl, {
username: username.trim(),
password,
remember: rememberMe,
turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined
});
@@ -262,6 +264,14 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
if(ok && ssoTicket)
{
try
{
const rememberToken = typeof payload.rememberToken === 'string' ? payload.rememberToken : '';
if(rememberMe && rememberToken) window.localStorage.setItem('nitro.remember.token', rememberToken);
else window.localStorage.removeItem('nitro.remember.token');
}
catch { /* localStorage may be disabled in private mode */ }
clearLock();
onAuthenticated(ssoTicket);
return;
@@ -282,7 +292,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
{
setSubmitting(false);
}
}, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]);
}, [ submitting, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]);
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
@@ -472,6 +482,10 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
onChange={ e => setPassword(e.target.value) }
/>
</div>
<label className="remember-me">
<input type="checkbox" checked={ rememberMe } onChange={ e => setRememberMe(e.target.checked) } />
<span>{ t('login.remember_me', 'Remember me') }</span>
</label>
{ turnstileEnabled && mode === 'login' &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
+5 -1
View File
@@ -64,6 +64,9 @@ export const PurseView: FC<{}> = props => {
const logoutUrl = GetConfigurationValue<string>('login.logout.endpoint', '/api/auth/logout');
const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? '';
let rememberToken = '';
try { rememberToken = window.localStorage.getItem('nitro.remember.token') ?? ''; }
catch { /* localStorage may be disabled */ }
try
{
@@ -76,11 +79,12 @@ export const PurseView: FC<{}> = props => {
'Accept': 'application/json',
'X-Requested-With': 'NitroPurseLogout'
},
body: JSON.stringify({ ssoTicket })
body: JSON.stringify({ ssoTicket, rememberToken })
});
}
catch { /* best-effort — proceed with local logout regardless */ }
try { window.localStorage.removeItem('nitro.remember.token'); } catch { /* noop */ }
if(window.NitroConfig) window.NitroConfig['sso.ticket'] = '';
window.location.reload();
}, []);