mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
🆕 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:
@@ -45,6 +45,11 @@
|
|||||||
"login.register.endpoint": "${api.url}/api/auth/register",
|
"login.register.endpoint": "${api.url}/api/auth/register",
|
||||||
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
|
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
|
||||||
"login.logout.endpoint": "${api.url}/api/auth/logout",
|
"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.enabled": false,
|
||||||
"login.turnstile.sitekey": "",
|
"login.turnstile.sitekey": "",
|
||||||
"avatar.mandatory.libraries": [
|
"avatar.mandatory.libraries": [
|
||||||
+55
-5
@@ -56,22 +56,72 @@ export const App: FC<{}> = props =>
|
|||||||
{
|
{
|
||||||
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
|
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 === '')
|
if(!ssoTicket || ssoTicket === '')
|
||||||
{
|
{
|
||||||
let configInitError: unknown = null;
|
|
||||||
try { await GetConfiguration().init(); }
|
try { await GetConfiguration().init(); }
|
||||||
catch(e) { configInitError = e; }
|
catch(e) { configInitError = e; }
|
||||||
|
|
||||||
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
|
|
||||||
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
|
|
||||||
|
|
||||||
if(configInitError)
|
if(configInitError)
|
||||||
{
|
{
|
||||||
NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', 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<string>('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<unknown>('login.screen.enabled', false);
|
||||||
|
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
|
||||||
|
|
||||||
if(loginScreenEnabled)
|
if(loginScreenEnabled)
|
||||||
{
|
{
|
||||||
try { await GetLocalizationManager().init(); }
|
try { await GetLocalizationManager().init(); }
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
const [ mode, setMode ] = useState<DialogMode>('login');
|
const [ mode, setMode ] = useState<DialogMode>('login');
|
||||||
const [ username, setUsername ] = useState('');
|
const [ username, setUsername ] = useState('');
|
||||||
const [ password, setPassword ] = useState('');
|
const [ password, setPassword ] = useState('');
|
||||||
|
const [ rememberMe, setRememberMe ] = useState(false);
|
||||||
const [ error, setError ] = useState<string | null>(null);
|
const [ error, setError ] = useState<string | null>(null);
|
||||||
const [ info, setInfo ] = useState<string | null>(null);
|
const [ info, setInfo ] = useState<string | null>(null);
|
||||||
const [ submitting, setSubmitting ] = useState(false);
|
const [ submitting, setSubmitting ] = useState(false);
|
||||||
@@ -255,6 +256,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
const { ok, payload } = await postJson(loginUrl, {
|
const { ok, payload } = await postJson(loginUrl, {
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password,
|
password,
|
||||||
|
remember: rememberMe,
|
||||||
turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined
|
turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -262,6 +264,14 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
|
|
||||||
if(ok && ssoTicket)
|
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();
|
clearLock();
|
||||||
onAuthenticated(ssoTicket);
|
onAuthenticated(ssoTicket);
|
||||||
return;
|
return;
|
||||||
@@ -282,7 +292,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
|||||||
{
|
{
|
||||||
setSubmitting(false);
|
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 checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
|
||||||
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
|
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) }
|
onChange={ e => setPassword(e.target.value) }
|
||||||
/>
|
/>
|
||||||
</div>
|
</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' &&
|
{ turnstileEnabled && mode === 'login' &&
|
||||||
<TurnstileWidget
|
<TurnstileWidget
|
||||||
siteKey={ turnstileSiteKey }
|
siteKey={ turnstileSiteKey }
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const PurseView: FC<{}> = props => {
|
|||||||
|
|
||||||
const logoutUrl = GetConfigurationValue<string>('login.logout.endpoint', '/api/auth/logout');
|
const logoutUrl = GetConfigurationValue<string>('login.logout.endpoint', '/api/auth/logout');
|
||||||
const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? '';
|
const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? '';
|
||||||
|
let rememberToken = '';
|
||||||
|
try { rememberToken = window.localStorage.getItem('nitro.remember.token') ?? ''; }
|
||||||
|
catch { /* localStorage may be disabled */ }
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -76,11 +79,12 @@ export const PurseView: FC<{}> = props => {
|
|||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'X-Requested-With': 'NitroPurseLogout'
|
'X-Requested-With': 'NitroPurseLogout'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ ssoTicket })
|
body: JSON.stringify({ ssoTicket, rememberToken })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch { /* best-effort — proceed with local logout regardless */ }
|
catch { /* best-effort — proceed with local logout regardless */ }
|
||||||
|
|
||||||
|
try { window.localStorage.removeItem('nitro.remember.token'); } catch { /* noop */ }
|
||||||
if(window.NitroConfig) window.NitroConfig['sso.ticket'] = '';
|
if(window.NitroConfig) window.NitroConfig['sso.ticket'] = '';
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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);
|
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 {
|
.nitro-login-card .submit-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user