refactor(server): use verificationToken model instead of tokenService (#9657)

This commit is contained in:
fengmk2
2025-01-14 03:39:05 +00:00
parent 290b2074c8
commit ee99b0cc9d
6 changed files with 55 additions and 271 deletions

View File

@@ -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',
})
);
});

View File

@@ -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();

View File

@@ -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';

View File

@@ -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<string> {
const token = await this.token.createToken(
const token = await this.models.verificationToken.create(
TokenType.ChangePassword,
userId
);

View File

@@ -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(),
},
},
});
}
}

View File

@@ -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);
}