diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ed544..cb66952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.1.1 + +- feat: add `verifyToken` and `verifyUser` functions +- fix: token timestamp + ## v0.1.0 - Initial release diff --git a/README.md b/README.md index 30b73da..0348734 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,26 @@ app.get('/user/me', totp.authenticate(), (req, res) => { }) ``` +#### Manual authentication + +If you want to manually check the OTP in your own middleware, you can use the `verifyUser` and +`verifyToken` methods. You will need to inject the user yourself in that case. However, you would +get more fine-tuned control over the response timing & structure. + +```typescript +if ('token' in req.query) { + console.log('Token is valid:', totp.verifyToken(userSecret, req.token)) + const user = await totp.verifyUser(req) + if (!user) { + next(new Error('Invalid OTP token')) + return + } + req.user = user + next(null) + return +} +``` + ## Contributing I am developing this package on my free time, so any support, whether code, issues, or just stars is diff --git a/package.json b/package.json index 2547c4a..1994c51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-otp", - "version": "0.1.0", + "version": "0.1.1", "description": "Easy OTP auth for your express app", "main": "index.js", "repository": "https://github.com/chenasraf/express-otp", diff --git a/src/index.ts b/src/index.ts index 68f97b9..962c4cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto' import { encode } from 'hi-base32' import QR from 'qrcode' import _totp from 'totp-generator' -import { Request, Response } from 'express' +import { NextFunction, Request, Response } from 'express' declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -81,7 +81,7 @@ export interface TotpApiOptions { getToken?(req: Request): PromiseOrValue } -export interface TotpMiddlewares { +export interface TotpMiddlewares { /** * Middleware for authenticating a user, using their secret and the token provided in the request. * @@ -128,8 +128,9 @@ export interface TotpMiddlewares { /** * Function for generating a QR code for a user from a given `secret` and `username`. * - * If `filename` is provided, this writes the QR code directly to that path, which you can later use to serve to the user. - * If `filename` is omitted (or blank), this returns a PNG image as a data URL. + * - If `filename` is provided, this writes the QR code directly to that path, which you can later use to serve to the + * user. + * - If `filename` is omitted (or blank), this returns a PNG image as a data URL. * * @param {string} username The username to use for generating the URL. * @param {string} secret The secret to use for generating the URL. @@ -141,6 +142,25 @@ export interface TotpMiddlewares { * Generates a random, 32-byte secret key. You can attach this to your user object or DB however you want. */ generateNewSecret(): string + + /** + * Verifies a given token against a given secret. If the provided token is equal to the generated token for given + * secret, it returns `true`. Otherwise, it returns `false`. + * + * @param secret The secret key of the user. + * @param token The request token to verify against. + * + * @returns {boolean} `true` if the token is valid, `false` otherwise. + */ + verifyToken(secret: string, token: string): boolean + + /** + * Returns the user, only if the token is valid. Otherwise, it returns `undefined`. + * + * @param req The request object. + * @returns {Promise} The user, or `undefined` if the token is invalid. + */ + verifyUser(req: Request): Promise } function generateQR(uri: string, filename?: string): Promise | Promise { @@ -174,53 +194,26 @@ const defaultOptions: Omit, 'issuer' | 'ge getToken: (req) => req.query.token as string, } -export default function totp(_options: TotpOptions & TotpApiOptions): TotpMiddlewares { +export default function totp(_options: TotpOptions & TotpApiOptions): TotpMiddlewares { const options = { ...defaultOptions, ..._options, } as Required> - async function authenticate(req: Request, res: Response, next: () => void): Promise { - const resp = await options.getUser(req) - if (!resp) { - next() - return - } + async function authenticate(req: Request, res: Response, next: NextFunction): Promise { + _authenticate(req, res, next, options) + } - const { user, secret } = resp - const token = await options.getToken(req) + async function verifyUser(req: Request): Promise { + return _verifyUser(options, req) + } - if (token) { - if (_totp(secret, options) !== token) { - res.status(401) - res.send('Unauthorized') - res.end() - return - } - - req.user = user - } - - next() + function verifyToken(secret: string, token: string): boolean { + return _verifyToken(options, secret, token) } function generateSecretURL(username: string, secret: string): string { - const uri = new URL('otpauth://totp/') - uri.searchParams.set('secret', secret) - uri.searchParams.set('issuer', options.issuer) - if (defaultOptions.algorithm !== options.algorithm) { - uri.searchParams.set('algorithm', options.algorithm) - } - if (defaultOptions.digits !== options.digits) { - uri.searchParams.set('digits', options.digits.toString()) - } - if (defaultOptions.period !== options.period) { - uri.searchParams.set('period', options.period.toString()) - } - uri.searchParams.set('account', username) - uri.username = options.issuer - uri.password = username - return uri.toString() + return _generateSecretURL(options, secret, username) } async function generateSecretQR( @@ -228,8 +221,7 @@ export default function totp(_options: TotpOptions & TotpApiOptions): Totp secret: string, filename?: string, ): Promise { - const uri = await generateSecretURL(username, secret) - return generateQR(uri, filename) as Promise + return await _generateSecretQR(options, username, secret, filename) } function generateNewSecret(): string { @@ -241,5 +233,92 @@ export default function totp(_options: TotpOptions & TotpApiOptions): Totp generateSecretURL, generateSecretQR, generateNewSecret, + verifyToken, + verifyUser, } } + +function _generateSecretQR( + options: Required>, + username: string, + secret: string, + filename: string | undefined, +) { + const uri = _generateSecretURL(options, username, secret) + return generateQR(uri, filename) as Promise +} + +function _generateSecretURL( + options: Required>, + secret: string, + username: string, +) { + const uri = new URL('otpauth://totp/') + uri.searchParams.set('secret', secret) + uri.searchParams.set('issuer', options.issuer) + if (defaultOptions.algorithm !== options.algorithm) { + uri.searchParams.set('algorithm', options.algorithm) + } + if (defaultOptions.digits !== options.digits) { + uri.searchParams.set('digits', options.digits.toString()) + } + if (defaultOptions.period !== options.period) { + uri.searchParams.set('period', options.period.toString()) + } + uri.searchParams.set('account', username) + uri.username = options.issuer + uri.password = username + return uri.toString() +} + +function _verifyToken( + options: Required>, + secret: string, + reqToken: string, +) { + options = { ...options, timestamp: options.timestamp ?? Date.now() } + const genToken = _totp(secret, options) + return genToken === reqToken +} + +async function _verifyUser( + options: Required>, + req: Request, +): Promise { + const resp = await options.getUser(req) + if (!resp) { + return + } + + const { user, secret } = resp + const token = await options.getToken(req) + + if (token) { + if (!_verifyToken(options, secret, token)) { + return + } + + return user + } +} + +async function _authenticate( + req: Request, + res: Response, + next: NextFunction, + options: Required>, +) { + const token = await options.getToken(req) + const user = await _verifyUser(options, req) + + if (token) { + if (!user) { + next(Error('Unauthorized')) + return + } + + req.user = user + } + + next(null) +}