From 6c02e21053e21bdd581e01671bd8d7ecaa06f952 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Mon, 13 Jan 2025 11:09:05 +0800 Subject: [PATCH] refactor(server): use verificationToken model instead of tokenService --- .../server/src/__tests__/auth/token.spec.ts | 93 ----------- .../server/src/core/auth/controller.ts | 19 ++- .../backend/server/src/core/auth/index.ts | 6 +- .../backend/server/src/core/auth/resolver.ts | 52 +++++-- .../backend/server/src/core/auth/token.ts | 146 ------------------ .../server/src/plugins/captcha/service.ts | 10 +- 6 files changed, 55 insertions(+), 271 deletions(-) delete mode 100644 packages/backend/server/src/__tests__/auth/token.spec.ts delete mode 100644 packages/backend/server/src/core/auth/token.ts diff --git a/packages/backend/server/src/__tests__/auth/token.spec.ts b/packages/backend/server/src/__tests__/auth/token.spec.ts deleted file mode 100644 index e9e9820bcd615..0000000000000 --- a/packages/backend/server/src/__tests__/auth/token.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { TestingModule } from '@nestjs/testing'; -import { PrismaClient } from '@prisma/client'; -import ava, { TestFn } from 'ava'; - -import { TokenService, TokenType } from '../../core/auth'; -import { createTestingModule } from '../utils'; - -const test = ava as TestFn<{ - ts: TokenService; - m: TestingModule; -}>; - -test.before(async t => { - const m = await createTestingModule({ - providers: [TokenService], - }); - - t.context.ts = m.get(TokenService); - t.context.m = m; -}); - -test.after.always(async t => { - await t.context.m.close(); -}); - -test('should be able to create token', async t => { - const { ts } = t.context; - const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); - - t.truthy( - await ts.verifyToken(TokenType.SignIn, token, { - credential: 'user@affine.pro', - }) - ); -}); - -test('should fail the verification if the token is invalid', async t => { - const { ts } = t.context; - - const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); - - // wrong type - t.falsy( - await ts.verifyToken(TokenType.ChangeEmail, token, { - credential: 'user@affine.pro', - }) - ); - - // no credential - t.falsy(await ts.verifyToken(TokenType.SignIn, token)); - - // wrong credential - t.falsy( - await ts.verifyToken(TokenType.SignIn, token, { - credential: 'wrong@affine.pro', - }) - ); -}); - -test('should fail if the token expired', async t => { - const { ts } = t.context; - const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); - - await t.context.m.get(PrismaClient).verificationToken.updateMany({ - data: { - expiresAt: new Date(Date.now() - 1000), - }, - }); - - t.falsy( - await ts.verifyToken(TokenType.SignIn, token, { - credential: 'user@affine.pro', - }) - ); -}); - -test('should be able to verify only once', async t => { - const { ts } = t.context; - const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); - - t.truthy( - await ts.verifyToken(TokenType.SignIn, token, { - credential: 'user@affine.pro', - }) - ); - - // will be invalid after the first time of verification - t.falsy( - await ts.verifyToken(TokenType.SignIn, token, { - credential: 'user@affine.pro', - }) - ); -}); diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index dd954eeb9c812..218e2037ed6cd 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -26,12 +26,12 @@ import { URLHelper, UseNamedGuard, } from '../../base'; +import { Models, TokenType } from '../../models'; import { UserService } from '../user'; import { validators } from '../utils/validators'; import { Public } from './guard'; import { AuthService } from './service'; import { CurrentUser, Session } from './session'; -import { TokenService, TokenType } from './token'; interface PreflightResponse { registered: boolean; @@ -57,7 +57,7 @@ export class AuthController { private readonly url: URLHelper, private readonly auth: AuthService, private readonly user: UserService, - private readonly token: TokenService, + private readonly models: Models, private readonly config: Config, private readonly runtime: Runtime ) { @@ -194,7 +194,10 @@ export class AuthController { } } - const token = await this.token.createToken(TokenType.SignIn, email); + const token = await this.models.verificationToken.create( + TokenType.SignIn, + email + ); const magicLink = this.url.link(callbackUrl, { token, @@ -248,9 +251,13 @@ export class AuthController { validators.assertValidEmail(email); - const tokenRecord = await this.token.verifyToken(TokenType.SignIn, token, { - credential: email, - }); + const tokenRecord = await this.models.verificationToken.verify( + TokenType.SignIn, + token, + { + credential: email, + } + ); if (!tokenRecord) { throw new InvalidEmailToken(); diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts index f698c57fba6f6..d85355295d5d6 100644 --- a/packages/backend/server/src/core/auth/index.ts +++ b/packages/backend/server/src/core/auth/index.ts @@ -9,23 +9,21 @@ import { AuthController } from './controller'; import { AuthGuard, AuthWebsocketOptionsProvider } from './guard'; import { AuthResolver } from './resolver'; import { AuthService } from './service'; -import { TokenService, TokenType } from './token'; @Module({ imports: [FeatureModule, UserModule, QuotaModule], providers: [ AuthService, AuthResolver, - TokenService, AuthGuard, AuthWebsocketOptionsProvider, ], - exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider, TokenService], + exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider], controllers: [AuthController], }) export class AuthModule {} export * from './guard'; export { ClientTokenType } from './resolver'; -export { AuthService, TokenService, TokenType }; +export { AuthService }; export * from './session'; diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index 37e9b912f4e77..2ddcadb88830f 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -21,6 +21,7 @@ import { Throttle, URLHelper, } from '../../base'; +import { Models, TokenType } from '../../models'; import { Admin } from '../common'; import { UserService } from '../user'; import { UserType } from '../user/types'; @@ -28,7 +29,6 @@ import { validators } from '../utils/validators'; import { Public } from './guard'; import { AuthService } from './service'; import { CurrentUser } from './session'; -import { TokenService, TokenType } from './token'; @ObjectType('tokenType') export class ClientTokenType { @@ -49,7 +49,7 @@ export class AuthResolver { private readonly url: URLHelper, private readonly auth: AuthService, private readonly user: UserService, - private readonly token: TokenService + private readonly models: Models ) {} @SkipThrottle() @@ -96,7 +96,7 @@ export class AuthResolver { } // NOTE: Set & Change password are using the same token type. - const valid = await this.token.verifyToken( + const valid = await this.models.verificationToken.verify( TokenType.ChangePassword, token, { @@ -121,9 +121,13 @@ export class AuthResolver { @Args('email') email: string ) { // @see [sendChangeEmail] - const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, { - credential: user.id, - }); + const valid = await this.models.verificationToken.verify( + TokenType.VerifyEmail, + token, + { + credential: user.id, + } + ); if (!valid) { throw new InvalidEmailToken(); @@ -152,7 +156,7 @@ export class AuthResolver { throw new EmailVerificationRequired(); } - const token = await this.token.createToken( + const token = await this.models.verificationToken.create( TokenType.ChangePassword, user.id ); @@ -195,7 +199,10 @@ export class AuthResolver { throw new EmailVerificationRequired(); } - const token = await this.token.createToken(TokenType.ChangeEmail, user.id); + const token = await this.models.verificationToken.create( + TokenType.ChangeEmail, + user.id + ); const url = this.url.link(callbackUrl, { token }); @@ -215,9 +222,13 @@ export class AuthResolver { } validators.assertValidEmail(email); - const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, { - credential: user.id, - }); + const valid = await this.models.verificationToken.verify( + TokenType.ChangeEmail, + token, + { + credential: user.id, + } + ); if (!valid) { throw new InvalidEmailToken(); @@ -233,7 +244,7 @@ export class AuthResolver { } } - const verifyEmailToken = await this.token.createToken( + const verifyEmailToken = await this.models.verificationToken.create( TokenType.VerifyEmail, user.id ); @@ -249,7 +260,10 @@ export class AuthResolver { @CurrentUser() user: CurrentUser, @Args('callbackUrl') callbackUrl: string ) { - const token = await this.token.createToken(TokenType.VerifyEmail, user.id); + const token = await this.models.verificationToken.create( + TokenType.VerifyEmail, + user.id + ); const url = this.url.link(callbackUrl, { token }); @@ -266,9 +280,13 @@ export class AuthResolver { throw new EmailTokenNotFound(); } - const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, { - credential: user.id, - }); + const valid = await this.models.verificationToken.verify( + TokenType.VerifyEmail, + token, + { + credential: user.id, + } + ); if (!valid) { throw new InvalidEmailToken(); @@ -287,7 +305,7 @@ export class AuthResolver { @Args('userId') userId: string, @Args('callbackUrl') callbackUrl: string ): Promise { - const token = await this.token.createToken( + const token = await this.models.verificationToken.create( TokenType.ChangePassword, userId ); diff --git a/packages/backend/server/src/core/auth/token.ts b/packages/backend/server/src/core/auth/token.ts deleted file mode 100644 index 3ee5a2a41295a..0000000000000 --- a/packages/backend/server/src/core/auth/token.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { Injectable } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { PrismaClient } from '@prisma/client'; - -import { CryptoHelper } from '../../base/helpers'; - -export enum TokenType { - SignIn, - VerifyEmail, - ChangeEmail, - ChangePassword, - Challenge, -} - -@Injectable() -export class TokenService { - constructor( - private readonly db: PrismaClient, - private readonly crypto: CryptoHelper - ) {} - - async createToken( - type: TokenType, - credential?: string, - ttlInSec: number = 30 * 60 - ) { - const plaintextToken = randomUUID(); - - const { token } = await this.db.verificationToken.create({ - data: { - type, - token: plaintextToken, - credential, - expiresAt: new Date(Date.now() + ttlInSec * 1000), - }, - }); - - return this.crypto.encrypt(token); - } - - /** - * get token by type - * - * token will be revoked if expired or keep is not set - */ - async getToken(type: TokenType, token: string, keep?: boolean) { - token = this.crypto.decrypt(token); - const record = await this.db.verificationToken.findUnique({ - where: { - type_token: { - token, - type, - }, - }, - }); - - if (!record) { - return null; - } - - const expired = record.expiresAt <= new Date(); - - // always revoke expired token - if (expired || !keep) { - const deleted = await this.revokeToken(type, token); - - // already deleted, means token has been used - if (!deleted.count) { - return null; - } - } - - return !expired ? record : null; - } - - /** - * get token and verify credential - * - * if credential is not provided, it will be failed - * - * token will be revoked if expired or keep is not set - */ - async verifyToken( - type: TokenType, - token: string, - { - credential, - keep, - }: { - credential?: string; - keep?: boolean; - } = {} - ) { - token = this.crypto.decrypt(token); - const record = await this.db.verificationToken.findUnique({ - where: { - type_token: { - token, - type, - }, - }, - }); - - if (!record) { - return null; - } - - const expired = record.expiresAt <= new Date(); - const valid = - !expired && (!record.credential || record.credential === credential); - - // always revoke expired token - if (expired || (valid && !keep)) { - const deleted = await this.revokeToken(type, token); - - // already deleted, means token has been used - if (!deleted.count) { - return null; - } - } - - return valid ? record : null; - } - - async revokeToken(type: TokenType, token: string) { - return await this.db.verificationToken.deleteMany({ - where: { - token, - type, - }, - }); - } - - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async cleanExpiredTokens() { - await this.db.verificationToken.deleteMany({ - where: { - expiresAt: { - lte: new Date(), - }, - }, - }); - } -} diff --git a/packages/backend/server/src/plugins/captcha/service.ts b/packages/backend/server/src/plugins/captcha/service.ts index 556b36b22bc34..252a13fe9d7aa 100644 --- a/packages/backend/server/src/plugins/captcha/service.ts +++ b/packages/backend/server/src/plugins/captcha/service.ts @@ -11,7 +11,7 @@ import { Config, verifyChallengeResponse, } from '../../base'; -import { TokenService, TokenType } from '../../core/auth/token'; +import { Models, TokenType } from '../../models'; import { CaptchaConfig } from './types'; const validator = z @@ -26,7 +26,7 @@ export class CaptchaService { constructor( private readonly config: Config, - private readonly token: TokenService + private readonly models: Models ) { assert(config.plugins.captcha); this.captcha = config.plugins.captcha; @@ -66,7 +66,7 @@ export class CaptchaService { async getChallengeToken() { const resource = randomUUID(); - const challenge = await this.token.createToken( + const challenge = await this.models.verificationToken.create( TokenType.Challenge, resource, 5 * 60 @@ -90,8 +90,8 @@ export class CaptchaService { const challenge = credential.challenge; let resource: string | null = null; if (typeof challenge === 'string' && challenge) { - resource = await this.token - .getToken(TokenType.Challenge, challenge) + resource = await this.models.verificationToken + .get(TokenType.Challenge, challenge) .then(token => token?.credential || null); }