refactor(api): emit moderation events and apply account actions (#3031)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 12:24:15 +02:00
1 parent c488906131
commit 364084c819
385 files changed
+8994 -22221

No files matched your search

+12 -7
View File
@@ -3,10 +3,15 @@
import type {ApplicationID, UserID} from '@app/api/BrandedTypes';
import {generateOAuthTokenSecret} from '@app/api/oauth/OAuthTokenSecret';
import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {canOwnerRunBots} from '@app/api/user/UserHelpers';
import {hashPassword, verifyPassword} from '@app/api/utils/PasswordUtils';
export class BotAuthService {
constructor(private readonly applicationRepository: IApplicationRepository) {}
constructor(
private readonly applicationRepository: IApplicationRepository,
private readonly users: Pick<IUserRepository, 'findUnique'>,
) {}
private parseBotToken(token: string): {
applicationId: ApplicationID;
@@ -38,12 +43,12 @@ export class BotAuthService {
if (!application?.hasBotUser() || !application.botTokenHash) {
return null;
}
try {
const isValid = await verifyPassword({password: secret, passwordHash: application.botTokenHash});
return isValid ? application.getBotUserId() : null;
} catch {
return null;
}
const [isValid, owner] = await Promise.all([
verifyPassword({password: secret, passwordHash: application.botTokenHash}).catch(() => false),
this.users.findUnique(application.ownerUserId),
]);
if (!isValid || (owner && !canOwnerRunBots(owner))) return null;
return application.getBotUserId();
}
async generateBotToken(applicationId: ApplicationID): Promise<{
@@ -22,6 +22,7 @@ import {ACCESS_TOKEN_TTL_SECONDS, type OAuth2Service} from '@app/api/oauth/OAuth
import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository';
import type {IOAuth2TokenRepository} from '@app/api/oauth/repositories/IOAuth2TokenRepository';
import {parseClientCredentials} from '@app/api/oauth/utils/ParseClientCredentials';
import {isSignInRefused} from '@app/api/user/UserHelpers';
import {mapUserToOAuthResponse, mapUserToPartialResponse} from '@app/api/user/UserMappers';
import {verifyPassword} from '@app/api/utils/PasswordUtils';
import {canAuthorizeBotInvite, normalizeBotInvitePermissions} from '@fluxer/constants/src/BotPermissionUtils';
@@ -277,7 +278,6 @@ export class OAuth2RequestService {
}
}
await this.guildService.members.addUserToGuild({
skipRiskGate: true,
userId: botUserId,
guildId,
skipGuildLimitCheck: true,
@@ -356,9 +356,12 @@ export class OAuth2RequestService {
scopes,
expires: expiresAt.toISOString(),
};
if (tokenData.userId && tokenData.scope.has('identify')) {
if (tokenData.userId) {
const user = await this.apiContext.services.users.findUnique(tokenData.userId);
if (user) {
if (user && isSignInRefused(user)) {
throw new InvalidTokenError();
}
if (user && tokenData.scope.has('identify')) {
response.user = mapUserToOAuthResponse(user, {includeEmail: tokenData.scope.has('email')});
}
}
+19 -1
View File
@@ -18,6 +18,7 @@ import {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepo
import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository';
import type {IOAuth2TokenRepository} from '@app/api/oauth/repositories/IOAuth2TokenRepository';
import {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
import {isSignInRefused} from '@app/api/user/UserHelpers';
import {mapUserToOAuthResponse} from '@app/api/user/UserMappers';
import {verifyPassword} from '@app/api/utils/PasswordUtils';
import {InvalidRequestError} from '@fluxer/errors/src/domains/core/InvalidRequestError';
@@ -50,6 +51,11 @@ export class OAuth2Service {
this.tokens = deps.oauth2TokenRepository ?? new OAuth2TokenRepository();
}
private async findActiveUser(userId: UserID) {
const user = await this.apiContext.services.users.findUnique(userId);
return user && !isSignInRefused(user) ? user : null;
}
private parseScope(scope: string): Array<string> {
return scope.split(/[\s+]+/).filter(Boolean);
}
@@ -295,6 +301,9 @@ export class OAuth2Service {
throw new InvalidGrantError();
}
}
if (authCode.userId && !(await this.findActiveUser(authCode.userId))) {
throw new InvalidGrantError();
}
await this.tokens.deleteAuthorizationCode(code);
const res = await this.issueTokens({
application,
@@ -316,6 +325,9 @@ export class OAuth2Service {
if (refresh.applicationId !== application.applicationId) {
throw new InvalidGrantError();
}
if (!(await this.findActiveUser(refresh.userId))) {
throw new InvalidGrantError();
}
await this.tokens.deleteRefreshToken(params.refreshToken!, refresh.applicationId, refresh.userId);
const res = await this.issueTokens({
application,
@@ -340,7 +352,7 @@ export class OAuth2Service {
if (!application) {
throw new InvalidTokenError();
}
const user = await this.apiContext.services.users.findUnique(token.userId);
const user = await this.findActiveUser(token.userId);
if (!user) {
throw new InvalidTokenError();
}
@@ -378,6 +390,9 @@ export class OAuth2Service {
}
const accessToken = await this.tokens.getAccessToken(tokenStr);
if (accessToken && accessToken.applicationId === application.applicationId) {
if (accessToken.userId && !(await this.findActiveUser(accessToken.userId))) {
return {active: false};
}
return {
active: true,
client_id: accessToken.applicationId.toString(),
@@ -390,6 +405,9 @@ export class OAuth2Service {
}
const refreshToken = await this.tokens.getRefreshToken(tokenStr);
if (refreshToken && refreshToken.applicationId === application.applicationId) {
if (!(await this.findActiveUser(refreshToken.userId))) {
return {active: false};
}
return {
active: true,
client_id: refreshToken.applicationId.toString(),
@@ -30,6 +30,8 @@ function isAuthorizationCodeExpired(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() > AUTHORIZATION_CODE_TTL_SECONDS * 1000;
}
const TOKEN_DELETE_BATCH_STATEMENTS = 60;
const SELECT_AUTHORIZATION_CODE = OAuth2AuthorizationCodes.selectCql({
where: OAuth2AuthorizationCodes.where.eq('code'),
});
@@ -119,7 +121,7 @@ export class OAuth2TokenRepository implements IOAuth2TokenRepository {
batch.addPrepared(OAuth2AccessTokens.deleteByPk({token_: tokenRow.token_}));
batch.addPrepared(OAuth2AccessTokensByUser.deleteByPk({user_id: userId, token_: tokenRow.token_}));
}
await batch.execute();
await batch.executeChunked(TOKEN_DELETE_BATCH_STATEMENTS, true);
}
async createRefreshToken(data: OAuth2RefreshTokenRow): Promise<OAuth2RefreshToken> {
@@ -159,7 +161,7 @@ export class OAuth2TokenRepository implements IOAuth2TokenRepository {
batch.addPrepared(OAuth2RefreshTokens.deleteByPk({token_: tokenRow.token_}));
batch.addPrepared(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: tokenRow.token_}));
}
await batch.execute();
await batch.executeChunked(TOKEN_DELETE_BATCH_STATEMENTS, true);
}
async listRefreshTokensForUser(userId: UserID): Promise<Array<OAuth2RefreshToken>> {
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createUserID} from '@app/api/BrandedTypes';
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import {
authorizeOAuth2,
createOAuth2TestSetup,
exchangeOAuth2AuthorizationCode,
introspectOAuth2Token,
refreshOAuth2Token,
} from '@app/api/oauth/tests/OAuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
const HOUR_MS = 3_600_000;
async function setStanding(userId: string, change: {addFlags?: bigint; tempBannedUntil?: Date | null}): Promise<void> {
const users = getUserRepository();
const user = (await users.findUnique(createUserID(BigInt(userId))))!;
await users.patchUpsert(
user.id,
{
flags: user.flags | (change.addFlags ?? 0n),
...(change.tempBannedUntil !== undefined ? {temp_banned_until: change.tempBannedUntil} : {}),
},
user.toRow(),
);
}
describe('OAuth2 grants follow the account standing', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
async function grant() {
const setup = await createOAuth2TestSetup(harness);
const code = await authorizeOAuth2(harness, setup.endUser.token, {
client_id: setup.application.id,
redirect_uri: setup.redirectURI,
scope: 'identify email',
});
const tokens = await exchangeOAuth2AuthorizationCode(harness, {
client_id: setup.application.id,
client_secret: setup.application.client_secret,
code: code.code,
redirect_uri: setup.redirectURI,
});
return {...setup, tokens};
}
async function refreshStatus(
application: {id: string; client_secret: string},
refreshToken: string,
): Promise<number> {
const response = await harness.app.request('/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${application.id}:${application.client_secret}`).toString('base64')}`,
'x-forwarded-for': '127.0.0.1',
},
body: new URLSearchParams({grant_type: 'refresh_token', refresh_token: refreshToken}).toString(),
});
return response.status;
}
async function bearerStatus(path: string, accessToken: string): Promise<number> {
const {response} = await createBuilder(harness, `Bearer ${accessToken}`).get(path).executeRaw();
return response.status;
}
test('a closed account gets no refreshed tokens, no userinfo and inactive introspection', async () => {
const {endUser, application, tokens} = await grant();
expect(await bearerStatus('/oauth2/userinfo', tokens.access_token)).toBe(HTTP_STATUS.OK);
await setStanding(endUser.userId, {addFlags: UserFlags.DELETED});
expect(await bearerStatus('/oauth2/userinfo', tokens.access_token)).toBe(HTTP_STATUS.UNAUTHORIZED);
expect(await bearerStatus('/oauth2/@me', tokens.access_token)).toBe(HTTP_STATUS.UNAUTHORIZED);
expect(await refreshStatus(application, tokens.refresh_token)).toBe(HTTP_STATUS.BAD_REQUEST);
const introspected = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.access_token,
});
expect(introspected.active).toBe(false);
const introspectedRefresh = await introspectOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
token: tokens.refresh_token,
});
expect(introspectedRefresh.active).toBe(false);
});
test('an authorization code issued before the account closed cannot be exchanged', async () => {
const setup = await createOAuth2TestSetup(harness);
const code = await authorizeOAuth2(harness, setup.endUser.token, {
client_id: setup.application.id,
redirect_uri: setup.redirectURI,
scope: 'identify',
});
await setStanding(setup.endUser.userId, {addFlags: UserFlags.DELETED});
await expect(
exchangeOAuth2AuthorizationCode(harness, {
client_id: setup.application.id,
client_secret: setup.application.client_secret,
code: code.code,
redirect_uri: setup.redirectURI,
}),
).rejects.toThrow(/Expected 200, got 400/);
});
test('a temporary ban pauses the grant and the refresh token works again after it ends', async () => {
const {endUser, application, tokens} = await grant();
await setStanding(endUser.userId, {
addFlags: UserFlags.DISABLED,
tempBannedUntil: new Date(Date.now() + HOUR_MS),
});
expect(await bearerStatus('/oauth2/userinfo', tokens.access_token)).toBe(HTTP_STATUS.UNAUTHORIZED);
expect(await refreshStatus(application, tokens.refresh_token)).toBe(HTTP_STATUS.BAD_REQUEST);
await setStanding(endUser.userId, {tempBannedUntil: new Date(Date.now() - HOUR_MS)});
const refreshed = await refreshOAuth2Token(harness, {
client_id: application.id,
client_secret: application.client_secret,
refresh_token: tokens.refresh_token,
});
expect(await bearerStatus('/oauth2/userinfo', refreshed.access_token)).toBe(HTTP_STATUS.OK);
});
});