-
-
Notifications
You must be signed in to change notification settings - Fork 636
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add VerificationToken abstract class
- Loading branch information
1 parent
befb276
commit ec313c2
Showing
4 changed files
with
327 additions
and
0 deletions.
There are no files selected for viewing
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
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
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,147 @@ | ||
/* | ||
* @adonisjs/core | ||
* | ||
* (c) AdonisJS | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
import { createHash } from 'node:crypto' | ||
import string from '@poppinss/utils/string' | ||
import { base64, safeEqual, Secret } from '@poppinss/utils' | ||
|
||
/** | ||
* Verification token class can be used to create tokens publicly | ||
* shareable tokens while storing the token hash within the database. | ||
* | ||
* This class is used by the Auth and the Persona packages to manage | ||
* tokens | ||
*/ | ||
export abstract class VerificationToken { | ||
/** | ||
* Decodes a publicly shared token and return the series | ||
* and the token value from it. | ||
* | ||
* Returns null when unable to decode the token because of | ||
* invalid format or encoding. | ||
*/ | ||
static decode(value: string): null | { identifier: string; secret: Secret<string> } { | ||
/** | ||
* Ensure value is a string and starts with the prefix. | ||
*/ | ||
if (typeof value !== 'string') { | ||
return null | ||
} | ||
|
||
/** | ||
* Remove prefix from the rest of the token. | ||
*/ | ||
if (!value) { | ||
return null | ||
} | ||
|
||
const [identifier, ...tokenValue] = value.split('.') | ||
if (!identifier || tokenValue.length === 0) { | ||
return null | ||
} | ||
|
||
const decodedIdentifier = base64.urlDecode(identifier) | ||
const decodedSecret = base64.urlDecode(tokenValue.join('.')) | ||
if (!decodedIdentifier || !decodedSecret) { | ||
return null | ||
} | ||
|
||
return { | ||
identifier: decodedIdentifier, | ||
secret: new Secret(decodedSecret), | ||
} | ||
} | ||
|
||
/** | ||
* Creates a transient token that can be shared with the persistence | ||
* layer. | ||
*/ | ||
static createTransientToken( | ||
userId: string | number | BigInt, | ||
size: number, | ||
expiresIn: string | number | ||
) { | ||
const expiresAt = new Date() | ||
expiresAt.setSeconds(expiresAt.getSeconds() + string.seconds.parse(expiresIn)) | ||
|
||
return { | ||
userId, | ||
expiresAt, | ||
...this.seed(size), | ||
} | ||
} | ||
|
||
/** | ||
* Creates a secret opaque token and its hash. | ||
*/ | ||
static seed(size: number) { | ||
const seed = string.random(size) | ||
const secret = new Secret(seed) | ||
const hash = createHash('sha256').update(secret.release()).digest('hex') | ||
return { secret, hash } | ||
} | ||
|
||
/** | ||
* Identifer is a unique sequence to identify the | ||
* token within database. It should be the | ||
* primary/unique key | ||
*/ | ||
declare identifier: string | number | BigInt | ||
|
||
/** | ||
* Reference to the user id for whom the token | ||
* is generated. | ||
*/ | ||
declare tokenableId: string | number | BigInt | ||
|
||
/** | ||
* Hash is computed from the seed to later verify the validity | ||
* of seed | ||
*/ | ||
declare hash: string | ||
|
||
/** | ||
* Timestamp at which the token will expire | ||
*/ | ||
declare expiresAt: Date | ||
|
||
/** | ||
* The value is a public representation of a token. It is created | ||
* by combining the "identifier"."secret" via the "computeValue" | ||
* method | ||
*/ | ||
declare value?: Secret<string> | ||
|
||
/** | ||
* Compute the value property using the given secret. You can | ||
* get secret via the static "createTransientToken" method. | ||
*/ | ||
protected computeValue(secret: Secret<string>) { | ||
this.value = new Secret( | ||
`${base64.urlEncode(String(this.identifier))}.${base64.urlEncode(secret.release())}` | ||
) | ||
} | ||
|
||
/** | ||
* Check if the token has been expired. Verifies | ||
* the "expiresAt" timestamp with the current | ||
* date. | ||
*/ | ||
isExpired() { | ||
return this.expiresAt < new Date() | ||
} | ||
|
||
/** | ||
* Verifies the value of a token against the pre-defined hash | ||
*/ | ||
verify(secret: Secret<string>): boolean { | ||
const newHash = createHash('sha256').update(secret.release()).digest('hex') | ||
return safeEqual(this.hash, newHash) | ||
} | ||
} |
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,178 @@ | ||
/* | ||
* @adonisjs/persona | ||
* | ||
* (C) AdonisJS | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
import timekeeper from 'timekeeper' | ||
import { Secret, base64 } from '@poppinss/utils' | ||
import { getActiveTest, test } from '@japa/runner' | ||
|
||
import { VerificationToken } from '../src/helpers/verification_token.js' | ||
|
||
function freezeTime() { | ||
const t = getActiveTest() | ||
if (!t) { | ||
throw new Error('Cannot use "freezeTime" outside of a Japa test') | ||
} | ||
|
||
timekeeper.reset() | ||
|
||
const date = new Date() | ||
timekeeper.freeze(date) | ||
|
||
t.cleanup(() => { | ||
timekeeper.reset() | ||
}) | ||
} | ||
|
||
class EmailVerificationToken extends VerificationToken { | ||
constructor(props: { | ||
identifier: number | ||
tokenableId: number | ||
hash: string | ||
expiresAt: Date | ||
secret?: Secret<string> | ||
}) { | ||
super() | ||
this.identifier = props.identifier | ||
this.tokenableId = props.tokenableId | ||
this.hash = props.hash | ||
this.expiresAt = props.expiresAt | ||
if (props.secret) { | ||
this.computeValue(props.secret) | ||
} | ||
} | ||
} | ||
|
||
test.group('VerificationToken token | decode', () => { | ||
test('decode "{input}" as token') | ||
.with([ | ||
{ | ||
input: null, | ||
output: null, | ||
}, | ||
{ | ||
input: '', | ||
output: null, | ||
}, | ||
{ | ||
input: '..', | ||
output: null, | ||
}, | ||
{ | ||
input: 'foobar', | ||
output: null, | ||
}, | ||
{ | ||
input: 'foo.baz', | ||
output: null, | ||
}, | ||
{ | ||
input: `bar.${base64.urlEncode('baz')}`, | ||
output: null, | ||
}, | ||
{ | ||
input: `${base64.urlEncode('baz')}.bar`, | ||
output: null, | ||
}, | ||
{ | ||
input: `${base64.urlEncode('bar')}.${base64.urlEncode('baz')}`, | ||
output: { | ||
identifier: 'bar', | ||
secret: 'baz', | ||
}, | ||
}, | ||
]) | ||
.run(({ assert }, { input, output }) => { | ||
const decoded = VerificationToken.decode(input as string) | ||
if (!decoded) { | ||
assert.deepEqual(decoded, output) | ||
} else { | ||
assert.deepEqual( | ||
{ identifier: decoded.identifier, secret: decoded.secret.release() }, | ||
output | ||
) | ||
} | ||
}) | ||
}) | ||
|
||
test.group('VerificationToken token | create', () => { | ||
test('create a transient token', ({ assert }) => { | ||
freezeTime() | ||
const date = new Date() | ||
const expiresAt = new Date() | ||
expiresAt.setSeconds(date.getSeconds() + 60 * 20) | ||
|
||
const token = VerificationToken.createTransientToken(1, 40, '20 mins') | ||
assert.equal(token.userId, 1) | ||
assert.exists(token.hash) | ||
assert.equal(token.expiresAt!.getTime(), expiresAt.getTime()) | ||
assert.instanceOf(token.secret, Secret) | ||
}) | ||
|
||
test('create token from persisted information', ({ assert }) => { | ||
const createdAt = new Date() | ||
const expiresAt = new Date() | ||
expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) | ||
|
||
const token = new EmailVerificationToken({ | ||
identifier: 12, | ||
tokenableId: 1, | ||
hash: '1234', | ||
expiresAt, | ||
}) | ||
|
||
assert.equal(token.identifier, 12) | ||
assert.equal(token.hash, '1234') | ||
assert.equal(token.tokenableId, 1) | ||
assert.equal(token.expiresAt!.getTime(), expiresAt.getTime()) | ||
|
||
assert.isUndefined(token.value) | ||
assert.isFalse(token.isExpired()) | ||
}) | ||
|
||
test('create token with a secret', ({ assert }) => { | ||
const createdAt = new Date() | ||
const expiresAt = new Date() | ||
expiresAt.setSeconds(createdAt.getSeconds() + 60 * 20) | ||
|
||
const transientToken = EmailVerificationToken.createTransientToken(1, 40, '20 mins') | ||
|
||
const token = new EmailVerificationToken({ | ||
identifier: 12, | ||
tokenableId: 1, | ||
hash: transientToken.hash, | ||
expiresAt, | ||
secret: transientToken.secret, | ||
}) | ||
|
||
const decoded = EmailVerificationToken.decode(token.value!.release()) | ||
|
||
assert.equal(token.identifier, 12) | ||
assert.equal(token.tokenableId, 1) | ||
assert.equal(token.hash, transientToken.hash) | ||
assert.instanceOf(token.value, Secret) | ||
assert.isTrue(token.verify(transientToken.secret)) | ||
assert.isTrue(token.verify(decoded!.secret)) | ||
assert.equal(token.expiresAt!.getTime(), expiresAt.getTime()) | ||
assert.isFalse(token.isExpired()) | ||
}) | ||
|
||
test('verify token hash', ({ assert }) => { | ||
const transientToken = EmailVerificationToken.createTransientToken(1, 40, '20 mins') | ||
|
||
const token = new EmailVerificationToken({ | ||
identifier: 12, | ||
tokenableId: 1, | ||
hash: transientToken.hash, | ||
expiresAt: new Date(), | ||
secret: transientToken.secret, | ||
}) | ||
|
||
assert.isTrue(token.verify(transientToken.secret)) | ||
}) | ||
}) |