auth: sign-in ways, sign-in mode, and the admin who keeps a password (S1)
Spec 12 S1: migration 0012 (password_hash nullable, user_identities, sessions.method and the
OpenID token columns), auth.mode (only "password" until S2), GET /api/auth/methods, login body
{login, password} (email still accepted), signIn on users and /api/auth/me, a Signs in with
column on Users. The last active admin with a password can't be deleted, disabled or demoted.
This commit is contained in:
1 parent
ba293f8703
commit
3a9423af1f
19 files changed
+4847
-43
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user