feat(auth): implement JWT-based authentication with admin access controls

This commit is contained in:
Juergen Kunz
2025-06-08 07:19:31 +00:00
parent 61778bdba8
commit 5faca8c1b6
10 changed files with 617 additions and 92 deletions

View File

@ -11,7 +11,7 @@ export class OpsServer {
public typedrouter = new plugins.typedrequest.TypedRouter();
// Handler instances
private adminHandler: handlers.AdminHandler;
public adminHandler: handlers.AdminHandler;
private configHandler: handlers.ConfigHandler;
private logsHandler: handlers.LogsHandler;
private securityHandler: handlers.SecurityHandler;
@ -36,7 +36,7 @@ export class OpsServer {
this.server.typedrouter.addTypedRouter(this.dcRouterRef.typedrouter);
// Set up handlers
this.setupHandlers();
await this.setupHandlers();
await this.server.start(3000);
}
@ -44,9 +44,11 @@ export class OpsServer {
/**
* Set up all TypedRequest handlers
*/
private setupHandlers(): void {
private async setupHandlers(): Promise<void> {
// Instantiate all handlers - they self-register with the typedrouter
this.adminHandler = new handlers.AdminHandler(this);
await this.adminHandler.initialize(); // JWT needs async initialization
this.configHandler = new handlers.ConfigHandler(this);
this.logsHandler = new handlers.LogsHandler(this);
this.securityHandler = new handlers.SecurityHandler(this);

View File

@ -2,57 +2,100 @@ import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export interface IJwtData {
userId: string;
status: 'loggedIn' | 'loggedOut';
expiresAt: number;
}
export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
// Simple in-memory session storage (in production, use proper session management)
private sessions = new Map<string, {
identity: interfaces.data.IIdentity;
createdAt: number;
lastAccess: number;
// JWT instance
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>;
// Simple in-memory user storage (in production, use proper database)
private users = new Map<string, {
id: string;
username: string;
password: string;
role: string;
}>();
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
}
public async initialize(): Promise<void> {
await this.initializeJwt();
this.initializeDefaultUsers();
this.registerHandlers();
}
private async initializeJwt(): Promise<void> {
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
await this.smartjwtInstance.init();
// For development, create new keypair each time
// In production, load from storage like cloudly does
await this.smartjwtInstance.createNewKeyPair();
}
private initializeDefaultUsers(): void {
// Add default admin user
const adminId = plugins.uuid.v4();
this.users.set(adminId, {
id: adminId,
username: 'admin',
password: 'admin',
role: 'admin',
});
}
private registerHandlers(): void {
// Admin Login Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'adminLoginWithUsernameAndPassword',
async (dataArg, toolsArg) => {
async (dataArg) => {
try {
// TODO: Implement proper authentication
// For now, use a simple hardcoded check
if (dataArg.username === 'admin' && dataArg.password === 'admin') {
const token = plugins.uuid.v4();
const identity: interfaces.data.IIdentity = {
token,
expiresAt: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
permissions: ['admin'],
};
// Store session
this.sessions.set(token, {
identity,
createdAt: Date.now(),
lastAccess: Date.now(),
});
// Clean up old sessions
this.cleanupSessions();
return {
identity,
};
} else {
return {};
// Find user by username and password
let user: { id: string; username: string; password: string; role: string } | null = null;
for (const [_, userData] of this.users) {
if (userData.username === dataArg.username && userData.password === dataArg.password) {
user = userData;
break;
}
}
if (!user) {
throw new plugins.typedrequest.TypedResponseError('login failed');
}
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24 * 7; // 7 days
const jwt = await this.smartjwtInstance.createJWT({
userId: user.id,
status: 'loggedIn',
expiresAt: expiresAtTimestamp,
});
return {
identity: {
jwt,
userId: user.id,
name: user.username,
expiresAt: expiresAtTimestamp,
role: user.role,
type: 'user',
},
};
} catch (error) {
return {};
if (error instanceof plugins.typedrequest.TypedResponseError) {
throw error;
}
throw new plugins.typedrequest.TypedResponseError('login failed');
}
}
)
@ -62,17 +105,12 @@ export class AdminHandler {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
'adminLogout',
async (dataArg, toolsArg) => {
if (dataArg.identity?.token && this.sessions.has(dataArg.identity.token)) {
this.sessions.delete(dataArg.identity.token);
return {
success: true,
};
} else {
return {
success: false,
};
}
async (dataArg) => {
// In a real implementation, you might want to blacklist the JWT
// For now, just return success
return {
success: true,
};
}
)
);
@ -81,27 +119,50 @@ export class AdminHandler {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
'verifyIdentity',
async (dataArg, toolsArg) => {
if (!dataArg.identity?.token) {
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return {
valid: false,
};
}
const session = this.sessions.get(dataArg.identity.token);
if (session && session.identity.expiresAt > Date.now()) {
// Update last access
session.lastAccess = Date.now();
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
// Check if expired
if (jwtData.expiresAt < Date.now()) {
return {
valid: false,
};
}
// Check if logged in
if (jwtData.status !== 'loggedIn') {
return {
valid: false,
};
}
// Find user
const user = this.users.get(jwtData.userId);
if (!user) {
return {
valid: false,
};
}
return {
valid: true,
identity: session.identity,
identity: {
jwt: dataArg.identity.jwt,
userId: user.id,
name: user.username,
expiresAt: jwtData.expiresAt,
role: user.role,
type: 'user',
},
};
} else {
// Clean up expired session
if (session) {
this.sessions.delete(dataArg.identity.token);
}
} catch (error) {
return {
valid: false,
};
@ -112,37 +173,68 @@ export class AdminHandler {
}
/**
* Clean up expired sessions (older than 24 hours)
* Create a guard for valid identity (matching cloudly pattern)
*/
private cleanupSessions(): void {
const now = Date.now();
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
for (const [token, session] of this.sessions.entries()) {
if (now - session.lastAccess > maxAge) {
this.sessions.delete(token);
}
}
}
/**
* Create a guard for authentication
* This can be used by other handlers to protect endpoints
*/
public createAuthGuard() {
return async (dataArg: { identity?: interfaces.data.IIdentity }) => {
if (!dataArg.identity?.token) {
public validIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return false;
}
const session = this.sessions.get(dataArg.identity.token);
if (session && session.identity.expiresAt > Date.now()) {
// Update last access
session.lastAccess = Date.now();
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
// Check expiration
if (jwtData.expiresAt < Date.now()) {
return false;
}
// Check status
if (jwtData.status !== 'loggedIn') {
return false;
}
// Verify data hasn't been tampered with
if (dataArg.identity.expiresAt !== jwtData.expiresAt) {
return false;
}
if (dataArg.identity.userId !== jwtData.userId) {
return false;
}
return true;
} catch (error) {
return false;
}
},
{
failedHint: 'identity is not valid',
name: 'validIdentityGuard',
}
);
/**
* Create a guard for admin identity (matching cloudly pattern)
*/
public adminIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
// First check if identity is valid
const isValid = await this.validIdentityGuard.exec(dataArg);
if (!isValid) {
return false;
}
return false;
};
}
// Check if user has admin role
return dataArg.identity.role === 'admin';
},
{
failedHint: 'user is not admin',
name: 'adminIdentityGuard',
}
);
}

View File

@ -1,6 +1,7 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireAdminIdentity } from '../helpers/guards.js';
export class ConfigHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
@ -32,12 +33,18 @@ export class ConfigHandler {
'updateConfiguration',
async (dataArg, toolsArg) => {
try {
// Require admin access to update configuration
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const updatedConfig = await this.updateConfiguration(dataArg.section, dataArg.config);
return {
updated: true,
config: updatedConfig,
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) {
throw error;
}
return {
updated: false,
config: null,

View File

@ -0,0 +1,56 @@
import * as plugins from '../../plugins.js';
import type { AdminHandler } from '../handlers/admin.handler.js';
import * as interfaces from '../../../ts_interfaces/index.js';
/**
* Helper function to use identity guards in handlers
*
* @example
* // In a handler:
* await passGuards(toolsArg, this.opsServerRef.adminHandler.validIdentityGuard, dataArg);
*/
export async function passGuards<T extends { identity?: any }>(
toolsArg: any,
guard: plugins.smartguard.Guard<T>,
dataArg: T
): Promise<void> {
const result = await guard.exec(dataArg);
if (!result) {
const failedHint = await guard.getFailedHint(dataArg);
throw new plugins.typedrequest.TypedResponseError(failedHint || 'Guard check failed');
}
}
/**
* Helper to check admin identity in handlers
*/
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
adminHandler: AdminHandler,
dataArg: T
): Promise<void> {
if (!dataArg.identity) {
throw new plugins.typedrequest.TypedResponseError('No identity provided');
}
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
}
/**
* Helper to check valid identity in handlers
*/
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
adminHandler: AdminHandler,
dataArg: T
): Promise<void> {
if (!dataArg.identity) {
throw new plugins.typedrequest.TypedResponseError('No identity provided');
}
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
}
}

View File

@ -46,6 +46,8 @@ import * as smartacme from '@push.rocks/smartacme';
import * as smartdata from '@push.rocks/smartdata';
import * as smartdns from '@push.rocks/smartdns';
import * as smartfile from '@push.rocks/smartfile';
import * as smartguard from '@push.rocks/smartguard';
import * as smartjwt from '@push.rocks/smartjwt';
import * as smartlog from '@push.rocks/smartlog';
import * as smartmail from '@push.rocks/smartmail';
import * as smartnetwork from '@push.rocks/smartnetwork';
@ -55,8 +57,9 @@ import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrule from '@push.rocks/smartrule';
import * as smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique';
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartlog, smartmail, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx };
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx, smartunique };
// Define SmartLog types for use in error handling
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';