UNPKG

homebridge-config-ui-x

Version:

A web based management, configuration and control platform for Homebridge.

441 lines • 17.4 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; import { Buffer } from 'node:buffer'; import { pbkdf2, randomBytes, timingSafeEqual } from 'node:crypto'; import { BadRequestException, ConflictException, ForbiddenException, HttpException, Inject, Injectable, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { createGuardrails } from '@otplib/core'; import { pathExists, readJson, writeJson } from 'fs-extra/esm'; import NodeCache from 'node-cache'; import { generateSecret, generateURI, verify } from 'otplib'; import { ConfigService } from '../config/config.service.js'; import { Logger } from '../logger/logger.service.js'; let AuthService = class AuthService { jwtService; configService; logger; otpUsageCache = new NodeCache({ stdTTL: 90 }); legacyOtpGuardrails = createGuardrails({ MIN_SECRET_BYTES: 10, MAX_SECRET_BYTES: 64, }); constructor(jwtService, configService, logger) { this.jwtService = jwtService; this.configService = configService; this.logger = logger; this.checkAuthFile(); } async authenticate(username, password, otp) { try { const user = await this.findByUsername(username); if (!user) { throw new ForbiddenException(); } await this.checkPassword(user, password); if (user.otpActive && !otp) { throw new HttpException('2FA Code Required', 412); } if (user.otpActive && !await this.verifyOtpToken(user, otp)) { throw new HttpException('2FA Code Invalid', 412); } if (user) { return { username: user.username, name: user.name, admin: user.admin, instanceId: this.configService.instanceId, otpLegacySecret: user.otpLegacySecret || false, }; } } catch (e) { if (e instanceof ForbiddenException) { this.logger.warn('Failed login attempt.'); this.logger.warn('If you have forgotten your password, you can reset to the default ' + `of admin/admin by deleting the "auth.json" file at ${this.configService.authPath} and then restarting Homebridge.`); throw e; } if (e instanceof HttpException) { throw e; } throw new ForbiddenException(); } } async signIn(username, password, otp) { const user = await this.authenticate(username, password, otp); const token = this.jwtService.sign(user); return { access_token: token, token_type: 'Bearer', expires_in: this.configService.ui.sessionTimeout, }; } async checkPassword(user, password) { const passwordAttemptHash = await this.hashPassword(password, user.salt); const passwordAttemptHashBuff = Buffer.from(passwordAttemptHash, 'hex'); const knownPasswordHashBuff = Buffer.from(user.hashedPassword, 'hex'); if (timingSafeEqual(passwordAttemptHashBuff, knownPasswordHashBuff)) { return user; } else { throw new ForbiddenException(); } } async generateNoAuthToken() { if (this.configService.ui.auth !== 'none') { throw new UnauthorizedException(); } const users = await this.getUsers(); const user = users.find(x => x.admin === true); const token = this.jwtService.sign({ username: user.username, name: user.name, admin: user.admin, instanceId: this.configService.instanceId, otpLegacySecret: user.otpLegacySecret || false, }); return { access_token: token, token_type: 'Bearer', expires_in: this.configService.ui.sessionTimeout, }; } async refreshToken(user) { const currentUser = await this.findByUsername(user.username); if (!currentUser) { throw new UnauthorizedException('User no longer exists'); } this.logger.log(`Request received to refresh token for ${user.username}.`); if (currentUser.admin !== user.admin) { throw new UnauthorizedException('User permissions have changed, please log in again'); } if (user.instanceId !== this.configService.instanceId) { throw new UnauthorizedException('Token is not valid for this instance'); } const token = this.jwtService.sign({ username: user.username, name: user.name, admin: user.admin, instanceId: user.instanceId, otpLegacySecret: currentUser.otpLegacySecret || false, }); return { access_token: token, token_type: 'Bearer', expires_in: this.configService.ui.sessionTimeout, }; } async validateUser(payload) { return payload; } async hashPassword(password, salt) { return new Promise((resolve, reject) => { pbkdf2(password, salt, 1000, 64, 'sha512', (err, derivedKey) => { if (err) { return reject(err); } return resolve(derivedKey.toString('hex')); }); }); } async genSalt() { return new Promise((resolve, reject) => { randomBytes(32, (err, buf) => { if (err) { return reject(err); } return resolve(buf.toString('hex')); }); }); } async setupFirstUser(user) { if (this.configService.setupWizardComplete) { throw new ForbiddenException(); } if (!user.password) { throw new BadRequestException('Password missing.'); } user.admin = true; await writeJson(this.configService.authPath, []); const createdUser = await this.addUser(user); this.configService.setupWizardComplete = true; return createdUser; } async generateSetupWizardToken() { if (this.configService.setupWizardComplete !== false) { throw new ForbiddenException(); } const token = this.jwtService.sign({ username: 'setup-wizard', name: 'setup-wizard', admin: true, instanceId: 'xxxxx', }, { expiresIn: '5m' }); return { access_token: token, token_type: 'Bearer', expires_in: 300, }; } async checkAuthFile() { if (!await pathExists(this.configService.authPath)) { this.configService.setupWizardComplete = false; return; } try { const authfile = await readJson(this.configService.authPath); if (!authfile.some(x => x.admin === true)) { this.configService.setupWizardComplete = false; } } catch (e) { this.configService.setupWizardComplete = false; } } desensitiseUserProfile(user) { return { id: user.id, name: user.name, username: user.username, admin: user.admin, otpActive: user.otpActive || false, otpLegacySecret: user.otpLegacySecret || false, }; } async getUsers(strip) { const users = await readJson(this.configService.authPath); if (strip) { return users.map(this.desensitiseUserProfile); } return users; } async findByUsername(username) { const users = await this.getUsers(); return users.find(x => x.username === username); } async saveUserFile(users) { return await writeJson(this.configService.authPath, users, { spaces: 4 }); } async addUser(user) { const authfile = await this.getUsers(); const salt = await this.genSalt(); const newUser = { id: authfile.length ? Math.max(...authfile.map(x => x.id)) + 1 : 1, username: user.username, name: user.name, hashedPassword: await this.hashPassword(user.password, salt), salt, admin: user.admin, }; if (authfile.some(x => x.username.toLowerCase() === newUser.username.toLowerCase())) { throw new ConflictException(`User with username '${newUser.username}' already exists.`); } authfile.push(newUser); await this.saveUserFile(authfile); this.logger.warn(`Added new user: ${user.username}.`); return this.desensitiseUserProfile(newUser); } async deleteUser(id) { const authfile = await this.getUsers(); const index = authfile.findIndex(x => x.id === id); if (index < 0) { throw new BadRequestException('User Not Found'); } if (authfile[index].admin && authfile.filter(x => x.admin === true).length < 2) { throw new BadRequestException('Cannot delete only admin user'); } authfile.splice(index, 1); await this.saveUserFile(authfile); this.logger.warn(`Deleted user with ID ${id}.`); } async updateUser(id, update) { const authfile = await this.getUsers(); const user = authfile.find(x => x.id === id); if (!user) { throw new BadRequestException('User Not Found'); } if (user.username !== update.username) { if (authfile.some(x => x.username.toLowerCase() === update.username.toLowerCase())) { throw new ConflictException(`User with username '${update.username}' already exists.`); } this.logger.log(`Updated user: changed username from ${user.username} to ${update.username}.`); user.username = update.username; } user.name = update.name || user.name; user.admin = (update.admin === undefined) ? user.admin : update.admin; if (update.password) { const salt = await this.genSalt(); user.hashedPassword = await this.hashPassword(update.password, salt); user.salt = salt; } await this.saveUserFile(authfile); this.logger.log(`Updated user: ${user.username}.`); return this.desensitiseUserProfile(user); } async updateOwnPassword(username, currentPassword, newPassword) { const authfile = await this.getUsers(); const user = authfile.find(x => x.username === username); if (!user) { throw new NotFoundException('User not found.'); } await this.checkPassword(user, currentPassword); const salt = await this.genSalt(); user.hashedPassword = await this.hashPassword(newPassword, salt); user.salt = salt; await this.saveUserFile(authfile); return this.desensitiseUserProfile(user); } async setupOtp(username) { const authfile = await this.getUsers(); const user = authfile.find(x => x.username === username); if (!user) { throw new NotFoundException('User not found.'); } if (user.otpActive) { throw new ForbiddenException('2FA has already been activated.'); } user.otpSecret = generateSecret(); await this.saveUserFile(authfile); const appName = `Homebridge UI (${this.configService.instanceId.slice(0, 7)})`; return { timestamp: new Date(), otpauth: generateURI({ issuer: appName, label: user.username, secret: user.otpSecret, }), }; } async activateOtp(username, code) { const authfile = await this.getUsers(); const user = authfile.find(x => x.username === username); if (!user) { throw new NotFoundException('User not found.'); } if (!user.otpSecret) { throw new BadRequestException('2FA has not been setup.'); } let valid = false; try { const result = await verify({ token: code, secret: user.otpSecret, epochTolerance: 30, }); valid = result.valid; } catch (error) { if (error instanceof Error && error.name === 'SecretTooShortError' && user.otpSecret.length === 16) { this.logger.warn(`${user.username} is attempting to activate a legacy 16-character OTP secret.`); const result = await verify({ token: code, secret: user.otpSecret, epochTolerance: 30, guardrails: this.legacyOtpGuardrails, }); valid = result.valid; if (valid) { user.otpLegacySecret = true; } } else { throw error; } } if (valid) { user.otpActive = true; await this.saveUserFile(authfile); this.logger.warn(`Activated 2FA for ${user.username}.`); return this.desensitiseUserProfile(user); } else { throw new BadRequestException('2FA code is not valid.'); } } async deactivateOtp(username, password) { const authfile = await this.getUsers(); const user = authfile.find(x => x.username === username); if (!user) { throw new NotFoundException('User not found.'); } await this.checkPassword(user, password); user.otpActive = false; delete user.otpSecret; delete user.otpLegacySecret; await this.saveUserFile(authfile); this.logger.warn(`Deactivated 2FA for ${username}.`); return this.desensitiseUserProfile(user); } async verifyOtpToken(user, otp) { const otpCacheKey = user.username + otp; if (this.otpUsageCache.get(otpCacheKey)) { this.logger.warn(`${user.username} attempted to reuse one-time-password.`); return false; } try { const { valid } = await verify({ token: otp, secret: user.otpSecret, epochTolerance: 30, }); if (valid) { this.otpUsageCache.set(otpCacheKey, 'true'); return true; } } catch (error) { if (error instanceof Error && error.name === 'SecretTooShortError' && user.otpSecret.length === 16) { this.logger.warn(`${user.username} is using a legacy 16-character OTP secret. They should re-setup 2FA for better security.`); const { valid } = await verify({ token: otp, secret: user.otpSecret, epochTolerance: 30, guardrails: this.legacyOtpGuardrails, }); if (valid) { this.otpUsageCache.set(otpCacheKey, 'true'); user.otpLegacySecret = true; this.markUserAsLegacyOtp(user.username).catch((err) => { const message = err instanceof Error ? err.message : 'Unknown error'; this.logger.error(`Failed to mark user ${user.username} as having legacy OTP: ${message}`); }); return true; } } else { throw error; } } return false; } async markUserAsLegacyOtp(username) { const authfile = await this.getUsers(); const user = authfile.find(x => x.username === username); if (user && !user.otpLegacySecret) { user.otpLegacySecret = true; await this.saveUserFile(authfile); this.logger.warn(`Marked ${username} as having legacy OTP secret.`); } } }; AuthService = __decorate([ Injectable(), __param(0, Inject(JwtService)), __param(1, Inject(ConfigService)), __param(2, Inject(Logger)), __metadata("design:paramtypes", [JwtService, ConfigService, Logger]) ], AuthService); export { AuthService }; //# sourceMappingURL=auth.service.js.map