homebridge-config-ui-x
Version:
A web based management, configuration and control platform for Homebridge.
611 lines • 25.8 kB
JavaScript
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 } from 'fs-extra/esm';
import NodeCache from 'node-cache';
import { generateSecret, generateURI, verify } from 'otplib';
import { PluginsSettingsUiTicketService } from '../../modules/custom-plugins/plugins-settings-ui/plugins-settings-ui-ticket.service.js';
import { ConfigService } from '../config/config.service.js';
import { JsonFileStoreService } from '../fs/json-file-store.service.js';
import { Logger } from '../logger/logger.service.js';
const PBKDF2_ITERATIONS = 210000;
const LEGACY_PBKDF2_ITERATIONS = 1000;
const MAX_LOGIN_FAILURES = 10;
const LOGIN_LOCKOUT_SECONDS = 300;
const USER_CACHE_TTL_SECONDS = 5;
const MAX_SERVICE_TOKEN_LIFETIME_SECONDS = 300;
let AuthService = class AuthService {
jwtService;
configService;
jsonStore;
logger;
pluginUiTicketService;
otpUsageCache = new NodeCache({ stdTTL: 90 });
loginFailureCache = new NodeCache({ stdTTL: LOGIN_LOCKOUT_SECONDS });
userCache = new NodeCache({ stdTTL: USER_CACHE_TTL_SECONDS });
firstUserSetupInProgress = false;
legacyOtpGuardrails = createGuardrails({
MIN_SECRET_BYTES: 10,
MAX_SECRET_BYTES: 64,
});
constructor(jwtService, configService, jsonStore, logger, pluginUiTicketService) {
this.jwtService = jwtService;
this.configService = configService;
this.jsonStore = jsonStore;
this.logger = logger;
this.pluginUiTicketService = pluginUiTicketService;
this.checkAuthFile();
}
async authenticate(username, password, otp, clientId) {
const throttleKey = `${(username || '').toLowerCase()}|${clientId || ''}`;
if ((this.loginFailureCache.get(throttleKey) || 0) >= MAX_LOGIN_FAILURES) {
this.logger.warn(`Too many failed login attempts for '${username}' - temporarily locked out.`);
throw new HttpException('Too many failed attempts. Please wait a few minutes and try again.', 429);
}
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);
}
await this.upgradePasswordHashIfNeeded(user, password);
this.loginFailureCache.del(throttleKey);
if (user) {
return {
username: user.username,
name: user.name,
admin: user.admin,
instanceId: this.configService.instanceId,
sessionVersion: user.sessionVersion ?? 0,
otpLegacySecret: user.otpLegacySecret || false,
};
}
}
catch (e) {
const is2faPrompt = e instanceof HttpException && e.getStatus() === 412 && e.message === '2FA Code Required';
if (!is2faPrompt) {
const failures = (this.loginFailureCache.get(throttleKey) || 0) + 1;
this.loginFailureCache.set(throttleKey, failures);
}
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, clientId) {
const user = await this.authenticate(username, password, otp, clientId);
const token = this.jwtService.sign(user);
return {
access_token: token,
token_type: 'Bearer',
expires_in: this.configService.ui.sessionTimeout,
};
}
async checkPassword(user, password) {
const iterations = user.passwordIterations ?? LEGACY_PBKDF2_ITERATIONS;
const passwordAttemptHash = await this.hashPassword(password, user.salt, iterations);
const passwordAttemptHashBuff = Buffer.from(passwordAttemptHash, 'hex');
const knownPasswordHashBuff = Buffer.from(user.hashedPassword, 'hex');
if (timingSafeEqual(passwordAttemptHashBuff, knownPasswordHashBuff)) {
return user;
}
else {
throw new ForbiddenException();
}
}
async upgradePasswordHashIfNeeded(user, password) {
const current = user.passwordIterations ?? LEGACY_PBKDF2_ITERATIONS;
if (current >= PBKDF2_ITERATIONS) {
return;
}
try {
const salt = await this.genSalt();
const hashedPassword = await this.hashPassword(password, salt, PBKDF2_ITERATIONS);
await this.withAuthFile((authfile) => {
const stored = authfile.find(x => x.username === user.username);
if (stored) {
stored.salt = salt;
stored.hashedPassword = hashedPassword;
stored.passwordIterations = PBKDF2_ITERATIONS;
}
});
this.logger.log(`Upgraded stored password hash strength for ${user.username}.`);
}
catch (e) {
this.logger.warn(`Could not upgrade password hash for ${user.username}: ${e.message}`);
}
}
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,
sessionVersion: user.sessionVersion ?? 0,
otpLegacySecret: user.otpLegacySecret || false,
});
return {
access_token: token,
token_type: 'Bearer',
expires_in: this.configService.ui.sessionTimeout,
};
}
async refreshToken(user, reason) {
const currentUser = await this.findByUsername(user.username);
if (!currentUser) {
throw new UnauthorizedException('User no longer exists');
}
this.logger.log(this.refreshTokenLogMessage(user.username, reason));
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,
sessionVersion: currentUser.sessionVersion ?? 0,
otpLegacySecret: currentUser.otpLegacySecret || false,
});
return {
access_token: token,
token_type: 'Bearer',
expires_in: this.configService.ui.sessionTimeout,
};
}
refreshTokenLogMessage(username, reason) {
switch (reason) {
case 'admin-guard':
return `Verifying admin session for ${username} (admin-guard token refresh).`;
case 'session-extension':
return `Extending session for ${username} (inactivity-based token refresh).`;
case 'profile-update':
return `Refreshing token for ${username} after profile/auth change.`;
default:
return `Request received to refresh token for ${username}.`;
}
}
async validateUser(payload) {
if (payload?.username === 'setup-wizard' && this.configService.setupWizardComplete === false) {
return payload;
}
if (payload?.service !== undefined) {
return this.validateServiceToken(payload);
}
const user = await this.findCurrentUser(payload?.username);
if (!user) {
this.logger.debug(`Rejected a correctly signed token for '${payload?.username}': no such user. A plugin authenticating with its own token must set the "service" claim.`);
return null;
}
if (!!user.admin !== !!payload.admin) {
return null;
}
if ((user.sessionVersion ?? 0) !== (payload.sessionVersion ?? 0)) {
return null;
}
return payload;
}
validateServiceToken(payload) {
if (typeof payload.service !== 'string' || payload.service.trim() === '') {
this.logger.warn('Rejected a service token: the "service" claim must name the caller.');
return null;
}
const { iat, exp, service } = payload;
if (typeof iat !== 'number' || typeof exp !== 'number') {
this.logger.warn(`Rejected a service token from '${service}': it must carry an expiry.`);
return null;
}
if (exp - iat > MAX_SERVICE_TOKEN_LIFETIME_SECONDS) {
this.logger.warn(`Rejected a service token from '${service}': valid for ${exp - iat} seconds, the maximum is ${MAX_SERVICE_TOKEN_LIFETIME_SECONDS}.`);
return null;
}
return payload;
}
async findCurrentUser(username) {
if (!username) {
return undefined;
}
let users = this.userCache.get('users');
if (!users) {
users = await this.getUsers();
this.userCache.set('users', users);
}
return users.find(x => x.username === username);
}
invalidateUserCache() {
this.userCache.del('users');
}
async hashPassword(password, salt, iterations = PBKDF2_ITERATIONS) {
return new Promise((resolve, reject) => {
pbkdf2(password, salt, iterations, 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.');
}
if (this.firstUserSetupInProgress) {
throw new ConflictException('First user setup is already in progress.');
}
this.firstUserSetupInProgress = true;
try {
user.admin = true;
await this.jsonStore.write(this.configService.authPath, [], { spaces: 4 });
const createdUser = await this.addUser(user);
this.configService.setupWizardComplete = true;
return createdUser;
}
finally {
this.firstUserSetupInProgress = false;
}
}
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 withAuthFile(mutator) {
let result;
await this.jsonStore.mutate(this.configService.authPath, async (current) => {
const users = current ?? [];
result = await mutator(users);
return users;
}, { spaces: 4 });
this.invalidateUserCache();
return result;
}
async addUser(user) {
const salt = await this.genSalt();
const hashedPassword = await this.hashPassword(user.password, salt);
return this.withAuthFile((authfile) => {
if (authfile.some(x => x.username.toLowerCase() === user.username.toLowerCase())) {
throw new ConflictException(`User with username '${user.username}' already exists.`);
}
const newUser = {
id: authfile.length ? Math.max(...authfile.map(x => x.id)) + 1 : 1,
username: user.username,
name: user.name,
hashedPassword,
salt,
passwordIterations: PBKDF2_ITERATIONS,
sessionVersion: 0,
admin: user.admin,
};
authfile.push(newUser);
this.logger.warn(`Added new user: ${user.username}.`);
return this.desensitiseUserProfile(newUser);
});
}
async deleteUser(id) {
let deletedUsername;
await this.withAuthFile((authfile) => {
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');
}
deletedUsername = authfile[index].username;
authfile.splice(index, 1);
this.logger.warn(`Deleted user with ID ${id}.`);
});
this.pluginUiTicketService.revokeUser(deletedUsername);
}
async updateUser(id, update) {
let newSalt;
let newHashedPassword;
if (update.password) {
newSalt = await this.genSalt();
newHashedPassword = await this.hashPassword(update.password, newSalt);
}
let previousUsername;
const result = await this.withAuthFile((authfile) => {
const user = authfile.find(x => x.id === id);
if (!user) {
throw new BadRequestException('User Not Found');
}
previousUsername = user.username;
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;
const adminChanged = update.admin !== undefined && !!update.admin !== !!user.admin;
if (adminChanged && !update.admin && authfile.filter(x => x.admin === true).length < 2) {
throw new BadRequestException('Cannot remove admin from only admin user');
}
user.admin = (update.admin === undefined) ? user.admin : update.admin;
if (newHashedPassword && newSalt) {
user.hashedPassword = newHashedPassword;
user.salt = newSalt;
user.passwordIterations = PBKDF2_ITERATIONS;
}
if (newHashedPassword || adminChanged) {
user.sessionVersion = (user.sessionVersion ?? 0) + 1;
}
this.logger.log(`Updated user: ${user.username}.`);
return this.desensitiseUserProfile(user);
});
this.pluginUiTicketService.revokeUser(previousUsername);
return result;
}
async updateOwnPassword(username, currentPassword, newPassword) {
const newSalt = await this.genSalt();
const newHashedPassword = await this.hashPassword(newPassword, newSalt);
const result = await this.withAuthFile(async (authfile) => {
const user = authfile.find(x => x.username === username);
if (!user) {
throw new NotFoundException('User not found.');
}
await this.checkPassword(user, currentPassword);
user.hashedPassword = newHashedPassword;
user.salt = newSalt;
user.passwordIterations = PBKDF2_ITERATIONS;
user.sessionVersion = (user.sessionVersion ?? 0) + 1;
return this.desensitiseUserProfile(user);
});
this.pluginUiTicketService.revokeUser(username);
return result;
}
async setupOtp(username) {
return this.withAuthFile((authfile) => {
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();
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 result = await this.withAuthFile(async (authfile) => {
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) {
throw new BadRequestException('2FA code is not valid.');
}
user.otpActive = true;
user.sessionVersion = (user.sessionVersion ?? 0) + 1;
this.logger.warn(`Activated 2FA for ${user.username}.`);
return this.desensitiseUserProfile(user);
});
this.pluginUiTicketService.revokeUser(username);
return result;
}
async deactivateOtp(username, password) {
const result = await this.withAuthFile(async (authfile) => {
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;
user.sessionVersion = (user.sessionVersion ?? 0) + 1;
this.logger.warn(`Deactivated 2FA for ${username}.`);
return this.desensitiseUserProfile(user);
});
this.pluginUiTicketService.revokeUser(username);
return result;
}
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;
}
this.otpUsageCache.set(otpCacheKey, 'pending');
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 {
this.otpUsageCache.del(otpCacheKey);
throw error;
}
}
this.otpUsageCache.del(otpCacheKey);
return false;
}
async markUserAsLegacyOtp(username) {
await this.jsonStore.mutate(this.configService.authPath, (current) => {
const authfile = current ?? [];
const user = authfile.find(x => x.username === username);
if (!user || user.otpLegacySecret) {
return null;
}
user.otpLegacySecret = true;
this.logger.warn(`Marked ${username} as having legacy OTP secret.`);
return authfile;
}, { spaces: 4 });
}
};
AuthService = __decorate([
Injectable(),
__param(0, Inject(JwtService)),
__param(1, Inject(ConfigService)),
__param(2, Inject(JsonFileStoreService)),
__param(3, Inject(Logger)),
__param(4, Inject(PluginsSettingsUiTicketService)),
__metadata("design:paramtypes", [JwtService,
ConfigService,
JsonFileStoreService,
Logger,
PluginsSettingsUiTicketService])
], AuthService);
export { AuthService };
//# sourceMappingURL=auth.service.js.map