feat: the password door is gone; One Sign In is the only way in
POST /api/auth/login answers 410 for everyone, administrators included. The sign-in screen never draws an email or a password: it is the provider's button, or a sentence saying sign-in is not configured. The admin CLI no longer resets passwords. The e2e harness mints its sessions inside the container instead of signing in with a password. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
This commit is contained in:
parent
29ff7e435c
commit
3c04d662f6
14 changed files with 102 additions and 211 deletions
|
|
@ -41,9 +41,8 @@ The app runs as an authenticated Express/Postgres service with a browser fronten
|
||||||
|
|
||||||
### Admin And Security
|
### Admin And Security
|
||||||
|
|
||||||
- Sign in with a password or a six-digit code emailed to you — offered side by side, because a code depends on mail arriving and a password does not.
|
- One Sign In (OIDC, Authentik) is the only way in. There is no password sign-in, no registration and no password reset in the app; accounts, invitations and roles (from groups) are managed at the provider.
|
||||||
- Role-based access, TOTP 2FA, OIDC/SSO, email verification, and optional Turnstile. Passwords are argon2id, with bcrypt rows rehashed on their next sign-in.
|
- Role-based access from the provider's groups, audit trail, and optional Turnstile on the routes that still take free text.
|
||||||
- Registration can be open, closed, or invite-only with generated codes. A code can be revoked while live, and deleted only once it is spent.
|
|
||||||
- Admin panel for users, settings, prompts, models, and logs.
|
- Admin panel for users, settings, prompts, models, and logs.
|
||||||
- Audit, API, access, and client-error logs with redaction hardening.
|
- Audit, API, access, and client-error logs with redaction hardening.
|
||||||
- OpenBao secret loading support at container startup.
|
- OpenBao secret loading support at container startup.
|
||||||
|
|
@ -106,7 +105,6 @@ Supported text AI providers are LiteLLM, OpenRouter, AWS Bedrock, and Azure Open
|
||||||
docker exec pediatric-ai-scribe node admin-cli.js list-users
|
docker exec pediatric-ai-scribe node admin-cli.js list-users
|
||||||
docker exec pediatric-ai-scribe node admin-cli.js create-admin admin@example.com password123 "Dr. Admin"
|
docker exec pediatric-ai-scribe node admin-cli.js create-admin admin@example.com password123 "Dr. Admin"
|
||||||
docker exec pediatric-ai-scribe node admin-cli.js make-admin user@example.com
|
docker exec pediatric-ai-scribe node admin-cli.js make-admin user@example.com
|
||||||
docker exec pediatric-ai-scribe node admin-cli.js reset-password user@example.com newpassword
|
|
||||||
docker exec pediatric-ai-scribe node admin-cli.js stats
|
docker exec pediatric-ai-scribe node admin-cli.js stats
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
14
admin-cli.js
14
admin-cli.js
|
|
@ -77,19 +77,6 @@ async function main() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'reset-password': {
|
|
||||||
var email = args[1];
|
|
||||||
var newPass = args[2];
|
|
||||||
if (!email || !newPass) { console.log('Usage: node admin-cli.js reset-password <email> <new-password>'); process.exit(1); }
|
|
||||||
if (newPass.length < 8) { console.log('❌ Password must be 8+ characters'); process.exit(1); }
|
|
||||||
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
|
|
||||||
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
|
|
||||||
var hash = await bcrypt.hash(newPass, 12);
|
|
||||||
await db.run('UPDATE users SET password = ? WHERE id = ?', [hash, user.id]);
|
|
||||||
console.log('✅ Password reset for ' + user.name + ' (' + email + ')');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'create-admin': {
|
case 'create-admin': {
|
||||||
var email = args[1];
|
var email = args[1];
|
||||||
var password = args[2];
|
var password = args[2];
|
||||||
|
|
@ -161,7 +148,6 @@ async function main() {
|
||||||
console.log(' disable-user <email> Disable user account');
|
console.log(' disable-user <email> Disable user account');
|
||||||
console.log(' enable-user <email> Enable user account');
|
console.log(' enable-user <email> Enable user account');
|
||||||
console.log(' delete-user <email> Delete user permanently');
|
console.log(' delete-user <email> Delete user permanently');
|
||||||
console.log(' reset-password <email> <new-password> Reset user password');
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('App Settings:');
|
console.log('App Settings:');
|
||||||
console.log(' stats Show app statistics');
|
console.log(' stats Show app statistics');
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@
|
||||||
The front door is `sso.pedshub.com` (Authentik, `/home/danvics/docker/authentik-pedshub`).
|
The front door is `sso.pedshub.com` (Authentik, `/home/danvics/docker/authentik-pedshub`).
|
||||||
With `oidc.enabled` set, `/register` (always 410), `/forgot-password`,
|
With `oidc.enabled` set, `/register` (always 410), `/forgot-password`,
|
||||||
`/reset-password`, `/change-password` and the 2FA routes answer 403
|
`/reset-password`, `/change-password` and the 2FA routes answer 403
|
||||||
(`requireLocalAuth`); `/api/auth/login` admits administrators only, as the way
|
(`requireLocalAuth`); `/api/auth/login` always answers 410. There is no
|
||||||
back in if the provider is down ("Administrator sign-in" on the sign-in screen).
|
password door. If the provider is down, fix the provider: authentik's own
|
||||||
|
recovery link is minted on the host with
|
||||||
|
`docker exec authentik-pedshub-server ak create_recovery_key 10 akadmin`.
|
||||||
Roles come from the provider's groups (`oidc.admin_groups`, `oidc.moderator_groups`). The OIDC client is `src/routes/oidc.js`:
|
Roles come from the provider's groups (`oidc.admin_groups`, `oidc.moderator_groups`). The OIDC client is `src/routes/oidc.js`:
|
||||||
signed state cookie, PKCE, nonce, `email_verified` required before an existing
|
signed state cookie, PKCE, nonce, `email_verified` required before an existing
|
||||||
local account is linked, `sub` mismatch refused, session row written before the
|
local account is linked, `sub` mismatch refused, session row written before the
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_my_table
|
||||||
|
|
||||||
| File | Mount | Auth | Purpose |
|
| File | Mount | Auth | Purpose |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `auth.js` | `/api/auth` | Public | Register, login, 2FA, email verify, password reset, backup codes |
|
| `auth.js` | `/api/auth` | Public | `/me`, logout, sessions; `/login` and `/register` answer 410; the password and 2FA routes sit behind `requireLocalAuth` |
|
||||||
| `oidc.js` | `/api/auth` | Public | OIDC SSO (Authorization Code + PKCE) |
|
| `oidc.js` | `/api/auth` | Public | OIDC SSO (Authorization Code + PKCE) |
|
||||||
| `sessions.js` | `/api/sessions` | Auth | Active sessions list + revoke |
|
| `sessions.js` | `/api/sessions` | Auth | Active sessions list + revoke |
|
||||||
| `hpi.js` | `/api` | Auth | HPI from encounter or dictation |
|
| `hpi.js` | `/api` | Auth | HPI from encounter or dictation |
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,9 @@ their email and the code that is sent to it — no password. New people are
|
||||||
invited with a sign-up link (`authentik-pedshub/invite.py` on the host mints
|
invited with a sign-up link (`authentik-pedshub/invite.py` on the host mints
|
||||||
one); they enter a name and email, confirm with a code, and land in the
|
one); they enter a name and email, confirm with a code, and land in the
|
||||||
`pedshub-members` group, which is what both PedsHub apps admit. The same
|
`pedshub-members` group, which is what both PedsHub apps admit. The same
|
||||||
account signs into the quiz app at `pedshub.com`. Registration, password reset and the app's own emailed codes are gone; the
|
account signs into the quiz app at `pedshub.com`. Registration, password reset, password sign-in and the app's own emailed
|
||||||
only password door is the administrators' "Administrator sign-in", the way
|
codes are all gone; there is no door but the provider's. A local account with
|
||||||
back in if the provider is down. A local account with the same email is the
|
the same email is the same account.
|
||||||
same account.
|
|
||||||
|
|
||||||
## Text To Speech
|
## Text To Speech
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ const base = require('@playwright/test');
|
||||||
const E2E_BASE = process.env.E2E_AUTH_BASE_URL || 'http://127.0.0.1:3553';
|
const E2E_BASE = process.env.E2E_AUTH_BASE_URL || 'http://127.0.0.1:3553';
|
||||||
|
|
||||||
const TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-user@ped-ai.test';
|
const TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-user@ped-ai.test';
|
||||||
const TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'E2E-testPassword123!';
|
|
||||||
// Seeded with the admin role by e2e/seed.js. Kept as a separate account rather
|
// Seeded with the admin role by e2e/seed.js. Kept as a separate account rather
|
||||||
// than promoting the ordinary user, so a test that asserts something is denied
|
// than promoting the ordinary user, so a test that asserts something is denied
|
||||||
// to a non-admin still has a non-admin to assert it with.
|
// to a non-admin still has a non-admin to assert it with.
|
||||||
|
|
@ -53,28 +52,32 @@ function isAllowedConsoleNoise(text) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth — module-scoped token cache ────────────────────────
|
// ── Auth — module-scoped token cache ────────────────────────
|
||||||
// Keeps one login per account per worker to avoid the 10/15-min login
|
// One session per account per worker. There is no password sign-in to call,
|
||||||
// rate-limiter. Keyed by email, because there is more than one account now and
|
// so the session is minted inside the app container by the seed script (a
|
||||||
// a single slot would have each login evicting the other's token.
|
// signed token and a session row, exactly what the server writes after the
|
||||||
|
// provider has vouched for the person). Keyed by email, because there is more
|
||||||
|
// than one account and a single slot would have each evicting the other.
|
||||||
const _tokenCache = new Map();
|
const _tokenCache = new Map();
|
||||||
async function tokenFor(request, email) {
|
async function tokenFor(request, email) {
|
||||||
if (_tokenCache.has(email)) return _tokenCache.get(email);
|
if (_tokenCache.has(email)) return _tokenCache.get(email);
|
||||||
const r = await request.post(E2E_BASE + '/api/auth/login', {
|
const { execFileSync } = require('child_process');
|
||||||
data: { email, password: TEST_PASSWORD },
|
const path = require('path');
|
||||||
});
|
let token;
|
||||||
if (!r.ok()) {
|
try {
|
||||||
const text = await r.text();
|
token = execFileSync('docker', ['compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.e2e.yml',
|
||||||
|
'exec', '-T', 'pediatric-scribe-e2e', 'node', 'e2e/seed.js', 'token', email],
|
||||||
|
{ cwd: path.resolve(__dirname, '..'), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||||
|
} catch (err) {
|
||||||
// The overwhelmingly likely cause is an unseeded database, and saying so
|
// The overwhelmingly likely cause is an unseeded database, and saying so
|
||||||
// beats leaving someone to work back from a 401.
|
// beats leaving someone to work back from a stack trace.
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`E2E login failed for ${email} (status ${r.status()}): ${text}\n` +
|
`E2E could not mint a session for ${email}: ${(err.stderr || err.message || '').toString().trim()}\n` +
|
||||||
'If the account does not exist, seed it: docker exec pediatric-ai-scribe-e2e node e2e/seed.js'
|
'If the account does not exist, seed it: docker compose -f docker-compose.yml -f docker-compose.e2e.yml exec -T pediatric-scribe-e2e node e2e/seed.js'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const body = await r.json();
|
if (!token) throw new Error('Seed script printed no token for ' + email);
|
||||||
if (!body.token) throw new Error('Login response missing token: ' + JSON.stringify(body));
|
_tokenCache.set(email, token);
|
||||||
_tokenCache.set(email, body.token);
|
return token;
|
||||||
return body.token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAuthToken(request) { return tokenFor(request, TEST_EMAIL); }
|
async function getAuthToken(request) { return tokenFor(request, TEST_EMAIL); }
|
||||||
|
|
@ -204,7 +207,6 @@ module.exports = {
|
||||||
expect,
|
expect,
|
||||||
E2E_BASE,
|
E2E_BASE,
|
||||||
TEST_EMAIL,
|
TEST_EMAIL,
|
||||||
TEST_PASSWORD,
|
|
||||||
ADMIN_EMAIL,
|
ADMIN_EMAIL,
|
||||||
loginAs,
|
loginAs,
|
||||||
getAuthToken,
|
getAuthToken,
|
||||||
|
|
|
||||||
24
e2e/seed.js
24
e2e/seed.js
|
|
@ -41,6 +41,26 @@ var passwords = require('../src/utils/passwords');
|
||||||
var TEST_DOMAIN = '@ped-ai.test';
|
var TEST_DOMAIN = '@ped-ai.test';
|
||||||
var PASSWORD = process.env.E2E_TEST_PASSWORD || 'E2E-testPassword123!';
|
var PASSWORD = process.env.E2E_TEST_PASSWORD || 'E2E-testPassword123!';
|
||||||
|
|
||||||
|
// `node e2e/seed.js token <email>` prints a signed session token for a seeded
|
||||||
|
// account. The suite used to sign in with the password; there is no password
|
||||||
|
// sign-in any more, so the harness mints the session the way the server would
|
||||||
|
// after the provider vouched for the person: a signed JWT and a session row.
|
||||||
|
async function mintToken(email) {
|
||||||
|
email = String(email || '').toLowerCase().trim();
|
||||||
|
if (email.slice(-TEST_DOMAIN.length) !== TEST_DOMAIN) {
|
||||||
|
throw new Error('refusing to mint a token for ' + email + ': only ' + TEST_DOMAIN + ' addresses');
|
||||||
|
}
|
||||||
|
var user = await db.get('SELECT id FROM users WHERE email = ?', [email]);
|
||||||
|
if (!user) throw new Error('no such seeded account: ' + email);
|
||||||
|
var jwt = require('jsonwebtoken');
|
||||||
|
var JWT_SECRET = require('../src/middleware/auth').JWT_SECRET;
|
||||||
|
var sessions = require('../src/utils/sessions');
|
||||||
|
var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
|
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[sessions.generateSessionId(), user.id, sessions.hashToken(token), '127.0.0.1', 'e2e', 'e2e']);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
var ACCOUNTS = [
|
var ACCOUNTS = [
|
||||||
{ email: process.env.E2E_TEST_EMAIL || 'e2e-user' + TEST_DOMAIN, name: 'E2E User', role: 'user' },
|
{ email: process.env.E2E_TEST_EMAIL || 'e2e-user' + TEST_DOMAIN, name: 'E2E User', role: 'user' },
|
||||||
{ email: process.env.E2E_ADMIN_EMAIL || 'e2e-admin' + TEST_DOMAIN, name: 'E2E Admin', role: 'admin' }
|
{ email: process.env.E2E_ADMIN_EMAIL || 'e2e-admin' + TEST_DOMAIN, name: 'E2E Admin', role: 'admin' }
|
||||||
|
|
@ -108,6 +128,10 @@ async function seed(account) {
|
||||||
|
|
||||||
(async function () {
|
(async function () {
|
||||||
try {
|
try {
|
||||||
|
if (process.argv[2] === 'token') {
|
||||||
|
process.stdout.write(await mintToken(process.argv[3]));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
for (var i = 0; i < ACCOUNTS.length; i++) await seed(ACCOUNTS[i]);
|
for (var i = 0; i < ACCOUNTS.length; i++) await seed(ACCOUNTS[i]);
|
||||||
await seedSettings();
|
await seedSettings();
|
||||||
console.log('e2e accounts ready');
|
console.log('e2e accounts ready');
|
||||||
|
|
|
||||||
|
|
@ -11,36 +11,24 @@ test.describe('Unauthenticated auth screen', () => {
|
||||||
|
|
||||||
// Use the base test that doesn't auto-login.
|
// Use the base test that doesn't auto-login.
|
||||||
//
|
//
|
||||||
// Signing in is a stepped flow, not one form: email first, then a choice
|
// There is no password sign-in. The screen is the provider's button when a
|
||||||
// between a password and an emailed code. The password field exists in the
|
// provider is configured, and otherwise a sentence saying sign-in is not
|
||||||
// DOM from the start but stays hidden until that choice is made, so asserting
|
// set up; the email and password fields exist in the DOM for the local
|
||||||
// it visible on the landing screen tests a page that no longer exists.
|
// machinery behind the switch and are never shown.
|
||||||
test('landing asks for the email only, and hides the rest of the flow', async ({ page }) => {
|
test('the sign-in screen never offers an email or a password', async ({ page }) => {
|
||||||
await page.goto(E2E_BASE + '/');
|
await page.goto(E2E_BASE + '/');
|
||||||
await expect(page.locator('#auth-screen')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('#auth-screen')).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.locator('#login-email')).toBeVisible();
|
await expect(page.locator('#login-email')).toBeHidden();
|
||||||
await expect(page.locator('#btn-login-continue')).toBeVisible();
|
await expect(page.locator('#btn-login-continue')).toBeHidden();
|
||||||
// Later steps are present but not yet offered.
|
|
||||||
await expect(page.locator('#login-password')).toBeHidden();
|
await expect(page.locator('#login-password')).toBeHidden();
|
||||||
await expect(page.locator('#btn-local-login')).toBeHidden();
|
await expect(page.locator('#btn-local-login')).toBeHidden();
|
||||||
|
await expect(page.locator('#show-admin-login')).toHaveCount(0);
|
||||||
|
// One of the two things the screen can be.
|
||||||
|
await expect(page.locator('#btn-sso:visible, #signin-unavailable:visible')).toHaveCount(1);
|
||||||
// main app body must be hidden while unauthenticated
|
// main app body must be hidden while unauthenticated
|
||||||
await expect(page.locator('#main-app')).toBeHidden();
|
await expect(page.locator('#main-app')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an email leads straight to the password', async ({ page }) => {
|
|
||||||
await page.goto(E2E_BASE + '/');
|
|
||||||
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
|
||||||
await page.fill('#login-email', 'someone@ped-ai.test');
|
|
||||||
await page.click('#btn-login-continue');
|
|
||||||
|
|
||||||
// The address is fixed once the flow has moved past it; "use a different
|
|
||||||
// email" is how you go back, and it only appears after the first step.
|
|
||||||
await expect(page.locator('#login-email')).toHaveJSProperty('readOnly', true);
|
|
||||||
await expect(page.locator('#login-change-email')).toBeVisible();
|
|
||||||
await expect(page.locator('#login-password')).toBeVisible();
|
|
||||||
await expect(page.locator('#btn-local-login')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('register form DOM is wired correctly if manually unhidden', async ({ page }) => {
|
test('register form DOM is wired correctly if manually unhidden', async ({ page }) => {
|
||||||
await page.goto(E2E_BASE + '/');
|
await page.goto(E2E_BASE + '/');
|
||||||
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@
|
||||||
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
|
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
|
||||||
<hr style="border:none;border-top:1px solid #e5e7eb;position:absolute;top:50%;left:0;right:0;margin:0;">
|
<hr style="border:none;border-top:1px solid #e5e7eb;position:absolute;top:50%;left:0;right:0;margin:0;">
|
||||||
</div>
|
</div>
|
||||||
|
<p id="signin-unavailable" class="hidden" style="display:none;margin:8px 0 0;font-size:14px;color:#6b7280;text-align:center;">Sign-in is not configured on this server. Accounts sign in through One Sign In, which has to be set up first.</p>
|
||||||
<a href="/api/auth/oidc" id="btn-sso" class="btn-auth" style="display:none;text-align:center;text-decoration:none;background:linear-gradient(135deg,#0f172a,#334155);margin-top:0;">
|
<a href="/api/auth/oidc" id="btn-sso" class="btn-auth" style="display:none;text-align:center;text-decoration:none;background:linear-gradient(135deg,#0f172a,#334155);margin-top:0;">
|
||||||
<i class="fas fa-shield-halved"></i> <span id="sso-label">Sign in with SSO</span>
|
<i class="fas fa-shield-halved"></i> <span id="sso-label">Sign in with SSO</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -95,7 +96,6 @@
|
||||||
<div class="auth-links">
|
<div class="auth-links">
|
||||||
<a href="#" id="show-register" style="display:none">Create account</a>
|
<a href="#" id="show-register" style="display:none">Create account</a>
|
||||||
<a href="#" id="show-forgot">Forgot password?</a>
|
<a href="#" id="show-forgot">Forgot password?</a>
|
||||||
<a href="#" id="show-admin-login" style="display:none">Administrator sign-in</a>
|
|
||||||
<a href="#" id="login-change-email" class="hidden" style="display:none;">Use a different email</a>
|
<a href="#" id="login-change-email" class="hidden" style="display:none;">Use a different email</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
no_email: 'Your identity provider did not return an email',
|
no_email: 'Your identity provider did not return an email',
|
||||||
disabled: 'Account disabled',
|
disabled: 'Account disabled',
|
||||||
sso_failed: 'SSO login failed',
|
sso_failed: 'SSO login failed',
|
||||||
sso_disabled: 'SSO is no longer enabled. Use local sign-in.',
|
sso_disabled: 'One Sign In is not enabled on this server.',
|
||||||
account_link_required: 'This local account is unverified. Account recovery and administrator-assisted linking are required before SSO sign-in.',
|
account_link_required: 'This local account is unverified. Account recovery and administrator-assisted linking are required before SSO sign-in.',
|
||||||
email_unverified: 'Your identity provider did not confirm your email is verified. Contact your administrator.',
|
email_unverified: 'Your identity provider did not confirm your email is verified. Contact your administrator.',
|
||||||
sub_mismatch: 'Your SSO identity does not match the linked account. Contact your administrator.'
|
sub_mismatch: 'Your SSO identity does not match the linked account. Contact your administrator.'
|
||||||
|
|
@ -268,32 +268,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
fetch('/api/auth/oidc-status')
|
fetch('/api/auth/oidc-status')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
// The password door is gone from the server, so the email and password
|
||||||
|
// fields are never drawn: with a provider the button is the whole
|
||||||
|
// screen, and without one a sentence says sign-in is not set up.
|
||||||
|
var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, ' +
|
||||||
|
'#btn-login-continue, #login-change-email, #show-register, #show-forgot, #bio-divider');
|
||||||
|
localFields.forEach(function(el) { el.style.display = 'none'; });
|
||||||
|
var ssoDivider = document.getElementById('sso-divider');
|
||||||
|
if (ssoDivider) ssoDivider.style.display = 'none';
|
||||||
if (data.oidcEnabled) {
|
if (data.oidcEnabled) {
|
||||||
var ssoBtn = document.getElementById('btn-sso');
|
var ssoBtn = document.getElementById('btn-sso');
|
||||||
var ssoDivider = document.getElementById('sso-divider');
|
|
||||||
var ssoLabel = document.getElementById('sso-label');
|
var ssoLabel = document.getElementById('sso-label');
|
||||||
if (ssoBtn) ssoBtn.style.display = 'block';
|
if (ssoBtn) ssoBtn.style.display = 'block';
|
||||||
if (ssoDivider) ssoDivider.style.display = 'block';
|
|
||||||
if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel;
|
if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel;
|
||||||
if (data.disableLocalAuth) {
|
} else {
|
||||||
// Hide local login form fields, only show SSO. A small link brings
|
var unavailable = document.getElementById('signin-unavailable');
|
||||||
// them back for an administrator: the password is the way back in
|
if (unavailable) { unavailable.className = unavailable.className.replace('hidden', '').trim(); unavailable.style.display = ''; }
|
||||||
// when the provider is down, and nobody else's door.
|
|
||||||
var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, ' +
|
|
||||||
'#btn-login-continue, #login-change-email, #show-register, #show-forgot');
|
|
||||||
localFields.forEach(function(el) { el.style.display = 'none'; });
|
|
||||||
if (ssoDivider) ssoDivider.style.display = 'none';
|
|
||||||
var adminLink = document.getElementById('show-admin-login');
|
|
||||||
if (adminLink) {
|
|
||||||
adminLink.style.display = '';
|
|
||||||
adminLink.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
document.querySelectorAll('#login-form .form-group, #btn-local-login, #btn-login-continue')
|
|
||||||
.forEach(function(el) { el.style.display = ''; });
|
|
||||||
adminLink.style.display = 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {});
|
.catch(function() {});
|
||||||
|
|
|
||||||
|
|
@ -222,103 +222,13 @@ router.post('/resend-verification', async (req, res) => {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// LOGIN (checks disabled status)
|
// LOGIN (checks disabled status)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
router.post('/login', async (req, res) => {
|
// Closed. Every account signs in through One Sign In. This was the last
|
||||||
try {
|
// password door, kept for administrators as the way back in if the provider
|
||||||
var { email, password, totpCode } = req.body;
|
// was down; the provider's own recovery key is the way back in (see
|
||||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
// docs/authentication.md), and a second credential nobody rotates is a hole,
|
||||||
|
// not a spare key. The address stays so an old client is told plainly.
|
||||||
// No Turnstile on login. The widget could not reliably complete a
|
router.post('/login', function (req, res) {
|
||||||
// challenge inside the Capacitor WebView, which locked mobile users out.
|
res.status(410).json({ error: 'Password sign-in has been removed. Sign in with One Sign In.', code: 'sso_only' });
|
||||||
// Brute-force cover here comes from the 10-per-15-min per-IP rate limit
|
|
||||||
// (server.js), the constant-time bcrypt comparison below, and TOTP 2FA.
|
|
||||||
// Registration and password reset — the endpoints that actually attract
|
|
||||||
// bots — are still gated.
|
|
||||||
|
|
||||||
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
|
||||||
// Enumeration-resistant: always run bcrypt to keep timing constant, and return
|
|
||||||
// the same generic message for unknown-user, wrong-password, disabled, unverified.
|
|
||||||
var DUMMY_HASH = '$2b$12$CwTycUXWue0Thq9StjUM0uJ8.aDLA18dY6nB5xuWz5M6l8lR6rYS.';
|
|
||||||
if (!user) {
|
|
||||||
// Server-side only — no email in the message, so operators can watch
|
|
||||||
// lookup-miss rates in Grafana without leaking which addresses exist.
|
|
||||||
console.warn('[Auth] login: user not found (ip=' + req.ip + ')');
|
|
||||||
await bcrypt.compare(password, DUMMY_HASH).catch(function(){});
|
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
|
|
||||||
var valid;
|
|
||||||
try {
|
|
||||||
valid = await passwords.verify(password, user.password);
|
|
||||||
} catch (verifyErr) {
|
|
||||||
console.error('[Auth] passwords.verify threw:', verifyErr.message);
|
|
||||||
return res.status(500).json({ error: 'Request failed' });
|
|
||||||
}
|
|
||||||
if (!valid) {
|
|
||||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){});
|
|
||||||
logger.access(user.id, 'login_failed', req, false);
|
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transparent migration: bcrypt → argon2id on next successful login.
|
|
||||||
passwords.maybeRehash(password, user.password).then(function(newHash) {
|
|
||||||
if (newHash) db.run('UPDATE users SET password = ? WHERE id = ?', [newHash, user.id]).catch(function(){});
|
|
||||||
}).catch(function(){});
|
|
||||||
|
|
||||||
if (user.disabled) {
|
|
||||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
|
||||||
[user.id, 'login_blocked', req.ip, 'Account disabled']).catch(function(){});
|
|
||||||
logger.access(user.id, 'login_blocked', req, false);
|
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.email_verified) {
|
|
||||||
return res.status(403).json({ error: 'Email not verified', needsVerification: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.totp_enabled) {
|
|
||||||
if (!totpCode) return res.json({ requires2FA: true });
|
|
||||||
var totpInput = String(totpCode).trim();
|
|
||||||
var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpInput, window: 1 });
|
|
||||||
if (!verified) {
|
|
||||||
// Backup-code fallback. Backup codes are 10-char alphanumeric and
|
|
||||||
// each hash is bcrypt — consumed on use.
|
|
||||||
var consumed = await tryConsumeBackupCode(user.id, totpInput);
|
|
||||||
if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' });
|
|
||||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
|
||||||
[user.id, '2fa_backup_code_used', req.ip, 'Backup code consumed at login']).catch(function(){});
|
|
||||||
logger.audit(user.id, '2fa_backup_code_used', 'Backup code consumed at login', req, { category: 'auth' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var token = signAuthToken(user.id, req);
|
|
||||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]);
|
|
||||||
logger.access(user.id, 'login', req, true);
|
|
||||||
|
|
||||||
// Create session record
|
|
||||||
// Everyone signs in through One Sign In. A password is the administrators'
|
|
||||||
// way back in when the provider is unreachable, nothing more; anyone else
|
|
||||||
// who still holds one is sent to the front door. Checked after the
|
|
||||||
// credential so this route does not say which addresses hold a password.
|
|
||||||
var ssoOnly;
|
|
||||||
try { ssoOnly = await isSSOOnly(); } catch (e) {
|
|
||||||
return res.status(503).json({ error: 'Authentication policy unavailable' });
|
|
||||||
}
|
|
||||||
if (ssoOnly && user.role !== 'admin') {
|
|
||||||
return res.status(403).json({ error: 'Sign in with One Sign In. Password sign-in is kept for administrators.', code: 'sso_only' });
|
|
||||||
}
|
|
||||||
var sessionId = generateSessionId();
|
|
||||||
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
|
||||||
[sessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
|
||||||
|
|
||||||
// Notify user of new login (fire-and-forget)
|
|
||||||
notifyNewLogin(user.id, parseUserAgent(req.headers['user-agent']), req.ip);
|
|
||||||
|
|
||||||
setAuthCookie(res, token);
|
|
||||||
res.json({
|
|
||||||
success: true, token: token, sessionId: sessionId,
|
|
||||||
user: { id: user.id, email: user.email, name: user.name, role: user.role, totp_enabled: user.totp_enabled, email_verified: user.email_verified }
|
|
||||||
});
|
|
||||||
} catch (err) { console.error('[Auth] Login error:', err.message); res.status(500).json({ error: 'Login failed' }); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 2FA backup codes helpers ──────────────────────────────
|
// ── 2FA backup codes helpers ──────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ var operations = {
|
||||||
},
|
},
|
||||||
// ── Session ─────────────────────────────────────────────────────────
|
// ── Session ─────────────────────────────────────────────────────────
|
||||||
'POST /api/auth/login': {
|
'POST /api/auth/login': {
|
||||||
summary: 'Sign in with a password',
|
summary: 'Closed',
|
||||||
description: 'Returns a token and sets the ped_auth cookie. Rate limited.',
|
description: 'Always 410. Sign in through One Sign In (GET /api/auth/oidc).',
|
||||||
public: true
|
public: true
|
||||||
},
|
},
|
||||||
'POST /api/auth/register': {
|
'POST /api/auth/register': {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
|
|
||||||
// One Sign In is the front door whenever OIDC is configured. There is no
|
// One Sign In is the only door whenever OIDC is configured. There is no
|
||||||
// second switch: the only password door that remains is /api/auth/login for
|
// second switch and no password door: /api/auth/login answers 410, and the
|
||||||
// administrators, the way back in if the provider is down (see auth.js).
|
// local password machinery behind requireLocalAuth is reachable only on a
|
||||||
|
// server with no provider configured.
|
||||||
async function isSSOOnly() {
|
async function isSSOOnly() {
|
||||||
return await db.getSetting('oidc.enabled') === 'true';
|
return await db.getSetting('oidc.enabled') === 'true';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ test('OIDC refuses unsafe linking, disabled/mismatched identities, and session f
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('with One Sign In on, every password door but the administrators\' login is shut; with it off, local auth works; a settings outage answers 503', async t => {
|
test('with One Sign In on, every password door is shut; the login route is closed whatever the switch says', async t => {
|
||||||
const f = fixture(); const request = await f.serve(t, true);
|
const f = fixture(); const request = await f.serve(t, true);
|
||||||
for (const route of ['/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/change-password', '/api/auth/setup-2fa', '/api/auth/verify-2fa', '/api/auth/2fa/backup-codes', '/api/admin/users/7/reset-password']) {
|
for (const route of ['/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/change-password', '/api/auth/setup-2fa', '/api/auth/verify-2fa', '/api/auth/2fa/backup-codes', '/api/admin/users/7/reset-password']) {
|
||||||
const response = await request(route, { method: 'POST', body: {}, authenticated: true });
|
const response = await request(route, { method: 'POST', body: {}, authenticated: true });
|
||||||
|
|
@ -250,31 +250,22 @@ test('with One Sign In on, every password door but the administrators\' login is
|
||||||
assert.equal(f.state.writes.length, 0);
|
assert.equal(f.state.writes.length, 0);
|
||||||
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, true);
|
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, true);
|
||||||
assert.equal((await request('/api/auth/me', { authenticated: true })).data.user.canLocalAuth, false);
|
assert.equal((await request('/api/auth/me', { authenticated: true })).data.user.canLocalAuth, false);
|
||||||
// An administrator's password still opens the door; a learner's does not.
|
// No password opens the door — not an administrator's, not with the switch
|
||||||
const admin = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
// off, not when the settings store is away. Nothing is read, nothing is written.
|
||||||
assert.equal(admin.status, 200); assert.equal(authCookies(admin).length, 1);
|
for (const arrange of [() => {}, () => { f.state.user.role = 'user'; }, () => { f.state.settings['oidc.enabled'] = 'false'; }, () => { f.state.settingsError = true; }]) {
|
||||||
f.state.user.role = 'user';
|
arrange();
|
||||||
const learner = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
const closed = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
||||||
assert.equal(learner.status, 403); assert.equal(learner.data.code, 'sso_only'); assert.equal(authCookies(learner).length, 0);
|
assert.equal(closed.status, 410); assert.equal(closed.data.code, 'sso_only'); assert.equal(authCookies(closed).length, 0);
|
||||||
f.state.user.role = 'admin';
|
}
|
||||||
f.state.settings['oidc.enabled'] = 'false';
|
assert.equal(f.state.writes.filter(w => w.sql.includes('INSERT INTO user_sessions')).length, 0);
|
||||||
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, false);
|
|
||||||
f.state.user.role = 'user';
|
|
||||||
const local = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
|
||||||
assert.equal(local.status, 200); assert.equal(authCookies(local).length, 1);
|
|
||||||
f.state.settingsError = true;
|
|
||||||
const unavailable = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
|
||||||
assert.equal(unavailable.status, 503); assert.equal(authCookies(unavailable).length, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('local login never issues success/cookie on session insert failure, and registration is closed', async t => {
|
test('registration is closed for good', async t => {
|
||||||
const f = fixture(); const request = await f.serve(t); f.state.sessionError = true;
|
const f = fixture(); const request = await f.serve(t);
|
||||||
const login = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
|
// Answers 410 before touching anything.
|
||||||
assert.equal(login.status, 500); assert.equal(authCookies(login).length, 0); assert.equal(login.data.success, undefined);
|
|
||||||
assert.equal(f.state.writes.filter(w => w.sql.includes('INSERT INTO user_sessions')).length, 1);
|
|
||||||
// Local registration is closed for good: it answers 410 before touching anything.
|
|
||||||
const registration = await request('/api/auth/register', { method: 'POST', body: { email: 'new@example.test', password: 'synthetic-password', name: 'Synthetic' } });
|
const registration = await request('/api/auth/register', { method: 'POST', body: { email: 'new@example.test', password: 'synthetic-password', name: 'Synthetic' } });
|
||||||
assert.equal(registration.status, 410); assert.equal(authCookies(registration).length, 0);
|
assert.equal(registration.status, 410); assert.equal(authCookies(registration).length, 0);
|
||||||
|
assert.equal(f.state.writes.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('final model allowlist is enforced for streaming/nonstream, invalid settings/outage, defaults and fallback', async () => {
|
test('final model allowlist is enforced for streaming/nonstream, invalid settings/outage, defaults and fallback', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue