mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
refactor(server): use verificationToken model instead of tokenService (#9657)
This commit is contained in:
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user