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,
|
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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user