mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(api): expire auth tokens and harden login checks (#2073)
This commit is contained in:
1 parent
9a6ab93e01
commit
bbd93df239
8 files changed
+42
-9
No files matched your search
@@ -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': {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user