Skip to content

Commit fa3c6e3

Browse files
committed
feat(server): verificationToken model
1 parent 45a0131 commit fa3c6e3

File tree

3 files changed

+356
-2
lines changed

3 files changed

+356
-2
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { TestingModule } from '@nestjs/testing';
2+
import { PrismaClient } from '@prisma/client';
3+
import ava, { TestFn } from 'ava';
4+
5+
import {
6+
TokenType,
7+
VerificationTokenModel,
8+
} from '../../models/verification-token';
9+
import { createTestingModule, initTestingDB } from '../utils';
10+
11+
interface Context {
12+
module: TestingModule;
13+
verificationToken: VerificationTokenModel;
14+
db: PrismaClient;
15+
}
16+
17+
const test = ava as TestFn<Context>;
18+
19+
test.before(async t => {
20+
const module = await createTestingModule({
21+
providers: [VerificationTokenModel],
22+
});
23+
24+
t.context.verificationToken = module.get(VerificationTokenModel);
25+
t.context.db = module.get(PrismaClient);
26+
t.context.module = module;
27+
});
28+
29+
test.beforeEach(async t => {
30+
await initTestingDB(t.context.db);
31+
});
32+
33+
test.after(async t => {
34+
await t.context.module.close();
35+
});
36+
37+
test('should be able to create token', async t => {
38+
const { verificationToken } = t.context;
39+
const token = await verificationToken.create(
40+
TokenType.SignIn,
41+
'user@affine.pro'
42+
);
43+
44+
t.truthy(
45+
await verificationToken.verify(TokenType.SignIn, token, {
46+
credential: 'user@affine.pro',
47+
})
48+
);
49+
});
50+
51+
test('should be able to get token', async t => {
52+
const { verificationToken } = t.context;
53+
const token = await verificationToken.create(
54+
TokenType.SignIn,
55+
'user@affine.pro'
56+
);
57+
58+
t.truthy(await verificationToken.get(TokenType.SignIn, token));
59+
// will be delete after the first time of verification
60+
t.falsy(await verificationToken.get(TokenType.SignIn, token));
61+
});
62+
63+
test('should be able to get token and keep work', async t => {
64+
const { verificationToken } = t.context;
65+
const token = await verificationToken.create(
66+
TokenType.SignIn,
67+
'user@affine.pro'
68+
);
69+
70+
t.truthy(await verificationToken.get(TokenType.SignIn, token, true));
71+
t.truthy(await verificationToken.get(TokenType.SignIn, token));
72+
t.falsy(await verificationToken.get(TokenType.SignIn, token));
73+
});
74+
75+
test('should fail the verification if the token is invalid', async t => {
76+
const { verificationToken } = t.context;
77+
const token = await verificationToken.create(
78+
TokenType.SignIn,
79+
'user@affine.pro'
80+
);
81+
82+
// wrong type
83+
t.falsy(
84+
await verificationToken.verify(TokenType.ChangeEmail, token, {
85+
credential: 'user@affine.pro',
86+
})
87+
);
88+
89+
// no credential
90+
t.falsy(await verificationToken.verify(TokenType.SignIn, token));
91+
92+
// wrong credential
93+
t.falsy(
94+
await verificationToken.verify(TokenType.SignIn, token, {
95+
credential: 'wrong@affine.pro',
96+
})
97+
);
98+
});
99+
100+
test('should fail if the token expired', async t => {
101+
const { verificationToken, db } = t.context;
102+
const token = await verificationToken.create(
103+
TokenType.SignIn,
104+
'user@affine.pro'
105+
);
106+
107+
await db.verificationToken.updateMany({
108+
data: {
109+
expiresAt: new Date(Date.now() - 1000),
110+
},
111+
});
112+
113+
t.falsy(
114+
await verificationToken.verify(TokenType.SignIn, token, {
115+
credential: 'user@affine.pro',
116+
})
117+
);
118+
});
119+
120+
test('should be able to verify without credential', async t => {
121+
const { verificationToken } = t.context;
122+
const token = await verificationToken.create(TokenType.SignIn);
123+
124+
t.truthy(await verificationToken.verify(TokenType.SignIn, token));
125+
126+
// will be invalid after the first time of verification
127+
t.falsy(await verificationToken.verify(TokenType.SignIn, token));
128+
});
129+
130+
test('should be able to verify only once', async t => {
131+
const { verificationToken } = t.context;
132+
const token = await verificationToken.create(
133+
TokenType.SignIn,
134+
'user@affine.pro'
135+
);
136+
137+
t.truthy(
138+
await verificationToken.verify(TokenType.SignIn, token, {
139+
credential: 'user@affine.pro',
140+
})
141+
);
142+
143+
// will be invalid after the first time of verification
144+
t.falsy(
145+
await verificationToken.verify(TokenType.SignIn, token, {
146+
credential: 'user@affine.pro',
147+
})
148+
);
149+
});
150+
151+
test('should be able to verify and keep work', async t => {
152+
const { verificationToken } = t.context;
153+
const token = await verificationToken.create(
154+
TokenType.SignIn,
155+
'user@affine.pro'
156+
);
157+
158+
t.truthy(
159+
await verificationToken.verify(TokenType.SignIn, token, {
160+
credential: 'user@affine.pro',
161+
keep: true,
162+
})
163+
);
164+
165+
t.truthy(
166+
await verificationToken.verify(TokenType.SignIn, token, {
167+
credential: 'user@affine.pro',
168+
})
169+
);
170+
171+
// will be invalid without keep
172+
t.falsy(
173+
await verificationToken.verify(TokenType.SignIn, token, {
174+
credential: 'user@affine.pro',
175+
})
176+
);
177+
});
178+
179+
test('should cleanup expired tokens', async t => {
180+
const { verificationToken, db } = t.context;
181+
await verificationToken.create(TokenType.SignIn, 'user@affine.pro');
182+
183+
await db.verificationToken.updateMany({
184+
data: {
185+
expiresAt: new Date(Date.now() - 1000),
186+
},
187+
});
188+
189+
let count = await verificationToken.cleanExpired();
190+
t.is(count, 1);
191+
count = await verificationToken.cleanExpired();
192+
t.is(count, 0);
193+
});

