mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
refactor(api): emit moderation events and apply account actions (#3031)
This commit is contained in:
1 parent
c488906131
commit
364084c819
385 files changed
+8994
-22221
No files matched your search
@@ -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')});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user