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:
2026-04-19 01:30:54 +00:00
parent 0c9eb0653d
commit 618d4d674f
34 changed files with 585 additions and 255 deletions
+99 -57
View File
@@ -43,6 +43,14 @@ const IV_LENGTH = 12;
const SALT_LENGTH = 32;
const PBKDF2_ITERATIONS = 100000;
interface IS3ConnectionInfo {
endpoint: string;
accessKey: string;
secretKey: string;
bucket: string;
region: string;
}
export class BackupManager {
private oneboxRef: Onebox;
public archive: plugins.ContainerArchive | null = null;
@@ -519,7 +527,8 @@ export class BackupManager {
* Get backup password from settings
*/
private getBackupPassword(): string | null {
return this.oneboxRef.database.getSetting('backup_encryption_password');
return this.oneboxRef.database.getSetting('backup_encryption_password')
|| this.oneboxRef.database.getSetting('backupPassword');
}
/**
@@ -860,47 +869,48 @@ export class BackupManager {
const bucketDir = `${dataDir}/${resource.resourceName}`;
await Deno.mkdir(bucketDir, { recursive: true });
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
const bucket = credentials.bucket || credentials.S3_BUCKET;
const s3Info = this.getS3ConnectionInfo(credentials);
const s3Client = this.createS3Client(s3Info);
let objectCount = 0;
let continuationToken: string | undefined;
if (!endpoint || !accessKey || !secretKey || !bucket) {
throw new Error('MinIO credentials incomplete');
}
do {
const response = await s3Client.send(
new plugins.awsS3.ListObjectsV2Command({
Bucket: s3Info.bucket,
ContinuationToken: continuationToken,
}),
);
const s3Client = new plugins.smartstorage.SmartStorage({
endpoint,
accessKey,
secretKey,
bucket,
});
for (const object of response.Contents || []) {
const objectKey = object.Key;
if (!objectKey) continue;
await s3Client.start();
const objectResponse = await s3Client.send(
new plugins.awsS3.GetObjectCommand({
Bucket: s3Info.bucket,
Key: objectKey,
}),
);
const objects = await s3Client.listObjects();
if (!objectResponse.Body) continue;
for (const obj of objects) {
const objectKey = obj.Key;
if (!objectKey) continue;
const objectData = await s3Client.getObject(objectKey);
if (objectData) {
const objectPath = `${bucketDir}/${objectKey}`;
const parentDir = plugins.path.dirname(objectPath);
await Deno.mkdir(parentDir, { recursive: true });
await Deno.writeFile(objectPath, objectData);
await Deno.writeFile(objectPath, await objectResponse.Body.transformToByteArray());
objectCount++;
}
}
await s3Client.stop();
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
} while (continuationToken);
await Deno.writeTextFile(
`${bucketDir}/_metadata.json`,
JSON.stringify({ bucket, objectCount: objects.length }, null, 2)
JSON.stringify({ bucket: s3Info.bucket, objectCount }, null, 2)
);
logger.success(`MinIO bucket exported: ${resource.resourceName} (${objects.length} objects)`);
logger.success(`MinIO bucket exported: ${resource.resourceName} (${objectCount} objects)`);
}
/**
@@ -1279,40 +1289,26 @@ export class BackupManager {
const bucketDir = `${dataDir}/${backupResourceName}`;
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
const bucket = credentials.bucket || credentials.S3_BUCKET;
if (!endpoint || !accessKey || !secretKey || !bucket) {
throw new Error('MinIO credentials incomplete');
}
const s3Client = new plugins.smartstorage.SmartStorage({
endpoint,
accessKey,
secretKey,
bucket,
});
await s3Client.start();
const s3Info = this.getS3ConnectionInfo(credentials);
const s3Client = this.createS3Client(s3Info);
let uploadedCount = 0;
for await (const entry of Deno.readDir(bucketDir)) {
if (entry.name === '_metadata.json') continue;
for await (const filePath of this.walkFiles(bucketDir)) {
if (plugins.path.basename(filePath) === '_metadata.json') continue;
const filePath = `${bucketDir}/${entry.name}`;
if (entry.isFile) {
const fileData = await Deno.readFile(filePath);
await s3Client.putObject(entry.name, fileData);
uploadedCount++;
}
const fileData = await Deno.readFile(filePath);
const objectKey = plugins.path.relative(bucketDir, filePath).replaceAll('\\', '/');
await s3Client.send(
new plugins.awsS3.PutObjectCommand({
Bucket: s3Info.bucket,
Key: objectKey,
Body: fileData,
}),
);
uploadedCount++;
}
await s3Client.stop();
logger.success(`MinIO bucket imported: ${resource.resourceName} (${uploadedCount} objects)`);
}
@@ -1585,7 +1581,7 @@ export class BackupManager {
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
salt: this.toArrayBuffer(salt),
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
@@ -1600,8 +1596,54 @@ export class BackupManager {
* Compute SHA-256 checksum
*/
private async computeChecksum(data: Uint8Array): Promise<string> {
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashBuffer = await crypto.subtle.digest('SHA-256', this.toArrayBuffer(data));
const hashArray = new Uint8Array(hashBuffer);
return 'sha256:' + Array.from(hashArray).map((b) => b.toString(16).padStart(2, '0')).join('');
}
private getS3ConnectionInfo(credentials: Record<string, string>): IS3ConnectionInfo {
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
const bucket = credentials.bucket || credentials.S3_BUCKET;
if (!endpoint || !accessKey || !secretKey || !bucket) {
throw new Error('MinIO credentials incomplete');
}
return {
endpoint,
accessKey,
secretKey,
bucket,
region: credentials.region || credentials.AWS_REGION || 'us-east-1',
};
}
private createS3Client(s3Info: IS3ConnectionInfo) {
return new plugins.awsS3.S3Client({
endpoint: s3Info.endpoint,
region: s3Info.region,
forcePathStyle: true,
credentials: {
accessKeyId: s3Info.accessKey,
secretAccessKey: s3Info.secretKey,
},
});
}
private async *walkFiles(directory: string): AsyncGenerator<string> {
for await (const entry of Deno.readDir(directory)) {
const entryPath = plugins.path.join(directory, entry.name);
if (entry.isDirectory) {
yield* this.walkFiles(entryPath);
} else if (entry.isFile) {
yield entryPath;
}
}
}
private toArrayBuffer(data: Uint8Array): ArrayBuffer {
return data.slice().buffer as ArrayBuffer;
}
}
+2 -1
View File
@@ -24,7 +24,8 @@ export class CloudflareDomainSync {
*/
async init(): Promise<void> {
try {
const apiKey = this.database.getSetting('cloudflareAPIKey');
const apiKey = this.database.getSetting('cloudflareAPIKey')
|| this.database.getSetting('cloudflareToken');
if (!apiKey) {
logger.warn('Cloudflare API key not configured. Domain sync will be limited.');
+2 -1
View File
@@ -27,7 +27,8 @@ export class OneboxDnsManager {
async init(): Promise<void> {
try {
// Get Cloudflare credentials from settings
const apiKey = this.database.getSetting('cloudflareAPIKey');
const apiKey = this.database.getSetting('cloudflareAPIKey')
|| this.database.getSetting('cloudflareToken');
const serverIP = this.database.getSetting('serverIP');
if (!apiKey) {
+16 -6
View File
@@ -97,7 +97,11 @@ export class CredentialEncryption {
*/
async encrypt(data: Record<string, string>): Promise<string> {
if (!this.key) {
throw new Error('Encryption not initialized. Call init() first.');
await this.init();
}
const key = this.key;
if (!key) {
throw new Error('Encryption key initialization failed.');
}
const iv = crypto.getRandomValues(new Uint8Array(this.ivLength));
@@ -105,7 +109,7 @@ export class CredentialEncryption {
const ciphertext = await crypto.subtle.encrypt(
{ name: this.algorithm, iv },
this.key,
key,
encoded
);
@@ -120,9 +124,15 @@ export class CredentialEncryption {
/**
* Decrypt a base64 string back to credentials object
*/
async decrypt(encrypted: string): Promise<Record<string, string>> {
async decrypt<T extends Record<string, string> = Record<string, string>>(
encrypted: string,
): Promise<T> {
if (!this.key) {
throw new Error('Encryption not initialized. Call init() first.');
await this.init();
}
const key = this.key;
if (!key) {
throw new Error('Encryption key initialization failed.');
}
const combined = this.base64ToBytes(encrypted);
@@ -133,12 +143,12 @@ export class CredentialEncryption {
const decrypted = await crypto.subtle.decrypt(
{ name: this.algorithm, iv },
this.key,
key,
ciphertext
);
const decoded = new TextDecoder().decode(decrypted);
return JSON.parse(decoded);
return JSON.parse(decoded) as T;
}
/**
+14 -10
View File
@@ -6,6 +6,7 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { hashPassword, needsPasswordUpgrade, verifyPassword } from '../utils/auth.ts';
import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts';
import type {
@@ -404,15 +405,17 @@ export class OneboxHttpServer {
logger.info(`User found: ${username}, checking password...`);
// Verify password (simple base64 comparison for now)
const passwordHash = btoa(password);
logger.info(`Password hash: ${passwordHash}, stored hash: ${user.passwordHash}`);
if (passwordHash !== user.passwordHash) {
const passwordMatches = await verifyPassword(password, user.passwordHash);
if (!passwordMatches) {
logger.info(`Password mismatch for user: ${username}`);
return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401);
}
if (needsPasswordUpgrade(user.passwordHash)) {
const upgradedHash = await hashPassword(password);
this.oneboxRef.database.updateUserPassword(user.username, upgradedHash);
}
// Generate simple token (in production, use proper JWT)
const token = btoa(`${user.username}:${Date.now()}`);
@@ -1324,7 +1327,7 @@ export class OneboxHttpServer {
type: 'service',
name: service.name,
domain: service.domain || null,
targetHost: service.containerIP || 'unknown',
targetHost: service.containerID || 'unknown',
targetPort: service.port || 80,
status: service.status,
});
@@ -1380,6 +1383,7 @@ export class OneboxHttpServer {
rabbitmq: 5672,
caddy: 80,
clickhouse: 8123,
mariadb: 3306,
};
return ports[type] || 0;
}
@@ -1396,11 +1400,11 @@ export class OneboxHttpServer {
success: true,
data: {
proxy: {
running: proxyStatus.running,
httpPort: proxyStatus.httpPort,
httpsPort: proxyStatus.httpsPort,
running: proxyStatus.http.running || proxyStatus.https.running,
httpPort: proxyStatus.http.port,
httpsPort: proxyStatus.https.port,
routes: proxyStatus.routes,
certificates: proxyStatus.certificates,
certificates: proxyStatus.https.certificates,
},
logReceiver: {
running: logReceiverStats.running,
+2 -2
View File
@@ -6,6 +6,7 @@
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { hashPassword } from '../utils/auth.ts';
import { OneboxDatabase } from './database.ts';
import { OneboxDockerManager } from './docker.ts';
import { OneboxServicesManager } from './services.ts';
@@ -226,8 +227,7 @@ export class Onebox {
if (!adminUser) {
logger.info('Creating default admin user...');
// Simple base64 encoding for now - should use bcrypt in production
const passwordHash = btoa('admin');
const passwordHash = await hashPassword('admin');
await this.database.createUser({
username: 'admin',
@@ -76,7 +76,9 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing ClickHouse credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
platformService.adminCredentialsEncrypted,
);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new ClickHouse admin credentials');
@@ -191,7 +193,9 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
throw new Error('ClickHouse platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const adminCreds = await credentialEncryption.decrypt<{ username: string; password: string }>(
platformService.adminCredentialsEncrypted,
);
const containerName = this.getContainerName();
// Generate resource names and credentials
@@ -247,7 +251,9 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
throw new Error('ClickHouse platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const adminCreds = await credentialEncryption.decrypt<{ username: string; password: string }>(
platformService.adminCredentialsEncrypted,
);
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
@@ -74,7 +74,9 @@ export class MariaDBProvider extends BasePlatformServiceProvider {
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing MariaDB credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
platformService.adminCredentialsEncrypted,
);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new MariaDB admin credentials');
@@ -80,7 +80,9 @@ export class MinioProvider extends BasePlatformServiceProvider {
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing MinIO credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
platformService.adminCredentialsEncrypted,
);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new MinIO admin credentials');
@@ -74,7 +74,9 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing MongoDB credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
platformService.adminCredentialsEncrypted,
);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new MongoDB admin credentials');
@@ -76,7 +76,9 @@ export class RedisProvider extends BasePlatformServiceProvider {
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing Redis credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
platformService.adminCredentialsEncrypted,
);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new Redis admin credentials');
+17 -8
View File
@@ -9,6 +9,9 @@ import type { IRegistry } from '../types.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.ts';
import { credentialEncryption } from './encryption.ts';
const encryptedPasswordPrefix = 'enc:v1:';
export class OneboxRegistriesManager {
private oneboxRef: any; // Will be Onebox instance
@@ -22,17 +25,23 @@ export class OneboxRegistriesManager {
/**
* Encrypt a password (simple base64 for now, should use proper encryption)
*/
private encryptPassword(password: string): string {
// TODO: Use proper encryption with a secret key
// For now, using base64 encoding (NOT SECURE, just for structure)
return plugins.encoding.encodeBase64(new TextEncoder().encode(password));
private async encryptPassword(password: string): Promise<string> {
const encrypted = await credentialEncryption.encrypt({ password });
return `${encryptedPasswordPrefix}${encrypted}`;
}
/**
* Decrypt a password
*/
private decryptPassword(encrypted: string): string {
// TODO: Use proper decryption
private async decryptPassword(encrypted: string): Promise<string> {
if (encrypted.startsWith(encryptedPasswordPrefix)) {
const decrypted = await credentialEncryption.decrypt<{ password: string }>(
encrypted.slice(encryptedPasswordPrefix.length),
);
return decrypted.password;
}
// Legacy compatibility for older databases that stored base64-encoded passwords.
return new TextDecoder().decode(plugins.encoding.decodeBase64(encrypted));
}
@@ -48,7 +57,7 @@ export class OneboxRegistriesManager {
}
// Encrypt password
const passwordEncrypted = this.encryptPassword(password);
const passwordEncrypted = await this.encryptPassword(password);
// Create registry in database
const registry = await this.database.createRegistry({
@@ -111,7 +120,7 @@ export class OneboxRegistriesManager {
try {
logger.info(`Logging into registry: ${registry.url}`);
const password = this.decryptPassword(registry.passwordEncrypted);
const password = await this.decryptPassword(registry.passwordEncrypted);
// Use docker login command
const command = [
+2 -1
View File
@@ -39,7 +39,8 @@ export class OneboxSslManager {
this.acmeEmail = acmeEmail;
// Get Cloudflare API key (reuse from DNS manager)
const cfApiKey = this.database.getSetting('cloudflareAPIKey');
const cfApiKey = this.database.getSetting('cloudflareAPIKey')
|| this.database.getSetting('cloudflareToken');
if (!cfApiKey) {
logger.warn('Cloudflare API key not configured. SSL certificate management will be limited.');