UNPKG

@nephele/authenticator-htpasswd

Version:

Apache htpasswd based authenticator for the Nephele WebDAV server.

265 lines (239 loc) 8.22 kB
import type { Readable } from 'node:stream'; import crypto from 'node:crypto'; import fsp from 'node:fs/promises'; import { constants } from 'node:fs'; import type { Request } from 'express'; import basicAuth from 'basic-auth'; import apacheMD5 from 'apache-md5'; import crypt from 'apache-crypt'; import bcrypt from 'bcrypt'; import type { Authenticator as AuthenticatorInterface, AuthResponse as NepheleAuthResponse, Resource, } from 'nephele'; import { ForbiddenError, InternalServerError, UnauthorizedError, ResourceNotFoundError, } 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; /** * htpasswd filename. * * This file must be accessible to the adapter that is mounted when the user * has not yet been authenticated. * * For every request, the root directory is searched for this file, then each * directory in turn down to the directory of the request. Whichever file is * found first is what will be used. (Eg, if /dir/ contains a file with only * username "bob", and /dir/sub/ contains a file with username "jane", user * "jane" not be able to access either directory, and user "bob" will be able * to access both.) */ authUserFilename?: string; /** * A specific htpasswd file to use for every request. * * If this filename is given, it points to the htpasswd file used to * authenticate every request. */ authUserFile?: string; /** * Block access to the htpasswd file(s) that match `authUserFilename`. * * This is important, because a user with access to the file could add any * other users they wanted to the file. If you have some setup that guarantees * the htpasswd file used is not accessible to the adapter managing files, you * don't need this. * * This is not the only risk with htpasswd files! If a user has the ability to * move or delete a directory, and the directory contains the htpasswd file, * they could do nefarious things. You should consider the implications of * giving a user access to manage the directory that their htpasswd file is * stored in. * * If you are using `authUserFile`, this option is ignored. */ blockAuthUserFilename?: boolean; }; export type AuthResponse = NepheleAuthResponse<any, { user: User }>; /** * Nephele htpasswd authenticator. * * For information on how to create .htpasswd files, see * https://httpd.apache.org/docs/current/programs/htpasswd.html */ export default class Authenticator implements AuthenticatorInterface { realm: string; unauthorizedAccess: boolean; authUserFilename: string; authUserFile?: string; blockAuthUserFilename: boolean; constructor({ realm = 'Nephele WebDAV Service', unauthorizedAccess = false, authUserFilename = '.htpasswd', authUserFile = undefined, blockAuthUserFilename = true, }: AuthenticatorConfig = {}) { this.realm = realm; this.unauthorizedAccess = unauthorizedAccess; this.authUserFilename = authUserFilename; this.authUserFile = authUserFile; this.blockAuthUserFilename = blockAuthUserFilename; } async authenticate(request: Request, response: AuthResponse) { const authorization = request.get('Authorization'); let username = ''; let password = ''; if ( this.authUserFile == null && this.blockAuthUserFilename && (request.path.endsWith(`/${this.authUserFilename}`) || request.path.endsWith(`/${this.authUserFilename}/`)) ) { throw new ForbiddenError( "You don't have permission to access this resource.", ); } if (authorization) { const auth = basicAuth.parse(authorization); if (auth) { username = auth.name; password = auth.pass; } } try { if (username.trim() === '') { throw new UnauthorizedError( 'Authentication is required to use this server.', ); } const htpasswd = await this._getHtpasswdFile(request, response); if (!(await this._checkHtpasswd(username, password, htpasswd))) { throw new UnauthorizedError( 'The provided credentials are not correct.', ); } return new User({ username }); } catch (e: any) { if (e instanceof UnauthorizedError) { response.set( 'WWW-Authenticate', `Basic realm="${this.realm}", charset="UTF-8"`, ); } if (this.unauthorizedAccess) { return new User({ username: 'nobody' }); } throw e; } } async cleanAuthentication(_request: Request, _response: AuthResponse) { // Nothing is required for auth cleanup. return; } async _checkHtpasswd(username: string, password: string, htpasswd: string) { let lines = htpasswd.split('\n'); for (let line of lines) { const [user, digest, extra] = line.split(':'); if (extra) { // This format means there is a realm, and it only works with Digest // authentication. continue; } if (user === username) { return await this._checkPassword(digest, password); } } return false; } async _checkPassword(digest: string, password: string) { if (digest.startsWith('$apr1$')) { return digest === apacheMD5(password, digest); } else if (digest.startsWith('{SHA}')) { let hash = crypto.createHash('sha1'); hash.update(password); return '{SHA}' + hash.digest('base64') === digest; } else if (digest.startsWith('$2y$')) { return await bcrypt.compare(password, '$2b$' + digest.substring(4)); } return digest === password || crypt(password, digest) === digest; } async _getHtpasswdFile(request: Request, response: AuthResponse) { if (this.authUserFile != null) { try { await fsp.access(this.authUserFile, constants.F_OK); return await fsp.readFile(this.authUserFile, { encoding: 'utf-8' }); } catch (e: any) { throw new InternalServerError( "The server's authentication user file is not accessible.", ); } } const adapter = response.locals.adapter; const baseUrl = response.locals.baseUrl; const urlParts = request.path .substring(baseUrl.pathname.length) .replace(/(?:^\/|\/$)/g, '') .split('/'); for (let i = 0; i < urlParts.length; i++) { const htpasswdUrl = new URL( `${[...urlParts.slice(0, i), ''].join('/')}${this.authUserFilename}`, baseUrl, ); let htpasswdResource: Resource | undefined = undefined; try { htpasswdResource = await adapter.getResource(htpasswdUrl, baseUrl); } catch (e: any) { if (e instanceof ResourceNotFoundError) { continue; } throw e; } if (htpasswdResource != null) { try { const stream = await htpasswdResource.getStream(); return await this._streamToString(stream); } catch (e: any) { throw new InternalServerError( "The server's authentication user file is not accessible.", ); } } } return ''; } _streamToString(stream: Readable): Promise<string> { const chunks: Buffer[] = []; return new Promise((resolve, reject) => { stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))); stream.on('error', (err) => reject(err)); stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); }); } }