fix(api): expire auth tokens and harden login checks (#2073)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-29 14:55:04 +02:00
1 parent 9a6ab93e01
commit bbd93df239
8 files changed
+42 -9

No files matched your search

+19 -1
View File
@@ -21,7 +21,7 @@ import {
verifyAuthenticationResponse, verifyAuthenticationResponse,
verifyRegistrationResponse, verifyRegistrationResponse,
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import {seconds} from 'itty-time'; import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../ApiContext'; import type {ApiContext} from '../ApiContext';
import {createUserID, type UserID} from '../BrandedTypes'; import {createUserID, type UserID} from '../BrandedTypes';
import {Logger} from '../Logger'; import {Logger} from '../Logger';
@@ -410,6 +410,20 @@ export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: Us
return options; return options;
} }
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
const {rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `sudo-mfa:user:${userId}`,
maxAttempts: SUDO_MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode('mfa_code', ValidationErrorCodes.INVALID_MFA_CODE);
}
}
export async function verifySudoMfa( export async function verifySudoMfa(
ctx: ApiContext, ctx: ApiContext,
params: SudoMfaVerificationParams, params: SudoMfaVerificationParams,
@@ -427,7 +441,11 @@ export async function verifySudoMfa(
case 'totp': { case 'totp': {
if (!code) return {success: false, error: 'TOTP code is required'}; if (!code) return {success: false, error: 'TOTP code is required'};
if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'}; if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'};
await consumeSudoMfaAttempt(ctx, userId);
const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true}); const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true});
if (isValid) {
await ctx.services.rateLimit.resetLimit(`sudo-mfa:user:${userId}`);
}
return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'}; return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'};
} }
case 'webauthn': { case 'webauthn': {
+1
View File
@@ -91,6 +91,7 @@ export async function createAuthSession(
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError(); if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError(); if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError(); if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
user = await AuthUtility.handleBanStatus(ctx, user);
const now = new Date(); const now = new Date();
const token = await AuthUtility.generateAuthToken(ctx); const token = await AuthUtility.generateAuthToken(ctx);
let clientCountry: string | null = null; let clientCountry: string | null = null;
@@ -205,7 +205,7 @@ function readMatchingEmailVerified(
return coerceEmailVerified(record['email_verified']); return coerceEmailVerified(record['email_verified']);
} }
function resolveEmailVerified({ export function resolveEmailVerified({
claims, claims,
userInfo, userInfo,
idTokenEmail, idTokenEmail,
@@ -225,7 +225,7 @@ function resolveEmailVerified({
if (values.includes(false)) { if (values.includes(false)) {
return false; return false;
} }
return values.length === 0 || values.includes(true); return values.length > 0 && values.includes(true);
} }
function isJsonWebKeySet(value: unknown): value is JSONWebKeySet { function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
@@ -316,7 +316,10 @@ export class OAuth2RequestService {
throw err; throw err;
} }
} }
Logger.info({redirectTo}, 'OAuth2 consent: returning redirect URL'); Logger.info(
{clientId: params.body.client_id.toString(), userId: params.userId.toString()},
'OAuth2 consent: returning redirect URL',
);
return {redirect_to: redirectTo}; return {redirect_to: redirectTo};
} }
@@ -3,3 +3,4 @@
import {seconds} from 'itty-time'; import {seconds} from 'itty-time';
export const ACCESS_TOKEN_TTL_SECONDS = seconds('7 days'); export const ACCESS_TOKEN_TTL_SECONDS = seconds('7 days');
export const AUTHORIZATION_CODE_TTL_SECONDS = seconds('10 minutes');
@@ -19,13 +19,17 @@ import {
OAuth2RefreshTokens, OAuth2RefreshTokens,
OAuth2RefreshTokensByUser, OAuth2RefreshTokensByUser,
} from '../../Tables'; } from '../../Tables';
import {ACCESS_TOKEN_TTL_SECONDS} from '../OAuth2TokenConstants'; import {ACCESS_TOKEN_TTL_SECONDS, AUTHORIZATION_CODE_TTL_SECONDS} from '../OAuth2TokenConstants';
import type {IOAuth2TokenRepository} from './IOAuth2TokenRepository'; import type {IOAuth2TokenRepository} from './IOAuth2TokenRepository';
function isAccessTokenExpired(createdAt: Date): boolean { function isAccessTokenExpired(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() > ACCESS_TOKEN_TTL_SECONDS * 1000; return Date.now() - createdAt.getTime() > ACCESS_TOKEN_TTL_SECONDS * 1000;
} }
function isAuthorizationCodeExpired(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() > AUTHORIZATION_CODE_TTL_SECONDS * 1000;
}
const SELECT_AUTHORIZATION_CODE = OAuth2AuthorizationCodes.selectCql({ const SELECT_AUTHORIZATION_CODE = OAuth2AuthorizationCodes.selectCql({
where: OAuth2AuthorizationCodes.where.eq('code'), where: OAuth2AuthorizationCodes.where.eq('code'),
}); });
@@ -46,13 +50,19 @@ const SELECT_REFRESH_TOKENS_BY_USER = OAuth2RefreshTokensByUser.selectCql({
export class OAuth2TokenRepository implements IOAuth2TokenRepository { export class OAuth2TokenRepository implements IOAuth2TokenRepository {
async createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode> { async createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode> {
await upsertOne(OAuth2AuthorizationCodes.insert(data)); await upsertOne(OAuth2AuthorizationCodes.insertWithTtl(data, AUTHORIZATION_CODE_TTL_SECONDS));
return new OAuth2AuthorizationCode(data); return new OAuth2AuthorizationCode(data);
} }
async getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null> { async getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null> {
const row = await fetchOne<OAuth2AuthorizationCodeRow>(SELECT_AUTHORIZATION_CODE, {code}); const row = await fetchOne<OAuth2AuthorizationCodeRow>(SELECT_AUTHORIZATION_CODE, {code});
return row ? new OAuth2AuthorizationCode(row) : null; if (!row) {
return null;
}
if (isAuthorizationCodeExpired(row.created_at)) {
return null;
}
return new OAuth2AuthorizationCode(row);
} }
async deleteAuthorizationCode(code: string): Promise<void> { async deleteAuthorizationCode(code: string): Promise<void> {
@@ -256,7 +256,7 @@ export class StripeRefundService {
return; return;
} }
const subscriptionId = refund.metadata.subscription_id; const subscriptionId = refund.metadata.subscription_id;
if (subscriptionId && !user.firstRefundAt) { if (subscriptionId) {
try { try {
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund'); await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
} catch (error) { } catch (error) {
@@ -118,7 +118,7 @@ export class TokenRepository {
} }
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> { async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
await upsertOne(EmailRevertTokens.insert(tokenData)); await upsertOne(EmailRevertTokens.insertWithTtl(tokenData, seconds('24 hours')));
return new EmailRevertToken(tokenData); return new EmailRevertToken(tokenData);
} }