diff --git a/src/example/server.ts b/src/example/server.ts index 8b7ea16..dc6f1a9 100644 --- a/src/example/server.ts +++ b/src/example/server.ts @@ -19,11 +19,23 @@ const totp = otipi({ }, }) -app.use('/generate', totp.createToken()) - -app.use('/token/uri', totp.generateTokenURL()) - -app.use('/token/qr', totp.generateTokenQR()) +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)), +) +app.use('/token/qr', async (req, res) => + res + .status(200) + .setHeader('Content-Type', 'text/html') + .send( + '', + ), +) app.use(totp.authenticate()) diff --git a/src/index.ts b/src/index.ts index fb9c09f..6110b58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,9 @@ import _totp from 'totp-generator' import { Request, Response } from 'express' 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, @@ -22,38 +25,121 @@ export interface TotpOptions { | '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 { - issuer: string + /** + * 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> - getToken?(req: Request): PromiseOrValue + + /** + * 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 { - authenticate: () => (req: Request, res: Response, next: () => void) => Promise - generateTokenURL: () => (req: Request, res: Response, next: () => void) => Promise - generateTokenQR: () => (req: Request, res: Response, next: () => void) => Promise - createToken: () => (req: Request, res: Response, next: () => void) => Promise + /** + * 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): Promise + + /** + * 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(): Promise } -export function base32secret(length = 32) { +function base32secret(length = 32) { return encode(crypto.randomBytes(32)).slice(0, length) } -export type OTPUriParams = Record<'secret' | 'issuer' | 'username', string> +type OTPUriParams = Record<'secret' | 'issuer' | 'username', string> -export function generateTokenUri({ secret, issuer, username }: OTPUriParams) { +function generateTokenUri({ secret, issuer, username }: OTPUriParams) { const uri = new URL('otpauth://totp/') uri.searchParams.set('secret', secret) uri.searchParams.set('issuer', issuer) @@ -66,14 +152,26 @@ export function generateTokenUri({ secret, issuer, username }: OTPUriParams) { return uri.toString() } -export function generateQR(uri: string): Promise { - return new Promise((resolve, reject) => { - QR.toDataURL(uri, (err, uri) => { +function generateQR(uri: string, filename?: string): Promise | Promise { + if (!filename) { + return new Promise((resolve, reject) => { + QR.toDataURL(uri, (err, uri) => { + if (err) { + reject(err) + return + } + resolve(uri) + }) + }) + } + + return new Promise((resolve, reject) => { + QR.toFile(filename, uri, (err) => { if (err) { reject(err) return } - resolve(uri) + resolve() }) }) } @@ -91,7 +189,7 @@ export default function totp(_options: TotpOptions & TotpApiOptions): Totp ..._options, } as Required> - async function authenticate(req: Request, res: Response, next: () => void) { + async function authenticate(req: Request, res: Response, next: () => void): Promise { const resp = await options.getUser(req) if (!resp) { res.status(401) @@ -116,72 +214,36 @@ export default function totp(_options: TotpOptions & TotpApiOptions): Totp next() } - async function generateTokenURL(req: Request, res: Response) { - const resp = await options.getUser(req) - if (!resp) { - res.status(401) - res.send('Unauthorized') - res.end() - return - } - const { username, secret } = resp - - if (!secret || !username) { - res.status(400) - res.send('Invalid secret or username') - res.end() - return - } + async function generateSecretURL(username: string, secret: string): Promise { + return generateTokenUri({ + secret, + issuer: options.issuer, + username, + }) + } + async function generateSecretQR( + username: string, + secret: string, + filename?: string, + ): Promise { const uri = generateTokenUri({ secret, issuer: options.issuer, username, }) - res.setHeader('Content-Type', 'text/plain') - res.status(200) - res.send(uri) + return generateQR(uri, filename) as Promise } - async function generateTokenQR(req: Request, res: Response) { - const resp = await options.getUser(req) - if (!resp) { - res.status(401) - res.send('Unauthorized') - res.end() - return - } - const { username, secret } = resp - - if (!secret || !username) { - res.status(400) - res.send('Invalid secret or username') - res.end() - return - } - - const uri = generateTokenUri({ - secret, - issuer: options.issuer, - username, - }) - - res.setHeader('Content-Type', 'text/plain') - res.status(200) - res.send(await generateQR(uri)) - } - - async function createToken(req: Request, res: Response) { - res.setHeader('Content-Type', 'text/plain') - res.status(200) - res.send(base32secret()) + async function generateNewSecret(): Promise { + return base32secret() } return { authenticate: () => authenticate, - generateTokenURL: () => generateTokenURL, - generateTokenQR: () => generateTokenQR, - createToken: () => createToken, + generateSecretURL, + generateSecretQR, + generateNewSecret, } }