From bbd93df2391d88d6f22d2bb58bf9793e45ed055e Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 29 Aug 2026 14:55:04 +0200 Subject: [PATCH] fix(api): expire auth tokens and harden login checks (#2073) --- fluxer_api/src/api/auth/AuthMfa.ts | 20 ++++++++++++++++++- fluxer_api/src/api/auth/AuthSession.ts | 1 + .../src/api/auth/services/SsoService.ts | 4 ++-- .../src/api/oauth/OAuth2RequestService.ts | 5 ++++- .../src/api/oauth/OAuth2TokenConstants.ts | 1 + .../repositories/OAuth2TokenRepository.ts | 16 ++++++++++++--- .../stripe/services/StripeRefundService.ts | 2 +- .../user/repositories/auth/TokenRepository.ts | 2 +- 8 files changed, 42 insertions(+), 9 deletions(-) diff --git a/fluxer_api/src/api/auth/AuthMfa.ts b/fluxer_api/src/api/auth/AuthMfa.ts index 4240563c1..3e3450fcb 100644 --- a/fluxer_api/src/api/auth/AuthMfa.ts +++ b/fluxer_api/src/api/auth/AuthMfa.ts @@ -21,7 +21,7 @@ import { verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server'; -import {seconds} from 'itty-time'; +import {ms, seconds} from 'itty-time'; import type {ApiContext} from '../ApiContext'; import {createUserID, type UserID} from '../BrandedTypes'; import {Logger} from '../Logger'; @@ -410,6 +410,20 @@ export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: Us return options; } +const SUDO_MFA_USER_MAX_ATTEMPTS = 10; + +async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise { + 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( ctx: ApiContext, params: SudoMfaVerificationParams, @@ -427,7 +441,11 @@ export async function verifySudoMfa( case 'totp': { if (!code) return {success: false, error: 'TOTP code is required'}; 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}); + if (isValid) { + await ctx.services.rateLimit.resetLimit(`sudo-mfa:user:${userId}`); + } return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'}; } case 'webauthn': { diff --git a/fluxer_api/src/api/auth/AuthSession.ts b/fluxer_api/src/api/auth/AuthSession.ts index d2e8b2c6c..707f7e60e 100644 --- a/fluxer_api/src/api/auth/AuthSession.ts +++ b/fluxer_api/src/api/auth/AuthSession.ts @@ -91,6 +91,7 @@ export async function createAuthSession( if (user.isBot) throw new BotUserAuthSessionCreationDeniedError(); if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError(); if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError(); + user = await AuthUtility.handleBanStatus(ctx, user); const now = new Date(); const token = await AuthUtility.generateAuthToken(ctx); let clientCountry: string | null = null; diff --git a/fluxer_api/src/api/auth/services/SsoService.ts b/fluxer_api/src/api/auth/services/SsoService.ts index cea8a79e4..b578a070b 100644 --- a/fluxer_api/src/api/auth/services/SsoService.ts +++ b/fluxer_api/src/api/auth/services/SsoService.ts @@ -205,7 +205,7 @@ function readMatchingEmailVerified( return coerceEmailVerified(record['email_verified']); } -function resolveEmailVerified({ +export function resolveEmailVerified({ claims, userInfo, idTokenEmail, @@ -225,7 +225,7 @@ function resolveEmailVerified({ if (values.includes(false)) { return false; } - return values.length === 0 || values.includes(true); + return values.length > 0 && values.includes(true); } function isJsonWebKeySet(value: unknown): value is JSONWebKeySet { diff --git a/fluxer_api/src/api/oauth/OAuth2RequestService.ts b/fluxer_api/src/api/oauth/OAuth2RequestService.ts index b23db8cf7..8f9a2f917 100644 --- a/fluxer_api/src/api/oauth/OAuth2RequestService.ts +++ b/fluxer_api/src/api/oauth/OAuth2RequestService.ts @@ -316,7 +316,10 @@ export class OAuth2RequestService { 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}; } diff --git a/fluxer_api/src/api/oauth/OAuth2TokenConstants.ts b/fluxer_api/src/api/oauth/OAuth2TokenConstants.ts index 93de70e88..542ee8602 100644 --- a/fluxer_api/src/api/oauth/OAuth2TokenConstants.ts +++ b/fluxer_api/src/api/oauth/OAuth2TokenConstants.ts @@ -3,3 +3,4 @@ import {seconds} from 'itty-time'; export const ACCESS_TOKEN_TTL_SECONDS = seconds('7 days'); +export const AUTHORIZATION_CODE_TTL_SECONDS = seconds('10 minutes'); diff --git a/fluxer_api/src/api/oauth/repositories/OAuth2TokenRepository.ts b/fluxer_api/src/api/oauth/repositories/OAuth2TokenRepository.ts index 68c9c2b5c..d48740d10 100644 --- a/fluxer_api/src/api/oauth/repositories/OAuth2TokenRepository.ts +++ b/fluxer_api/src/api/oauth/repositories/OAuth2TokenRepository.ts @@ -19,13 +19,17 @@ import { OAuth2RefreshTokens, OAuth2RefreshTokensByUser, } 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'; function isAccessTokenExpired(createdAt: Date): boolean { 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({ where: OAuth2AuthorizationCodes.where.eq('code'), }); @@ -46,13 +50,19 @@ const SELECT_REFRESH_TOKENS_BY_USER = OAuth2RefreshTokensByUser.selectCql({ export class OAuth2TokenRepository implements IOAuth2TokenRepository { async createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise { - await upsertOne(OAuth2AuthorizationCodes.insert(data)); + await upsertOne(OAuth2AuthorizationCodes.insertWithTtl(data, AUTHORIZATION_CODE_TTL_SECONDS)); return new OAuth2AuthorizationCode(data); } async getAuthorizationCode(code: string): Promise { const row = await fetchOne(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 { diff --git a/fluxer_api/src/api/stripe/services/StripeRefundService.ts b/fluxer_api/src/api/stripe/services/StripeRefundService.ts index 571517612..1aa2c8fc7 100644 --- a/fluxer_api/src/api/stripe/services/StripeRefundService.ts +++ b/fluxer_api/src/api/stripe/services/StripeRefundService.ts @@ -256,7 +256,7 @@ export class StripeRefundService { return; } const subscriptionId = refund.metadata.subscription_id; - if (subscriptionId && !user.firstRefundAt) { + if (subscriptionId) { try { await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund'); } catch (error) { diff --git a/fluxer_api/src/api/user/repositories/auth/TokenRepository.ts b/fluxer_api/src/api/user/repositories/auth/TokenRepository.ts index 902e76713..7e9ad4efc 100644 --- a/fluxer_api/src/api/user/repositories/auth/TokenRepository.ts +++ b/fluxer_api/src/api/user/repositories/auth/TokenRepository.ts @@ -118,7 +118,7 @@ export class TokenRepository { } async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise { - await upsertOne(EmailRevertTokens.insert(tokenData)); + await upsertOne(EmailRevertTokens.insertWithTtl(tokenData, seconds('24 hours'))); return new EmailRevertToken(tokenData); }