57 lines
1.8 KiB
TypeScript
57 lines
1.8 KiB
TypeScript
import { Secret, TOTP } from 'otpauth'
|
|
import encryption from '@adonisjs/core/services/encryption'
|
|
import type User from '#models/user'
|
|
|
|
const ISSUER = 'Big Brother'
|
|
const PERIOD = 30
|
|
|
|
function build(secret: Secret, username: string) {
|
|
return new TOTP({ issuer: ISSUER, label: username, secret, period: PERIOD, digits: 6 })
|
|
}
|
|
|
|
/**
|
|
* Two-factor authentication with a 6-digit code (Google Authenticator,
|
|
* Aegis, 2FAS…). The secret is stored encrypted with the APP_KEY.
|
|
*/
|
|
const totpService = {
|
|
/**
|
|
* New secret, its encrypted form for the database and the otpauth://
|
|
* URI shown as a QR code.
|
|
*/
|
|
generate(username: string) {
|
|
const secret = new Secret({ size: 20 })
|
|
return {
|
|
encrypted: encryption.encrypt(secret.base32),
|
|
base32: secret.base32,
|
|
uri: build(secret, username).toString(),
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Checks a code against the given encrypted secret, accepting the
|
|
* previous and next periods (clock drift). Returns the time step used.
|
|
*/
|
|
check(encryptedSecret: string, username: string, code: string): number | null {
|
|
const base32 = encryption.decrypt<string>(encryptedSecret)
|
|
if (!base32 || !/^\d{6}$/.test(code)) return null
|
|
const totp = build(Secret.fromBase32(base32), username)
|
|
const delta = totp.validate({ token: code, window: 1 })
|
|
if (delta === null) return null
|
|
return Math.floor(Date.now() / 1000 / PERIOD) + delta
|
|
},
|
|
|
|
/**
|
|
* Checks the code of a user and refuses a code already used (replay).
|
|
*/
|
|
async verify(user: User, code: string) {
|
|
const step = this.check(user.totpSecret, user.username, code)
|
|
if (step === null) return false
|
|
if (user.totpLastStep !== null && step <= user.totpLastStep) return false
|
|
user.totpLastStep = step
|
|
await user.save()
|
|
return true
|
|
},
|
|
}
|
|
|
|
export default totpService
|