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, URLHelper,
UseNamedGuard, UseNamedGuard,
} from '../../base'; } from '../../base';
import { Models, TokenType } from '../../models';
import { UserService } from '../user'; import { UserService } from '../user';
import { validators } from '../utils/validators'; import { validators } from '../utils/validators';
import { Public } from './guard'; import { Public } from './guard';
import { AuthService } from './service'; import { AuthService } from './service';
import { CurrentUser, Session } from './session'; import { CurrentUser, Session } from './session';
import { TokenService, TokenType } from './token';
interface PreflightResponse { interface PreflightResponse {
registered: boolean; registered: boolean;
@@ -57,7 +57,7 @@ export class AuthController {
private readonly url: URLHelper, private readonly url: URLHelper,
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly user: UserService, private readonly user: UserService,
private readonly token: TokenService, private readonly models: Models,
private readonly config: Config, private readonly config: Config,
private readonly runtime: Runtime 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, { const magicLink = this.url.link(callbackUrl, {
token, token,
@@ -248,9 +251,13 @@ export class AuthController {
validators.assertValidEmail(email); validators.assertValidEmail(email);
const tokenRecord = await this.token.verifyToken(TokenType.SignIn, token, { const tokenRecord = await this.models.verificationToken.verify(
credential: email, TokenType.SignIn,
}); token,
{
credential: email,
}
);
if (!tokenRecord) { if (!tokenRecord) {
throw new InvalidEmailToken(); throw new InvalidEmailToken();

View File

@@ -9,23 +9,21 @@ import { AuthController } from './controller';
import { AuthGuard, AuthWebsocketOptionsProvider } from './guard'; import { AuthGuard, AuthWebsocketOptionsProvider } from './guard';
import { AuthResolver } from './resolver'; import { AuthResolver } from './resolver';
import { AuthService } from './service'; import { AuthService } from './service';
import { TokenService, TokenType } from './token';
@Module({ @Module({
imports: [FeatureModule, UserModule, QuotaModule], imports: [FeatureModule, UserModule, QuotaModule],
providers: [ providers: [
AuthService, AuthService,
AuthResolver, AuthResolver,
TokenService,
AuthGuard, AuthGuard,
AuthWebsocketOptionsProvider, AuthWebsocketOptionsProvider,
], ],
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider, TokenService], exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule {} export class AuthModule {}
export * from './guard'; export * from './guard';
export { ClientTokenType } from './resolver'; export { ClientTokenType } from './resolver';
export { AuthService, TokenService, TokenType }; export { AuthService };
export * from './session'; export * from './session';

View File

@@ -21,6 +21,7 @@ import {
Throttle, Throttle,
URLHelper, URLHelper,
} from '../../base'; } from '../../base';
import { Models, TokenType } from '../../models';
import { Admin } from '../common'; import { Admin } from '../common';
import { UserService } from '../user'; import { UserService } from '../user';
import { UserType } from '../user/types'; import { UserType } from '../user/types';
@@ -28,7 +29,6 @@ import { validators } from '../utils/validators';
import { Public } from './guard'; import { Public } from './guard';
import { AuthService } from './service'; import { AuthService } from './service';
import { CurrentUser } from './session'; import { CurrentUser } from './session';
import { TokenService, TokenType } from './token';
@ObjectType('tokenType') @ObjectType('tokenType')
export class ClientTokenType { export class ClientTokenType {
@@ -49,7 +49,7 @@ export class AuthResolver {
private readonly url: URLHelper, private readonly url: URLHelper,
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly user: UserService, private readonly user: UserService,
private readonly token: TokenService private readonly models: Models
) {} ) {}
@SkipThrottle() @SkipThrottle()
@@ -96,7 +96,7 @@ export class AuthResolver {
} }
// NOTE: Set & Change password are using the same token type. // 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, TokenType.ChangePassword,
token, token,
{ {
@@ -121,9 +121,13 @@ export class AuthResolver {
@Args('email') email: string @Args('email') email: string
) { ) {
// @see [sendChangeEmail] // @see [sendChangeEmail]
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, { const valid = await this.models.verificationToken.verify(
credential: user.id, TokenType.VerifyEmail,
}); token,
{
credential: user.id,
}
);
if (!valid) { if (!valid) {
throw new InvalidEmailToken(); throw new InvalidEmailToken();
@@ -152,7 +156,7 @@ export class AuthResolver {
throw new EmailVerificationRequired(); throw new EmailVerificationRequired();
} }
const token = await this.token.createToken( const token = await this.models.verificationToken.create(
TokenType.ChangePassword, TokenType.ChangePassword,
user.id user.id
); );
@@ -195,7 +199,10 @@ export class AuthResolver {
throw new EmailVerificationRequired(); 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 }); const url = this.url.link(callbackUrl, { token });
@@ -215,9 +222,13 @@ export class AuthResolver {
} }
validators.assertValidEmail(email); validators.assertValidEmail(email);
const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, { const valid = await this.models.verificationToken.verify(
credential: user.id, TokenType.ChangeEmail,
}); token,
{
credential: user.id,
}
);
if (!valid) { if (!valid) {
throw new InvalidEmailToken(); 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, TokenType.VerifyEmail,
user.id user.id
); );
@@ -249,7 +260,10 @@ export class AuthResolver {
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('callbackUrl') callbackUrl: string @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 }); const url = this.url.link(callbackUrl, { token });
@@ -266,9 +280,13 @@ export class AuthResolver {
throw new EmailTokenNotFound(); throw new EmailTokenNotFound();
} }
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, { const valid = await this.models.verificationToken.verify(
credential: user.id, TokenType.VerifyEmail,
}); token,
{
credential: user.id,
}
);
if (!valid) { if (!valid) {
throw new InvalidEmailToken(); throw new InvalidEmailToken();
@@ -287,7 +305,7 @@ export class AuthResolver {
@Args('userId') userId: string, @Args('userId') userId: string,
@Args('callbackUrl') callbackUrl: string @Args('callbackUrl') callbackUrl: string
): Promise<string> { ): Promise<string> {
const token = await this.token.createToken( const token = await this.models.verificationToken.create(
TokenType.ChangePassword, TokenType.ChangePassword,
userId 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, Config,
verifyChallengeResponse, verifyChallengeResponse,
} from '../../base'; } from '../../base';
import { TokenService, TokenType } from '../../core/auth/token'; import { Models, TokenType } from '../../models';
import { CaptchaConfig } from './types'; import { CaptchaConfig } from './types';
const validator = z const validator = z
@@ -26,7 +26,7 @@ export class CaptchaService {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly token: TokenService private readonly models: Models
) { ) {
assert(config.plugins.captcha); assert(config.plugins.captcha);
this.captcha = config.plugins.captcha; this.captcha = config.plugins.captcha;
@@ -66,7 +66,7 @@ export class CaptchaService {
async getChallengeToken() { async getChallengeToken() {
const resource = randomUUID(); const resource = randomUUID();
const challenge = await this.token.createToken( const challenge = await this.models.verificationToken.create(
TokenType.Challenge, TokenType.Challenge,
resource, resource,
5 * 60 5 * 60
@@ -90,8 +90,8 @@ export class CaptchaService {
const challenge = credential.challenge; const challenge = credential.challenge;
let resource: string | null = null; let resource: string | null = null;
if (typeof challenge === 'string' && challenge) { if (typeof challenge === 'string' && challenge) {
resource = await this.token resource = await this.models.verificationToken
.getToken(TokenType.Challenge, challenge) .get(TokenType.Challenge, challenge)
.then(token => token?.credential || null); .then(token => token?.credential || null);
} }