From 969f4a07d27db91e670a6609c99d1e6fc8ebf90f Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 23 Apr 2026 10:16:32 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=95=20Token=20login=20added?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...er-config.json => renderer-config.example} | 5 ++ src/App.tsx | 60 +++++++++++++++++-- src/components/login/LoginView.tsx | 16 ++++- src/components/purse/PurseView.tsx | 6 +- src/css/login/LoginView.css | 16 +++++ 5 files changed, 96 insertions(+), 7 deletions(-) rename public/{renderer-config.json => renderer-config.example} (97%) diff --git a/public/renderer-config.json b/public/renderer-config.example similarity index 97% rename from public/renderer-config.json rename to public/renderer-config.example index 9eda4a8..a959ce2 100644 --- a/public/renderer-config.json +++ b/public/renderer-config.example @@ -45,6 +45,11 @@ "login.register.endpoint": "${api.url}/api/auth/register", "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", "login.logout.endpoint": "${api.url}/api/auth/logout", + "login.health.endpoint": "${api.url}/api/health", + "login.check-email.endpoint": "${api.url}/api/auth/check-email", + "login.check-username.endpoint": "${api.url}/api/auth/check-username", + "login.room_templates.endpoint": "${api.url}/api/auth/room-templates", + "login.remember.endpoint": "${api.url}/api/auth/remember", "login.turnstile.enabled": false, "login.turnstile.sitekey": "", "avatar.mandatory.libraries": [ diff --git a/src/App.tsx b/src/App.tsx index 032ac0c..241209b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,22 +56,72 @@ export const App: FC<{}> = props => { if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); - const ssoTicket = window.NitroConfig['sso.ticket']; + let ssoTicket = window.NitroConfig['sso.ticket']; + let configInitError: unknown = null; if(!ssoTicket || ssoTicket === '') { - let configInitError: unknown = null; try { await GetConfiguration().init(); } catch(e) { configInitError = e; } - const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); - const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; - if(configInitError) { NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError); } + if(!configInitError) + { + let storedRemember: string | null = null; + try { storedRemember = window.localStorage.getItem('nitro.remember.token'); } + catch {} + + if(storedRemember) + { + const rememberUrlTemplate = GetConfiguration().getValue('login.remember.endpoint', '/api/auth/remember'); + const rememberUrl = GetConfiguration().interpolate(rememberUrlTemplate); + try + { + const response = await fetch(rememberUrl, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'NitroRememberMe' + }, + body: JSON.stringify({ rememberToken: storedRemember }) + }); + if(response.ok) + { + const payload = await response.json(); + const ticket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : ''; + if(ticket) + { + window.NitroConfig['sso.ticket'] = ticket; + ssoTicket = ticket; + try + { + if(typeof payload.rememberToken === 'string' && payload.rememberToken.length) + window.localStorage.setItem('nitro.remember.token', payload.rememberToken); + } + catch {} + } + } + else if(response.status === 401) + { + try { window.localStorage.removeItem('nitro.remember.token'); } catch {} + } + } + catch {} + } + } + } + + if(!ssoTicket || ssoTicket === '') + { + const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); + const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + if(loginScreenEnabled) { try { await GetLocalizationManager().init(); } diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 38aca42..352014b 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -64,6 +64,7 @@ export const LoginView: FC = ({ onAuthenticated }) => const [ mode, setMode ] = useState('login'); const [ username, setUsername ] = useState(''); const [ password, setPassword ] = useState(''); + const [ rememberMe, setRememberMe ] = useState(false); const [ error, setError ] = useState(null); const [ info, setInfo ] = useState(null); const [ submitting, setSubmitting ] = useState(false); @@ -255,6 +256,7 @@ export const LoginView: FC = ({ 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 = ({ 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 = ({ 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('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); @@ -472,6 +482,10 @@ export const LoginView: FC = ({ onAuthenticated }) => onChange={ e => setPassword(e.target.value) } /> + { turnstileEnabled && mode === 'login' && = props => { const logoutUrl = GetConfigurationValue('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(); }, []); diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index 7798934..c88081c 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -152,6 +152,22 @@ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(63, 106, 133, 0.3); } +.nitro-login-card .remember-me { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #0a2e45; + user-select: none; + cursor: pointer; + margin: -2px 0 2px 0; +} + +.nitro-login-card .remember-me input[type="checkbox"] { + margin: 0; + cursor: pointer; +} + .nitro-login-card .submit-row { display: flex; justify-content: center;