From 45490860a3cdf02fda8f7def885571d41e828e60 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Tue, 29 Nov 2022 00:32:12 +0200 Subject: [PATCH] feat: add `errorResponse` option --- .vscode/settings.json | 1 + CHANGELOG.md | 4 + README.md | 16 ++- package.json | 4 +- scripts/build.ts | 6 +- src/error.ts | 35 +++++++ src/example/server.ts | 28 +++--- src/index.ts | 222 +++++++++--------------------------------- src/types.ts | 171 ++++++++++++++++++++++++++++++++ 9 files changed, 291 insertions(+), 196 deletions(-) create mode 100644 src/error.ts create mode 100644 src/types.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index f02a16b..1b9f7e8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ "Middlewares", + "otpauth", "qrcode", "totp" ] diff --git a/CHANGELOG.md b/CHANGELOG.md index cb66952..ee509d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.2.0 + +- feat: add `errorResponse` option + ## v0.1.1 - feat: add `verifyToken` and `verifyUser` functions diff --git a/README.md b/README.md index 0348734..d3c2b62 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ import otp from 'express-otp' const totp = otp({ // Any identifier that is for your app issuer: 'my-issuer', + // This should return user information if a request contains a valid user // attempt (such as username or email) getUser: async (req) => { @@ -37,9 +38,19 @@ const totp = otp({ } return { user: user.details, secret: user.secret, username: user.username } }, + // By default, the token is fetched using `req.query.token`. You can change // that by providing a `getToken` option: getToken: (req) => req.headers['X-OTP-Token'] as string, + + // Use this option to immediately respond with an error when a token is + // missing/invalid. If this is omitted, the next route/middleware will fire + // normally, but without `req.user` injected. Providing this function ends + // the response if it's fired. + errorResponse(req, res, next, error) { + res.send(error.message) + res.status(401) + }, }) ``` @@ -73,8 +84,9 @@ await totp.generateSecretQR(username, secret, '/path/to/qr.png') ### Authenticate a user To lock any endpoint behind authentication, use the provided `authenticate()` middleware. If the -user provided the token by your specified method, the user is injected into the request. If the -`req` object contains a `user`, that means your user is authenticated! +user provided the token by your specified method, the user is injected into the request. Otherwise, +an error will be chained to the next middleware. You can make it respond immediately with an error +by using `errorResponse` option. Further requests will still need to be validated with a correct token. The authentication state will **not be saved in memory** between sessions - that is up to you to implement (if necessary). diff --git a/package.json b/package.json index 229f600..ab2bd8c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "express-otp", - "version": "0.1.1", - "description": "Easy OTP auth for your express app", + "version": "0.2.0", + "description": "OTP auth for your nodejs/express app, as easy as it gets!", "main": "index.js", "repository": "https://github.com/chenasraf/express-otp", "homepage": "https://casraf.dev/express-otp", diff --git a/scripts/build.ts b/scripts/build.ts index bde0178..318ee53 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -17,9 +17,9 @@ async function main() { console.log('Done') } -async function copyFile(file: string) { - const readme = await fs.readFile(file, 'utf-8') - await fs.writeFile(path.join('build', file), readme) +async function copyFile(filename: string) { + const data = await fs.readFile(filename, 'utf-8') + await fs.writeFile(path.join('build', filename), data) } main() diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..52f3f64 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,35 @@ +/** + * The reason for an OTP error. + * + * - `no_user` - No user was found for the request. + * - `no_token` - No token was found in the request. + * - `invalid_token` - The token was invalid. + */ +export type OTPErrorReason = 'invalid_token' | 'no_token' | 'no_user' + +/** + * The error thrown when OTP verification fails. + */ +export class OTPError { + /** + * The reason for the error. + * @param type The type of error. + */ + constructor(public type: OTPErrorReason) {} + + /** + * The error message. + */ + public get message(): string { + switch (this.type) { + case 'invalid_token': + return 'Invalid token' + case 'no_token': + return 'No token provided' + case 'no_user': + return 'No user found' + default: + return `Unknown error: ${this.type}` + } + } +} diff --git a/src/example/server.ts b/src/example/server.ts index 2849f95..1033aff 100644 --- a/src/example/server.ts +++ b/src/example/server.ts @@ -6,27 +6,36 @@ dotenv.config() const app = express() -const sampleUser = { id: 1, username: process.env.USER_NAME!, secret: process.env.USER_SECRET! } +const sampleUser = { + id: 1, + username: process.env.USER_NAME!, + secret: process.env.USER_SECRET!, +} const totp = otp({ issuer: 'my-issuer', - getUser: (req) => { + getUser(req) { const user = [sampleUser].find((x) => x.username === req.query.username) if (!user) { return undefined } return { user, secret: user.secret, username: user.username } }, - getToken: (req) => req.headers['X-OTP-Token'] as string, + errorResponse(req, res, next, error) { + res.send(error.message) + res.status(401) + }, }) app.use('/generate', (req, res) => res.status(200).send(totp.generateNewSecret())) + app.use('/token/uri', async (req, res) => res .status(200) .setHeader('Content-Type', 'text/plain') - .send(await totp.generateSecretURL(sampleUser.username, sampleUser.secret)), + .send(totp.generateSecretURL(sampleUser.username, sampleUser.secret)), ) + app.use('/token/qr', async (req, res) => res .status(200) @@ -38,17 +47,8 @@ app.use('/token/qr', async (req, res) => ), ) -app.use(totp.authenticate()) - -app.use('/verify', (req, res) => { +app.use('/verify', totp.authenticate(), (req, res) => { res.setHeader('Content-Type', 'text/plain') - - if (!req.user) { - res.send('Not logged in') - res.status(401) - return - } - res.send('Logged in as user ' + JSON.stringify(req.user)) res.status(200) }) diff --git a/src/index.ts b/src/index.ts index ba6f9ed..af117f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,165 +3,9 @@ import { encode } from 'hi-base32' import QR from 'qrcode' import _totp from 'totp-generator' import { NextFunction, Request, Response } from 'express' - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Express { - export interface Request { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - user?: any - } - } -} - -export interface TotpOptions { - /** The issuer for your app (required) */ - issuer: string - /** The time it takes for a new token to generate, in seconds. Defaults to 30 */ - period?: number | undefined - /** - * The desired SHA variant (SHA-1, SHA-224, SHA-256, SHA-384, SHA-512, - * SHA3-224, SHA3-256, SHA3-384, SHA3-512, SHAKE128, or SHAKE256). - */ - algorithm?: - | 'SHA-1' - | 'SHA-224' - | 'SHA-256' - | 'SHA-384' - | 'SHA-512' - | 'SHA3-224' - | 'SHA3-256' - | 'SHA3-384' - | 'SHA3-512' - | 'SHAKE128' - | 'SHAKE256' - /** Amount of token digits to use. Defaults to 6 */ - digits?: number | undefined - /** The epoch time. Defaults to 0 (unix epoch) */ - timestamp?: number | undefined -} - -export interface UserData { - /** The user object that will get injected into further requests. */ - user: U - /** The secret key of the user, used for generating a comparison key. */ - secret: string - /** The username used for generating the token URL/QR. */ - username: string -} - -type PromiseOrValue = T | Promise - -export interface TotpApiOptions { - /** - * If the return value is not `undefined`, it uses this function to verify and then inject the correct user into further - * requests in a middleware (usually before request processing). - * - * This is where you would decide which users this request belongs to, via whatever method you want - going into DB, - * checking headers, etc. - * - * - The `user` property of the return value will be injected into further requests. - * - The `secret` property of the return value will be used to generate a comparison key. - * - The `username` property of the return value will be used to generate the token URL/QR. - * - * Returning `undefined` will cause the request to continue as normal, without any user injected. - * - * It is up to you to return an error in the actual request if necessary. - * - * @param {Request} req The request object. - */ - getUser(req: Request): PromiseOrValue | undefined> - - /** - * This function should return the token from the request (e.g. from a header, or from a query parameter). - * - * @param {Request} req The request object. - * @returns {string | undefined} The token from the request, or `undefined` if it doesn't exist. - */ - getToken?(req: Request): PromiseOrValue -} - -export interface TotpMiddlewares { - /** - * Middleware for authenticating a user, using their secret and the token provided in the request. - * - * Use `getUser` in the options to control which user gets used for comparing the token to and later injected into - * further requests. - * - * Use `getToken` in the options to control how the token is fetched in the request (query, headers, etc). - */ - authenticate(): (req: Request, res: Response, next: () => void) => Promise - - /** - * Function for generating a secret URL for a user from a given `secret` and `username`. - * - * @param {string} username The username to use for generating the URL. - * @param {string} secret The secret to use for generating the URL. - * - * @returns {string} The URL for the user. - */ - generateSecretURL(username: string, secret: string): string - - /** - * Function for generating a QR code for a user from a given `secret` and `username`. - * - * 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. - * - * @returns {Promise} The QR code as a data URL. - */ - generateSecretQR(username: string, secret: string): Promise - - /** - * Function for generating a QR code for a user from a given `secret` and `username`. - * - * This writes the QR code directly to a file, which you can later use to serve to the user. - * - * @param {string} username The username to use for generating the URL. - * @param {string} secret The secret to use for generating the URL. - * @param {string} filename The path of the file to write the QR image to. - */ - generateSecretQR(username: string, secret: string, filename: string): Promise - - /** - * 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. - * - * @param {string} username The username to use for generating the URL. - * @param {string} secret The secret to use for generating the URL. - * @param {string} filename If provided, will use as path of the file to write the QR image to. - */ - generateSecretQR(username: string, secret: string, filename?: string): Promise - - /** - * 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 -} +import { TotpApiOptions, TotpMiddlewares, TotpOptions, UserData } from './types' +import { OTPError } from './error' +export * from './types' function generateQR(uri: string, filename?: string): Promise | Promise { if (!filename) { @@ -205,7 +49,8 @@ export default function totp(_options: TotpOptions & TotpApiOptions): Totp } async function verifyUser(req: Request): Promise { - return _verifyUser(options, req) + const resp = await options.getUser(req) + return _verifyUser(options, req, resp) } function verifyToken(secret: string, token: string): boolean { @@ -254,20 +99,25 @@ function _generateSecretURL( username: string, ) { const uri = new URL('otpauth://totp/') - uri.searchParams.set('secret', secret) + uri.username = options.issuer + uri.password = username + uri.searchParams.set('issuer', options.issuer) + uri.searchParams.set('account', username) + uri.searchParams.set('secret', secret) + 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() } @@ -283,13 +133,13 @@ function _verifyToken( async function _verifyUser( options: Required>, req: Request, + userData: UserData | undefined, ): Promise { - const resp = await options.getUser(req) - if (!resp) { + if (!userData) { return } - const { user, secret } = resp + const { user, secret } = userData const token = await options.getToken(req) if (token) { @@ -308,16 +158,38 @@ async function _authenticate( options: Required>, ) { const token = await options.getToken(req) - const user = await _verifyUser(options, req) + const resp = await options.getUser(req) + const user = await _verifyUser(options, req, resp) - if (token) { - if (!user) { - next(Error('Unauthorized')) - return - } - - req.user = user + if (!token) { + return respondWithError(req, res, next, new OTPError('no_token'), options) } + if (!resp?.user) { + return respondWithError(req, res, next, new OTPError('no_user'), options) + } + + if (!user) { + return respondWithError(req, res, next, new OTPError('invalid_token'), options) + } + + req.user = user next(null) } + +function respondWithError( + req: Request, + res: Response, + next: NextFunction, + error: OTPError, + options: TotpOptions & TotpApiOptions, +) { + if (options.errorResponse !== undefined) { + options.errorResponse(req, res, next, error) + if (!res.writableEnded) { + res.end() + } + return + } + next(error) +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c07875e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,171 @@ +import { NextFunction, Request, Response } from 'express' +import { OTPError } from './error' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + export interface Request { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + user?: any + } + } +} + +export interface TotpOptions { + /** The issuer for your app (required) */ + issuer: string + /** The time it takes for a new token to generate, in seconds. Defaults to 30 */ + period?: number | undefined + /** + * The desired SHA variant (SHA-1, SHA-224, SHA-256, SHA-384, SHA-512, + * SHA3-224, SHA3-256, SHA3-384, SHA3-512, SHAKE128, or SHAKE256). + */ + algorithm?: + | 'SHA-1' + | 'SHA-224' + | 'SHA-256' + | 'SHA-384' + | 'SHA-512' + | 'SHA3-224' + | 'SHA3-256' + | 'SHA3-384' + | 'SHA3-512' + | 'SHAKE128' + | 'SHAKE256' + /** Amount of token digits to use. Defaults to 6 */ + digits?: number | undefined + /** The epoch time. Defaults to 0 (unix epoch) */ + timestamp?: number | undefined +} + +export interface UserData { + /** The user object that will get injected into further requests. */ + user: U + /** The secret key of the user, used for generating a comparison key. */ + secret: string + /** The username used for generating the token URL/QR. */ + username: string +} + +type PromiseOrValue = T | Promise + +export interface TotpApiOptions { + /** + * If the return value is not `undefined`, it uses this function to verify and then inject the correct user into further + * requests in a middleware (usually before request processing). + * + * This is where you would decide which users this request belongs to, via whatever method you want - going into DB, + * checking headers, etc. + * + * - The `user` property of the return value will be injected into further requests. + * - The `secret` property of the return value will be used to generate a comparison key. + * - The `username` property of the return value will be used to generate the token URL/QR. + * + * Returning `undefined` will cause the request to continue as normal, without any user injected. + * + * It is up to you to return an error in the actual request if necessary. + * + * @param {Request} req The request object. + */ + getUser(req: Request): PromiseOrValue | undefined> + + /** + * This function should return the token from the request (e.g. from a header, or from a query parameter). + * + * @param {Request} req The request object. + * @returns {string | undefined} The token from the request, or `undefined` if it doesn't exist. + */ + getToken?(req: Request): PromiseOrValue + + /** + * If this function is provided, it will be used to respond to the user with an error when OTP verification fails. + * The response ends after this function is called. + * + * @param req The request object. + * @param res The response object. + * @param next The next function. + */ + errorResponse?(req: Request, res: Response, next: NextFunction, reason: OTPError): void +} + +export interface TotpMiddlewares { + /** + * Middleware for authenticating a user, using their secret and the token provided in the request. + * + * Use `getUser` in the options to control which user gets used for comparing the token to and later injected into + * further requests. + * + * Use `getToken` in the options to control how the token is fetched in the request (query, headers, etc). + */ + authenticate(): (req: Request, res: Response, next: () => void) => Promise + + /** + * Function for generating a secret URL for a user from a given `secret` and `username`. + * + * @param {string} username The username to use for generating the URL. + * @param {string} secret The secret to use for generating the URL. + * + * @returns {string} The URL for the user. + */ + generateSecretURL(username: string, secret: string): string + + /** + * Function for generating a QR code for a user from a given `secret` and `username`. + * + * 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. + * + * @returns {Promise} The QR code as a data URL. + */ + generateSecretQR(username: string, secret: string): Promise + + /** + * Function for generating a QR code for a user from a given `secret` and `username`. + * + * This writes the QR code directly to a file, which you can later use to serve to the user. + * + * @param {string} username The username to use for generating the URL. + * @param {string} secret The secret to use for generating the URL. + * @param {string} filename The path of the file to write the QR image to. + */ + generateSecretQR(username: string, secret: string, filename: string): Promise + + /** + * 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. + * + * @param {string} username The username to use for generating the URL. + * @param {string} secret The secret to use for generating the URL. + * @param {string} filename If provided, will use as path of the file to write the QR image to. + */ + generateSecretQR(username: string, secret: string, filename?: string): Promise + + /** + * 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 +}