UNPKG

@nephele/authenticator-custom

Version:

Custom logic authenticator for the Nephele WebDAV server.

373 lines (331 loc) 11.7 kB
import crypto from 'node:crypto'; import type { Request } from 'express'; import { parseAuthorizationHeader, BASIC, DIGEST, } from 'http-auth-utils-hperrin'; import { v4 as uuid } from 'uuid'; import type { Authenticator as AuthenticatorInterface, AuthResponse as NepheleAuthResponse, } from 'nephele'; import { UnauthorizedError } from 'nephele'; import User from './User.js'; export type AuthenticatorConfig = { /** * The realm is the name reported by the server when the user is prompted to * authenticate. * * It should be HTTP header safe (shouldn't include double quotes or * semicolon). */ realm?: string; /** * Allow the user to proceed, even if they are not authenticated. * * The authenticator will advertise that authentication is available, but the * user will have access to the server without providing authentication. * * In the unauthorized state, the `user` presented to the Nephele adapter will * have the username "nobody". * * WARNING: It is very dangerous to allow unauthorized access if write actions * are allowed! */ unauthorizedAccess?: boolean; /** * A function that takes a username and returns a promise that resolves to a * user if the user exists or it's not possible to tell whether they exist, or * null otherwise. */ getUser: (username: string) => Promise<User | null>; /** * A private key used to calculate nonce values for Digest authentication. * * If you do not provide one, one will be generated, but this does mean that * with Digest authentication, clients will only be able to authenticate to * _that_ particular server. If you have multiple servers or multiple * instances of Nephele that serve the same source data, you should provide * the same key to all of them in order to use Digest authentication * correctly. */ key?: string; /** * The number of milliseconds for which a nonce is valid once issued. Defaults * to 6 hours. */ nonceTimeout?: number; } & ( | { /** * Authorize a User returned by `getUser` with a password. * * The returned promise should resolve to true if the user is successfully * authenticated, false otherwise. * * The Basic mechanism requires the user to submit their username and * password in plain text with the request, so only use this if the * connection is secured through some means like TLS. If you provide * `authBasic`, the server will advertise support for the Basic mechanism. */ authBasic: (user: User, password: string) => Promise<boolean>; } | { /** * Retrieve a User's password or hash for Digest authentication. * * The returned promise should resolve to the password or hash if the user * exists, or null otherwise. If the password is returned, it will be * hashed, however, you can also return a prehashed string of * SHA256(username:realm:password) or MD5(username:realm:password), * depending on the requested algorithm. * * The Digest mechansism requires the user to cryptographically hash their * password with the request, so it will not divulge their password to * eaves droppers. However, it is still less safe than using TLS and Basic * authentication. If you provide `authDigest`, the server will advertise * support for the Digest mechanism. */ authDigest: ( user: User, realm: string, algorithm: 'sha256' | 'md5', ) => Promise<{ password: string } | { hash: string } | null>; } ); export type AuthResponse = NepheleAuthResponse<any, { user: User }>; class StaleUnauthorizedError extends UnauthorizedError {} function hash(input: string, algorithm: 'md5' | 'sha256') { return crypto.createHash(algorithm).update(input).digest('hex').toLowerCase(); } /** * Nephele custom authenticator. */ export default class Authenticator implements AuthenticatorInterface { getUser: (username: string) => Promise<User | null>; authBasic?: (user: User, password: string) => Promise<boolean>; authDigest?: ( user: User, realm: string, algorithm: 'sha256' | 'md5', ) => Promise<{ password: string } | { hash: string } | null>; realm: string; unauthorizedAccess: boolean; key: string; nonceTimeout: number; constructor(config: AuthenticatorConfig) { this.getUser = config.getUser; if ('authBasic' in config) { this.authBasic = config.authBasic; } if ('authDigest' in config) { this.authDigest = config.authDigest; } if (this.authBasic == null && this.authDigest == null) { throw new Error( 'You must provide at least one auth function to authenticator-custom.', ); } this.realm = config.realm || 'Nephele WebDAV Service'; this.unauthorizedAccess = config.unauthorizedAccess || false; this.key = config.key || uuid(); // Nonce is valid for 6 hours by default. this.nonceTimeout = config.nonceTimeout || 1000 * 60 * 60 * 6; } async authenticate(request: Request, response: AuthResponse) { const authorization = request.get('Authorization'); try { if (authorization) { const auth = parseAuthorizationHeader(authorization); if (auth.type === 'Basic' && 'password' in auth.data) { let { username, password } = auth.data; if (this.authBasic == null) { throw new UnauthorizedError( 'Basic authentication is not supported on this server.', ); } if (!username && !password) { throw new UnauthorizedError( 'You must provide a username and password to access this server.', ); } const user = await this.getUser(username); if (user == null) { throw new UnauthorizedError( 'The provided credentials are not correct.', ); } if (!(await this.authBasic(user, password))) { throw new UnauthorizedError( 'The provided credentials are not correct.', ); } return user; } else if (auth.type === 'Digest' && 'response' in auth.data) { let { username, realm, nonce, uri, algorithm, response, cnonce, nc, qop, opaque, } = auth.data; if (this.authDigest == null) { throw new UnauthorizedError( 'Digest authentication is not supported on this server.', ); } if (!username) { throw new UnauthorizedError( 'You must provide a username to access this server.', ); } if (opaque == null) { throw new UnauthorizedError( 'You must provide an opaque value to access this server.', ); } if (realm !== this.realm) { throw new UnauthorizedError( 'The provided realm does not match this server.', ); } if ( uri !== request.url && uri !== `${request.protocol}://${request.headers.host}${request.url}` ) { throw new UnauthorizedError( 'The provided auth URI does not match this request.', ); } const timestamp = parseInt(opaque, 16); if ( isNaN(timestamp) || timestamp < new Date().getTime() - this.nonceTimeout ) { throw new StaleUnauthorizedError('The provided nonce has expired.'); } const checkNonce = hash( `${request.ip}:${opaque}:${this.key}`, 'sha256', ); if (checkNonce !== nonce) { throw new StaleUnauthorizedError( 'The provided nonce was not issued to this client by this server.', ); } const user = await this.getUser(username); if (user == null) { throw new UnauthorizedError( 'The provided credentials are not correct.', ); } if ( (algorithm != null && algorithm !== 'SHA256-sess' && algorithm !== 'MD5-sess') || cnonce == null || qop === 'auth-int' ) { throw new UnauthorizedError( 'The provided credentials are not in a supported format.', ); } const digestInfo = await this.authDigest( user, this.realm, algorithm === 'MD5-sess' ? 'md5' : 'sha256', ); if (digestInfo == null) { throw new UnauthorizedError( 'The provided credentials are not correct.', ); } const hashAlg = algorithm === 'MD5-sess' ? 'md5' : 'sha256'; let HA1: string; if ('hash' in digestInfo) { HA1 = hash(`${digestInfo.hash}:${nonce}:${cnonce}`, hashAlg); } else { const { password } = digestInfo; HA1 = hash( `${hash( `${username}:${this.realm}:${password}`, hashAlg, )}:${nonce}:${cnonce}`, hashAlg, ); } let HA2 = hash(`${request.method}:${uri}`, hashAlg); let check: string; if (qop === 'auth') { check = hash( `${HA1}:${nonce}:${nc}:${cnonce}:${qop}:${HA2}`, hashAlg, ); } else { check = hash(`${HA1}:${nonce}:${HA2}`, hashAlg); } if (check !== response) { throw new UnauthorizedError( 'The provided credentials are not correct.', ); } return user; } } throw new UnauthorizedError( 'You must authenticate to access this server.', ); } catch (e: any) { if (e instanceof UnauthorizedError) { const auths: string[] = []; if (this.authBasic != null) { auths.push( `Basic ${BASIC.buildWWWAuthenticateRest({ realm: this.realm, })}, charset="UTF-8"`, ); } if (this.authDigest != null) { const opaque = new Date().getTime().toString(16); const nonce = hash(`${request.ip}:${opaque}:${this.key}`, 'sha256'); auths.push( `Digest ${DIGEST.buildWWWAuthenticateRest({ nonce, opaque, qop: 'auth', algorithm: 'SHA256-sess' as 'MD5-sess', realm: this.realm, stale: e instanceof StaleUnauthorizedError ? 'true' : 'false', })}, charset="UTF-8"`, ); auths.push( `Digest ${DIGEST.buildWWWAuthenticateRest({ nonce, opaque, qop: 'auth', algorithm: 'MD5-sess', realm: this.realm, stale: e instanceof StaleUnauthorizedError ? 'true' : 'false', })}, charset="UTF-8"`, ); } response.set('WWW-Authenticate', auths); } if (this.unauthorizedAccess) { return new User({ username: 'nobody' }); } throw e; } } async cleanAuthentication(_request: Request, _response: AuthResponse) { // Nothing is required for auth cleanup. return; } }