mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
refactor(auth): users table through UsersRepository; JWT and MFA lookups repository-backed
This commit is contained in:
@@ -74,3 +74,28 @@ export function columnIncrementedBy(platform: Platform, ref: string, amount: num
|
||||
}
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* A case-insensitive column reference, for use as a filter's object key:
|
||||
* `{ [lower(platform, 'email')]: value.toLowerCase() }` produces
|
||||
* `WHERE LOWER(email) = ?`. This is the parity-preserving equivalent of the
|
||||
* legacy `LOWER(col) = LOWER(?)` sites (`AuthService`/`OidcService`/
|
||||
* `UserProfileService`'s username/email CI lookups): the caller lowercases
|
||||
* the bound value itself (every one of this migration's call sites already
|
||||
* does, via `String.prototype.toLowerCase`), so the comparison is between
|
||||
* two already-lowercased values either way — SQLite's `LOWER()` is ASCII-only
|
||||
* exactly like `toLowerCase()` is for the identifier characters these
|
||||
* columns hold (usernames/emails), so the two spellings agree on every
|
||||
* input this codebase stores.
|
||||
*
|
||||
* Typed `RawQueryFragment & symbol` (not the bare `RawQueryFragment` the
|
||||
* other helpers in this file return): TypeScript only accepts a
|
||||
* `string | number | symbol` as a computed property name, and a raw
|
||||
* fragment is only usable as an object key because `raw()`'s default
|
||||
* generic already brands it with a `[Symbol.toPrimitive]`. The other
|
||||
* helpers here are only ever used as VALUES, where that brand isn't needed.
|
||||
*/
|
||||
export function lower(platform: Platform, ref: string): RawQueryFragment & symbol {
|
||||
if (platform instanceof SqlitePlatform) return raw(`LOWER(${column(ref)})`);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
@@ -37,12 +37,20 @@ export class InviteTokensRepository extends EntityRepository<InviteTokens> {
|
||||
/**
|
||||
* `SELECT * FROM invite_tokens WHERE token = ?`
|
||||
*
|
||||
* Named `findByToken`, not `findValid` (Task 0 review addendum, LOW item
|
||||
* 1): the statement has no validity filter of its own — no `expires_at`,
|
||||
* no `used_count < max_uses` in the WHERE — it is a plain lookup by the
|
||||
* unique `token` column, and validity (expiry, capacity) is checked in the
|
||||
* caller's JS afterward, identically for `AuthService.registerUser` (AU8)
|
||||
* and `OidcService.findOrCreateUser` (O11). A name promising "valid" would
|
||||
* describe a filter this statement doesn't have.
|
||||
*
|
||||
* Not a primary-key filter — `token` is a unique column, not `id` — so this
|
||||
* always re-queries; no `{ refresh: true }` needed (Task 3a review's
|
||||
* precision ruling: the identity-map short-circuit only fires for a
|
||||
* PK-only filter).
|
||||
*/
|
||||
async findValid(token: string): Promise<InviteTokenRow | null> {
|
||||
async findByToken(token: string): Promise<InviteTokenRow | null> {
|
||||
const invite = await this.findOne({ token });
|
||||
return invite ? (toRow(invite) as InviteTokenRow) : null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Users } from '../entities/Users.entity';
|
||||
import { toRow, type AssertRowKeys } from './_shared/rows';
|
||||
import { columnIncrementedBy, currentTimestamp, lower } from '../dialect/sql-functions';
|
||||
import { EntityRepository } from '@mikro-orm/sql';
|
||||
|
||||
/**
|
||||
@@ -13,6 +15,166 @@ import { EntityRepository } from '@mikro-orm/sql';
|
||||
*/
|
||||
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key' | 'amap_api_key';
|
||||
|
||||
/**
|
||||
* A full `users` row (`SELECT * FROM users WHERE ...`), as the API emits it.
|
||||
* Every scalar column of the entity, hand-nullability-annotated the way
|
||||
* `AppSettingsRepository`/`CategoriesRepository`'s row interfaces are —
|
||||
* `AssertRowKeys` below fails `tsc` the moment this list and the entity's
|
||||
* scalar columns drift.
|
||||
*/
|
||||
export interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
maps_api_key: string | null;
|
||||
unsplash_api_key: string | null;
|
||||
amap_api_key: string | null;
|
||||
openweather_api_key: string | null;
|
||||
avatar: string | null;
|
||||
oidc_sub: string | null;
|
||||
oidc_issuer: string | null;
|
||||
last_login: string | null;
|
||||
mfa_enabled: number | null;
|
||||
mfa_secret: string | null;
|
||||
mfa_backup_codes: string | null;
|
||||
immich_url: string | null;
|
||||
immich_access_token: string | null;
|
||||
synology_url: string | null;
|
||||
synology_username: string | null;
|
||||
synology_password: string | null;
|
||||
synology_sid: string | null;
|
||||
must_change_password: number | null;
|
||||
password_version: number;
|
||||
feed_token: string | null;
|
||||
is_guest: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
immich_api_key: string | null;
|
||||
synology_skip_ssl: number;
|
||||
synology_did: string | null;
|
||||
first_seen_version: string;
|
||||
login_count: number;
|
||||
immich_auto_upload: number;
|
||||
airtrail_url: string | null;
|
||||
airtrail_api_key: string | null;
|
||||
airtrail_allow_insecure_tls: number | null;
|
||||
airtrail_write_enabled: number | null;
|
||||
display_name: string | null;
|
||||
}
|
||||
|
||||
const _userRowKeys: AssertRowKeys<UserRow, Users> = true;
|
||||
|
||||
/** `AuthService.registerUser` (AU10) / `OidcService.findOrCreateUser` (O14) — the two column sets `insertUser` covers as one method (Task 1 brief's Required set: "one method, nullable oidc/avatar fields"). */
|
||||
export interface NewUserRow {
|
||||
username: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
first_seen_version: string;
|
||||
oidc_sub?: string | null;
|
||||
oidc_issuer?: string | null;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
/** `UserProfileService.updateApiKeys` (UP3) — the four encrypted key columns, already resolved by the service. */
|
||||
export interface ApiKeysPatch {
|
||||
maps_api_key: string | null;
|
||||
openweather_api_key: string | null;
|
||||
unsplash_api_key: string | null;
|
||||
amap_api_key: string | null;
|
||||
}
|
||||
|
||||
/** `UserProfileService.updateSettings`'s bounded dynamic SET clause (UP7) — a closed key union, never a built SQL string. */
|
||||
export interface UserProfilePatch {
|
||||
maps_api_key?: string | null;
|
||||
openweather_api_key?: string | null;
|
||||
unsplash_api_key?: string | null;
|
||||
amap_api_key?: string | null;
|
||||
username?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/** `UserProfileService.getApiKeyColumns`/`currentKeys` (UP1). */
|
||||
export interface UserApiKeyColumns {
|
||||
role: string;
|
||||
maps_api_key: string | null;
|
||||
openweather_api_key: string | null;
|
||||
unsplash_api_key: string | null;
|
||||
amap_api_key: string | null;
|
||||
}
|
||||
|
||||
/** `UserProfileService.updateApiKeys`/`updateSettings`'s re-select (UP4/UP8). */
|
||||
export interface UserProfileWithKeys {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
maps_api_key: string | null;
|
||||
openweather_api_key: string | null;
|
||||
unsplash_api_key: string | null;
|
||||
amap_api_key: string | null;
|
||||
avatar: string | null;
|
||||
mfa_enabled: number | null;
|
||||
}
|
||||
|
||||
/** `UserProfileService.saveAvatar`'s re-select (UP11). */
|
||||
export interface UserProfileBasic {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
/** `UserProfileService.listUsers` (UP14). */
|
||||
export interface UserDirectoryRow {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
/** `AuthService.getCurrentUser` (AU14). */
|
||||
export interface CurrentUserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
avatar: string | null;
|
||||
oidc_issuer: string | null;
|
||||
created_at: string | null;
|
||||
mfa_enabled: number | null;
|
||||
must_change_password: number | null;
|
||||
}
|
||||
|
||||
/** `jwt-verify.ts#verifyJwtAndLoadUser` (JV1). */
|
||||
export interface UserWithPasswordVersion {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
password_version: number;
|
||||
}
|
||||
|
||||
/** `AuthService.requestPasswordReset` (AU36). */
|
||||
export interface PasswordResetLookup {
|
||||
id: number;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
oidc_sub: string | null;
|
||||
}
|
||||
|
||||
/** `AuthService.resetPassword`'s reset-target read (AU40). */
|
||||
export interface ResetTargetRow {
|
||||
id: number;
|
||||
email: string;
|
||||
mfa_enabled: number | null;
|
||||
mfa_secret: string | null;
|
||||
mfa_backup_codes: string | null;
|
||||
password_version: number;
|
||||
}
|
||||
|
||||
export class UsersRepository extends EntityRepository<Users> {
|
||||
/**
|
||||
* `SELECT email FROM users WHERE id = ?`
|
||||
@@ -42,4 +204,611 @@ export class UsersRepository extends EntityRepository<Users> {
|
||||
const row = await this.findOne({ id: userId }, { fields: [name], refresh: true });
|
||||
return row ? (row[name] ?? null) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// JV1 — jwt-verify.ts
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `SELECT id, username, email, role, password_version FROM users WHERE id = ?`
|
||||
*
|
||||
* The canonical session-invalidation read: `verifyJwtAndLoadUser` compares
|
||||
* the returned `password_version` against the JWT's `pv` claim on every
|
||||
* request. PK-only `findOne` → `refresh: true` (I1).
|
||||
*/
|
||||
async findByIdWithPasswordVersion(id: number): Promise<UserWithPasswordVersion | null> {
|
||||
const row = await this.findOne(
|
||||
{ id },
|
||||
{ fields: ['id', 'username', 'email', 'role', 'password_version'], refresh: true },
|
||||
);
|
||||
return row ? { id: row.id, username: row.username, email: row.email, role: row.role, password_version: row.password_version } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU2 / TK7 / O2 — password_version (session-invalidation gate)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT password_version FROM users WHERE id = ?`. `?? 0` stays in the caller (AU2/TK7/O2 each fall back differently in spirit, identically in value). */
|
||||
async getPasswordVersion(id: number): Promise<number | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['password_version'], refresh: true });
|
||||
return row?.password_version ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU4 / AU7 / O10 — first-user / admin-role decisions
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0`. `is_guest` is `INTEGER NOT NULL DEFAULT 0` (verified in the baseline + guest-members migrations) — no row can store NULL there, so `{ is_guest: 0 }` is exact parity for the `COALESCE(is_guest, 0) = 0` guard, not an approximation. */
|
||||
async countNonGuest(): Promise<number> {
|
||||
return this.count({ is_guest: 0 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU6 — setup_complete
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1`. */
|
||||
async findAdminNeedingPasswordChange(): Promise<number | null> {
|
||||
const row = await this.findOne({ role: 'admin', must_change_password: 1 }, { fields: ['id'] });
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU9 — registration collision check
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `SELECT id FROM users WHERE (LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?))
|
||||
* AND COALESCE(is_guest, 0) = 0`
|
||||
*/
|
||||
async findIdByEmailOrUsernameCI(email: string, username: string): Promise<number | null> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.findOne(
|
||||
{
|
||||
$or: [{ [lower(platform, 'email')]: email.toLowerCase() }, { [lower(platform, 'username')]: username.toLowerCase() }],
|
||||
is_guest: 0,
|
||||
},
|
||||
{ fields: ['id'] },
|
||||
);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU10 / O14 — account creation
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count)
|
||||
* VALUES (?, ?, ?, ?, ?, 0)` (AU10, local registration) and
|
||||
* `INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, avatar, first_seen_version, login_count)
|
||||
* VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)` (O14, SSO registration) — ONE method
|
||||
* (Task 1 brief's Required set), the `oidc_sub`/`oidc_issuer`/`avatar`
|
||||
* columns nullable so AU10's caller simply omits them. `login_count` is
|
||||
* always the literal `0` on insert, never a parameter, matching both
|
||||
* statements exactly.
|
||||
*
|
||||
* Followed by `refresh` (D4's create rule), not relied-on `RETURNING`:
|
||||
* the returned row carries the generated `id` and the `defaultRaw`
|
||||
* `created_at` the same way a legacy INSERT-then-reselect pair did.
|
||||
*/
|
||||
async insertUser(row: NewUserRow): Promise<UserRow> {
|
||||
const user = this.create({
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
password_hash: row.password_hash,
|
||||
role: row.role,
|
||||
first_seen_version: row.first_seen_version,
|
||||
login_count: 0,
|
||||
oidc_sub: row.oidc_sub ?? null,
|
||||
oidc_issuer: row.oidc_issuer ?? null,
|
||||
avatar: row.avatar ?? null,
|
||||
});
|
||||
await this.getEntityManager().persist(user).flush();
|
||||
await this.getEntityManager().refresh(user);
|
||||
return toRow(user) as UserRow;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU12 / O4 — email lookup (login + OIDC email-fallback)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0` */
|
||||
async findByEmailCI(email: string): Promise<UserRow | null> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const user = await this.findOne({ [lower(platform, 'email')]: email.toLowerCase(), is_guest: 0 });
|
||||
return user ? (toRow(user) as UserRow) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU13 / AU34 / AU35 / PK12 / O16 — login bookkeeping
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1
|
||||
* WHERE id = ?` — ONE statement via `nativeUpdate` with a raw sibling-column
|
||||
* increment (`columnIncrementedBy`), never a read-modify-write: two
|
||||
* concurrent calls both read `login_count` from the row at commit time, not
|
||||
* from a value fetched earlier, so both increments land (proven by
|
||||
* `USERSREPO` `touchLastLogin` concurrency test — two concurrent calls →
|
||||
* `+2`, never `+1`).
|
||||
*/
|
||||
async touchLastLogin(id: number): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate(
|
||||
{ id },
|
||||
{ last_login: currentTimestamp(platform), login_count: columnIncrementedBy(platform, 'login_count', 1) },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU14 — /api/auth/me
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?` */
|
||||
async findMeRow(id: number): Promise<CurrentUserRow | null> {
|
||||
const row = await this.findOne(
|
||||
{ id },
|
||||
{
|
||||
fields: ['id', 'username', 'email', 'role', 'avatar', 'oidc_issuer', 'created_at', 'mfa_enabled', 'must_change_password'],
|
||||
refresh: true,
|
||||
},
|
||||
);
|
||||
return row
|
||||
? {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
avatar: row.avatar ?? null,
|
||||
oidc_issuer: row.oidc_issuer ?? null,
|
||||
created_at: row.created_at ?? null,
|
||||
mfa_enabled: row.mfa_enabled ?? null,
|
||||
must_change_password: row.must_change_password ?? null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU15 — current-password check
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT password_hash, password_version FROM users WHERE id = ?` */
|
||||
async getPasswordHashAndVersion(id: number): Promise<{ password_hash: string; password_version: number } | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['password_hash', 'password_version'], refresh: true });
|
||||
return row ? { password_hash: row.password_hash, password_version: row.password_version } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU16 / AU43 — password change / reset
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `UPDATE users SET password_hash = ?, must_change_password = 0,
|
||||
* password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`
|
||||
* (AU16 `changePassword`, AU43 `resetPassword` — identical statement).
|
||||
*/
|
||||
async setPassword(id: number, passwordHash: string, passwordVersion: number): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate(
|
||||
{ id },
|
||||
{ password_hash: passwordHash, must_change_password: 0, password_version: passwordVersion, updated_at: currentTimestamp(platform) },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU19 / O7 — last-admin guard
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT COUNT(*) as count FROM users WHERE role = 'admin'` */
|
||||
async countAdmins(): Promise<number> {
|
||||
return this.count({ role: 'admin' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU20 / AU22 — admin gate
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT role FROM users WHERE id = ?` */
|
||||
async getRole(id: number): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['role'], refresh: true });
|
||||
return row?.role ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU23 / AU27 / MP2 — MFA state
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `SELECT mfa_enabled FROM users WHERE id = ?`
|
||||
*
|
||||
* Returns the ROW, not the bare column: `mfa-policy.guard.ts` (MP2) needs
|
||||
* to tell "no row for this id" (`null` — the user vanished mid-request,
|
||||
* `MfaPolicyGuard` lets the request through) apart from "row present with
|
||||
* `mfa_enabled` unset" (`{ mfa_enabled: null }` — still governed by the
|
||||
* policy) — the same distinction the legacy `if (!row) return true;`
|
||||
* before `row.mfa_enabled === 1` made. A bare `T | null` return would
|
||||
* collapse both into the same value.
|
||||
*/
|
||||
async getMfaEnabled(id: number): Promise<{ mfa_enabled: number | null } | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['mfa_enabled'], refresh: true });
|
||||
return row ? { mfa_enabled: row.mfa_enabled ?? null } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU28 — enable MFA
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?,
|
||||
* updated_at = CURRENT_TIMESTAMP WHERE id = ?`. `secret`/`codes` arrive
|
||||
* already encrypted/hashed — this repository never touches either.
|
||||
*/
|
||||
async enableMfa(id: number, encryptedSecret: string, backupCodes: string): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate(
|
||||
{ id },
|
||||
{ mfa_enabled: 1, mfa_secret: encryptedSecret, mfa_backup_codes: backupCodes, updated_at: currentTimestamp(platform) },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU30 / AU32 / PK4 / PK10 / O15 — full-row reads
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `SELECT * FROM users WHERE id = ?` — the full row, reused by every
|
||||
* caller that needs more than a handful of columns (AU30 `disableMfa`,
|
||||
* AU32 `verifyMfaLogin`, PK4 `passkeyRegisterOptions`, PK10
|
||||
* `passkeyLoginVerify`, O15 `findOrCreateUser`'s post-transaction re-select).
|
||||
* PK-only `findOne` → `refresh: true` (I1).
|
||||
*/
|
||||
async findById(id: number): Promise<UserRow | null> {
|
||||
const user = await this.findOne({ id }, { refresh: true });
|
||||
return user ? (toRow(user) as UserRow) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU31 — disable MFA
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?` */
|
||||
async disableMfa(id: number): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate(
|
||||
{ id },
|
||||
{ mfa_enabled: 0, mfa_secret: null, mfa_backup_codes: null, updated_at: currentTimestamp(platform) },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU33 / AU44 — backup-code burn
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP
|
||||
* WHERE id = ?` (AU33, `verifyMfaLogin`'s backup-code branch — inside the
|
||||
* same transaction as `touchLastLogin`).
|
||||
*/
|
||||
async setBackupCodesAndTouch(id: number, backupCodes: string): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { mfa_backup_codes: backupCodes, updated_at: currentTimestamp(platform) });
|
||||
}
|
||||
|
||||
/**
|
||||
* `UPDATE users SET mfa_backup_codes = ? WHERE id = ?` (AU44,
|
||||
* `resetPassword`'s conditional backup-code splice — no `updated_at`,
|
||||
* unlike AU33's otherwise-identical statement; two methods, byte-exact
|
||||
* parity per statement).
|
||||
*/
|
||||
async setBackupCodes(id: number, backupCodes: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { mfa_backup_codes: backupCodes });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU36 — password-reset request lookup
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ?
|
||||
* AND COALESCE(is_guest, 0) = 0` — case-SENSITIVE on `email`, unlike
|
||||
* every other email lookup in this repository (no `LOWER()` in the legacy
|
||||
* statement here); kept exactly as written, not "fixed" to match the
|
||||
* others (parity is law).
|
||||
*/
|
||||
async findForPasswordReset(email: string): Promise<PasswordResetLookup | null> {
|
||||
const row = await this.findOne(
|
||||
{ email, is_guest: 0 },
|
||||
{ fields: ['id', 'email', 'password_hash', 'oidc_sub'] },
|
||||
);
|
||||
return row ? { id: row.id, email: row.email, password_hash: row.password_hash, oidc_sub: row.oidc_sub ?? null } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// AU40 — reset-target read
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT id, email, mfa_enabled, mfa_secret, mfa_backup_codes, password_version FROM users WHERE id = ?` */
|
||||
async findResetTarget(id: number): Promise<ResetTargetRow | null> {
|
||||
const row = await this.findOne(
|
||||
{ id },
|
||||
{ fields: ['id', 'email', 'mfa_enabled', 'mfa_secret', 'mfa_backup_codes', 'password_version'], refresh: true },
|
||||
);
|
||||
return row
|
||||
? {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
mfa_enabled: row.mfa_enabled ?? null,
|
||||
mfa_secret: row.mfa_secret ?? null,
|
||||
mfa_backup_codes: row.mfa_backup_codes ?? null,
|
||||
password_version: row.password_version,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PK15 — re-auth before passkey deletion
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT password_hash FROM users WHERE id = ?` */
|
||||
async getPasswordHash(id: number): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['password_hash'], refresh: true });
|
||||
return row?.password_hash ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PK17 — admin passkey-reset target
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT id, email FROM users WHERE id = ?` */
|
||||
async findIdAndEmail(id: number): Promise<{ id: number; email: string } | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['id', 'email'], refresh: true });
|
||||
return row ? { id: row.id, email: row.email } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// O3 — OIDC primary identity match
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?` */
|
||||
async findByOidcIdentity(sub: string, issuer: string): Promise<UserRow | null> {
|
||||
const user = await this.findOne({ oidc_sub: sub, oidc_issuer: issuer });
|
||||
return user ? (toRow(user) as UserRow) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// O5 / O6 — OIDC identity linking
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?` (O5, the
|
||||
* email-matched/unlinked/verified-email branch; O6, the provider-switch
|
||||
* branch — identical statement, two call sites, no `updated_at`).
|
||||
*/
|
||||
async linkOidcIdentity(id: number, sub: string, issuer: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { oidc_sub: sub, oidc_issuer: issuer });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// O8 — claims-based role change
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `UPDATE users SET role = ? WHERE id = ?` — no `updated_at` (legacy statement). */
|
||||
async setRole(id: number, role: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { role });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// O9 — OIDC avatar sync
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `UPDATE users SET avatar = ? WHERE id = ?` — no `updated_at`, unlike
|
||||
* `setAvatar` (UP10/UP13) below, which is the profile-upload path's
|
||||
* otherwise-identical statement. Two methods, byte-exact parity per
|
||||
* statement (same rule as `setBackupCodes`/`setBackupCodesAndTouch`).
|
||||
*/
|
||||
async setAvatarRaw(id: number, avatar: string | null): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { avatar });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// O12 / UP5 — username collision checks
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?
|
||||
* AND COALESCE(is_guest, 0) = 0` (UP5, `updateSettings`'s own-username
|
||||
* collision check).
|
||||
*/
|
||||
async findIdByUsernameCI(username: string, excludeId: number): Promise<number | null> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.findOne(
|
||||
{ [lower(platform, 'username')]: username.toLowerCase(), id: { $ne: excludeId }, is_guest: 0 },
|
||||
{ fields: ['id'] },
|
||||
);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT id FROM users WHERE LOWER(username) = LOWER(?)` (O12, OIDC
|
||||
* derived-username collision avoidance — no `excludeId`, no guest filter:
|
||||
* a genuinely different WHERE than UP5's, so a separate method per D4
|
||||
* rather than an optional parameter that would silently change UP5's
|
||||
* shape.
|
||||
*/
|
||||
async findIdByUsernameCIAny(username: string): Promise<number | null> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.findOne({ [lower(platform, 'username')]: username.toLowerCase() }, { fields: ['id'] });
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP1 — API-key columns read (`currentKeys`)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?` */
|
||||
async getApiKeyColumns(id: number): Promise<UserApiKeyColumns | null> {
|
||||
const row = await this.findOne(
|
||||
{ id },
|
||||
{ fields: ['role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'], refresh: true },
|
||||
);
|
||||
return row
|
||||
? {
|
||||
role: row.role,
|
||||
maps_api_key: row.maps_api_key ?? null,
|
||||
openweather_api_key: row.openweather_api_key ?? null,
|
||||
unsplash_api_key: row.unsplash_api_key ?? null,
|
||||
amap_api_key: row.amap_api_key ?? null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP2 — single-key (maps) update
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` */
|
||||
async updateMapsKey(id: number, encryptedMapsApiKey: string | null): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { maps_api_key: encryptedMapsApiKey, updated_at: currentTimestamp(platform) });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP3 — four-key update
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `UPDATE users SET maps_api_key = ?, openweather_api_key = ?,
|
||||
* unsplash_api_key = ?, amap_api_key = ?, updated_at = CURRENT_TIMESTAMP
|
||||
* WHERE id = ?`. The per-column "encrypt the new value or keep the
|
||||
* current one" branching (a deleted-row race degrading to a 0-row UPDATE,
|
||||
* not a crash) is the service's, per D4 — this writes exactly the four
|
||||
* final values it is given.
|
||||
*/
|
||||
async updateApiKeys(id: number, keys: ApiKeysPatch): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { ...keys, updated_at: currentTimestamp(platform) });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP4 / UP8 — profile-with-keys re-select
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?` */
|
||||
async findProfileWithKeys(id: number): Promise<UserProfileWithKeys | null> {
|
||||
const row = await this.findOne(
|
||||
{ id },
|
||||
{
|
||||
fields: ['id', 'username', 'email', 'role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key', 'avatar', 'mfa_enabled'],
|
||||
refresh: true,
|
||||
},
|
||||
);
|
||||
return row
|
||||
? {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
maps_api_key: row.maps_api_key ?? null,
|
||||
openweather_api_key: row.openweather_api_key ?? null,
|
||||
unsplash_api_key: row.unsplash_api_key ?? null,
|
||||
amap_api_key: row.amap_api_key ?? null,
|
||||
avatar: row.avatar ?? null,
|
||||
mfa_enabled: row.mfa_enabled ?? null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP6 — email collision check
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0` */
|
||||
async findIdByEmailCI(email: string, excludeId: number): Promise<number | null> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.findOne(
|
||||
{ [lower(platform, 'email')]: email.toLowerCase(), id: { $ne: excludeId }, is_guest: 0 },
|
||||
{ fields: ['id'] },
|
||||
);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP7 — bounded dynamic-column profile patch
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `` UPDATE users SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
||||
* WHERE id = ? `` — the SET clause is built at the CALL SITE from a fixed
|
||||
* set of 6 possible literal column names, never here: `patchProfile` takes
|
||||
* only the already-decided final columns as a typed partial and writes
|
||||
* them in ONE `nativeUpdate` (never `assign`+`flush`, which would be a
|
||||
* second statement shape for the identical intent) — the entity-API
|
||||
* equivalent of the legacy's dynamic `SET` list, without ever building SQL
|
||||
* text. `updated_at` is always stamped, matching the legacy: this method's
|
||||
* only caller (`UserProfileService.updateSettings`) calls it only when at
|
||||
* least one field changed (`updates.length > 0`), the same guard the
|
||||
* legacy statement was built under.
|
||||
*/
|
||||
async patchProfile(id: number, changes: UserProfilePatch): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { ...changes, updated_at: currentTimestamp(platform) });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP9 / UP12 — avatar read (before cleanup/replace)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT avatar FROM users WHERE id = ?` */
|
||||
async getAvatar(id: number): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['avatar'], refresh: true });
|
||||
return row?.avatar ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP10 / UP13 — avatar write
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`
|
||||
* (UP10 `saveAvatar` with a filename, UP13 `deleteAvatar` with `NULL` —
|
||||
* same statement shape, `avatar` nullable either way).
|
||||
*/
|
||||
async setAvatar(id: number, avatar: string | null): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { avatar, updated_at: currentTimestamp(platform) });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP11 — post-avatar-write re-select
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT id, username, email, role, avatar FROM users WHERE id = ?` */
|
||||
async findProfileBasic(id: number): Promise<UserProfileBasic | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'avatar'], refresh: true });
|
||||
return row ? { id: row.id, username: row.username, email: row.email, role: row.role, avatar: row.avatar ?? null } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP14 — user directory (trip member / contributor pickers)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT id, username, avatar FROM users WHERE id != ? AND COALESCE(is_guest, 0) = 0 ORDER BY username ASC` */
|
||||
async listOthersNonGuest(excludeId: number): Promise<UserDirectoryRow[]> {
|
||||
const rows = await this.find(
|
||||
{ id: { $ne: excludeId }, is_guest: 0 },
|
||||
{ fields: ['id', 'username', 'avatar'], orderBy: { username: 'asc' } },
|
||||
);
|
||||
return rows.map((row) => ({ id: row.id, username: row.username, avatar: row.avatar ?? null }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// UP15 — key-validation probe
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** `SELECT role, openweather_api_key FROM users WHERE id = ?` */
|
||||
async getRoleAndWeatherKey(id: number): Promise<{ role: string; openweather_api_key: string | null } | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['role', 'openweather_api_key'], refresh: true });
|
||||
return row ? { role: row.role, openweather_api_key: row.openweather_api_key ?? null } : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
import type { WebauthnCredentials } from '../entities/WebauthnCredentials.entity';
|
||||
import { EntityRepository } from '@mikro-orm/sql';
|
||||
|
||||
export class WebauthnCredentialsRepository extends EntityRepository<WebauthnCredentials> {}
|
||||
/**
|
||||
* Plan 3b Task 1 reserves this class (empty stub since Plan 3a) and implements
|
||||
* exactly the one method the AUTH cluster's Task 1 needs NOW: `mfa-policy.guard.ts`
|
||||
* (MP3) and, cross-service, `AuthService.updateAppSettings` (AU24, Task 5) both
|
||||
* read "does this user own at least one passkey" — a passkey satisfies the
|
||||
* `require_mfa` policy the same way TOTP does. Task 3 (`PasskeyService`)
|
||||
* extends this class with the rest of the WebAuthn ceremony state
|
||||
* (`listByUser`, `create`, `findByCredentialId`, `updateCounterAndLastUsed`,
|
||||
* `renameOwned`, `deleteOwned`, `deleteAllForUser`) — this method's name and
|
||||
* shape are chosen so nothing here needs to change when that lands.
|
||||
*/
|
||||
export class WebauthnCredentialsRepository extends EntityRepository<WebauthnCredentials> {
|
||||
/**
|
||||
* `SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1` (MP3/AU24
|
||||
* — identical statement, two callers). `count` with an implicit `LIMIT 1`
|
||||
* via `findOne` mirrors the legacy existence probe without pulling a row;
|
||||
* MikroORM's `count()` has no built-in short-circuit, so `findOne` with a
|
||||
* narrow `fields` selection is the closer match to "does one exist" than
|
||||
* `count() > 0`, which would still scan/aggregate every matching row.
|
||||
*/
|
||||
async hasAny(userId: number): Promise<boolean> {
|
||||
const row = await this.findOne({ user: userId }, { fields: ['id'] });
|
||||
return row !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../mcp/nest-mcp-policy';
|
||||
import mikroOrmConfig from '../mikro-orm.config';
|
||||
import { AppSettings } from '../db/entities/AppSettings.entity';
|
||||
import { Users } from '../db/entities/Users.entity';
|
||||
import { WebauthnCredentials } from '../db/entities/WebauthnCredentials.entity';
|
||||
import { McpModule } from '../nest-mcp';
|
||||
import { AccommodationsModule } from './accommodations/accommodations.module';
|
||||
import { AddonsModule } from './addons/addons.module';
|
||||
@@ -155,6 +158,13 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||
// Not part of DatabaseModule: the e2e suites that compose `DatabaseModule`
|
||||
// without the ORM would fail on an EntityManager-dependent provider there.
|
||||
OrmModule,
|
||||
// MfaPolicyGuard (Plan 3b Task 1) is registered directly below as this
|
||||
// module's own APP_GUARD, never per-controller — so it is the one place
|
||||
// that needs `forFeature` for its three repositories; `Users` is not
|
||||
// otherwise global (JwtAuthGuard and friends inject `EntityManager`
|
||||
// instead precisely to avoid needing this everywhere — see their own
|
||||
// docstrings).
|
||||
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials]),
|
||||
],
|
||||
providers: [
|
||||
// Default-deny: a route is authenticated unless it carries @Public() or
|
||||
|
||||
@@ -1097,6 +1097,6 @@ export class AuthService {
|
||||
* route) should go through.
|
||||
*/
|
||||
async verifyJwtToken(token: string): Promise<User | null> {
|
||||
return verifyJwtAndLoadUser(token);
|
||||
return verifyJwtAndLoadUser(token, this.usersRepo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/core';
|
||||
import type { Request } from 'express';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { verifyJwtAndLoadUser } from './jwt-verify';
|
||||
|
||||
/**
|
||||
@@ -7,16 +9,23 @@ import { verifyJwtAndLoadUser } from './jwt-verify';
|
||||
* trek_session cookie (never a Bearer token), so CSRF-sensitive state-changing
|
||||
* OAuth endpoints (consent submit, client/session mutations) can't be driven by
|
||||
* a leaked Bearer. Error bodies + codes match the legacy 401 shapes exactly.
|
||||
*
|
||||
* `EntityManager` injection, not `@InjectRepository(Users)` — same reasoning
|
||||
* as `JwtAuthGuard` (Plan 3b Task 1 RULING): the guard is instantiated per the
|
||||
* HOST controller's module, and using the always-global `EntityManager`
|
||||
* avoids new `forFeature` wiring in every module that applies it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CookieAuthGuard implements CanActivate {
|
||||
constructor(private readonly em: EntityManager) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest<Request & { cookies?: Record<string, string> }>();
|
||||
const cookieToken = req.cookies?.trek_session;
|
||||
if (!cookieToken) {
|
||||
throw new HttpException({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
const user = await verifyJwtAndLoadUser(cookieToken);
|
||||
const user = await verifyJwtAndLoadUser(cookieToken, this.em.getRepository(Users));
|
||||
if (!user) {
|
||||
throw new HttpException({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { EntityManager } from '@mikro-orm/core';
|
||||
import type { Request } from 'express';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
|
||||
import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator';
|
||||
|
||||
@@ -28,7 +30,14 @@ import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator';
|
||||
*/
|
||||
@Injectable()
|
||||
export class GlobalAuthGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
// EntityManager injection, not @InjectRepository(Users) — same reasoning
|
||||
// as JwtAuthGuard (Plan 3b Task 1 RULING): kept uniform with the other
|
||||
// three verifyJwtAndLoadUser callers even though this guard, registered
|
||||
// only as AppModule's own APP_GUARD, would have tolerated either.
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const handler = context.getHandler();
|
||||
@@ -40,7 +49,7 @@ export class GlobalAuthGuard implements CanActivate {
|
||||
|
||||
if (this.reflector.getAllAndOverride(OPTIONAL_AUTH, [handler, controller])) {
|
||||
const token = extractToken(req);
|
||||
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token) : null) || null;
|
||||
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users)) : null) || null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -57,7 +66,7 @@ export class GlobalAuthGuard implements CanActivate {
|
||||
];
|
||||
if (declared.length > 0) {
|
||||
const declaredToken = extractToken(req);
|
||||
(req as { user: unknown }).user = (declaredToken ? await verifyJwtAndLoadUser(declaredToken) : null) || null;
|
||||
(req as { user: unknown }).user = (declaredToken ? await verifyJwtAndLoadUser(declaredToken, this.em.getRepository(Users)) : null) || null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -65,7 +74,7 @@ export class GlobalAuthGuard implements CanActivate {
|
||||
if (!token) {
|
||||
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
const user = await verifyJwtAndLoadUser(token);
|
||||
const user = await verifyJwtAndLoadUser(token, this.em.getRepository(Users));
|
||||
if (!user) {
|
||||
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/core';
|
||||
import type { Request } from 'express';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
|
||||
|
||||
/**
|
||||
@@ -9,16 +11,37 @@ import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
|
||||
* and the loaded user are IDENTICAL to the Express middleware. No new tokens.
|
||||
*
|
||||
* Error bodies match the legacy 401 shape exactly so the client is unaffected.
|
||||
*
|
||||
* Injects `EntityManager`, not `@InjectRepository(Users)` (Plan 3b Task 1
|
||||
* RULING on `verifyJwtAndLoadUser`'s callers): this guard is applied via
|
||||
* `@UseGuards(JwtAuthGuard)` on ~70 controllers across nearly every domain
|
||||
* module in the app, and Nest resolves a class-referenced guard's
|
||||
* constructor dependencies from the HOST CONTROLLER'S OWN module graph, not
|
||||
* from a single "the guard's module" — `@InjectRepository(Users)` would need
|
||||
* `MikroOrmModule.forFeature([Users])` added to every one of those ~70
|
||||
* modules (and to every partial e2e-testing-module harness that composes a
|
||||
* guarded controller without the full app), which is the exact blast radius
|
||||
* D5 exists to avoid. `EntityManager`/`MikroORM`, by contrast, come from
|
||||
* `MikroOrmModule.forRoot`'s core module, which IS `@Global()` — already
|
||||
* required wherever a JWT-guarded route is exercised for a real user (the
|
||||
* ORM owns `users` everywhere else too) — so no module needs new wiring.
|
||||
* `this.em.getRepository(Users)` inside `canActivate` is the same
|
||||
* `orm.em.getRepository(Users)`-inside-the-request pattern
|
||||
* `platform.routes.ts::servePhoto` uses (Task 0); `this.em` here is already
|
||||
* the request-scoped fork, since `canActivate` only ever runs inside a real
|
||||
* HTTP request Nest's `@mikro-orm/nestjs` middleware has already wrapped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly em: EntityManager) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
const user = await verifyJwtAndLoadUser(token);
|
||||
const user = await verifyJwtAndLoadUser(token, this.em.getRepository(Users));
|
||||
if (!user) {
|
||||
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Request } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from '../../db/database';
|
||||
import { JWT_SECRET } from '../../config';
|
||||
import type { User } from '../../types';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
|
||||
/**
|
||||
* The canonical JWT session check. Every auth surface goes through here — the
|
||||
@@ -15,6 +15,16 @@ import type { User } from '../../types';
|
||||
* would have no way to inject one. Note JWT_SECRET is deliberately a live
|
||||
* binding from src/config, not an app-config value: the admin panel rotates it
|
||||
* at runtime and `export let` is what makes a rotation take effect in-process.
|
||||
*
|
||||
* `verifyJwtAndLoadUser` stays a free function (Plan 3b Task 1 RULING) and
|
||||
* gains an explicit `users: UsersRepository` parameter — DI over a
|
||||
* module-level accessor (CLAUDE.md "DI over global mutable module state").
|
||||
* This file imports neither the ORM nor `RequestContext`: every caller
|
||||
* resolves its own `UsersRepository` (through Nest DI, or — for the one
|
||||
* pre-init caller, `platform.routes.ts::servePhoto` — `orm.em.getRepository(Users)`
|
||||
* resolved INSIDE `applyPlatformUploads`'s `withRequestContext` wrapper,
|
||||
* `orm` threaded in from `bootstrap.ts`) and passes it in. The legacy `db`
|
||||
* proxy import is gone.
|
||||
*/
|
||||
export function extractToken(req: Request): string | null {
|
||||
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
|
||||
@@ -54,16 +64,14 @@ export function decodeSessionClaims(token: string | undefined): SessionClaims |
|
||||
return decoded as SessionClaims;
|
||||
}
|
||||
|
||||
export async function verifyJwtAndLoadUser(token: string): Promise<User | null> {
|
||||
export async function verifyJwtAndLoadUser(token: string, users: UsersRepository): Promise<User | null> {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number; purpose?: string };
|
||||
// Purpose-scoped tokens (e.g. the short-lived mfa_login token) share this
|
||||
// secret but are not full session tokens — only their dedicated endpoint
|
||||
// may accept them, so reject any token carrying a purpose claim here.
|
||||
if (decoded.purpose) return null;
|
||||
const row = db.prepare(
|
||||
'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
|
||||
).get(decoded.id) as (User & { password_version?: number }) | undefined;
|
||||
const row = await users.findByIdWithPasswordVersion(decoded.id);
|
||||
if (!row) return null;
|
||||
// Session invalidation: any token whose embedded password_version
|
||||
// predates the user's current one is rejected. Tokens issued before
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable, SetMetadata } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { DEMO_EMAILS } from '../common/demo';
|
||||
import { IS_PUBLIC } from './public.decorator';
|
||||
import type { User } from '../../types';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity';
|
||||
import type { WebauthnCredentialsRepository } from '../../db/repositories/WebauthnCredentials.repository';
|
||||
|
||||
/** Metadata key `@MfaExempt()` writes. */
|
||||
export const MFA_EXEMPT = 'trek:mfa-exempt';
|
||||
@@ -44,7 +50,9 @@ type MfaRequest = Request & { user?: User };
|
||||
@Injectable()
|
||||
export class MfaPolicyGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Users) private readonly users: UsersRepository,
|
||||
@InjectRepository(WebauthnCredentials) private readonly webauthnCredentials: WebauthnCredentialsRepository,
|
||||
private readonly env: RuntimeEnvService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
@@ -60,24 +68,19 @@ export class MfaPolicyGuard implements CanActivate {
|
||||
// No session: whoever answers next decides, exactly as before.
|
||||
if (!user) return true;
|
||||
|
||||
const requireRow = this.db.get<{ value: string }>(
|
||||
"SELECT value FROM app_settings WHERE key = 'require_mfa'",
|
||||
);
|
||||
if (requireRow?.value !== 'true') return true;
|
||||
const requireMfa = await this.appSettings.getValue('require_mfa');
|
||||
if (requireMfa !== 'true') return true;
|
||||
|
||||
if (this.env.isDemoMode() && user.email && DEMO_EMAILS.has(user.email)) return true;
|
||||
|
||||
const row = this.db.get<{ mfa_enabled: number | boolean }>(
|
||||
'SELECT mfa_enabled FROM users WHERE id = ?',
|
||||
user.id,
|
||||
);
|
||||
const row = await this.users.getMfaEnabled(user.id);
|
||||
if (!row) return true;
|
||||
|
||||
// A user-verified passkey is phishing-resistant and inherently two-factor, so
|
||||
// owning at least one satisfies require_mfa exactly like TOTP does.
|
||||
// (All stored passkeys were registered with userVerification required.)
|
||||
const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
|
||||
const passkeyOk = !!this.db.get('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1', user.id);
|
||||
const mfaOk = row.mfa_enabled === 1;
|
||||
const passkeyOk = await this.webauthnCredentials.hasAny(user.id);
|
||||
if (mfaOk || passkeyOk) return true;
|
||||
|
||||
if (this.reflector.getAllAndOverride(MFA_EXEMPT, [handler, controller])) return true;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/core';
|
||||
import type { Request } from 'express';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
|
||||
|
||||
/**
|
||||
@@ -7,13 +9,18 @@ import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
|
||||
* loaded user when a valid token is present, otherwise leaves it null — and
|
||||
* always allows the request through (never 401). Used for endpoints whose
|
||||
* response varies by auth state but don't require it (e.g. /app-config).
|
||||
*
|
||||
* `EntityManager` injection, not `@InjectRepository(Users)` — same reasoning
|
||||
* as `JwtAuthGuard` (Plan 3b Task 1 RULING).
|
||||
*/
|
||||
@Injectable()
|
||||
export class OptionalJwtGuard implements CanActivate {
|
||||
constructor(private readonly em: EntityManager) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const token = extractToken(req);
|
||||
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token) : null) || null;
|
||||
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users)) : null) || null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { readEnv, getAppUrl } from '../../app-config';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
@@ -16,11 +15,10 @@ import {
|
||||
type InstanceApiKeyName,
|
||||
} from '../settings/instance-api-keys';
|
||||
import { SEARCH_TEXT_FIELD_MASK } from '../maps/maps.helpers';
|
||||
import { User } from '../../types';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import type { UsersRepository, UserApiKeyColumns, UserProfilePatch } from '../../db/repositories/Users.repository';
|
||||
|
||||
/**
|
||||
* The account a user administers about themselves: display settings, avatar,
|
||||
@@ -30,20 +28,19 @@ import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
* Split out of AuthService, which had grown to 1471 lines by owning identity,
|
||||
* profile, settings and tokens together. None of this is identity: no password
|
||||
* is checked here, no session is issued, no MFA secret is touched. Everything
|
||||
* moved verbatim — same SQL, same validation order, same masking, same error
|
||||
* strings and status codes.
|
||||
* moved verbatim — same statement shapes, same validation order, same masking,
|
||||
* same error strings and status codes.
|
||||
*
|
||||
* DatabaseService was the only injected dependency at first, which is what
|
||||
* made this the second-cheapest cut after tokens; `AppSettingsRepository`/
|
||||
* `UsersRepository` (Plan 3a Task 5) were added later purely so the three
|
||||
* `instance-api-keys.ts` calls below pass their own repository rather than
|
||||
* that file resolving one itself — every other read/write here is still
|
||||
* `DatabaseService`'s raw SQL (auth is outside Plan 3a).
|
||||
* Plan 3b Task 1: every read/write here now goes through `UsersRepository`
|
||||
* (`AppSettingsRepository`/`UsersRepository` were already present, Plan 3a
|
||||
* Task 5, purely so `instance-api-keys.ts` could take its own repository
|
||||
* rather than resolving one itself); `DatabaseService` is gone from the
|
||||
* constructor entirely — nothing in this file reads or writes raw SQL any
|
||||
* more.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserProfileService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly uow: UnitOfWork,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@@ -64,12 +61,12 @@ export class UserProfileService {
|
||||
return readEnv().managed.enabled;
|
||||
}
|
||||
|
||||
/** The three key columns plus the role that decides where a save lands. */
|
||||
private async currentKeys(userId: number) {
|
||||
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'>>(
|
||||
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?',
|
||||
userId
|
||||
);
|
||||
/**
|
||||
* The three key columns plus the role that decides where a save lands.
|
||||
* `SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?` (UP1).
|
||||
*/
|
||||
private async currentKeys(userId: number): Promise<UserApiKeyColumns | null> {
|
||||
return this.usersRepo.getApiKeyColumns(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +78,7 @@ export class UserProfileService {
|
||||
*/
|
||||
private async storedKeyPlaintext(
|
||||
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key',
|
||||
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'> | undefined,
|
||||
current: UserApiKeyColumns | null,
|
||||
isAdmin: boolean,
|
||||
): Promise<string> {
|
||||
if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
|
||||
@@ -104,7 +101,7 @@ export class UserProfileService {
|
||||
*/
|
||||
private async changedKeyNames(
|
||||
body: Record<string, unknown>,
|
||||
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'> | undefined,
|
||||
current: UserApiKeyColumns | null,
|
||||
isAdmin: boolean,
|
||||
skipped: string[] = [],
|
||||
): Promise<string[]> {
|
||||
@@ -143,10 +140,7 @@ export class UserProfileService {
|
||||
const isAdmin = current?.role === 'admin';
|
||||
const changedKeys = await this.changedKeyNames({ maps_api_key }, current, isAdmin);
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run(
|
||||
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
maybe_encrypt_api_key(maps_api_key), userId
|
||||
);
|
||||
await this.usersRepo.updateMapsKey(userId, maybe_encrypt_api_key(maps_api_key));
|
||||
await this.mirrorInstanceKeys({ maps_api_key }, isAdmin);
|
||||
});
|
||||
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key), changedKeys };
|
||||
@@ -163,23 +157,18 @@ export class UserProfileService {
|
||||
await this.uow.transactional(async () => {
|
||||
// `?? null` instead of the former non-null assertions: a user row deleted
|
||||
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
|
||||
this.db.run(
|
||||
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, amap_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
|
||||
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
|
||||
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
|
||||
body.amap_api_key !== undefined ? maybe_encrypt_api_key(body.amap_api_key) : current?.amap_api_key ?? null,
|
||||
userId
|
||||
);
|
||||
await this.usersRepo.updateApiKeys(userId, {
|
||||
maps_api_key: body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
|
||||
openweather_api_key: body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
|
||||
unsplash_api_key: body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
|
||||
amap_api_key: body.amap_api_key !== undefined ? maybe_encrypt_api_key(body.amap_api_key) : current?.amap_api_key ?? null,
|
||||
});
|
||||
await this.mirrorInstanceKeys(body, isAdmin);
|
||||
});
|
||||
|
||||
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key' | 'avatar' | 'mfa_enabled'>>(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
|
||||
userId
|
||||
);
|
||||
const updated = await this.usersRepo.findProfileWithKeys(userId);
|
||||
|
||||
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
|
||||
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1) } : undefined;
|
||||
return {
|
||||
success: true,
|
||||
...(blocked.length ? { managed_keys: blocked } : {}),
|
||||
@@ -203,7 +192,7 @@ export class UserProfileService {
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) {
|
||||
return { error: 'Username can only contain letters, numbers, underscores, dots and hyphens', status: 400 };
|
||||
}
|
||||
const conflict = this.db.get('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0', trimmed, userId);
|
||||
const conflict = await this.usersRepo.findIdByUsernameCI(trimmed, userId);
|
||||
if (conflict) return { error: 'Username already taken', status: 409 };
|
||||
}
|
||||
|
||||
@@ -212,24 +201,26 @@ export class UserProfileService {
|
||||
if (!trimmed || !EMAIL_REGEX.test(trimmed)) {
|
||||
return { error: 'Invalid email format', status: 400 };
|
||||
}
|
||||
const conflict = this.db.get('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0', trimmed, userId);
|
||||
const conflict = await this.usersRepo.findIdByEmailCI(trimmed, userId);
|
||||
if (conflict) return { error: 'Email already taken', status: 409 };
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
|
||||
// The name and email half of this body stays the user's own in every mode;
|
||||
// only the three key columns answer to the operator.
|
||||
const { blocked } = splitManagedKeys(body, this.managed);
|
||||
const keyLocked = blocked.length > 0;
|
||||
|
||||
if (maps_api_key !== undefined && !keyLocked) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); }
|
||||
if (openweather_api_key !== undefined && !keyLocked) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); }
|
||||
if (unsplash_api_key !== undefined && !keyLocked) { updates.push('unsplash_api_key = ?'); params.push(maybe_encrypt_api_key(unsplash_api_key)); }
|
||||
if (amap_api_key !== undefined && !keyLocked) { updates.push('amap_api_key = ?'); params.push(maybe_encrypt_api_key(amap_api_key)); }
|
||||
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
|
||||
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
|
||||
// The bounded set of columns UP7's dynamic SET clause could touch — built
|
||||
// here, at the call site, exactly as the legacy `updates`/`params` arrays
|
||||
// were; `UsersRepository.patchProfile` writes this typed partial in ONE
|
||||
// statement, never a built SQL string.
|
||||
const changes: UserProfilePatch = {};
|
||||
if (maps_api_key !== undefined && !keyLocked) changes.maps_api_key = maybe_encrypt_api_key(maps_api_key);
|
||||
if (openweather_api_key !== undefined && !keyLocked) changes.openweather_api_key = maybe_encrypt_api_key(openweather_api_key);
|
||||
if (unsplash_api_key !== undefined && !keyLocked) changes.unsplash_api_key = maybe_encrypt_api_key(unsplash_api_key);
|
||||
if (amap_api_key !== undefined && !keyLocked) changes.amap_api_key = maybe_encrypt_api_key(amap_api_key);
|
||||
if (username !== undefined) changes.username = username.trim();
|
||||
if (email !== undefined) changes.email = email.trim();
|
||||
|
||||
// Read before the write, so the comparison sees the old value; the role in
|
||||
// the same row decides whether the two instance-wide names travel with it.
|
||||
@@ -237,21 +228,16 @@ export class UserProfileService {
|
||||
const isAdmin = current?.role === 'admin';
|
||||
const changedKeys = keyLocked ? [] : await this.changedKeyNames(body, current, isAdmin, blocked);
|
||||
|
||||
if (updates.length > 0) {
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(userId);
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
|
||||
await this.usersRepo.patchProfile(userId, changes);
|
||||
if (!keyLocked) await this.mirrorInstanceKeys(body, isAdmin);
|
||||
});
|
||||
}
|
||||
|
||||
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key' | 'avatar' | 'mfa_enabled'>>(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
|
||||
userId
|
||||
);
|
||||
const updated = await this.usersRepo.findProfileWithKeys(userId);
|
||||
|
||||
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
|
||||
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1) } : undefined;
|
||||
return {
|
||||
success: true,
|
||||
...(blocked.length ? { managed_keys: blocked } : {}),
|
||||
@@ -261,10 +247,7 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
async getSettings(userId: number): Promise<{ error?: string; status?: number; settings?: Record<string, unknown> }> {
|
||||
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'>>(
|
||||
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?',
|
||||
userId
|
||||
);
|
||||
const user = await this.usersRepo.getApiKeyColumns(userId);
|
||||
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
|
||||
|
||||
// The one endpoint in the codebase that hands back a stored key in the
|
||||
@@ -303,29 +286,29 @@ export class UserProfileService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async saveAvatar(userId: number, filename: string) {
|
||||
const current = this.db.get<{ avatar: string | null }>('SELECT avatar FROM users WHERE id = ?', userId);
|
||||
const current = await this.usersRepo.getAvatar(userId);
|
||||
// Only a locally uploaded file has something to clean up. An OIDC picture URL
|
||||
// (#1399) has no storage object, so skip the delete entirely.
|
||||
if (current?.avatar && !/^https:\/\//i.test(current.avatar)) {
|
||||
if (current && !/^https:\/\//i.test(current)) {
|
||||
// Fire-and-forget parity: leftover objects are harmless; the DB update is
|
||||
// the source of truth for which avatar is current. The catch also
|
||||
// swallows a hostile stored value the central key validation rejects.
|
||||
await this.storage.delete('avatars', current.avatar).catch(() => {});
|
||||
await this.storage.delete('avatars', current).catch(() => {});
|
||||
}
|
||||
|
||||
this.db.run('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', filename, userId);
|
||||
await this.usersRepo.setAvatar(userId, filename);
|
||||
|
||||
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'avatar'>>('SELECT id, username, email, role, avatar FROM users WHERE id = ?', userId);
|
||||
const updated = await this.usersRepo.findProfileBasic(userId);
|
||||
return { success: true, avatar_url: avatarUrl(updated || {}) };
|
||||
}
|
||||
|
||||
async deleteAvatar(userId: number) {
|
||||
const current = this.db.get<{ avatar: string | null }>('SELECT avatar FROM users WHERE id = ?', userId);
|
||||
const current = await this.usersRepo.getAvatar(userId);
|
||||
// An OIDC picture URL (#1399) has no storage object — only delete an uploaded one.
|
||||
if (current?.avatar && !/^https:\/\//i.test(current.avatar)) {
|
||||
await this.storage.delete('avatars', current.avatar).catch(() => {});
|
||||
if (current && !/^https:\/\//i.test(current)) {
|
||||
await this.storage.delete('avatars', current).catch(() => {});
|
||||
}
|
||||
this.db.run('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', userId);
|
||||
await this.usersRepo.setAvatar(userId, null);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -336,10 +319,7 @@ export class UserProfileService {
|
||||
async listUsers(excludeUserId: number) {
|
||||
// The global user directory feeds the trip member-add / contributor pickers —
|
||||
// guests (#1362) are trip-scoped and must never be selectable here.
|
||||
const users = this.db.all<Pick<User, 'id' | 'username' | 'avatar'>>(
|
||||
'SELECT id, username, avatar FROM users WHERE id != ? AND COALESCE(is_guest, 0) = 0 ORDER BY username ASC',
|
||||
excludeUserId
|
||||
);
|
||||
const users = await this.usersRepo.listOthersNonGuest(excludeUserId);
|
||||
return users.map(u => ({ ...u, avatar_url: avatarUrl(u) }));
|
||||
}
|
||||
|
||||
@@ -348,7 +328,7 @@ export class UserProfileService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
|
||||
const user = this.db.get<Pick<User, 'role' | 'openweather_api_key'>>('SELECT role, openweather_api_key FROM users WHERE id = ?', userId);
|
||||
const user = await this.usersRepo.getRoleAndWeatherKey(userId);
|
||||
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
|
||||
|
||||
const result: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { getAppUrl, readEnv } from '../../app-config';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
|
||||
function hostOf(url: string): string | null {
|
||||
try {
|
||||
@@ -68,10 +70,11 @@ export interface WebauthnConfig {
|
||||
*/
|
||||
@Injectable()
|
||||
export class WebauthnConfigService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
constructor(@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository) {}
|
||||
|
||||
/** `SELECT value FROM app_settings WHERE key = ?` (WC1), through `AppSettingsRepository.getValue` (Plan 3a). */
|
||||
private async setting(key: string): Promise<string | null> {
|
||||
const raw = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value;
|
||||
const raw = (await this.appSettings.getValue(key)) ?? undefined;
|
||||
const trimmed = raw?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
|
||||
import { verifyJwtAndLoadUser } from '../auth/jwt-verify';
|
||||
import { EntityManager } from '@mikro-orm/core';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { User, TripFile } from '../../types';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { DEFAULT_ALLOWED_EXTENSIONS } from './files.constants';
|
||||
@@ -79,6 +81,11 @@ export class FilesService {
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly tokens: EphemeralTokenService,
|
||||
private readonly storage: StorageService,
|
||||
// EntityManager, not @InjectRepository(Users) — same reasoning as
|
||||
// JwtAuthGuard (Plan 3b Task 1 RULING on verifyJwtAndLoadUser's callers):
|
||||
// kept uniform with the guards rather than adding a one-off
|
||||
// MikroOrmModule.forFeature([Users]) to FilesModule for this single call.
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async verifyTripAccess(tripId: string | number, userId: number) {
|
||||
@@ -110,7 +117,7 @@ export class FilesService {
|
||||
// Use the shared helper so the password_version gate applies here too;
|
||||
// previously this bypassed the check and stolen download tokens stayed
|
||||
// valid across a password reset.
|
||||
const user = await verifyJwtAndLoadUser(jwtToken);
|
||||
const user = await verifyJwtAndLoadUser(jwtToken, this.em.getRepository(Users));
|
||||
if (!user) return { error: 'Invalid or expired token', status: 401 };
|
||||
return { userId: user.id };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { db } from '../../db/database';
|
||||
import { withRequestContext } from '../database/request-context';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { StorageInvalidKeyError, StorageNotFoundError, type StorageCategory } from '../storage/storage.types';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
|
||||
// Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be
|
||||
// mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8).
|
||||
@@ -72,7 +74,7 @@ export function storageStaticHandler(storage: StorageService, category: StorageC
|
||||
};
|
||||
}
|
||||
|
||||
async function servePhoto(storage: StorageService, req: Request, res: Response): Promise<void> {
|
||||
async function servePhoto(storage: StorageService, req: Request, res: Response, users: UsersRepository): Promise<void> {
|
||||
const safeName = path.basename(req.params.filename);
|
||||
// Parity: after basename(), the old resolve()+startsWith guard could only
|
||||
// fire when the remaining segment was '..' — keep that exact 403.
|
||||
@@ -104,7 +106,7 @@ async function servePhoto(storage: StorageService, req: Request, res: Response):
|
||||
}
|
||||
|
||||
// JWT session path (with pv check).
|
||||
const user = await verifyJwtAndLoadUser(rawToken);
|
||||
const user = await verifyJwtAndLoadUser(rawToken, users);
|
||||
if (user) return sendPhoto();
|
||||
|
||||
// Share-token path: require the token to cover the exact trip the
|
||||
@@ -132,14 +134,19 @@ async function servePhoto(storage: StorageService, req: Request, res: Response):
|
||||
* mounted here, on the raw Express instance, BEFORE `app.init()` — every
|
||||
* `/uploads/photos/*` request runs this handler entirely outside Nest's
|
||||
* per-request EntityManager fork, forever, not just during a boot window.
|
||||
* `verifyJwtAndLoadUser` (`jwt-verify.ts`) reads `users` through the legacy
|
||||
* `db` proxy today, so nothing here needs the ORM yet — but the inventory's
|
||||
* proposed conversion (`UsersRepository.findByIdWithPasswordVersion`, Task 1)
|
||||
* would throw `cannotUseGlobalContext` on every photo request the instant it
|
||||
* lands, because nothing forks a context for this pre-init route. Wrapping
|
||||
* the handler in `withRequestContext` NOW, before any repository call exists
|
||||
* on this path, means Task 1 converts `jwt-verify.ts` without touching this
|
||||
* file again and without a single broken photo request in between.
|
||||
* `verifyJwtAndLoadUser` (`jwt-verify.ts`, JV1) now reads `users` through
|
||||
* `UsersRepository` (Plan 3b Task 1) rather than the legacy `db` proxy — the
|
||||
* exact case Task 0's wrap was built for. `orm.em.getRepository(Users)` is
|
||||
* resolved inside the arrow passed to `withRequestContext` below: `orm.em`
|
||||
* is the global, context-resolving EntityManager, and MikroORM's repository
|
||||
* object holds a reference to that same proxy rather than a snapshot, so a
|
||||
* query issued through it later resolves whatever `AsyncLocalStorage`
|
||||
* context is active AT QUERY TIME — not at the moment `getRepository()` was
|
||||
* called. What actually gates this is `withRequestContext` wrapping the
|
||||
* whole `servePhoto(...)` call (query included): removing the wrapper
|
||||
* entirely reproduces Task 0's `ValidationError: Using global EntityManager
|
||||
* instance...` (verified directly — mutation-proof in `task-1-report.md`),
|
||||
* exactly the failure `PHOTOCTX-002`/`SEAM-002` guard against.
|
||||
*
|
||||
* Optional at the type level only, the same shape `TrekWsAdapter` uses for
|
||||
* its own D6 wrapper (`src/nest/realtime/trek-ws.adapter.ts`): `bootstrap.ts`
|
||||
@@ -184,7 +191,7 @@ export function applyPlatformUploads(app: express.Application, storage: StorageS
|
||||
next(new Error('applyPlatformUploads: no MikroORM available to build a request context for /uploads/photos/*'));
|
||||
return;
|
||||
}
|
||||
return withRequestContext(orm, () => servePhoto(storage, req, res)).catch(next);
|
||||
return withRequestContext(orm, () => servePhoto(storage, req, res, orm.em.getRepository(Users))).catch(next);
|
||||
});
|
||||
|
||||
// Block direct access to /uploads/files
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { All, Controller, Param, Req, Res } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/core';
|
||||
import type { Request, Response } from 'express';
|
||||
import { extractToken, verifyJwtAndLoadUser } from '../auth/jwt-verify';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
|
||||
/**
|
||||
* Proxies a plugin's own HTTP routes at /api/plugins/:id/* (#plugins, M2).
|
||||
@@ -74,7 +76,12 @@ function toRelativeLocation(loc: unknown): string | null {
|
||||
@Public('auth is data-driven per plugin route (route.auth in the manifest), asserted in the handler')
|
||||
@Controller('api/plugins/:pluginId')
|
||||
export class PluginsProxyController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
// EntityManager, not @InjectRepository(Users) — same reasoning as
|
||||
// JwtAuthGuard (Plan 3b Task 1 RULING on verifyJwtAndLoadUser's callers).
|
||||
constructor(
|
||||
private readonly runtime: PluginRuntimeService,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
@All('*path')
|
||||
async proxy(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
@@ -95,7 +102,7 @@ export class PluginsProxyController {
|
||||
let user: { id: number; username: string; role?: 'admin' | 'user' } | null = null;
|
||||
if (route.auth) {
|
||||
const token = extractToken(req);
|
||||
const loaded = token ? await verifyJwtAndLoadUser(token) : null;
|
||||
const loaded = token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users)) : null;
|
||||
if (!loaded) {
|
||||
res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||
return;
|
||||
|
||||
@@ -89,6 +89,15 @@ function withinBurst(socket: TrekWebSocket): boolean {
|
||||
* `error` event on the socket; unhandled, Node rethrows it and the process
|
||||
* dies. That is #1576, and it only reproduces against a hostile client, so
|
||||
* nothing in CI would catch its removal.
|
||||
*
|
||||
* All three per-connection lifecycle hooks the base adapter offers —
|
||||
* `bindClientConnect` (`OnGatewayConnection`), `bindMessageHandlers`
|
||||
* (`@SubscribeMessage`) and `bindClientDisconnect` (`OnGatewayDisconnect`) —
|
||||
* are overridden here to run their callback inside `withRequestContext`
|
||||
* (D6): none of the three has an HTTP request behind it, so none gets a
|
||||
* forked EntityManager unless this adapter forks one itself. `create`
|
||||
* above and `close` below are the only other overrides, and neither
|
||||
* dispatches into gateway/handler code, so neither needs the wrap.
|
||||
*/
|
||||
export class TrekWsAdapter extends WsAdapter {
|
||||
private readonly httpServerRef: HttpServer;
|
||||
@@ -184,6 +193,26 @@ export class TrekWsAdapter extends WsAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches Nest's `OnGatewayDisconnect` hook (`RealtimeGateway.handleDisconnect`)
|
||||
* the same way `bindClientConnect` above dispatches `OnGatewayConnection` —
|
||||
* the base `WsAdapter.bindClientDisconnect` just does `client.on(CLOSE_EVENT,
|
||||
* callback)` with no context of any kind. `handleDisconnect` reads no
|
||||
* repository today (Task 0 review addendum, LOW item 2), but it is a
|
||||
* lifecycle hook with no HTTP request behind it exactly like the connect and
|
||||
* message hooks are, so it gets the same wrap NOW rather than becoming the
|
||||
* next "the adapter is done" surprise the moment a future change makes it
|
||||
* read one. Same fail-closed shape as the other two: throw before calling.
|
||||
*/
|
||||
bindClientDisconnect(client: TrekWebSocket, callback: (...args: unknown[]) => void): void {
|
||||
client.on('close', (...args: unknown[]) => {
|
||||
if (!this.orm) {
|
||||
throw new Error('TrekWsAdapter: no MikroORM available to build a request context for this disconnect');
|
||||
}
|
||||
withRequestContext(this.orm, () => callback(...args));
|
||||
});
|
||||
}
|
||||
|
||||
bindMessageHandlers(
|
||||
socket: TrekWebSocket,
|
||||
handlers: MessageMappingProperties[],
|
||||
|
||||
Reference in New Issue
Block a user