Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,21 @@
"postinstall": "prepare"
},
"dependencies": {
"@arkstack/auth": "^0.20.1",
"@arkstack/cache": "^0.20.1",
"@arkstack/common": "^0.20.1",
"@arkstack/console": "^0.20.1",
"@arkstack/contract": "^0.20.1",
"@arkstack/database": "^0.20.1",
"@arkstack/driver-express": "^0.20.1",
"@arkstack/filesystem": "^0.20.1",
"@arkstack/foundry": "^0.20.1",
"@arkstack/http": "^0.20.1",
"@arkstack/jobs": "^0.20.1",
"@arkstack/notifications": "^0.20.1",
"@arkstack/queue": "^0.20.1",
"@arkstack/scheduler": "^0.20.1",
"@arkstack/view": "^0.20.1",
"@arkstack/auth": "^0.20.2",
"@arkstack/cache": "^0.20.2",
"@arkstack/common": "^0.20.2",
"@arkstack/console": "^0.20.2",
"@arkstack/contract": "^0.20.2",
"@arkstack/database": "^0.20.2",
"@arkstack/driver-express": "^0.20.2",
"@arkstack/filesystem": "^0.20.2",
"@arkstack/foundry": "^0.20.2",
"@arkstack/http": "^0.20.2",
"@arkstack/jobs": "^0.20.2",
"@arkstack/notifications": "^0.20.2",
"@arkstack/queue": "^0.20.2",
"@arkstack/scheduler": "^0.20.2",
"@arkstack/view": "^0.20.2",
"@arkyc/auth": "workspace:^",
"@arkyc/core": "workspace:^",
"@arkyc/face-match": "workspace:^",
Expand Down
74 changes: 48 additions & 26 deletions apps/api/src/app/http/controllers/auth/NewPasswordController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import EmptyResource from '@app/http/resources/EmptyResource'
import { Hash } from '@arkstack/common'
import { HttpContext } from 'clear-router/types/express'
import { PasswordReset } from '@app/models/PasswordReset'
import { PersonalAccessToken } from '@app/models/PersonalAccessToken'
import { User } from '@app/models/User'
import { auth } from 'src/core/auth-instance'
import { sendMail } from '@app/support/mail'

const RESET_TTL_SECONDS = 60 * 15
const resetKey = (identity: string) => `${identity}:password-reset`

/** Forgotten-password flow: request a reset code, verify it, set a new password. */
export default class NewPasswordController extends BaseController {
Expand All @@ -22,8 +24,15 @@ export default class NewPasswordController extends BaseController {
const data = await this.validate({ email: ['required', 'email', 'exists:users,email'] })
const user = await User.where({ email: data.email }).firstOrFail()

const token = Hash.otp(6, user.email, RESET_TTL_SECONDS).generate()
const pr = await PasswordReset.create({ email: user.email, phone: null, token })
const token = Hash.otp(6, resetKey(user.email), RESET_TTL_SECONDS).generate()

await PasswordReset.where({ email: user.email }).delete()
const pr = await PasswordReset.create({
email: user.email,
phone: null,
token,
attempts: 0,
})

const msg = config('messages.password_reset.email')

Expand All @@ -49,20 +58,16 @@ export default class NewPasswordController extends BaseController {
*
* @returns An EmptyResource confirming validity (HTTP 202).
*/
async show() {
const { token } = await Validator.make(this.params, {
token: ['required', 'string'],
}).validate()
async show({ req }: HttpContext) {
const data = await Validator.make(
{ ...this.params, email: req.query.email },
{
token: ['required', 'string'],
email: ['nullable', 'email'],
},
).validate()

const pr = await PasswordReset.fromToken(token).catch(() => null)
const delta = pr ? Hash.otp(6, pr.email ?? pr.phone!, RESET_TTL_SECONDS).validate({ token: pr.token }) : null

if (delta === null) {
if (pr) await pr.delete()
throw ValidationException.withMessages({
token: ['Invalid or expired password reset token.'],
})
}
await this.redeem(data.token, String(data.email))

return new EmptyResource({})
.additional({
Expand All @@ -84,25 +89,18 @@ export default class NewPasswordController extends BaseController {
{ ...this.params, ...this.body },
{
token: ['required', 'string'],
email: ['nullable', 'email'],
password: ['required', 'string', 'min:8'],
},
).validate()

const pr = await PasswordReset.fromToken(data.token).catch(() => null)
const delta = pr ? Hash.otp(6, pr.email ?? pr.phone!, RESET_TTL_SECONDS).validate({ token: pr.token }) : null

if (delta === null || pr === null) {
if (pr) await pr.delete()
throw ValidationException.withMessages({
token: ['Invalid or expired password reset token.'],
})
}

const pr = await this.redeem(data.token, data.email)
const user = await User.where({ email: pr.email! }).firstOrFail()

user.password = await Hash.make(data.password)
await user.save()
await pr.delete()
await PasswordReset.where({ email: pr.email! }).delete()
await PersonalAccessToken.where({ userId: user.id }).delete()

return new EmptyResource({})
.additional({
Expand Down Expand Up @@ -146,4 +144,28 @@ export default class NewPasswordController extends BaseController {
code: 200,
})
}

/** The reset matching the submitted code and email, or a validation error. */
private async redeem(token: string, email?: string | null): Promise<PasswordReset> {
const submitted = PasswordReset.parse(token, email)

if (!submitted.email) {
throw ValidationException.withMessages({
email: ['Enter the email address the reset code was sent to.'],
})
}

const pr = await PasswordReset.redeem(submitted.email, submitted.code)
const valid =
pr !== null && Hash.otp(6, resetKey(pr.email!), RESET_TTL_SECONDS).validate({ token: pr.token }) !== null

if (!valid) {
if (pr) await pr.delete()
throw ValidationException.withMessages({
token: ['Invalid or expired password reset token.'],
})
}

return pr
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default class VerificationController extends BaseController {
const user = req.user!

// Stateless time-based OTP keyed by the user's email (no storage needed).
const code = Hash.otp(6, user.email, 60 * 15).generate()
const code = Hash.otp(6, `${user.email}:email-verification`, 60 * 15).generate()
const msg = config('messages.verification.email')

sendMail(user.email, msg.template, msg.subject, {
Expand Down Expand Up @@ -51,7 +51,7 @@ export default class VerificationController extends BaseController {
const form = await this.validate({ code: ['required', 'string', 'size:6'] })
const user = req.user!

const delta = Hash.otp(6, user.email, 60 * 15).validate({ token: form.code })
const delta = Hash.otp(6, `${user.email}:email-verification`, 60 * 15).validate({ token: form.code })

if (delta === null) {
throw ValidationException.withMessages({ code: ['Invalid or expired verification code.'] })
Expand Down
67 changes: 57 additions & 10 deletions apps/api/src/app/models/PasswordReset.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,78 @@
import { Model } from 'arkormx'
import { timingSafeEqual } from 'node:crypto'

export class PasswordReset extends Model {
declare id: string
declare email: string | null
declare phone: string | null
declare token: string
declare attempts: number
declare createdAt: Date
declare updatedAt: Date

static fromToken(token: string) {
if (token.length > 6) {
token = Buffer.from(token, 'base64url').toString().split('-')[0]
static readonly MAX_ATTEMPTS = 5

/**
* Splits a submitted token into its code and email.
*
* A six-digit code needs the email it was sent to; a link token carries both.
*
* @param token
* @param email
* @returns
*/
static parse(token: string, email?: string | null): { code: string; email: string | null } {
if (token.length <= 6) return { code: token, email: email ?? null }

const decoded = Buffer.from(token, 'base64url').toString()
const separator = decoded.indexOf('-')

return separator > 0
? { code: decoded.slice(0, separator), email: decoded.slice(separator + 1) }
: { code: '', email: null }
}

/**
* The open reset for `email` whose code is `code`.
*
* Every check spends one attempt before comparing, and a reset is discarded
* once its attempts run out.
*
* @param email
* @param code
* @returns
*/
static async redeem(email: string, code: string): Promise<PasswordReset | null> {
const reset = await this.query().where({ email }).latest().first()

if (!reset) return null

if (reset.attempts >= this.MAX_ATTEMPTS) {
await reset.delete()

return null
}

return this.query().where({ token }).firstOrFail()
const spent = await this.query()
.where({ id: reset.id, attempts: reset.attempts })
.updateFrom({ attempts: reset.attempts + 1 })

return spent > 0 && reset.matches(code) ? reset : null
}

matches(code: string): boolean {
const expected = Buffer.from(this.token)
const actual = Buffer.from(code)

return expected.length === actual.length && timingSafeEqual(expected, actual)
}

/**
* get the password reset link
*/
link() {
const b64Token = Buffer.from(
str(this.token)
.append('-' + Math.random())
.toString(),
).toString('base64url')
const token = Buffer.from(`${this.token}-${this.email}`).toString('base64url')

return `${config('app.frontend_url')}/auth/reset-password?token=${b64Token}`
return `${config('app.frontend_url')}/auth/reset-password?token=${token}`
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { Migration, SchemaBuilder } from 'arkormx'

import { DB } from '@arkstack/database'

/**
* Client tokens for a verification session: the single-use `launch` token handed
* to the integrator, single-use `handoff` tokens carried by the cross-device QR,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Migration, SchemaBuilder } from 'arkormx'

/** Wrong guesses made against a password reset code. */
export default class AddAttemptsToPasswordResetsMigration extends Migration {
public async up(schema: SchemaBuilder): Promise<void> {
schema.alterTable('password_resets', (table) => {
table.integer('attempts').default(0)
})
}

public async down(schema: SchemaBuilder): Promise<void> {
schema.alterTable('password_resets', (table) => {
table.dropColumn('attempts')
})
}
}
12 changes: 8 additions & 4 deletions apps/api/tests/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('email verification', () => {
expect(send.status).toBe(201)

// The code is a stateless time-based OTP keyed by the email — regenerate it.
const code = Hash.otp(6, email, 60 * 15).generate()
const code = Hash.otp(6, `${email}:email-verification`, 60 * 15).generate()
const verify = await request(app)
.put('/api/v1/auth/verify/email')
.set('Authorization', `Bearer ${token}`)
Expand Down Expand Up @@ -72,11 +72,15 @@ describe('password reset', () => {
const pr = await PasswordReset.where({ email }).firstOrFail()
const code = pr.token

const check = await request(app).get(`/api/v1/auth/forgot/${code}`)
const withoutEmail = await request(app).put(`/api/v1/auth/forgot/${code}`).send({ password: 'newpass123' })

expect(withoutEmail.status).toBe(422)

const check = await request(app).get(`/api/v1/auth/forgot/${code}`).query({ email })

expect(check.status).toBe(202)

const reset = await request(app).put(`/api/v1/auth/forgot/${code}`).send({ password: 'newpass123' })
const reset = await request(app).put(`/api/v1/auth/forgot/${code}`).send({ email, password: 'newpass123' })

expect(reset.status).toBe(202)

Expand All @@ -91,7 +95,7 @@ describe('password reset', () => {
})

it('rejects an unknown reset token', async () => {
const res = await request(app).get('/api/v1/auth/forgot/123456')
const res = await request(app).get('/api/v1/auth/forgot/123456').query({ email: 'nobody@test.dev' })

expect(res.status).toBe(422)
})
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"dependencies": {
"@alova/adapter-axios": "^2.0.18",
"@arkstack/realtime": "^0.19.4",
"@arkstack/realtime": "^0.20.2",
"@arkyc/types": "workspace:^",
"@arkyc/widget": "workspace:^",
"@uidotdev/usehooks": "^2.4.1",
Expand Down
13 changes: 8 additions & 5 deletions apps/dashboard/src/lib/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,16 @@ export class Auth {
return alova.Post('/v1/auth/forgot', input, { name: 'auth:forgot' })
}

/** Check that a reset token is still valid (no auth). */
static verifyResetToken(token: string) {
return alova.Get(`/v1/auth/forgot/${encodeURIComponent(token)}`, { name: 'auth:forgot:verify' })
/** Check that a reset token is still valid (no auth). A typed code needs the email it was sent to. */
static verifyResetToken(token: string, email?: string) {
return alova.Get(`/v1/auth/forgot/${encodeURIComponent(token)}`, {
name: 'auth:forgot:verify',
params: email ? { email } : {},
})
}

/** Consume a reset token and set a new password (no auth). */
static resetPassword(token: string, input: { password: string }) {
/** Consume a reset token and set a new password (no auth). A typed code needs the email it was sent to. */
static resetPassword(token: string, input: { password: string; email?: string }) {
return alova.Put(`/v1/auth/forgot/${encodeURIComponent(token)}`, input, { name: 'auth:forgot:reset' })
}

Expand Down
Loading
Loading