-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(server): verificationToken model (#9655)
- Loading branch information
Showing
3 changed files
with
343 additions
and
2 deletions.
There are no files selected for viewing
193 changes: 193 additions & 0 deletions
193
packages/backend/server/src/__tests__/models/verification-token.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,193 @@ | ||
import { TestingModule } from '@nestjs/testing'; | ||
import { PrismaClient } from '@prisma/client'; | ||
import ava, { TestFn } from 'ava'; | ||
|
||
import { | ||
TokenType, | ||
VerificationTokenModel, | ||
} from '../../models/verification-token'; | ||
import { createTestingModule, initTestingDB } from '../utils'; | ||
|
||
interface Context { | ||
module: TestingModule; | ||
verificationToken: VerificationTokenModel; | ||
db: PrismaClient; | ||
} | ||
|
||
const test = ava as TestFn<Context>; | ||
|
||
test.before(async t => { | ||
const module = await createTestingModule({ | ||
providers: [VerificationTokenModel], | ||
}); | ||
|
||
t.context.verificationToken = module.get(VerificationTokenModel); | ||
t.context.db = module.get(PrismaClient); | ||
t.context.module = module; | ||
}); | ||
|
||
test.beforeEach(async t => { | ||
await initTestingDB(t.context.db); | ||
}); | ||
|
||
test.after(async t => { | ||
await t.context.module.close(); | ||
}); | ||
|
||
test('should be able to create token', async t => { | ||
const { verificationToken } = t.context; | ||
const token = await verificationToken.create( | ||
TokenType.SignIn, | ||
'[email protected]' | ||
); | ||
|
||
t.truthy( | ||
await verificationToken.verify(TokenType.SignIn, token, { | ||
credential: '[email protected]', | ||
}) | ||
); | ||
}); | ||
|
||
test('should be able to get token', async t => { | ||
const { verificationToken } = t.context; | ||
const token = await verificationToken.create( | ||
TokenType.SignIn, | ||
'[email protected]' | ||
); | ||
|
||
t.truthy(await verificationToken.get(TokenType.SignIn, token)); | ||
// will be delete after the first time of verification | ||
t.falsy(await verificationToken.get(TokenType.SignIn, token)); | ||
}); | ||
|
||
test('should be able to get token and keep work', async t => { | ||
const { verificationToken } = t.context; | ||
const token = await verificationToken.create( | ||
TokenType.SignIn, | ||
'[email protected]' | ||
); | ||
|
||
t.truthy(await verificationToken.get(TokenType.SignIn, token, true)); | ||
t.truthy(await verificationToken.get(TokenType.SignIn, token)); | ||
t.falsy(await verificationToken.get(TokenType.SignIn, token)); | ||
}); | ||
|
||
test('should fail the verification if the token is invalid', async t => { | ||
const { verificationToken } = t.context; | ||
const token = await verificationToken.create( | ||
TokenType.SignIn, | ||
'[email protected]' | ||
); | ||
|
||
// wrong type | ||
t.falsy( | ||
await verificationToken.verify(TokenType.ChangeEmail, token, { | ||
credential: '[email protected]', | ||
}) | ||
); | ||
|
||
// no credential | ||
t.falsy(await verificationToken.verify(TokenType.SignIn, token)); | ||
|
||
// wrong credential | ||
t.falsy( | ||
await verificationToken.verify(TokenType.SignIn, token, { | ||
credential: '[email protected]', | ||
}) | ||
); | ||
}); | ||
|
||
test('should fail if the token expired', async t => { | ||
const { verificationToken, db } = t.context; | ||
const token = await verificationToken.create( | ||
TokenType.SignIn, | ||
'[email protected]' | ||
); | ||
|
||
await db.verificationToken.updateMany({ | ||
data: { | ||
expiresAt: new Date(Date.now() - 1000), | ||
}, | ||
}); | ||
|
||
t.falsy( | ||
await verificationToken.verify(TokenType.SignIn, token, { | ||
credential: '[email protected]', | ||
}) | ||
); | ||
}); | ||
|
||
test('should be able to verify without credential', async t => { | ||
const { verificationToken } = t.context; | ||
const token = await verificationToken.create(TokenType.SignIn); | ||
|
||
t.truthy(await verificationToken.verify(TokenType.SignIn, token)); | ||
|
||
// will be invalid after the first time of verification | ||
t.falsy(await verificationToken.verify(TokenType.SignIn, token)); | ||
}); | ||
|
||
test('should be able to verify only once', async t => { | ||
const { verificationToken } = t.context; | ||
const token = await verificationToken.create( | ||
TokenType.SignIn, | ||
'[email protected]' | ||
); | ||
|
||
t.truthy( | ||
await verificationToken.verify(TokenType.SignIn, token, { | ||
credential: '[email protected]', | ||
}) | ||
); | ||
|
||
// will be invalid after the first time of verification | ||
t.falsy( | ||
await verificationToken.verify(TokenType.SignIn, token, { | ||
credential: '[email protected]', | ||
}) | ||
); | ||
}); | ||
|
||
test('should be able to verify and keep work', async t => { | ||
const { verificationToken } = t.context; | ||
const token = await verificationToken.create( | ||
TokenType.SignIn, | ||
'[email protected]' | ||
); | ||
|
||
t.truthy( | ||
await verificationToken.verify(TokenType.SignIn, token, { | ||
credential: '[email protected]', | ||
keep: true, | ||
}) | ||
); | ||
|
||
t.truthy( | ||
await verificationToken.verify(TokenType.SignIn, token, { | ||
credential: '[email protected]', | ||
}) | ||
); | ||
|
||
// will be invalid without keep | ||
t.falsy( | ||
await verificationToken.verify(TokenType.SignIn, token, { | ||
credential: '[email protected]', | ||
}) | ||
); | ||
}); | ||
|
||
test('should cleanup expired tokens', async t => { | ||
const { verificationToken, db } = t.context; | ||
await verificationToken.create(TokenType.SignIn, '[email protected]'); | ||
|
||
await db.verificationToken.updateMany({ | ||
data: { | ||
expiresAt: new Date(Date.now() - 1000), | ||
}, | ||
}); | ||
|
||
let count = await verificationToken.cleanExpired(); | ||
t.is(count, 1); | ||
count = await verificationToken.cleanExpired(); | ||
t.is(count, 0); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
144 changes: 144 additions & 0 deletions
144
packages/backend/server/src/models/verification-token.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
import { randomUUID } from 'node:crypto'; | ||
|
||
import { Injectable, Logger } from '@nestjs/common'; | ||
import { PrismaClient, type VerificationToken } from '@prisma/client'; | ||
|
||
import { CryptoHelper } from '../base/helpers'; | ||
|
||
export type { VerificationToken }; | ||
|
||
export enum TokenType { | ||
SignIn, | ||
VerifyEmail, | ||
ChangeEmail, | ||
ChangePassword, | ||
Challenge, | ||
} | ||
|
||
@Injectable() | ||
export class VerificationTokenModel { | ||
private readonly logger = new Logger(VerificationTokenModel.name); | ||
constructor( | ||
private readonly db: PrismaClient, | ||
private readonly crypto: CryptoHelper | ||
) {} | ||
|
||
/** | ||
* create token by type and credential (optional) with ttl in seconds (default 30 minutes) | ||
*/ | ||
async create( | ||
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 deleted if expired or keep is not set | ||
*/ | ||
async get(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 isExpired = record.expiresAt <= new Date(); | ||
|
||
// always delete expired token | ||
// or if keep is not set for one time token | ||
if (isExpired || !keep) { | ||
const count = await this.delete(type, token); | ||
|
||
// already deleted, means token has been used | ||
if (!count) { | ||
return null; | ||
} | ||
} | ||
|
||
return !isExpired ? record : null; | ||
} | ||
|
||
/** | ||
* get token and verify credential | ||
* | ||
* if credential is not provided, it will be failed | ||
* | ||
* token will be deleted if expired or keep is not set | ||
*/ | ||
async verify( | ||
type: TokenType, | ||
token: string, | ||
{ | ||
credential, | ||
keep, | ||
}: { | ||
credential?: string; | ||
keep?: boolean; | ||
} = {} | ||
) { | ||
const record = await this.get(type, token, true); | ||
if (!record) { | ||
return null; | ||
} | ||
|
||
const valid = !record.credential || record.credential === credential; | ||
// keep is not set for one time valid token | ||
if (valid && !keep) { | ||
const count = await this.delete(type, record.token); | ||
|
||
// already deleted, means token has been used | ||
if (!count) { | ||
return null; | ||
} | ||
} | ||
|
||
return valid ? record : null; | ||
} | ||
|
||
async delete(type: TokenType, token: string) { | ||
const { count } = await this.db.verificationToken.deleteMany({ | ||
where: { | ||
token, | ||
type, | ||
}, | ||
}); | ||
this.logger.log(`Deleted token success by type ${type} and token ${token}`); | ||
return count; | ||
} | ||
|
||
/** | ||
* clean expired tokens | ||
*/ | ||
async cleanExpired() { | ||
const { count } = await this.db.verificationToken.deleteMany({ | ||
where: { | ||
expiresAt: { | ||
lte: new Date(), | ||
}, | ||
}, | ||
}); | ||
this.logger.log(`Cleaned ${count} expired tokens`); | ||
return count; | ||
} | ||
} |