mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
refactor(auth): auth, passkey, profile, token and admin services refuse with DomainError instead of returning error envelopes
This commit is contained in:
@@ -29,15 +29,6 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import type { User } from '../../types';
|
||||
import { ManagedForbidden } from '../common/managed';
|
||||
|
||||
/** Throw the legacy {error,status} envelope when a service call reports failure. */
|
||||
function ok<T>(result: T): Exclude<T, { error: string }> {
|
||||
if (result && typeof result === 'object' && 'error' in (result as Record<string, unknown>)) {
|
||||
const r = result as unknown as { error: string; status?: number };
|
||||
throw new HttpException({ error: r.error }, r.status ?? 400);
|
||||
}
|
||||
return result as Exclude<T, { error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/admin — admin-only control surface (users, stats, permissions, audit log,
|
||||
* OIDC settings, invites, feature toggles, packing templates, addons, MCP/OAuth
|
||||
@@ -76,14 +67,14 @@ export class AdminController {
|
||||
@Post('users')
|
||||
@HttpCode(201)
|
||||
async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
|
||||
const result = ok(await this.admin.createUser(body as Parameters<AdminService['createUser']>[0]));
|
||||
const result = await this.admin.createUser(body as Parameters<AdminService['createUser']>[0]);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Put('users/:id')
|
||||
async updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
|
||||
const result = ok(await this.admin.updateUser(id, body));
|
||||
const result = await this.admin.updateUser(id, body);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
|
||||
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
|
||||
return { user: result.user };
|
||||
@@ -91,7 +82,7 @@ export class AdminController {
|
||||
|
||||
@Delete('users/:id')
|
||||
async deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(await this.admin.deleteUser(id, user.id));
|
||||
const result = await this.admin.deleteUser(id, user.id);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
||||
logInfo(`Admin ${user.email} deleted user ${result.email}`);
|
||||
return { success: true };
|
||||
@@ -99,14 +90,14 @@ export class AdminController {
|
||||
|
||||
@Delete('users/:id/passkeys')
|
||||
async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(await this.admin.resetUserPasskeys(id));
|
||||
const result = await this.admin.resetUserPasskeys(id);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
|
||||
return { success: true, deleted: result.deleted };
|
||||
}
|
||||
|
||||
@Delete('users/:id/mfa')
|
||||
async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(await this.admin.resetUserMfa(id, user.id));
|
||||
const result = await this.admin.resetUserMfa(id, user.id);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -132,9 +123,6 @@ export class AdminController {
|
||||
@HttpCode(200)
|
||||
async saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
|
||||
const result = await this.admin.saveDemoBaseline();
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
||||
return { success: true, message: result.message };
|
||||
}
|
||||
@@ -175,7 +163,7 @@ export class AdminController {
|
||||
|
||||
@Delete('invites/:id')
|
||||
async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
ok(await this.invites.deleteInvite(id));
|
||||
await this.invites.deleteInvite(id);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -290,7 +278,7 @@ export class AdminController {
|
||||
|
||||
@Put('addons/:id')
|
||||
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) {
|
||||
const result = await ok(await this.admin.updateAddon(id, body));
|
||||
const result = await this.admin.updateAddon(id, body);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
|
||||
// Sessions only need re-creating when the registered MCP surface can
|
||||
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
|
||||
@@ -316,7 +304,7 @@ export class AdminController {
|
||||
|
||||
@Delete('mcp-tokens/:id')
|
||||
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
ok(await this.tokens.adminDeleteMcpToken(id));
|
||||
await this.tokens.adminDeleteMcpToken(id);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -326,7 +314,7 @@ export class AdminController {
|
||||
|
||||
@Delete('oauth-sessions/:id')
|
||||
async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
ok(await this.oauth.adminRevokeOAuthSession(id));
|
||||
await this.oauth.adminRevokeOAuthSession(id);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -336,10 +324,7 @@ export class AdminController {
|
||||
@Post('rotate-jwt-secret')
|
||||
@HttpCode(200)
|
||||
async rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
|
||||
const result = this.admin.rotateJwtSecret();
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.admin.rotateJwtSecret();
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import bcrypt from 'bcryptjs';
|
||||
@@ -167,22 +168,22 @@ export class AdminService {
|
||||
const password = data.password?.trim();
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return { error: 'Username, email and password are required', status: 400 };
|
||||
throw new DomainError(400, 'Username, email and password are required');
|
||||
}
|
||||
|
||||
const pwCheck = validatePassword(password);
|
||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
||||
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
|
||||
|
||||
if (data.role && !['user', 'admin'].includes(data.role)) {
|
||||
return { error: 'Invalid role', status: 400 };
|
||||
throw new DomainError(400, 'Invalid role');
|
||||
}
|
||||
|
||||
// Guests (#1362) live in a reserved synthetic namespace; never let one block a real account.
|
||||
const existingUsername = await this.users.findIdByUsernameExact(username);
|
||||
if (existingUsername) return { error: 'Username already taken', status: 409 };
|
||||
if (existingUsername) throw new DomainError(409, 'Username already taken');
|
||||
|
||||
const existingEmail = await this.users.findIdByEmailCI(email);
|
||||
if (existingEmail) return { error: 'Email already taken', status: 409 };
|
||||
if (existingEmail) throw new DomainError(409, 'Email already taken');
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
|
||||
|
||||
@@ -193,7 +194,7 @@ export class AdminService {
|
||||
});
|
||||
} catch (err) {
|
||||
// Another write took the name or address between the check and the insert.
|
||||
if (err instanceof UserIdentityTakenError) return { error: identityTakenMessage(err), status: 409 };
|
||||
if (err instanceof UserIdentityTakenError) throw new DomainError(409, identityTakenMessage(err));
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -213,29 +214,29 @@ export class AdminService {
|
||||
const { role, password } = data;
|
||||
const user = await this.users.findById(userId);
|
||||
|
||||
if (!user) return { error: 'User not found', status: 404 };
|
||||
if (!user) throw new DomainError(404, 'User not found');
|
||||
|
||||
if (role && !['user', 'admin'].includes(role)) {
|
||||
return { error: 'Invalid role', status: 400 };
|
||||
throw new DomainError(400, 'Invalid role');
|
||||
}
|
||||
|
||||
// An empty string used to fall through `username || null` into COALESCE and
|
||||
// silently mean "leave unchanged". Say so instead of pretending it worked.
|
||||
if (username === '') return { error: 'Username cannot be empty', status: 400 };
|
||||
if (email === '') return { error: 'Email cannot be empty', status: 400 };
|
||||
if (username === '') throw new DomainError(400, 'Username cannot be empty');
|
||||
if (email === '') throw new DomainError(400, 'Email cannot be empty');
|
||||
|
||||
if (username && username !== user.username) {
|
||||
const conflict = await this.users.findIdByUsernameExactExcluding(username, userId);
|
||||
if (conflict) return { error: 'Username already taken', status: 409 };
|
||||
if (conflict) throw new DomainError(409, 'Username already taken');
|
||||
}
|
||||
if (email && email !== user.email) {
|
||||
const conflict = await this.users.findIdByEmailCI(email, userId);
|
||||
if (conflict) return { error: 'Email already taken', status: 409 };
|
||||
if (conflict) throw new DomainError(409, 'Email already taken');
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const pwCheck = validatePassword(password);
|
||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
||||
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
|
||||
}
|
||||
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
|
||||
|
||||
@@ -248,7 +249,7 @@ export class AdminService {
|
||||
const currentRole = await this.users.getRole(userId);
|
||||
if (currentRole === 'admin') {
|
||||
const adminCount = await this.users.countAdmins();
|
||||
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
|
||||
if (adminCount <= 1) throw new DomainError(400, 'Cannot remove the last admin');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +292,7 @@ export class AdminService {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof UserIdentityTakenError) return { error: identityTakenMessage(err), status: 409 };
|
||||
if (err instanceof UserIdentityTakenError) throw new DomainError(409, identityTakenMessage(err));
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -318,11 +319,11 @@ export class AdminService {
|
||||
// SECURITY: plain JS comparison, deliberately kept exactly here — never
|
||||
// folded into a repository method's WHERE clause (R4).
|
||||
if (Number.parseInt(id) === currentUserId) {
|
||||
return { error: 'Cannot delete own account', status: 400 };
|
||||
throw new DomainError(400, 'Cannot delete own account');
|
||||
}
|
||||
|
||||
const userToDel = await this.users.findIdAndEmail(Number(id));
|
||||
if (!userToDel) return { error: 'User not found', status: 404 };
|
||||
if (!userToDel) throw new DomainError(404, 'User not found');
|
||||
|
||||
await this.userCleanup.deleteUserCompletely(userToDel.id);
|
||||
await emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
|
||||
@@ -344,15 +345,15 @@ export class AdminService {
|
||||
* making that reachable from here would turn a stolen admin session into a
|
||||
* way to strip the second factor off the very account it came from.
|
||||
*/
|
||||
async resetUserMfa(id: string, actingUserId: number): Promise<{ error?: string; status?: number; success?: boolean; email?: string }> {
|
||||
async resetUserMfa(id: string, actingUserId: number): Promise<{ success?: boolean; email?: string }> {
|
||||
const targetId = Number(id);
|
||||
// SECURITY: plain JS comparison, deliberately kept exactly here — never
|
||||
// folded into a repository method's WHERE clause (R4).
|
||||
if (targetId === actingUserId) {
|
||||
return { error: 'Use Settings to change your own two-factor setup', status: 400 };
|
||||
throw new DomainError(400, 'Use Settings to change your own two-factor setup');
|
||||
}
|
||||
const target = await this.users.findIdEmailMfaEnabled(targetId);
|
||||
if (!target) return { error: 'User not found', status: 404 };
|
||||
if (!target) throw new DomainError(404, 'User not found');
|
||||
|
||||
// Same three columns disableMfa clears, so an admin reset and a self-service
|
||||
// disable leave the account in exactly one state rather than two; the
|
||||
@@ -426,9 +427,9 @@ export class AdminService {
|
||||
|
||||
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
||||
|
||||
async saveDemoBaseline(): Promise<{ error?: string; status?: number; message?: string }> {
|
||||
async saveDemoBaseline(): Promise<{ message?: string }> {
|
||||
if (!readEnv().demo.enabled) {
|
||||
return { error: 'Not found', status: 404 };
|
||||
throw new DomainError(404, 'Not found');
|
||||
}
|
||||
try {
|
||||
// Lazy require: demo-reset is a demo-only module.
|
||||
@@ -437,7 +438,7 @@ export class AdminService {
|
||||
return { message: 'Demo baseline saved. Hourly resets will restore to this state.' };
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
return { error: 'Failed to save baseline', status: 500 };
|
||||
throw new DomainError(500, 'Failed to save baseline');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,28 +677,28 @@ export class AdminService {
|
||||
const addon = await this.addonsRepo.findById(id);
|
||||
const provider = await this.photoProviders.findById(id);
|
||||
const docProvider = await this.documentProviders.findById(id);
|
||||
if (!addon && !provider && !docProvider) return { error: 'Addon not found', status: 404 };
|
||||
if (!addon && !provider && !docProvider) throw new DomainError(404, 'Addon not found');
|
||||
|
||||
// The whole addon, not just its config: on a centrally administered install
|
||||
// the operator owns the endpoint, the model and the per-document cost, so
|
||||
// there is nothing here for an instance admin to set — including whether it
|
||||
// runs at all. listAddons hides the row; this closes the route behind it.
|
||||
if (readEnv().managed.enabled && id === ADDON_IDS.LLM_PARSING) {
|
||||
return { error: MANAGED_FORBIDDEN_ERROR.error, status: 403 };
|
||||
throw new DomainError(403, MANAGED_FORBIDDEN_ERROR.error);
|
||||
}
|
||||
|
||||
// Photo providers are Journey's shelf rows — their whole UI lives inside
|
||||
// journeys, so enabling one under a disabled journey addon would only
|
||||
// advertise an integration nothing can reach.
|
||||
if (provider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) {
|
||||
return { error: 'Enable the Journey addon first', status: 409 };
|
||||
throw new DomainError(409, 'Enable the Journey addon first');
|
||||
}
|
||||
|
||||
// Same rule one shelf down: a document provider only exists to serve the
|
||||
// file manager, so switching one on under a disabled Documents addon would
|
||||
// advertise a sync nothing can reach.
|
||||
if (docProvider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) {
|
||||
return { error: 'Enable the Documents addon first', status: 409 };
|
||||
throw new DomainError(409, 'Enable the Documents addon first');
|
||||
}
|
||||
|
||||
await this.uow.transactional(async () => {
|
||||
@@ -784,17 +785,16 @@ export class AdminService {
|
||||
|
||||
// ── JWT Rotation ───────────────────────────────────────────────────────────
|
||||
|
||||
rotateJwtSecret(): { error?: string; status?: number } {
|
||||
rotateJwtSecret(): void {
|
||||
const newSecret = crypto.randomBytes(32).toString('hex');
|
||||
const { dataDir, jwtSecretFile: secretFile } = this.dataPaths;
|
||||
try {
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(secretFile, newSecret, { mode: 0o600 });
|
||||
} catch {
|
||||
return { error: 'Failed to persist new JWT secret to disk', status: 500 };
|
||||
throw new DomainError(500, 'Failed to persist new JWT secret to disk');
|
||||
}
|
||||
updateJwtSecret(newSecret);
|
||||
return {};
|
||||
}
|
||||
|
||||
invalidateMcpSessions() { invalidateMcpSessions(); }
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Public } from '../auth-core/public.decorator';
|
||||
import { MfaExempt } from '../auth-core/mfa-policy.guard';
|
||||
import { extractToken, verifiedSessionClaims } from '../auth-core/jwt-verify';
|
||||
import { SessionsService, sessionClientFrom } from '../sessions/sessions.service';
|
||||
import { catchDomainError, DomainError } from '../common/domain-error';
|
||||
|
||||
const WINDOW = 15 * 60 * 1000;
|
||||
const LOGIN_MIN_LATENCY_MS = 350;
|
||||
@@ -60,9 +61,6 @@ export class AuthPublicController {
|
||||
@HttpCode(200)
|
||||
async demoLogin(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.auth.demoLogin(sessionClientFrom(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
@@ -72,9 +70,6 @@ export class AuthPublicController {
|
||||
async invite(@Param('token') token: string, @Req() req: Request) {
|
||||
await this.limit('login', req, 10);
|
||||
const result = await this.auth.validateInviteToken(token);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at };
|
||||
}
|
||||
|
||||
@@ -84,9 +79,6 @@ export class AuthPublicController {
|
||||
async register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
await this.limit('login', req, 10);
|
||||
const result = await this.auth.registerUser(body, sessionClientFrom(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
@@ -156,10 +148,10 @@ export class AuthPublicController {
|
||||
// a dedicated bucket) — without it reset tokens could be guessed unthrottled.
|
||||
await this.limit('reset', req, 5);
|
||||
const ip = getClientIp(req);
|
||||
const result = await this.auth.resetPassword(body);
|
||||
if (result.error) {
|
||||
await this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
const result = await catchDomainError(() => this.auth.resetPassword(body));
|
||||
if (result instanceof DomainError) {
|
||||
await this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.publicMessage } });
|
||||
throw result;
|
||||
}
|
||||
if (result.mfa_required) {
|
||||
return { mfa_required: true };
|
||||
@@ -174,9 +166,6 @@ export class AuthPublicController {
|
||||
async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
await this.limit('mfa', req, 5);
|
||||
const result = await this.auth.verifyMfaLogin(body, sessionClientFrom(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
||||
this.auth.setAuthCookie(res, result.token!, req, result.remember);
|
||||
return { token: result.token, user: result.user };
|
||||
|
||||
@@ -119,9 +119,6 @@ export class AuthController {
|
||||
const remember = decodeSessionClaims(cookie)?.remember;
|
||||
const cookieSession = typeof cookie === 'string' && cookie.length > 0;
|
||||
const result = await this.auth.changePassword(user.id, user.email, body, remember, sessionClientFrom(req), cookieSession);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
// Refresh this device's cookie with the new password_version so the user
|
||||
// stays logged in here while all other sessions are invalidated.
|
||||
if (result.token) this.auth.setAuthCookie(res, result.token, req, remember);
|
||||
@@ -131,10 +128,7 @@ export class AuthController {
|
||||
|
||||
@Delete('me')
|
||||
async deleteAccount(@CurrentUser() user: User, @Req() req: Request) {
|
||||
const result = await this.auth.deleteAccount(user.id, user.email, user.role);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.auth.deleteAccount(user.id, user.email, user.role);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -180,9 +174,6 @@ export class AuthController {
|
||||
@Put('me/settings')
|
||||
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
|
||||
const result = await this.profile.updateSettings(user.id, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
|
||||
return { success: result.success, user: result.user };
|
||||
}
|
||||
@@ -190,9 +181,6 @@ export class AuthController {
|
||||
@Get('me/settings')
|
||||
async getSettings(@CurrentUser() user: User) {
|
||||
const result = await this.profile.getSettings(user.id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { settings: result.settings };
|
||||
}
|
||||
|
||||
@@ -226,9 +214,6 @@ export class AuthController {
|
||||
@Get('validate-keys')
|
||||
async validateKeys(@CurrentUser() user: User) {
|
||||
const result = await this.profile.validateKeys(user.id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { maps: result.maps, weather: result.weather, maps_details: result.maps_details };
|
||||
}
|
||||
|
||||
@@ -236,9 +221,6 @@ export class AuthController {
|
||||
@MfaExempt('the setup screen reads the policy it is asking the user to satisfy')
|
||||
async getAppSettings(@CurrentUser() user: User) {
|
||||
const result = await this.auth.getAppSettings(user.id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -246,9 +228,6 @@ export class AuthController {
|
||||
@MfaExempt('an admin locked out by their own policy must still be able to lift it')
|
||||
async updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) {
|
||||
const result = await this.auth.updateAppSettings(user.id, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
|
||||
// Named so the settings tab can say which fields the operator holds rather
|
||||
// than showing a saved value that silently did not save.
|
||||
@@ -264,9 +243,6 @@ export class AuthController {
|
||||
@HttpCode(200)
|
||||
async mfaSetup(@CurrentUser() user: User) {
|
||||
const result = await this.auth.setupMfa(user.id, user.email);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
try {
|
||||
const qr_svg = await result.qrPromise!;
|
||||
return { secret: result.secret, otpauth_url: result.otpauth_url, qr_svg };
|
||||
@@ -282,9 +258,6 @@ export class AuthController {
|
||||
async mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) {
|
||||
await this.limit('mfa', req, 5);
|
||||
const result = await this.auth.enableMfa(user.id, body.code);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
||||
return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes };
|
||||
}
|
||||
@@ -294,9 +267,6 @@ export class AuthController {
|
||||
async mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) {
|
||||
await this.limit('login', req, 5);
|
||||
const result = await this.auth.disableMfa(user.id, user.email, body, currentSessionId(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
||||
return { success: true, mfa_enabled: result.mfa_enabled };
|
||||
}
|
||||
@@ -312,18 +282,12 @@ export class AuthController {
|
||||
async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
|
||||
await this.limit('login', req, 5);
|
||||
const result = await this.tokens.createMcpToken(user.id, body.name);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { token: result.token };
|
||||
}
|
||||
|
||||
@Delete('mcp-tokens/:id')
|
||||
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
const result = await this.tokens.deleteMcpToken(user.id, id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.tokens.deleteMcpToken(user.id, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -352,18 +316,12 @@ export class AuthController {
|
||||
// before this field existed does. Narrowing stays opt-in so the change
|
||||
// cannot break an integration that is already running.
|
||||
const result = await this.tokens.createApiToken(user.id, body.name, body.scopes);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { token: result.token };
|
||||
}
|
||||
|
||||
@Delete('api-tokens/:id')
|
||||
async deleteApiToken(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
const result = await this.tokens.deleteApiToken(user.id, id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
await this.tokens.deleteApiToken(user.id, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -377,9 +335,6 @@ export class AuthController {
|
||||
// 503-ing every other user's ws and download tokens.
|
||||
await this.limitUser('ws_token', user.id, 120);
|
||||
const result = await this.tokens.createWsToken(user.id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { token: result.token };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
@@ -129,7 +130,6 @@ setInterval(() => {
|
||||
}, 5 * 60 * 1000).unref?.();
|
||||
|
||||
export interface ResetPasswordOutcome {
|
||||
error?: string;
|
||||
status?: number;
|
||||
success?: boolean;
|
||||
/** When true the client must collect a TOTP/backup code and call again. */
|
||||
@@ -410,28 +410,28 @@ export class AuthService {
|
||||
// Auth: register, login, demo
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async demoLogin(client?: SessionClient): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown> }> {
|
||||
async demoLogin(client?: SessionClient): Promise<{ token?: string; user?: Record<string, unknown> }> {
|
||||
if (!readEnv().demo.enabled) {
|
||||
return { error: 'Not found', status: 404 };
|
||||
throw new DomainError(404, 'Not found');
|
||||
}
|
||||
const user = await this.usersRepo.findByEmailExact(DEMO_EMAIL_PRIMARY);
|
||||
if (!user) return { error: 'Demo user not found', status: 500 };
|
||||
if (!user) throw new DomainError(500, 'Demo user not found');
|
||||
const token = await this.generateToken(user, undefined, client);
|
||||
const safe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
|
||||
return { token, user: { ...safe, avatar_url: avatarUrl(user) } };
|
||||
}
|
||||
|
||||
async validateInviteToken(token: string): Promise<{ error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string | null }> {
|
||||
async validateInviteToken(token: string): Promise<{ valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string | null }> {
|
||||
const invite = await this.inviteTokens.findByToken(token);
|
||||
if (!invite) return { error: 'Invalid invite link', status: 404 };
|
||||
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
|
||||
if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
|
||||
if (!invite) throw new DomainError(404, 'Invalid invite link');
|
||||
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) throw new DomainError(410, 'Invite link has been fully used');
|
||||
if (invite.expires_at && new Date(invite.expires_at) < new Date()) throw new DomainError(410, 'Invite link has expired');
|
||||
// A nullable column stays null on the wire: `?? undefined` would make
|
||||
// JSON.stringify drop the key for a never-expiring invite.
|
||||
return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at };
|
||||
}
|
||||
|
||||
async registerUser(rawBody: unknown, client?: SessionClient): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
|
||||
async registerUser(rawBody: unknown, client?: SessionClient): Promise<{ token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
|
||||
const body = rawBody as { username?: string; email?: string; password?: string; invite_token?: string };
|
||||
const username = typeof body.username === 'string' ? body.username.trim() : '';
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
||||
@@ -442,27 +442,27 @@ export class AuthService {
|
||||
let validInvite: Awaited<ReturnType<InviteTokensRepository['findByToken']>> = null;
|
||||
if (invite_token) {
|
||||
validInvite = await this.inviteTokens.findByToken(invite_token);
|
||||
if (!validInvite) return { error: 'Invalid invite link', status: 400 };
|
||||
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
|
||||
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
|
||||
if (!validInvite) throw new DomainError(400, 'Invalid invite link');
|
||||
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) throw new DomainError(410, 'Invite link has been fully used');
|
||||
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) throw new DomainError(410, 'Invite link has expired');
|
||||
}
|
||||
|
||||
if (userCount > 0 && !validInvite) {
|
||||
const toggles = await this.resolveAuthToggles();
|
||||
if (!toggles.password_registration) {
|
||||
return { error: 'Password registration is disabled. Contact your administrator.', status: 403 };
|
||||
throw new DomainError(403, 'Password registration is disabled. Contact your administrator.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return { error: 'Username, email and password are required', status: 400 };
|
||||
throw new DomainError(400, 'Username, email and password are required');
|
||||
}
|
||||
|
||||
const pwCheck = validatePassword(password);
|
||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
||||
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
|
||||
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
return { error: 'Invalid email format', status: 400 };
|
||||
throw new DomainError(400, 'Invalid email format');
|
||||
}
|
||||
|
||||
const password_hash = bcrypt.hashSync(password, BCRYPT_COST);
|
||||
@@ -476,7 +476,7 @@ export class AuthService {
|
||||
// Ignore guests (#1362): their synthetic username/email must never block a real signup.
|
||||
const existingUserId = await this.usersRepo.findIdByEmailOrUsernameCI(email, username);
|
||||
if (existingUserId !== null) {
|
||||
return { error: 'Registration failed. Please try different credentials.', status: 409 };
|
||||
throw new DomainError(409, 'Registration failed. Please try different credentials.');
|
||||
}
|
||||
const isFirstUser = (await this.usersRepo.countNonGuest()) === 0;
|
||||
const role = isFirstUser ? 'admin' : 'user';
|
||||
@@ -509,10 +509,11 @@ export class AuthService {
|
||||
} catch (err) {
|
||||
// The check above runs in the transaction, but a concurrent signup can
|
||||
// still win the insert; the database's unique indexes refuse the second.
|
||||
if (err instanceof DomainError) throw err;
|
||||
if (err instanceof UserIdentityTakenError) {
|
||||
return { error: 'Registration failed. Please try different credentials.', status: 409 };
|
||||
throw new DomainError(409, 'Registration failed. Please try different credentials.');
|
||||
}
|
||||
return { error: 'Error creating user', status: 500 };
|
||||
throw new DomainError(500, 'Error creating user');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,25 +624,25 @@ export class AuthService {
|
||||
remember?: boolean,
|
||||
client?: SessionClient,
|
||||
issueSession = true,
|
||||
): Promise<{ error?: string; status?: number; success?: boolean; token?: string }> {
|
||||
): Promise<{ success?: boolean; token?: string }> {
|
||||
const body = rawBody as { current_password?: string; new_password?: string };
|
||||
if (await this.isOidcOnlyMode()) {
|
||||
return { error: 'Password authentication is disabled.', status: 403 };
|
||||
throw new DomainError(403, 'Password authentication is disabled.');
|
||||
}
|
||||
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
||||
return { error: 'Password change is disabled in demo mode.', status: 403 };
|
||||
throw new DomainError(403, 'Password change is disabled in demo mode.');
|
||||
}
|
||||
|
||||
const { current_password, new_password } = body;
|
||||
if (!current_password) return { error: 'Current password is required', status: 400 };
|
||||
if (!new_password) return { error: 'New password is required', status: 400 };
|
||||
if (!current_password) throw new DomainError(400, 'Current password is required');
|
||||
if (!new_password) throw new DomainError(400, 'New password is required');
|
||||
|
||||
const pwCheck = validatePassword(new_password);
|
||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
||||
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
|
||||
|
||||
const user = await this.usersRepo.getPasswordHashAndVersion(userId);
|
||||
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
|
||||
return { error: 'Current password is incorrect', status: 401 };
|
||||
throw new DomainError(401, 'Current password is incorrect');
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(new_password, BCRYPT_COST);
|
||||
@@ -671,14 +672,14 @@ export class AuthService {
|
||||
return { success: true, token };
|
||||
}
|
||||
|
||||
async deleteAccount(userId: number, userEmail: string, userRole: string): Promise<{ error?: string; status?: number; success?: boolean }> {
|
||||
async deleteAccount(userId: number, userEmail: string, userRole: string): Promise<{ success?: boolean }> {
|
||||
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
||||
return { error: 'Account deletion is disabled in demo mode.', status: 403 };
|
||||
throw new DomainError(403, 'Account deletion is disabled in demo mode.');
|
||||
}
|
||||
if (userRole === 'admin') {
|
||||
const adminCount = await this.usersRepo.countAdmins();
|
||||
if (adminCount <= 1) {
|
||||
return { error: 'Cannot delete the last admin account', status: 400 };
|
||||
throw new DomainError(400, 'Cannot delete the last admin account');
|
||||
}
|
||||
}
|
||||
await this.userCleanup.deleteUserCompletely(userId);
|
||||
@@ -700,9 +701,9 @@ export class AuthService {
|
||||
// work, not with a move.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async getAppSettings(userId: number): Promise<{ error?: string; status?: number; data?: Record<string, string> }> {
|
||||
async getAppSettings(userId: number): Promise<{ data?: Record<string, string> }> {
|
||||
const role = await this.usersRepo.getRole(userId);
|
||||
if (role !== 'admin') return { error: 'Admin access required', status: 403 };
|
||||
if (role !== 'admin') throw new DomainError(403, 'Admin access required');
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of ADMIN_FORM_SETTING_KEYS) {
|
||||
@@ -716,8 +717,6 @@ export class AuthService {
|
||||
userId: number,
|
||||
rawBody: unknown
|
||||
): Promise<{
|
||||
error?: string;
|
||||
status?: number;
|
||||
success?: boolean;
|
||||
auditSummary?: Record<string, unknown>;
|
||||
auditDebugDetails?: Record<string, unknown>;
|
||||
@@ -726,7 +725,7 @@ export class AuthService {
|
||||
}> {
|
||||
const body = rawBody as Record<string, unknown>;
|
||||
const role = await this.usersRepo.getRole(userId);
|
||||
if (role !== 'admin') return { error: 'Admin access required', status: 403 };
|
||||
if (role !== 'admin') throw new DomainError(403, 'Admin access required');
|
||||
|
||||
const { require_mfa } = body;
|
||||
if (require_mfa === true || require_mfa === 'true') {
|
||||
@@ -735,10 +734,7 @@ export class AuthService {
|
||||
// their own account with a passkey may enable it too (not only TOTP).
|
||||
const adminHasPasskey = await this.webauthnCredentials.hasAny(userId);
|
||||
if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) {
|
||||
return {
|
||||
error: 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.',
|
||||
status: 400,
|
||||
};
|
||||
throw new DomainError(400, 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,7 +745,7 @@ export class AuthService {
|
||||
const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login;
|
||||
const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login;
|
||||
if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) {
|
||||
return { error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 };
|
||||
throw new DomainError(400, 'Cannot disable all login methods. At least one must remain enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,13 +803,13 @@ export class AuthService {
|
||||
// MFA
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async setupMfa(userId: number, userEmail: string): Promise<{ error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise<string> }> {
|
||||
async setupMfa(userId: number, userEmail: string): Promise<{ secret?: string; otpauth_url?: string; qrPromise?: Promise<string> }> {
|
||||
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
||||
return { error: 'MFA is not available in demo mode.', status: 403 };
|
||||
throw new DomainError(403, 'MFA is not available in demo mode.');
|
||||
}
|
||||
const row = await this.usersRepo.getMfaEnabled(userId);
|
||||
if (row?.mfa_enabled) {
|
||||
return { error: 'MFA is already enabled', status: 400 };
|
||||
throw new DomainError(400, 'MFA is already enabled');
|
||||
}
|
||||
let secret: string, otpauth_url: string;
|
||||
try {
|
||||
@@ -822,24 +818,24 @@ export class AuthService {
|
||||
otpauth_url = authenticator.keyuri(userEmail, 'TREK', secret);
|
||||
} catch (err) {
|
||||
console.error('[MFA] Setup error:', err);
|
||||
return { error: 'MFA setup failed', status: 500 };
|
||||
throw new DomainError(500, 'MFA setup failed');
|
||||
}
|
||||
return { secret, otpauth_url, qrPromise: QRCode.toString(otpauth_url, { type: 'svg', width: 250 }) };
|
||||
}
|
||||
|
||||
async enableMfa(userId: number, rawCode: unknown): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] }> {
|
||||
async enableMfa(userId: number, rawCode: unknown): Promise<{ success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] }> {
|
||||
const code = rawCode as string | undefined;
|
||||
if (!code) {
|
||||
return { error: 'Verification code is required', status: 400 };
|
||||
throw new DomainError(400, 'Verification code is required');
|
||||
}
|
||||
const pending = this.getPendingMfaSecret(userId);
|
||||
if (!pending) {
|
||||
return { error: 'No MFA setup in progress. Start the setup again.', status: 400 };
|
||||
throw new DomainError(400, 'No MFA setup in progress. Start the setup again.');
|
||||
}
|
||||
const tokenStr = String(code).replace(/\s/g, '');
|
||||
const ok = authenticator.verify({ token: tokenStr, secret: pending });
|
||||
if (!ok) {
|
||||
return { error: 'Invalid verification code', status: 401 };
|
||||
throw new DomainError(401, 'Invalid verification code');
|
||||
}
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupHashes = backupCodes.map(hashBackupCodeBcrypt);
|
||||
@@ -854,31 +850,31 @@ export class AuthService {
|
||||
userEmail: string,
|
||||
rawBody: unknown,
|
||||
currentSessionId?: string,
|
||||
): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean }> {
|
||||
): Promise<{ success?: boolean; mfa_enabled?: boolean }> {
|
||||
const body = rawBody as { password?: string; code?: string };
|
||||
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
||||
return { error: 'MFA cannot be changed in demo mode.', status: 403 };
|
||||
throw new DomainError(403, 'MFA cannot be changed in demo mode.');
|
||||
}
|
||||
const policy = await readAppSetting(this.appSettings, 'require_mfa');
|
||||
if (policy === 'true') {
|
||||
return { error: 'Two-factor authentication cannot be disabled while it is required for all users.', status: 403 };
|
||||
throw new DomainError(403, 'Two-factor authentication cannot be disabled while it is required for all users.');
|
||||
}
|
||||
const { password, code } = body;
|
||||
if (!password || !code) {
|
||||
return { error: 'Password and authenticator code are required', status: 400 };
|
||||
throw new DomainError(400, 'Password and authenticator code are required');
|
||||
}
|
||||
const user = await this.usersRepo.findById(userId);
|
||||
if (!user?.mfa_enabled || !user.mfa_secret) {
|
||||
return { error: 'MFA is not enabled', status: 400 };
|
||||
throw new DomainError(400, 'MFA is not enabled');
|
||||
}
|
||||
if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return { error: 'Incorrect password', status: 401 };
|
||||
throw new DomainError(401, 'Incorrect password');
|
||||
}
|
||||
const secret = decryptMfaSecret(user.mfa_secret);
|
||||
const tokenStr = String(code).replace(/\s/g, '');
|
||||
const ok = authenticator.verify({ token: tokenStr, secret });
|
||||
if (!ok) {
|
||||
return { error: 'Invalid verification code', status: 401 };
|
||||
throw new DomainError(401, 'Invalid verification code');
|
||||
}
|
||||
// Every other session ends with the second factor; this one just proved both.
|
||||
await this.uow.transactional(async () => {
|
||||
@@ -890,8 +886,6 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async verifyMfaLogin(rawBody: unknown, client?: SessionClient): Promise<{
|
||||
error?: string;
|
||||
status?: number;
|
||||
token?: string;
|
||||
user?: Record<string, unknown>;
|
||||
remember?: boolean;
|
||||
@@ -901,16 +895,16 @@ export class AuthService {
|
||||
const { mfa_token, code, remember_me } = body;
|
||||
const remember = remember_me === true;
|
||||
if (!mfa_token || !code) {
|
||||
return { error: 'Verification token and code are required', status: 400 };
|
||||
throw new DomainError(400, 'Verification token and code are required');
|
||||
}
|
||||
try {
|
||||
const decoded = jwt.verify(mfa_token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; purpose?: string };
|
||||
if (decoded.purpose !== 'mfa_login') {
|
||||
return { error: 'Invalid verification token', status: 401 };
|
||||
throw new DomainError(401, 'Invalid verification token');
|
||||
}
|
||||
const user = await this.usersRepo.findById(decoded.id);
|
||||
if (!user || user.mfa_enabled !== 1 || !user.mfa_secret) {
|
||||
return { error: 'Invalid session', status: 401 };
|
||||
throw new DomainError(401, 'Invalid session');
|
||||
}
|
||||
const secret = decryptMfaSecret(user.mfa_secret);
|
||||
const tokenStr = String(code).trim();
|
||||
@@ -921,7 +915,7 @@ export class AuthService {
|
||||
// any store older than the bcrypt migration keeps working.
|
||||
const idx = hashes.findIndex((h) => matchBackupCode(tokenStr, h));
|
||||
if (idx === -1) {
|
||||
return { error: 'Invalid verification code', status: 401 };
|
||||
throw new DomainError(401, 'Invalid verification code');
|
||||
}
|
||||
hashes.splice(idx, 1);
|
||||
// Consume the backup code and record the login atomically — the code
|
||||
@@ -941,8 +935,9 @@ export class AuthService {
|
||||
remember,
|
||||
auditUserId: Number(user.id),
|
||||
};
|
||||
} catch {
|
||||
return { error: 'Invalid or expired verification token', status: 401 };
|
||||
} catch (err) {
|
||||
if (err instanceof DomainError) throw err;
|
||||
throw new DomainError(401, 'Invalid or expired verification token');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1021,28 +1016,28 @@ export class AuthService {
|
||||
const body = rawBody as { token?: string; new_password?: string; mfa_code?: string };
|
||||
const { token, new_password, mfa_code } = body;
|
||||
if (!token || typeof token !== 'string') {
|
||||
return { error: 'Reset token is required', status: 400 };
|
||||
throw new DomainError(400, 'Reset token is required');
|
||||
}
|
||||
if (!new_password || typeof new_password !== 'string') {
|
||||
return { error: 'New password is required', status: 400 };
|
||||
throw new DomainError(400, 'New password is required');
|
||||
}
|
||||
// Check the policy BEFORE touching the token so an invalid password
|
||||
// does not burn the user's one-time link.
|
||||
const pwCheck = validatePassword(new_password);
|
||||
if (!pwCheck.ok) return { error: pwCheck.reason!, status: 400 };
|
||||
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason!);
|
||||
|
||||
const tokenHash = hashResetToken(token);
|
||||
const row = await this.passwordResetTokens.findByTokenHash(tokenHash);
|
||||
|
||||
if (!row) return { error: 'Invalid or expired reset link', status: 400 };
|
||||
if (row.consumed_at) return { error: 'This reset link has already been used', status: 400 };
|
||||
if (!row) throw new DomainError(400, 'Invalid or expired reset link');
|
||||
if (row.consumed_at) throw new DomainError(400, 'This reset link has already been used');
|
||||
if ((parseDbTimestamp(row.expires_at)?.getTime() ?? Number.NaN) < Date.now()) {
|
||||
return { error: 'Reset link has expired. Please request a new one.', status: 400 };
|
||||
throw new DomainError(400, 'Reset link has expired. Please request a new one.');
|
||||
}
|
||||
|
||||
const user = await this.usersRepo.findResetTarget(row.user_id);
|
||||
|
||||
if (!user) return { error: 'Invalid or expired reset link', status: 400 };
|
||||
if (!user) throw new DomainError(400, 'Invalid or expired reset link');
|
||||
|
||||
// MFA gate. If enabled, require a valid TOTP or backup code.
|
||||
const mfaOn = user.mfa_enabled === 1;
|
||||
@@ -1050,7 +1045,7 @@ export class AuthService {
|
||||
if (mfaOn) {
|
||||
if (!user.mfa_secret) {
|
||||
// Data inconsistency — fail closed.
|
||||
return { error: 'MFA is enabled but not configured. Contact your administrator.', status: 500 };
|
||||
throw new DomainError(500, 'MFA is enabled but not configured. Contact your administrator.');
|
||||
}
|
||||
const supplied = typeof mfa_code === 'string' ? mfa_code.trim() : '';
|
||||
if (!supplied) return { mfa_required: true, status: 200 };
|
||||
@@ -1060,7 +1055,7 @@ export class AuthService {
|
||||
if (!okTotp) {
|
||||
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
|
||||
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h));
|
||||
if (idx === -1) return { error: 'Invalid MFA code', status: 401 };
|
||||
if (idx === -1) throw new DomainError(401, 'Invalid MFA code');
|
||||
backupCodeConsumedIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ export class PasskeyController {
|
||||
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
|
||||
// — the RP ID itself is never derived from request headers.
|
||||
const result = await this.passkeys.passkeyRegisterOptions(user.id, body?.password, req.headers.origin);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
return result.options;
|
||||
}
|
||||
|
||||
@@ -63,7 +62,6 @@ export class PasskeyController {
|
||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
||||
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
|
||||
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
||||
return { success: true, credential: result.credential };
|
||||
}
|
||||
@@ -76,7 +74,6 @@ export class PasskeyController {
|
||||
async loginOptions(@Req() req: Request) {
|
||||
await this.limit('login', req, 10);
|
||||
const result = await this.passkeys.passkeyLoginOptions(req.headers.origin);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
return result.options;
|
||||
}
|
||||
|
||||
@@ -112,8 +109,7 @@ export class PasskeyController {
|
||||
@Patch('credentials/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
|
||||
const result = await this.passkeys.renamePasskey(user.id, id, body?.name);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
await this.passkeys.renamePasskey(user.id, id, body?.name);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -121,8 +117,7 @@ export class PasskeyController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) {
|
||||
await this.limit('login', req, 5);
|
||||
const result = await this.passkeys.deletePasskey(user.id, id, body?.password);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
await this.passkeys.deletePasskey(user.id, id, body?.password);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import bcrypt from 'bcryptjs';
|
||||
@@ -41,6 +42,9 @@ const NOT_CONFIGURED = { error: 'Passkey login is not configured for this server
|
||||
// used to tell "no such credential" apart from "bad signature" (CWE-203).
|
||||
const AUTH_FAILED = { error: 'Authentication failed', status: 401 } as const;
|
||||
|
||||
/** One of the canned `{ error, status }` pairs above, raised. */
|
||||
const refusal = (r: { error: string; status: number }) => new DomainError(r.status, r.error);
|
||||
|
||||
// Reference-compared sentinel (oidc invite_exhausted precedent): thrown inside
|
||||
// the register transaction to keep the duplicate 409 distinct from the generic
|
||||
// insert-failure 400 without string-matching SQLite errors.
|
||||
@@ -224,19 +228,19 @@ export class PasskeyService {
|
||||
userId: number,
|
||||
password: string | undefined,
|
||||
requestOrigin?: string,
|
||||
): Promise<{ error?: string; status?: number; options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
|
||||
): Promise<{ options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
|
||||
const cfg = await this.webauthn.resolve();
|
||||
if (!cfg) return { ...NOT_CONFIGURED };
|
||||
if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED };
|
||||
if (!cfg) throw refusal(NOT_CONFIGURED);
|
||||
if (this.originCannotVerify(cfg, requestOrigin)) throw refusal(NOT_CONFIGURED);
|
||||
|
||||
const user = await this.users.findById(userId);
|
||||
if (!user) return { error: 'User not found', status: 404 };
|
||||
if (!user) throw new DomainError(404, 'User not found');
|
||||
|
||||
// Re-authentication: a hijacked session must not be able to silently plant an
|
||||
// attacker-controlled passkey. Require the current password (parity with the
|
||||
// change-password / disable-MFA step-up).
|
||||
if (!password || !user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return { error: 'Incorrect password', status: 401 };
|
||||
throw new DomainError(401, 'Incorrect password');
|
||||
}
|
||||
|
||||
const existing = await this.webauthnCredentials.listExcludeCredentials(userId);
|
||||
@@ -264,20 +268,20 @@ export class PasskeyService {
|
||||
async passkeyRegisterVerify(
|
||||
userId: number,
|
||||
body: { attestationResponse?: unknown; name?: unknown },
|
||||
): Promise<{ error?: string; status?: number; success?: boolean; credential?: unknown }> {
|
||||
): Promise<{ success?: boolean; credential?: unknown }> {
|
||||
const cfg = await this.webauthn.resolve();
|
||||
if (!cfg) return { ...NOT_CONFIGURED };
|
||||
if (!cfg) throw refusal(NOT_CONFIGURED);
|
||||
|
||||
const resp = body?.attestationResponse;
|
||||
if (!resp) return { error: 'Invalid registration response', status: 400 };
|
||||
if (!resp) throw new DomainError(400, 'Invalid registration response');
|
||||
|
||||
const challenge = challengeFromResponse(resp);
|
||||
if (!challenge) return { error: 'Invalid registration response', status: 400 };
|
||||
if (!challenge) throw new DomainError(400, 'Invalid registration response');
|
||||
|
||||
const now = Date.now();
|
||||
const claimed = await this.claimChallenge(challenge, 'registration', now);
|
||||
if (!claimed || claimed.user_id !== userId) {
|
||||
return { error: 'Registration challenge expired. Please try again.', status: 400 };
|
||||
throw new DomainError(400, 'Registration challenge expired. Please try again.');
|
||||
}
|
||||
|
||||
const expectedOrigin = this.expectedOrigins(cfg, resp);
|
||||
@@ -296,11 +300,11 @@ export class PasskeyService {
|
||||
this.logger.warn(
|
||||
`Passkey registration rejected (expectedRPID=${cfg.rpID}, expectedOrigin=[${expectedOrigin.join(', ')}]): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return { error: 'Could not register this passkey.', status: 400 };
|
||||
throw new DomainError(400, 'Could not register this passkey.');
|
||||
}
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return { error: 'Could not register this passkey.', status: 400 };
|
||||
throw new DomainError(400, 'Could not register this passkey.');
|
||||
}
|
||||
|
||||
// Persist ONLY the values the verifier vouches for — never anything parsed
|
||||
@@ -329,9 +333,9 @@ export class PasskeyService {
|
||||
});
|
||||
} catch (err) {
|
||||
if (err === DUPLICATE_CREDENTIAL) {
|
||||
return { error: 'This passkey is already registered.', status: 409 };
|
||||
throw new DomainError(409, 'This passkey is already registered.');
|
||||
}
|
||||
return { error: 'Could not register this passkey.', status: 400 };
|
||||
throw new DomainError(400, 'Could not register this passkey.');
|
||||
}
|
||||
|
||||
const created = (await this.webauthnCredentials.findCreatedCredential(credential.id))!;
|
||||
@@ -343,13 +347,11 @@ export class PasskeyService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async passkeyLoginOptions(requestOrigin?: string): Promise<{
|
||||
error?: string;
|
||||
status?: number;
|
||||
options?: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
|
||||
}> {
|
||||
const cfg = await this.webauthn.resolve();
|
||||
if (!cfg) return { ...NOT_CONFIGURED };
|
||||
if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED };
|
||||
if (!cfg) throw refusal(NOT_CONFIGURED);
|
||||
if (this.originCannotVerify(cfg, requestOrigin)) throw refusal(NOT_CONFIGURED);
|
||||
|
||||
const now = Date.now();
|
||||
await this.purgeExpiredChallenges(now);
|
||||
@@ -452,9 +454,9 @@ export class PasskeyService {
|
||||
return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 }));
|
||||
}
|
||||
|
||||
async renamePasskey(userId: number, id: string, name: unknown): Promise<{ error?: string; status?: number; success?: boolean }> {
|
||||
async renamePasskey(userId: number, id: string, name: unknown): Promise<{ success?: boolean }> {
|
||||
const cleanName = sanitizeName(name);
|
||||
if (!cleanName) return { error: 'Name is required', status: 400 };
|
||||
if (!cleanName) throw new DomainError(400, 'Name is required');
|
||||
// Convert, VALIDATE, and answer the legacy not-found before the
|
||||
// repository call (program rule 15): the legacy `UPDATE ... WHERE id = ?
|
||||
// AND user_id = ?` bound `Number(id)` as a plain parameter — a
|
||||
@@ -466,10 +468,10 @@ export class PasskeyService {
|
||||
// reference and throws — a 500 where the legacy 404'd (Plan 3b Task 3
|
||||
// review, F1).
|
||||
const rowId = toRowId(id);
|
||||
if (rowId === null) return { error: 'Passkey not found', status: 404 };
|
||||
if (rowId === null) throw new DomainError(404, 'Passkey not found');
|
||||
// Ownership enforced in SQL (404 on miss, never a 403 that leaks existence).
|
||||
const changes = await this.webauthnCredentials.renameOwned(rowId, userId, cleanName);
|
||||
if (changes === 0) return { error: 'Passkey not found', status: 404 };
|
||||
if (changes === 0) throw new DomainError(404, 'Passkey not found');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -477,27 +479,27 @@ export class PasskeyService {
|
||||
userId: number,
|
||||
id: string,
|
||||
password: string | undefined,
|
||||
): Promise<{ error?: string; status?: number; success?: boolean }> {
|
||||
): Promise<{ success?: boolean }> {
|
||||
// Re-auth before removing a credential (a hijacked session must not be able to
|
||||
// strip the victim's passkeys). Deleting is always allowed because every
|
||||
// account keeps a usable password as recovery fallback — losing all passkeys
|
||||
// can never lock anyone out.
|
||||
const passwordHash = await this.users.getPasswordHash(userId);
|
||||
if (!passwordHash || !password || !bcrypt.compareSync(password, passwordHash)) {
|
||||
return { error: 'Incorrect password', status: 401 };
|
||||
throw new DomainError(401, 'Incorrect password');
|
||||
}
|
||||
// Same guard as `renamePasskey` (F1) — placed after the password check
|
||||
// to match the legacy statement order exactly (a wrong password still
|
||||
// answers 401 before a bad id is ever considered).
|
||||
const rowId = toRowId(id);
|
||||
if (rowId === null) return { error: 'Passkey not found', status: 404 };
|
||||
if (rowId === null) throw new DomainError(404, 'Passkey not found');
|
||||
const changes = await this.webauthnCredentials.deleteOwned(rowId, userId);
|
||||
if (changes === 0) return { error: 'Passkey not found', status: 404 };
|
||||
if (changes === 0) throw new DomainError(404, 'Passkey not found');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Admin: clear all of a user's passkeys (e.g. on suspected compromise). */
|
||||
async adminResetPasskeys(targetUserId: number): Promise<{ error?: string; status?: number; success?: boolean; deleted?: number; email?: string }> {
|
||||
async adminResetPasskeys(targetUserId: number): Promise<{ success?: boolean; deleted?: number; email?: string }> {
|
||||
// `AdminService.resetUserPasskeys` converts the route param with a bare
|
||||
// `Number(id)` before calling in — a non-numeric id arrives here as
|
||||
// `NaN`, still typed `number` at the JS level. Same F1 guard: validate
|
||||
@@ -505,9 +507,9 @@ export class PasskeyService {
|
||||
// `SELECT id, email FROM users WHERE id = ?` bound `Number(id)` too and
|
||||
// simply matched no row).
|
||||
const rowId = toRowId(targetUserId);
|
||||
if (rowId === null) return { error: 'User not found', status: 404 };
|
||||
if (rowId === null) throw new DomainError(404, 'User not found');
|
||||
const target = await this.users.findIdAndEmail(rowId);
|
||||
if (!target) return { error: 'User not found', status: 404 };
|
||||
if (!target) throw new DomainError(404, 'User not found');
|
||||
const deleted = await this.webauthnCredentials.deleteAllForUser(rowId);
|
||||
return { success: true, deleted, email: target.email };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import crypto from 'crypto';
|
||||
@@ -101,7 +102,7 @@ export class RegistrationInvitesService {
|
||||
const numericId = toRowId(id);
|
||||
// RI6 — the 404 check.
|
||||
if (numericId === null || (await this.inviteTokens.findIdById(numericId)) === null) {
|
||||
return { error: 'Invite not found', status: 404 };
|
||||
throw new DomainError(404, 'Invite not found');
|
||||
}
|
||||
// RI7.
|
||||
await this.inviteTokens.deleteById(numericId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { readEnv, getAppUrl } from '../../app-config';
|
||||
@@ -184,29 +185,29 @@ export class UserProfileService {
|
||||
async updateSettings(
|
||||
userId: number,
|
||||
rawBody: unknown
|
||||
): Promise<{ error?: string; status?: number; success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] }> {
|
||||
): Promise<{ success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] }> {
|
||||
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string; username?: string; email?: string };
|
||||
const { maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, username, email } = body;
|
||||
|
||||
if (username !== undefined) {
|
||||
const trimmed = username.trim();
|
||||
if (!trimmed || trimmed.length < 2 || trimmed.length > 50) {
|
||||
return { error: 'Username must be between 2 and 50 characters', status: 400 };
|
||||
throw new DomainError(400, 'Username must be between 2 and 50 characters');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) {
|
||||
return { error: 'Username can only contain letters, numbers, underscores, dots and hyphens', status: 400 };
|
||||
throw new DomainError(400, 'Username can only contain letters, numbers, underscores, dots and hyphens');
|
||||
}
|
||||
const conflict = await this.usersRepo.findIdByUsernameCI(trimmed, userId);
|
||||
if (conflict) return { error: 'Username already taken', status: 409 };
|
||||
if (conflict) throw new DomainError(409, 'Username already taken');
|
||||
}
|
||||
|
||||
if (email !== undefined) {
|
||||
const trimmed = email.trim();
|
||||
if (!trimmed || !EMAIL_REGEX.test(trimmed)) {
|
||||
return { error: 'Invalid email format', status: 400 };
|
||||
throw new DomainError(400, 'Invalid email format');
|
||||
}
|
||||
const conflict = await this.usersRepo.findIdByEmailCI(trimmed, userId);
|
||||
if (conflict) return { error: 'Email already taken', status: 409 };
|
||||
if (conflict) throw new DomainError(409, 'Email already taken');
|
||||
}
|
||||
|
||||
// The name and email half of this body stays the user's own in every mode;
|
||||
@@ -241,7 +242,7 @@ export class UserProfileService {
|
||||
} catch (err) {
|
||||
// Another write took the name or address between the check above and this one.
|
||||
if (err instanceof UserIdentityTakenError) {
|
||||
return { error: err.field === 'email' ? 'Email already taken' : 'Username already taken', status: 409 };
|
||||
throw new DomainError(409, err.field === 'email' ? 'Email already taken' : 'Username already taken');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -258,9 +259,9 @@ export class UserProfileService {
|
||||
};
|
||||
}
|
||||
|
||||
async getSettings(userId: number): Promise<{ error?: string; status?: number; settings?: Record<string, unknown> }> {
|
||||
async getSettings(userId: number): Promise<{ settings?: Record<string, unknown> }> {
|
||||
const user = await this.usersRepo.getApiKeyColumns(userId);
|
||||
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
|
||||
if (user?.role !== 'admin') throw new DomainError(403, 'Admin access required');
|
||||
|
||||
// The one endpoint in the codebase that hands back a stored key in the
|
||||
// clear. That is fine when the admin pasted it in themselves and wrong when
|
||||
@@ -348,9 +349,9 @@ export class UserProfileService {
|
||||
// Key validation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
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 } }> {
|
||||
async validateKeys(userId: number): Promise<{ 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 = await this.usersRepo.getRoleAndWeatherKey(userId);
|
||||
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
|
||||
if (user?.role !== 'admin') throw new DomainError(403, 'Admin access required');
|
||||
|
||||
const result: {
|
||||
maps: boolean;
|
||||
|
||||
@@ -79,14 +79,13 @@ export const conflict = (message: string, details?: Record<string, unknown>) =>
|
||||
* Runs `fn` and returns the DomainError it raised instead of throwing it; any
|
||||
* other error propagates. For the few callers that must act on a refusal before
|
||||
* passing it on (an audit row, a constant-time pad) and for service tests.
|
||||
* Test the result with `instanceof DomainError`.
|
||||
*/
|
||||
export async function catchDomainError<T>(
|
||||
fn: () => Promise<T> | T,
|
||||
): Promise<{ ok: true; value: T } | { ok: false; error: DomainError }> {
|
||||
export async function catchDomainError<T>(fn: () => Promise<T> | T): Promise<T | DomainError> {
|
||||
try {
|
||||
return { ok: true, value: await fn() };
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (err instanceof DomainError) return { ok: false, error: err };
|
||||
if (err instanceof DomainError) return err;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import crypto, { randomBytes, randomUUID } from 'crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
@@ -745,10 +746,10 @@ export class OauthService {
|
||||
|
||||
async adminRevokeOAuthSession(id: string) {
|
||||
const tokenId = toRowId(id);
|
||||
if (tokenId === null) return { error: 'Session not found', status: 404 };
|
||||
if (tokenId === null) throw new DomainError(404, 'Session not found');
|
||||
|
||||
const row = await this.tokens.findById(tokenId);
|
||||
if (!row) return { error: 'Session not found', status: 404 };
|
||||
if (!row) throw new DomainError(404, 'Session not found');
|
||||
await this.tokens.revokeById(tokenId);
|
||||
revokeUserSessionsForClient(row.user_id, row.client_id);
|
||||
return {};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
@@ -108,13 +109,13 @@ export class TokenService {
|
||||
return await this.createToken(userId, rawName, 'api', scopes);
|
||||
}
|
||||
|
||||
private async createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): Promise<{ error?: string; status?: number; token?: Record<string, unknown> }> {
|
||||
private async createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): Promise<{ token?: Record<string, unknown> }> {
|
||||
const name = rawName as string | undefined;
|
||||
if (!name?.trim()) return { error: 'Token name is required', status: 400 };
|
||||
if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 };
|
||||
if (!name?.trim()) throw new DomainError(400, 'Token name is required');
|
||||
if (name.trim().length > 100) throw new DomainError(400, 'Token name must be 100 characters or less');
|
||||
|
||||
const tokenCount = await this.tokens.countByUserAndKind(userId, kind);
|
||||
if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 };
|
||||
if (tokenCount >= 10) throw new DomainError(400, 'Maximum of 10 tokens per user reached');
|
||||
|
||||
const rawToken = 'trek_' + randomBytes(24).toString('hex');
|
||||
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
|
||||
@@ -162,7 +163,7 @@ export class TokenService {
|
||||
* happily delete a token the MCP panel manages, and the user would find a key
|
||||
* missing from a screen they never opened.
|
||||
*/
|
||||
private async deleteToken(userId: number, tokenId: string, kind: TokenKind): Promise<{ error?: string; status?: number; success?: boolean }> {
|
||||
private async deleteToken(userId: number, tokenId: string, kind: TokenKind): Promise<{ success?: boolean }> {
|
||||
// Convert, VALIDATE, and answer the legacy not-found before any
|
||||
// repository call (program rule 15): the legacy statement bound
|
||||
// `tokenId` straight into `WHERE id = ?` and let SQLite's affinity rules
|
||||
@@ -170,9 +171,9 @@ export class TokenService {
|
||||
// leniency, so a bare `Number()` turned a 404 into a 500 (Plan 3b Task 2
|
||||
// review, F1).
|
||||
const id = toRowId(tokenId);
|
||||
if (id === null) return { error: 'Token not found', status: 404 };
|
||||
if (id === null) throw new DomainError(404, 'Token not found');
|
||||
const token = await this.tokens.findOwnedByKind(id, userId, kind);
|
||||
if (!token) return { error: 'Token not found', status: 404 };
|
||||
if (!token) throw new DomainError(404, 'Token not found');
|
||||
await this.tokens.deleteById(id);
|
||||
// Best-effort, like the changePassword/resetPassword revocations: a session
|
||||
// sweep failure must not turn a successful token delete into a 500.
|
||||
@@ -184,12 +185,12 @@ export class TokenService {
|
||||
// Ephemeral tokens
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async createWsToken(userId: number): Promise<{ error?: string; status?: number; token?: string }> {
|
||||
async createWsToken(userId: number): Promise<{ token?: string }> {
|
||||
// Bind the ws-token to the user's current password_version so a token minted
|
||||
// before a password reset is rejected on connect (defence-in-depth session gate).
|
||||
const pv = (await this.users.getPasswordVersion(userId)) ?? 0;
|
||||
const token = this.ephemeral.create(userId, 'ws', { pv });
|
||||
if (!token) return { error: 'Service unavailable', status: 503 };
|
||||
if (!token) throw new DomainError(503, 'Service unavailable');
|
||||
return { token };
|
||||
}
|
||||
|
||||
@@ -222,9 +223,9 @@ export class TokenService {
|
||||
// Same guard as `deleteToken` above — convert, VALIDATE, answer the
|
||||
// legacy 404 before any repository call (F1).
|
||||
const numericId = toRowId(id);
|
||||
if (numericId === null) return { error: 'Token not found', status: 404 };
|
||||
if (numericId === null) throw new DomainError(404, 'Token not found');
|
||||
const token = await this.tokens.findBasic(numericId);
|
||||
if (!token) return { error: 'Token not found', status: 404 };
|
||||
if (!token) throw new DomainError(404, 'Token not found');
|
||||
await this.tokens.deleteById(numericId);
|
||||
revokeUserSessions(token.user_id);
|
||||
return {};
|
||||
|
||||
Reference in New Issue
Block a user