Feat/sso #3
No files matched your search
@@ -32,6 +32,7 @@ export async function createSession(
|
||||
userId: string,
|
||||
ip: string | null,
|
||||
userAgent: string | null,
|
||||
method: 'password' | 'ldap' | 'oidc' = 'password',
|
||||
): Promise<string> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await db.insert(sessions).values({
|
||||
@@ -40,6 +41,7 @@ export async function createSession(
|
||||
expiresAt: new Date(Date.now() + LIFETIME),
|
||||
ip,
|
||||
userAgent: userAgent?.slice(0, 500) ?? null,
|
||||
method,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TYPE "public"."identity_kind" AS ENUM('oidc', 'ldap');--> statement-breakpoint
|
||||
CREATE TABLE "user_identities" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"kind" "identity_kind" NOT NULL,
|
||||
"subject" text NOT NULL,
|
||||
"email" text,
|
||||
"groups" text[] DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
CONSTRAINT "user_identities_kind_subject_unique" UNIQUE("kind","subject"),
|
||||
CONSTRAINT "user_identities_userId_kind_unique" UNIQUE("user_id","kind")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "method" text DEFAULT 'password' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_refresh_enc" text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_id_token_enc" text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_checked_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_method_check" CHECK ("sessions"."method" in ('password', 'ldap', 'oidc'));
|
||||
File diff suppressed because it is too large.
Load diff
@@ -85,6 +85,13 @@
|
||||
"when": 1792788393381,
|
||||
"tag": "0011_contrack_call_log",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1792951905226,
|
||||
"tag": "0012_sso",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
@@ -53,7 +54,8 @@ export const users = pgTable('users', {
|
||||
id: id(),
|
||||
email: citext().notNull().unique(),
|
||||
displayName: text().notNull(),
|
||||
passwordHash: text().notNull(),
|
||||
/** argon2id; null: no password, signs in with OpenID only (12 §3.3). */
|
||||
passwordHash: text(),
|
||||
role: userRole().notNull().default('user'),
|
||||
isActive: boolean().notNull().default(true),
|
||||
totpSecretEnc: text(),
|
||||
@@ -83,9 +85,40 @@ export const sessions = pgTable(
|
||||
expiresAt: tstz().notNull(),
|
||||
ip: inet(),
|
||||
userAgent: text(),
|
||||
/** How it was made (12 §2): password, ldap or oidc. */
|
||||
method: text().notNull().default('password'),
|
||||
/** OpenID sessions (12 §3.5): refresh token for the leaver check, ID token for signing out. */
|
||||
idpRefreshEnc: text(),
|
||||
idpIdTokenEnc: text(),
|
||||
idpCheckedAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [index().on(t.userId)],
|
||||
(t) => [
|
||||
index().on(t.userId),
|
||||
check('sessions_method_check', sql`${t.method} in ('password', 'ldap', 'oidc')`),
|
||||
],
|
||||
);
|
||||
|
||||
// One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
|
||||
export const identityKind = pgEnum('identity_kind', ['oidc', 'ldap']);
|
||||
export const userIdentities = pgTable(
|
||||
'user_identities',
|
||||
{
|
||||
id: id(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
kind: identityKind().notNull(),
|
||||
/** oidc: "<iss> <sub>"; ldap: the entry's UUID attribute. */
|
||||
subject: text().notNull(),
|
||||
/** As the provider last said. */
|
||||
email: text(),
|
||||
/** oidc: groupsClaim values at the last sign-in (12 §3.4). */
|
||||
groups: emptyTextArray(),
|
||||
createdAt: createdAt(),
|
||||
lastUsedAt: tstz(),
|
||||
},
|
||||
(t) => [unique().on(t.kind, t.subject), unique().on(t.userId, t.kind)],
|
||||
);
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
|
||||
+50
-13
@@ -8,7 +8,9 @@ import {
|
||||
PasswordConfirmSchema,
|
||||
TotpEnableSchema,
|
||||
APP_NAME,
|
||||
type AuthMethods,
|
||||
type AuthUser,
|
||||
type SignInWays,
|
||||
type TotpSetup,
|
||||
type TotpStatus,
|
||||
} from '@selfphoneng/shared';
|
||||
@@ -22,7 +24,8 @@ import {
|
||||
otpauthUrl,
|
||||
} from '../auth/totp.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, users } from '../db/schema.ts';
|
||||
import { extensions, userIdentities, users } from '../db/schema.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import {
|
||||
unauthorized,
|
||||
rateLimited,
|
||||
@@ -49,11 +52,28 @@ import {
|
||||
setSessionCookie,
|
||||
} from '../auth/sessions.ts';
|
||||
|
||||
/** 12 §2: the user's password and connected accounts, from their user_identities rows. */
|
||||
export const signInWays = (
|
||||
u: { passwordHash: string | null },
|
||||
ids: { kind: 'oidc' | 'ldap'; email: string | null }[],
|
||||
): SignInWays => {
|
||||
const oidc = ids.find((i) => i.kind === 'oidc');
|
||||
return {
|
||||
password: u.passwordHash !== null,
|
||||
oidc: oidc ? { email: oidc.email } : null,
|
||||
ldap: ids.some((i) => i.kind === 'ldap'),
|
||||
};
|
||||
};
|
||||
|
||||
export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
|
||||
const [ext] = await db
|
||||
.select({ id: extensions.id, number: extensions.number, name: extensions.name })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.userId, u.id));
|
||||
const ids = await db
|
||||
.select({ kind: userIdentities.kind, email: userIdentities.email })
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, u.id));
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
@@ -61,6 +81,7 @@ export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
|
||||
role: u.role,
|
||||
mustChangePassword: u.mustChangePassword,
|
||||
extension: ext ?? null,
|
||||
signIn: signInWays(u, ids),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,7 +139,8 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
const u = await myRow(req.user!.id);
|
||||
if (u.totpSecretEnc) throw conflict('Two-step sign-in is already on');
|
||||
// Like turning it off: a stolen session alone can't lock the owner out with its own secret.
|
||||
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
|
||||
if (!u.passwordHash || !(await verifyPassword(u.passwordHash, password)))
|
||||
throw badRequest('Wrong password');
|
||||
const step = checkTotp(secret, code, null);
|
||||
if (step === null)
|
||||
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
|
||||
@@ -142,7 +164,8 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
async function confirmPassword(id: string, body: unknown) {
|
||||
const { password } = PasswordConfirmSchema.parse(body);
|
||||
const u = await myRow(id);
|
||||
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
|
||||
if (!u.passwordHash || !(await verifyPassword(u.passwordHash, password)))
|
||||
throw badRequest('Wrong password');
|
||||
return u;
|
||||
}
|
||||
|
||||
@@ -179,15 +202,24 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return { recoveryCodes: codes };
|
||||
});
|
||||
|
||||
/** The login page's choices (12 §2). Public: says nothing about any user. */
|
||||
app.get('/api/auth/methods', async (): Promise<AuthMethods> => ({
|
||||
mode: await getSetting(db, 'auth.mode'),
|
||||
ldap: false, // S4
|
||||
oidc: null, // S2
|
||||
}));
|
||||
|
||||
app.post('/api/auth/login', async (req, reply) => {
|
||||
const body = LoginSchema.parse(req.body);
|
||||
if (isLoginBlocked(req.ip, body.email)) throw rateLimited();
|
||||
const [user] = await db.select().from(users).where(eq(users.email, body.email));
|
||||
const ok = user?.isActive
|
||||
? await verifyPassword(user.passwordHash, body.password)
|
||||
: (await burnVerify(body.password), false);
|
||||
const { login, ...body } = LoginSchema.parse(req.body);
|
||||
if (isLoginBlocked(req.ip, login)) throw rateLimited();
|
||||
const [user] = await db.select().from(users).where(eq(users.email, login));
|
||||
// No password (OpenID only, 12 §3.3) fails like a wrong one, after the same work.
|
||||
const ok =
|
||||
user?.isActive && user.passwordHash
|
||||
? await verifyPassword(user.passwordHash, body.password)
|
||||
: (await burnVerify(body.password), false);
|
||||
if (!user || !ok) {
|
||||
recordLoginFailure(req.ip, body.email);
|
||||
recordLoginFailure(req.ip, login);
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
|
||||
await audit(db, {
|
||||
actorUserId: user?.id,
|
||||
@@ -195,16 +227,19 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user?.id,
|
||||
summary: `Failed login for ${body.email}`,
|
||||
summary: `Failed login for ${login}`,
|
||||
});
|
||||
throw unauthorized('Wrong email or password');
|
||||
}
|
||||
// OpenID only (12 §1.1): passwords are for admins, the way in when the provider is down.
|
||||
if (user.role !== 'admin' && (await getSetting(db, 'auth.mode')) === 'oidc')
|
||||
throw forbidden('Use the sign-in button instead', 'password_sign_in_off');
|
||||
// Two-step sign-in (08 §2.4): the password was right; now the code.
|
||||
if (user.totpSecretEnc) {
|
||||
if (!body.totp)
|
||||
throw new HttpError(401, 'totp_required', 'Enter the code from your authenticator app');
|
||||
if (!(await useSecondFactor(user, body.totp))) {
|
||||
recordLoginFailure(req.ip, body.email);
|
||||
recordLoginFailure(req.ip, login);
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed: code');
|
||||
await audit(db, {
|
||||
actorUserId: user.id,
|
||||
@@ -212,7 +247,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
summary: `Failed login for ${body.email} (wrong authenticator or recovery code)`,
|
||||
summary: `Failed login for ${login} (wrong authenticator or recovery code)`,
|
||||
});
|
||||
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
|
||||
}
|
||||
@@ -252,6 +287,8 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/api/auth/change-password', { preHandler: requireAuth }, async (req) => {
|
||||
const body = ChangePasswordSchema.parse(req.body);
|
||||
const user = req.user!;
|
||||
if (!user.passwordHash)
|
||||
throw badRequest('You have no password here. Ask an admin to reset it to get one.');
|
||||
if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
|
||||
throw badRequest('Current password is wrong');
|
||||
if (body.newPassword === body.currentPassword)
|
||||
|
||||
@@ -145,6 +145,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
|
||||
}
|
||||
await checkRefs(db, values);
|
||||
// 12 §1.1: mixed and oidc need OpenID Connect set up first.
|
||||
// S2 makes this "auth.oidc set and its Test passed".
|
||||
if (values['auth.mode'] !== undefined && values['auth.mode'] !== 'password')
|
||||
throw badRequest('Set up OpenID Connect first');
|
||||
for (const key of ['ai.llm', 'ai.stt', 'ai.tts'] as const) {
|
||||
const ep = values[key] as { url: string } | null | undefined;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/users (04 §7.3). Admin only.
|
||||
import { and, asc, count, eq, ne } from 'drizzle-orm';
|
||||
import { and, asc, count, eq, isNotNull, ne } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
@@ -10,17 +10,23 @@ import {
|
||||
type UserDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, users } from '../db/schema.ts';
|
||||
import { extensions, userIdentities, users } from '../db/schema.ts';
|
||||
import { badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { hashPassword } from '../auth/passwords.ts';
|
||||
import { deleteUserSessions } from '../auth/sessions.ts';
|
||||
import { randomAlnum } from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { signInWays } from './auth.ts';
|
||||
|
||||
type UserRow = typeof users.$inferSelect;
|
||||
type IdentityRow = { userId: string; kind: 'oidc' | 'ldap'; email: string | null };
|
||||
|
||||
export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): UserDto => ({
|
||||
export const toUserDto = (
|
||||
u: UserRow,
|
||||
extension: UserDto['extension'] = null,
|
||||
ids: IdentityRow[] = [],
|
||||
): UserDto => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
displayName: u.displayName,
|
||||
@@ -31,8 +37,20 @@ export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): U
|
||||
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
extension,
|
||||
signIn: signInWays(u, ids),
|
||||
});
|
||||
|
||||
/** Connected accounts (12 §2), for one user or all of them. */
|
||||
const identitiesOf = (db: Db, userId?: string): Promise<IdentityRow[]> =>
|
||||
db
|
||||
.select({
|
||||
userId: userIdentities.userId,
|
||||
kind: userIdentities.kind,
|
||||
email: userIdentities.email,
|
||||
})
|
||||
.from(userIdentities)
|
||||
.where(userId ? eq(userIdentities.userId, userId) : undefined);
|
||||
|
||||
const extOf = async (db: Db, userId: string) => {
|
||||
const [e] = await db
|
||||
.select({ id: extensions.id, number: extensions.number })
|
||||
@@ -56,13 +74,23 @@ async function getUser(db: Db, id: string): Promise<UserRow> {
|
||||
return u;
|
||||
}
|
||||
|
||||
/** Throws if removing `id` from the active admins would leave none. */
|
||||
/**
|
||||
* Throws if removing `id` from the active admins would leave none with a password: the way in
|
||||
* when the identity provider is down (12 §1).
|
||||
*/
|
||||
async function assertNotLastAdmin(db: Db, id: string) {
|
||||
const [r] = await db
|
||||
.select({ n: count() })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, 'admin'), eq(users.isActive, true), ne(users.id, id)));
|
||||
if (!r || r.n === 0) throw conflict('This is the last active admin');
|
||||
.where(
|
||||
and(
|
||||
eq(users.role, 'admin'),
|
||||
eq(users.isActive, true),
|
||||
isNotNull(users.passwordHash),
|
||||
ne(users.id, id),
|
||||
),
|
||||
);
|
||||
if (!r || r.n === 0) throw conflict('This is the last active admin with a password');
|
||||
}
|
||||
|
||||
export async function userRoutes(app: FastifyInstance) {
|
||||
@@ -76,9 +104,14 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
.from(users)
|
||||
.leftJoin(extensions, eq(extensions.userId, users.id))
|
||||
.orderBy(asc(users.displayName), asc(users.id));
|
||||
const ids = await identitiesOf(db);
|
||||
return {
|
||||
items: rows.map((r) =>
|
||||
toUserDto(r.u, r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null),
|
||||
toUserDto(
|
||||
r.u,
|
||||
r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null,
|
||||
ids.filter((i) => i.userId === r.u.id),
|
||||
),
|
||||
),
|
||||
nextCursor: null,
|
||||
};
|
||||
@@ -86,7 +119,7 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get('/api/users/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
return toUserDto(await getUser(db, id), await extOf(db, id));
|
||||
return toUserDto(await getUser(db, id), await extOf(db, id), await identitiesOf(db, id));
|
||||
});
|
||||
|
||||
app.post('/api/users', async (req, reply) => {
|
||||
@@ -150,7 +183,7 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
|
||||
},
|
||||
});
|
||||
return toUserDto(after, await extOf(db, id));
|
||||
return toUserDto(after, await extOf(db, id), await identitiesOf(db, id));
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', async (req) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ const EXPECTED: Record<string, Record<string, string>> = {
|
||||
id: 'uuid|NO|gen_random_uuid()',
|
||||
email: 'USER-DEFINED:citext|NO|',
|
||||
display_name: 'text|NO|',
|
||||
password_hash: 'text|NO|',
|
||||
password_hash: 'text|YES|',
|
||||
role: "USER-DEFINED:user_role|NO|'user'::user_role",
|
||||
is_active: 'boolean|NO|true',
|
||||
totp_secret_enc: 'text|YES|',
|
||||
@@ -115,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
|
||||
it('creates all 43 tables', async () => {
|
||||
it('creates all 44 tables', async () => {
|
||||
const r = await db.execute(
|
||||
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(r.rows[0]).toEqual({ n: 43 });
|
||||
expect(r.rows[0]).toEqual({ n: 44 });
|
||||
});
|
||||
|
||||
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S1: ways to sign in, the sign-in mode, and the admin with a password kept as the way in.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { sessions, userIdentities, users } from '../src/db/schema.ts';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let admin: string;
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
beforeEach(() => resetLoginLimiter());
|
||||
|
||||
const post = (url: string, payload: object, cookie?: string) =>
|
||||
t.app.inject({ method: 'POST', url, payload, headers: { ...H, ...(cookie && { cookie }) } });
|
||||
|
||||
it('GET /api/auth/methods is public and offers passwords only', async () => {
|
||||
const r = await t.app.inject({ url: '/api/auth/methods' });
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json()).toEqual({ mode: 'password', ldap: false, oidc: null });
|
||||
});
|
||||
|
||||
it('signs in with `login`, and still with `email`; the session says how', async () => {
|
||||
const r = await post('/api/auth/login', { login: ADMIN.email, password: ADMIN.password });
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().user.signIn).toEqual({ password: true, oidc: null, ldap: false });
|
||||
const rows = await t.db.select({ method: sessions.method }).from(sessions);
|
||||
expect(rows.every((s) => s.method === 'password')).toBe(true);
|
||||
});
|
||||
|
||||
it('a user without a password fails like a wrong password, and has none to change', async () => {
|
||||
const u = await t.addUser('nopw@example.com', 'temporary-pass-1');
|
||||
const cookie = await t.login('nopw@example.com', 'temporary-pass-1');
|
||||
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, u.id));
|
||||
const r = await post('/api/auth/login', { login: 'nopw@example.com', password: 'anything' });
|
||||
expect(r.statusCode).toBe(401);
|
||||
expect(r.json().error.message).toBe('Wrong email or password');
|
||||
const c = await post(
|
||||
'/api/auth/change-password',
|
||||
{ currentPassword: 'x', newPassword: 'a-new-password-1' },
|
||||
cookie,
|
||||
);
|
||||
expect(c.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('users show their connected accounts', async () => {
|
||||
const u = await t.addUser('linked@example.com', 'linked-password-1');
|
||||
await t.db
|
||||
.insert(userIdentities)
|
||||
.values({ userId: u.id, kind: 'oidc', subject: 'https://kc/realms/x abc', email: 'l@kc' });
|
||||
const r = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
|
||||
expect(r.json().signIn).toEqual({ password: true, oidc: { email: 'l@kc' }, ldap: false });
|
||||
});
|
||||
|
||||
it('auth.mode can only be password until OpenID Connect is set up', async () => {
|
||||
const r = t.app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/settings',
|
||||
payload: { 'auth.mode': 'mixed' },
|
||||
headers: { ...H, cookie: admin },
|
||||
});
|
||||
expect((await r).statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('OpenID-only mode refuses passwords, except for admins', async () => {
|
||||
await t.addUser('plain@example.com', 'plain-password-1');
|
||||
await setSetting(t.db, 'auth.mode', 'oidc');
|
||||
try {
|
||||
const r = await post('/api/auth/login', {
|
||||
login: 'plain@example.com',
|
||||
password: 'plain-password-1',
|
||||
});
|
||||
expect(r.statusCode).toBe(403);
|
||||
expect(r.json().error.code).toBe('password_sign_in_off');
|
||||
const a = await post('/api/auth/login', { login: ADMIN.email, password: ADMIN.password });
|
||||
expect(a.statusCode).toBe(200);
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.mode', 'password');
|
||||
}
|
||||
});
|
||||
|
||||
it('the last active admin with a password stays, even with other admins', async () => {
|
||||
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
|
||||
const other = await t.addUser('sso-admin@example.com', 'sso-admin-pass-1', 'admin');
|
||||
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, other.id));
|
||||
const demote = await t.app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/users/${me!.id}`,
|
||||
payload: { role: 'user' },
|
||||
headers: { ...H, cookie: admin },
|
||||
});
|
||||
expect(demote.statusCode).toBe(409);
|
||||
expect(demote.json().error.message).toBe('This is the last active admin with a password');
|
||||
// An admin without a password can go: the one with a password is still there.
|
||||
const del = await t.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/users/${other.id}`,
|
||||
headers: { ...H, cookie: admin },
|
||||
});
|
||||
expect(del.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -149,9 +149,9 @@ async function upload<T>(method: string, url: string, body: Blob): Promise<T> {
|
||||
|
||||
export const authApi = {
|
||||
me: () => api<{ user: AuthUser }>('GET', '/api/auth/me').then((r) => r.user),
|
||||
login: (email: string, password: string, totp?: string) =>
|
||||
login: (login: string, password: string, totp?: string) =>
|
||||
api<{ user: AuthUser }>('POST', '/api/auth/login', {
|
||||
email,
|
||||
login,
|
||||
password,
|
||||
...(totp ? { totp } : {}),
|
||||
}).then((r) => r.user),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import { APP_NAME, LoginSchema } from '@selfphoneng/shared';
|
||||
import { APP_NAME, LoginFields } from '@selfphoneng/shared';
|
||||
import { ApiError, authApi } from '../api/client.ts';
|
||||
import { homeFor } from '../auth/RequireAuth.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
|
||||
@@ -32,13 +32,13 @@ export function LoginPage() {
|
||||
// Two-step sign-in: shown once the server says the password was right and a code is needed.
|
||||
const [needCode, setNeedCode] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const form = useForm({ resolver: zodResolver(LoginSchema.omit({ totp: true })) });
|
||||
const form = useForm({ resolver: zodResolver(LoginFields.omit({ totp: true })) });
|
||||
const { errors, isSubmitting } = form.formState;
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ email, password }) => {
|
||||
const onSubmit = form.handleSubmit(async ({ login, password }) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
const user = await authApi.login(email, password, needCode ? code : undefined);
|
||||
const user = await authApi.login(login, password, needCode ? code : undefined);
|
||||
qc.setQueryData(['me'], user);
|
||||
const next = params.get('next');
|
||||
// Only same-site paths: never redirect to another origin.
|
||||
@@ -58,11 +58,11 @@ export function LoginPage() {
|
||||
<AuthCard title="Sign in">
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<Field label="Email" error={errors.email?.message}>
|
||||
<Field label="Email" error={errors.login?.message}>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('email')}
|
||||
{...form.register('login')}
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
className={inputClass}
|
||||
|
||||
@@ -151,6 +151,11 @@ function UsersTable({
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
// 12 §5: every way this user can sign in.
|
||||
const ways = (u: UserDto) =>
|
||||
[u.signIn.password && 'Password', u.signIn.oidc && 'OpenID', u.signIn.ldap && 'Directory']
|
||||
.filter(Boolean)
|
||||
.join(', ') || '–';
|
||||
const actions = (u: UserDto) => (
|
||||
<span className="flex gap-1">
|
||||
<Button variant="ghost" onClick={() => onEdit(u)} aria-label={`Edit ${u.displayName}`}>
|
||||
@@ -178,7 +183,9 @@ function UsersTable({
|
||||
{badge(u)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-zinc-500">
|
||||
<span>Last login: {fmtDate(u.lastLoginAt)}</span>
|
||||
<span>
|
||||
Last login: {fmtDate(u.lastLoginAt)} · {ways(u)}
|
||||
</span>
|
||||
{actions(u)}
|
||||
</div>
|
||||
</li>
|
||||
@@ -191,6 +198,7 @@ function UsersTable({
|
||||
<th className="py-2 font-medium">Name</th>
|
||||
<th className="py-2 font-medium">Email</th>
|
||||
<th className="py-2 font-medium">Role</th>
|
||||
<th className="py-2 font-medium">Signs in with</th>
|
||||
<th className="py-2 font-medium">Last login</th>
|
||||
<th className="py-2 font-medium">
|
||||
<span className="sr-only">Actions</span>
|
||||
@@ -204,6 +212,7 @@ function UsersTable({
|
||||
<td className="py-2 font-medium">{u.displayName}</td>
|
||||
<td className="py-2">{u.email}</td>
|
||||
<td className="py-2">{badge(u)}</td>
|
||||
<td className="py-2">{ways(u)}</td>
|
||||
<td className="py-2 text-zinc-500">{fmtDate(u.lastLoginAt)}</td>
|
||||
<td className="py-2 text-right">{actions(u)}</td>
|
||||
</tr>
|
||||
|
||||
@@ -69,7 +69,7 @@ create table users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email citext not null unique,
|
||||
display_name text not null,
|
||||
password_hash text not null, -- argon2id
|
||||
password_hash text, -- argon2id; null: OpenID only (12 §3.3)
|
||||
role user_role not null default 'user',
|
||||
is_active boolean not null default true,
|
||||
totp_secret_enc text, -- null = 2FA off (M11)
|
||||
@@ -88,10 +88,30 @@ create table sessions (
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
expires_at timestamptz not null,
|
||||
ip inet,
|
||||
user_agent text
|
||||
user_agent text,
|
||||
method text not null default 'password' -- password | ldap | oidc (12 §2)
|
||||
check (method in ('password', 'ldap', 'oidc')),
|
||||
idp_refresh_enc text, -- oidc: refresh token, encrypted (12 §3.5)
|
||||
idp_id_token_enc text, -- oidc: ID token, encrypted, to sign out there
|
||||
idp_checked_at timestamptz
|
||||
);
|
||||
create index on sessions (user_id);
|
||||
|
||||
-- One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
|
||||
create type identity_kind as enum ('oidc', 'ldap');
|
||||
create table user_identities (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
kind identity_kind not null,
|
||||
subject text not null, -- oidc: "<iss> <sub>"; ldap: the UUID attribute
|
||||
email text, -- as the provider last said
|
||||
groups text[] not null default '{}', -- oidc: groupsClaim values at the last sign-in
|
||||
created_at timestamptz not null default now(),
|
||||
last_used_at timestamptz,
|
||||
unique (kind, subject),
|
||||
unique (user_id, kind)
|
||||
);
|
||||
|
||||
create table api_tokens (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
@@ -608,6 +628,7 @@ with a zod schema per key and a default. Missing rows return the default.
|
||||
| `backup.includeRecordings` | boolean | false | |
|
||||
| `webhooks.allowPrivate` | boolean | false | allow webhook URLs on private/loopback IPs |
|
||||
| `setup.completed` | boolean | false | first-run wizard done |
|
||||
| `auth.mode` | `'password'\|'mixed'\|'oidc'` | `'password'` | sign-in ways offered (12 §1.1) |
|
||||
| `sms.webhookToken` | string | random 32 chars, made on first read | secret part of the voip.ms SMS callback URL |
|
||||
| `sip.restrictPhonesToLocalNets` | boolean | false | only accept phone registrations from `network.localNets` (+ `sip.extraAllowedNets`) |
|
||||
| `sip.extraAllowedNets` | string[] | `[]` | e.g. a VPN subnet |
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
- Login rate limit: 5 failures per IP per 15 min and 10 per email per hour → HTTP 429.
|
||||
Write `login_failed` to the audit log with the IP.
|
||||
- If `must_change_password`, every route except `/api/auth/*` returns 403 `{code:'password_change_required'}`.
|
||||
- Single sign-on (spec 12, as built in S1): the body is `{login, password, totp?}` (`login`: email, or a
|
||||
directory username from S4; `email` is still accepted). A user without a password fails like a wrong
|
||||
one. `auth.mode = 'oidc'` refuses passwords for non-admins (403 `password_sign_in_off`).
|
||||
`GET /api/auth/methods` (public) → `{mode, ldap, oidc}` for the login page. The last active admin
|
||||
**with a password** can't be deleted, disabled or demoted.
|
||||
|
||||
### 2.3 API tokens (integrations)
|
||||
- Header `Authorization: Bearer sp_<40 random base62 chars>`. Stored as sha256.
|
||||
|
||||
+12
-3
@@ -335,8 +335,8 @@ and the role it would get, or the step that failed.
|
||||
### 4.2 Flow (`POST /api/auth/login`)
|
||||
|
||||
The form field becomes **Email or username** when LDAP is on; the body is
|
||||
`{ login, password, totp? }` (`email` renamed to `login`, a string of 1–254; web and API change
|
||||
together, no outside client signs in with a password).
|
||||
`{ login, password, totp? }` (`email` renamed to `login`, a string of 1–254; `email` is still
|
||||
accepted, so older scripts keep working).
|
||||
|
||||
1. Rate limit as today, keyed by IP and `login`.
|
||||
2. A user with email = `login` who has a password, and it's right → that user (as today).
|
||||
@@ -449,12 +449,21 @@ For every active user with a directory account, one connection per run:
|
||||
|
||||
| Task | What | Done when |
|
||||
|---|---|---|
|
||||
| S1 | Decision 0004; migration (§2); `auth.mode` (§1.1) with only `password` selectable; break glass; `GET /api/auth/methods`; the `login` field; Users "Signs in with" | `pnpm lint && typecheck && test` pass; sign-in works as before |
|
||||
| S1 | Decision 0004; migration (§2); `auth.mode` (§1.1) with only `password` selectable; break glass; `GET /api/auth/methods`; the `login` field; Users "Signs in with" | `pnpm lint && typecheck && test` pass; sign-in works as before. Done, see §8.1 |
|
||||
| S2 | OIDC: settings + Test, start/callback, claims (§3.4), §3.3 (connect, disconnect, email match), `mixed` and `oidc` modes, sign out at the provider, login page and My account, fake provider tests | every §6 OIDC case passes against the fake provider |
|
||||
| S3 | OIDC session check (§3.5); Keycloak live test; admin guide page `docs/admin-guide/sign-in.md` with the §3.6 table | the owner connects their Keycloak account, signs in with it or their password, a Keycloak group makes them admin, signing out also signs out of Keycloak, and disabling them in Keycloak ends their Keycloak session here within 20 min |
|
||||
| S4 | LDAP: settings + Test, presets, sign-in flow (§4.2), §4.3, unit + lldap live tests | sign-in by uid and email against lldap over LDAPS; admin by group; no user made for a stranger |
|
||||
| S5 | `directoryCheck` (§4.5), dashboard warnings; Samba AD check | a user removed from the directory is disabled within an hour; AD nested-group admin works |
|
||||
|
||||
### 8.1 As built: S1
|
||||
|
||||
- Migration 0012: `users.password_hash` nullable, `user_identities`, the `sessions` columns (§2).
|
||||
- `auth.mode` exists; `PATCH /api/settings` refuses anything but `password` until S2 adds the OIDC
|
||||
check. `POST /api/auth/login` already refuses non-admin passwords when it's `oidc`.
|
||||
- `signIn` on `UserDto` and `AuthUser`; Users has a **Signs in with** column ("OpenID" until S2
|
||||
gives it the provider's label).
|
||||
- `apps/api/test/sign-in.test.ts`.
|
||||
|
||||
## 9. Decided
|
||||
|
||||
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
|
||||
|
||||
@@ -3,13 +3,21 @@ import { z } from 'zod';
|
||||
|
||||
export const PASSWORD_MIN = 10;
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
email: z.string().trim().min(3, 'Enter your email').max(254),
|
||||
/** The login form's fields: `login` is an email, or a directory username (12 §4.2). */
|
||||
export const LoginFields = z.object({
|
||||
login: z.string().trim().min(1, 'Enter your email').max(254),
|
||||
password: z.string().min(1, 'Enter your password').max(200),
|
||||
/** The authenticator code, or a recovery code ("k7m3q-x9tpw"), when two-step sign-in is on. */
|
||||
totp: z.string().trim().max(20).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/auth/login. `email`, the field's name before 12, is still accepted. */
|
||||
export const LoginSchema = z.preprocess(
|
||||
(v) =>
|
||||
v && typeof v === 'object' && !('login' in v) && 'email' in v ? { ...v, login: v.email } : v,
|
||||
LoginFields,
|
||||
);
|
||||
|
||||
export const ChangePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Enter your current password').max(200),
|
||||
newPassword: z
|
||||
@@ -28,8 +36,22 @@ export type AuthUser = {
|
||||
role: Role;
|
||||
mustChangePassword: boolean;
|
||||
extension: { id: string; number: string; name: string } | null;
|
||||
signIn: SignInWays;
|
||||
};
|
||||
|
||||
/** The ways a user can sign in (12 §2): their password, a connected OpenID account, the directory. */
|
||||
export type SignInWays = {
|
||||
password: boolean;
|
||||
oidc: { email: string | null } | null;
|
||||
ldap: boolean;
|
||||
};
|
||||
|
||||
/** 12 §1.1: which ways the login page offers. */
|
||||
export type AuthMode = 'password' | 'mixed' | 'oidc';
|
||||
|
||||
/** GET /api/auth/methods (public): what the login page shows. */
|
||||
export type AuthMethods = { mode: AuthMode; ldap: boolean; oidc: { label: string } | null };
|
||||
|
||||
/** Two-step sign-in (TOTP, 08 §2.4) for the signed-in user. */
|
||||
export type TotpStatus = { enabled: boolean; recoveryCodesLeft: number };
|
||||
/** Start: the secret to scan (not stored until a code confirms it). */
|
||||
|
||||
@@ -116,6 +116,8 @@ export const SettingsSchemas = {
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
/** Sign-in (12 §1.1): passwords only, OpenID only (admins keep passwords), or both. */
|
||||
'auth.mode': z.enum(['password', 'mixed', 'oidc']).default('password'),
|
||||
// Not in 02 §3's table but used by the generator (03 §3).
|
||||
'system.transportHash': z.string().nullable().default(null),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { z } from 'zod';
|
||||
import type { SignInWays } from './auth.ts';
|
||||
|
||||
export const RoleSchema = z.enum(['admin', 'user']);
|
||||
|
||||
@@ -34,6 +35,7 @@ export type UserDto = {
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
extension: { id: string; number: string } | null;
|
||||
signIn: SignInWays;
|
||||
};
|
||||
|
||||
export type ListResponse<T> = { items: T[]; nextCursor: string | null };
|
||||
Reference in new issue
Block a user