packages/backend/server/src/models/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import { Global, Injectable, Module } from '@nestjs/common';
22

33
import { SessionModel } from './session';
44
import { UserModel } from './user';
5+
import { VerificationTokenModel } from './verification-token';
56

6-
const models = [UserModel, SessionModel] as const;
7+
const models = [UserModel, SessionModel, VerificationTokenModel] as const;
78

89
@Injectable()
910
export class Models {
1011
constructor(
1112
public readonly user: UserModel,
12-
public readonly session: SessionModel
13+
public readonly session: SessionModel,
14+
public readonly verificationToken: VerificationTokenModel
1315
) {}
1416
}
1517

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { randomUUID } from 'node:crypto';
2+
3+
import { Injectable, Logger } from '@nestjs/common';
4+
import { PrismaClient, type VerificationToken } from '@prisma/client';
5+
6+
import { CryptoHelper } from '../base/helpers';
7+
8+
export type { VerificationToken };
9+
10+
export enum TokenType {
11+
SignIn,
12+
VerifyEmail,
13+
ChangeEmail,
14+
ChangePassword,
15+
Challenge,
16+
}
17+
18+
@Injectable()
19+
export class VerificationTokenModel {
20+
private readonly logger = new Logger(VerificationTokenModel.name);
21+
constructor(
22+
private readonly db: PrismaClient,
23+
private readonly crypto: CryptoHelper
24+
) {}
25+
26+
/**
27+
* create token by type and credential (optional) with ttl in seconds (default 30 minutes)
28+
*/
29+
async create(
30+
type: TokenType,
31+
credential?: string,
32+
ttlInSec: number = 30 * 60
33+
) {
34+
const plaintextToken = randomUUID();
35+
const { token } = await this.db.verificationToken.create({
36+
data: {
37+
type,
38+
token: plaintextToken,
39+
credential,
40+
expiresAt: new Date(Date.now() + ttlInSec * 1000),
41+
},
42+
});
43+
return this.crypto.encrypt(token);
44+
}
45+
46+
/**
47+
* get token by type
48+
*
49+
* token will be deleted if expired or keep is not set
50+
*/
51+
async get(type: TokenType, token: string, keep?: boolean) {
52+
token = this.crypto.decrypt(token);
53+
const record = await this.db.verificationToken.findUnique({
54+
where: {
55+
type_token: {
56+
token,
57+
type,
58+
},
59+
},
60+
});
61+
62+
if (!record) {
63+
return null;
64+
}
65+
66+
const isExpired = record.expiresAt <= new Date();
67+
68+
// always delete expired token
69+
// or if keep is not set for one time token
70+
if (isExpired || !keep) {
71+
const count = await this.delete(type, token);
72+
73+
// already deleted, means token has been used
74+
if (!count) {
75+
return null;
76+
}
77+
}
78+
79+
return !isExpired ? record : null;
80+
}
81+
82+
/**
83+
* get token and verify credential
84+
*
85+
* if credential is not provided, it will be failed
86+
*
87+
* token will be deleted if expired or keep is not set
88+
*/
89+
async verify(
90+
type: TokenType,
91+
token: string,
92+
{
93+
credential,
94+
keep,
95+
}: {
96+
credential?: string;
97+
keep?: boolean;
98+
} = {}
99+
) {
100+
token = this.crypto.decrypt(token);
101+
const record = await this.db.verificationToken.findUnique({
102+
where: {
103+
type_token: {
104+
token,
105+
type,
106+
},
107+
},
108+
});
109+
110+
if (!record) {
111+
return null;
112+
}
113+
114+
const isExpired = record.expiresAt <= new Date();
115+
const valid =
116+
!isExpired && (!record.credential || record.credential === credential);
117+
118+
// always delete expired token
119+
// or if keep is not set for one time valid token
120+
if (isExpired || (valid && !keep)) {
121+
const count = await this.delete(type, token);
122+
123+
// already deleted, means token has been used
124+
if (!count) {
125+
return null;
126+
}
127+
}
128+
129+
return valid ? record : null;
130+
}
131+
132+
async delete(type: TokenType, token: string) {
133+
const { count } = await this.db.verificationToken.deleteMany({
134+
where: {
135+
token,
136+
type,
137+
},
138+
});
139+
this.logger.log(
140+
`Deleted ${count} tokens by type ${type} and token ${token}`
141+
);
142+
return count;
143+
}
144+
145+
/**
146+
* clean expired tokens
147+
*/
148+
async cleanExpired() {
149+
const { count } = await this.db.verificationToken.deleteMany({
150+
where: {
151+
expiresAt: {
152+
lte: new Date(),
153+
},
154+
},
155+
});
156+
this.logger.log(`Cleaned ${count} expired tokens`);
157+
return count;
158+
}
159+
}

0 commit comments

Comments
 (0)