feat: add errorResponse option

This commit is contained in:
Chen Asraf
2022-11-29 00:32:12 +02:00
parent 29de0b252d
commit 45490860a3
9 changed files with 291 additions and 196 deletions

View File

@@ -1,6 +1,7 @@
{
"cSpell.words": [
"Middlewares",
"otpauth",
"qrcode",
"totp"
]

View File

@@ -1,5 +1,9 @@
# Changelog
## v0.2.0
- feat: add `errorResponse` option
## v0.1.1
- feat: add `verifyToken` and `verifyUser` functions

View File

@@ -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).

View File

@@ -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",

View File

@@ -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()

35
src/error.ts Normal file
View File

@@ -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}`
}
}
}

View File

@@ -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<typeof sampleUser>({
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)
})

View File

@@ -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<U> {
/** 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> = T | Promise<T>
export interface TotpApiOptions<U> {
/**
* 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<UserData<U> | 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<string | undefined>
}
export interface TotpMiddlewares<U> {
/**
* 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<void>
/**
* 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<string>} The QR code as a data URL.
*/
generateSecretQR(username: string, secret: string): Promise<string>
/**
* 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<void>
/**
* 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<string | void>
/**
* 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<U | undefined>} The user, or `undefined` if the token is invalid.
*/
verifyUser(req: Request): Promise<U | undefined>
}
import { TotpApiOptions, TotpMiddlewares, TotpOptions, UserData } from './types'
import { OTPError } from './error'
export * from './types'
function generateQR(uri: string, filename?: string): Promise<string> | Promise<void> {
if (!filename) {
@@ -205,7 +49,8 @@ export default function totp<U>(_options: TotpOptions & TotpApiOptions<U>): Totp
}
async function verifyUser(req: Request): Promise<U | undefined> {
return _verifyUser<U>(options, req)
const resp = await options.getUser(req)
return _verifyUser<U>(options, req, resp)
}
function verifyToken(secret: string, token: string): boolean {
@@ -254,20 +99,25 @@ function _generateSecretURL<U>(
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<U>(
async function _verifyUser<U>(
options: Required<TotpOptions & TotpApiOptions<U>>,
req: Request,
userData: UserData<U> | undefined,
): Promise<U | undefined> {
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<U>(
options: Required<TotpOptions & TotpApiOptions<U>>,
) {
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<U>(
req: Request,
res: Response,
next: NextFunction,
error: OTPError,
options: TotpOptions & TotpApiOptions<U>,
) {
if (options.errorResponse !== undefined) {
options.errorResponse(req, res, next, error)
if (!res.writableEnded) {
res.end()
}
return
}
next(error)
}

171
src/types.ts Normal file
View File

@@ -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<U> {
/** 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> = T | Promise<T>
export interface TotpApiOptions<U> {
/**
* 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<UserData<U> | 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<string | undefined>
/**
* 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<U> {
/**
* 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<void>
/**
* 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<string>} The QR code as a data URL.
*/
generateSecretQR(username: string, secret: string): Promise<string>
/**
* 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<void>
/**
* 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<string | void>
/**
* 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<U | undefined>} The user, or `undefined` if the token is invalid.
*/
verifyUser(req: Request): Promise<U | undefined>
}