Add tests for authentication and security features
- Implement unit tests for password handling in `auth_test.ts`, covering bcrypt and legacy password hashes. - Create a fake database for user management to facilitate testing of the `AdminHandler`. - Validate JWT-based identity verification against database records. - Introduce tests for credential encryption and registry management in `security_test.ts`. - Ensure registry passwords are securely stored and can be decrypted correctly, including legacy support. - Add utility functions for password hashing and verification in `auth.ts`.
This commit is contained in:
@@ -2,9 +2,12 @@ import * as plugins from '../../plugins.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import type { OpsServer } from '../classes.opsserver.ts';
|
||||
import * as interfaces from '../../../ts_interfaces/index.ts';
|
||||
import { hashPassword, needsPasswordUpgrade, verifyPassword } from '../../utils/auth.ts';
|
||||
|
||||
export interface IJwtData {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
status: 'loggedIn' | 'loggedOut';
|
||||
expiresAt: number;
|
||||
}
|
||||
@@ -18,12 +21,80 @@ export class AdminHandler {
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
||||
this.smartjwtInstance = new plugins.smartjwt.SmartJwt<IJwtData>();
|
||||
await this.smartjwtInstance.init();
|
||||
await this.smartjwtInstance.createNewKeyPair();
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async createIdentityForUser(
|
||||
user: interfaces.data.IUser & { id?: number },
|
||||
expiresAt: number,
|
||||
): Promise<interfaces.data.IIdentity> {
|
||||
const userId = String(user.id || user.username);
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
status: 'loggedIn',
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
jwt,
|
||||
userId,
|
||||
username: user.username,
|
||||
expiresAt,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
public async getVerifiedIdentity(
|
||||
identityArg: interfaces.data.IIdentity | null | undefined,
|
||||
): Promise<interfaces.data.IIdentity> {
|
||||
if (!identityArg?.jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
|
||||
let jwtData: IJwtData;
|
||||
try {
|
||||
jwtData = await this.smartjwtInstance.verifyJWTAndGetData(identityArg.jwt);
|
||||
} catch {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
|
||||
if (jwtData.expiresAt < Date.now() || jwtData.status !== 'loggedIn') {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
|
||||
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(jwtData.username);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
|
||||
const userId = String(user.id || user.username);
|
||||
if (jwtData.userId !== userId) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
|
||||
return {
|
||||
jwt: identityArg.jwt,
|
||||
userId,
|
||||
username: user.username,
|
||||
expiresAt: jwtData.expiresAt,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
public async getVerifiedAdminIdentity(
|
||||
identityArg: interfaces.data.IIdentity | null | undefined,
|
||||
): Promise<interfaces.data.IIdentity> {
|
||||
const identity = await this.getVerifiedIdentity(identityArg);
|
||||
if (identity.role !== 'admin') {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Login
|
||||
this.typedrouter.addTypedHandler(
|
||||
@@ -36,30 +107,24 @@ export class AdminHandler {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
|
||||
}
|
||||
|
||||
// Verify password (base64 comparison to match existing DB scheme)
|
||||
const passwordHash = btoa(dataArg.password);
|
||||
if (passwordHash !== user.passwordHash) {
|
||||
const passwordMatches = await verifyPassword(dataArg.password, user.passwordHash);
|
||||
if (!passwordMatches) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
|
||||
}
|
||||
|
||||
if (needsPasswordUpgrade(user.passwordHash)) {
|
||||
const upgradedHash = await hashPassword(dataArg.password);
|
||||
this.opsServerRef.oneboxRef.database.updateUserPassword(user.username, upgradedHash);
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + 24 * 3600 * 1000;
|
||||
const userId = String(user.id || user.username);
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId,
|
||||
status: 'loggedIn',
|
||||
expiresAt,
|
||||
});
|
||||
const freshUser = this.opsServerRef.oneboxRef.database.getUserByUsername(user.username) || user;
|
||||
const identity = await this.createIdentityForUser(freshUser, expiresAt);
|
||||
|
||||
logger.info(`User logged in: ${user.username}`);
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt,
|
||||
userId,
|
||||
username: user.username,
|
||||
expiresAt,
|
||||
role: user.role,
|
||||
},
|
||||
identity,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
||||
@@ -84,22 +149,11 @@ export class AdminHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'verifyIdentity',
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
return { valid: false };
|
||||
}
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
if (jwtData.expiresAt < Date.now()) return { valid: false };
|
||||
if (jwtData.status !== 'loggedIn') return { valid: false };
|
||||
const identity = await this.getVerifiedIdentity(dataArg.identity);
|
||||
return {
|
||||
valid: true,
|
||||
identity: {
|
||||
jwt: dataArg.identity.jwt,
|
||||
userId: jwtData.userId,
|
||||
username: dataArg.identity.username,
|
||||
expiresAt: jwtData.expiresAt,
|
||||
role: dataArg.identity.role,
|
||||
},
|
||||
identity,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false };
|
||||
@@ -110,21 +164,21 @@ export class AdminHandler {
|
||||
|
||||
// Change Password
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
|
||||
'changePassword',
|
||||
async (dataArg) => {
|
||||
await this.requireValidIdentity(dataArg);
|
||||
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(dataArg.identity.username);
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
|
||||
'changePassword',
|
||||
async (dataArg) => {
|
||||
const identity = await this.getVerifiedIdentity(dataArg.identity);
|
||||
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(identity.username);
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||
}
|
||||
|
||||
const currentHash = btoa(dataArg.currentPassword);
|
||||
if (currentHash !== user.passwordHash) {
|
||||
const currentPasswordMatches = await verifyPassword(dataArg.currentPassword, user.passwordHash);
|
||||
if (!currentPasswordMatches) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Current password is incorrect');
|
||||
}
|
||||
|
||||
const newHash = btoa(dataArg.newPassword);
|
||||
const newHash = await hashPassword(dataArg.newPassword);
|
||||
this.opsServerRef.oneboxRef.database.updateUserPassword(user.username, newHash);
|
||||
logger.info(`Password changed for user: ${user.username}`);
|
||||
|
||||
@@ -134,25 +188,13 @@ export class AdminHandler {
|
||||
);
|
||||
}
|
||||
|
||||
private async requireValidIdentity(dataArg: { identity: interfaces.data.IIdentity }): Promise<void> {
|
||||
const passed = await this.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
}
|
||||
|
||||
// Guard for valid identity
|
||||
public validIdentityGuard = new plugins.smartguard.Guard<{
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) return false;
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
if (jwtData.expiresAt < Date.now()) return false;
|
||||
if (jwtData.status !== 'loggedIn') return false;
|
||||
if (dataArg.identity.expiresAt !== jwtData.expiresAt) return false;
|
||||
if (dataArg.identity.userId !== jwtData.userId) return false;
|
||||
await this.getVerifiedIdentity(dataArg.identity);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -166,9 +208,12 @@ export class AdminHandler {
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
const isValid = await this.validIdentityGuard.exec(dataArg);
|
||||
if (!isValid) return false;
|
||||
return dataArg.identity.role === 'admin';
|
||||
try {
|
||||
const identity = await this.getVerifiedIdentity(dataArg.identity);
|
||||
return identity.role === 'admin';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ failedHint: 'user is not admin', name: 'adminIdentityGuard' },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user