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

@ -37,6 +37,8 @@
"@push.rocks/smartdata": "^5.15.1",
"@push.rocks/smartdns": "^7.5.0",
"@push.rocks/smartfile": "^11.2.5",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.1.8",
"@push.rocks/smartmail": "^2.1.0",
"@push.rocks/smartnetwork": "^4.0.2",
@ -47,6 +49,7 @@
"@push.rocks/smartrule": "^2.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.0.0",
"@push.rocks/smartunique": "^3.0.9",
"@serve.zone/interfaces": "^5.0.4",
"@tsclass/tsclass": "^9.2.0",
"@types/mailparser": "^3.4.6",

112
pnpm-lock.yaml generated
View File

@ -47,6 +47,12 @@ importers:
'@push.rocks/smartfile':
specifier: ^11.2.5
version: 11.2.5
'@push.rocks/smartguard':
specifier: ^3.1.0
version: 3.1.0
'@push.rocks/smartjwt':
specifier: ^2.2.1
version: 2.2.1
'@push.rocks/smartlog':
specifier: ^3.1.8
version: 3.1.8
@ -77,6 +83,9 @@ importers:
'@push.rocks/smartstate':
specifier: ^2.0.0
version: 2.0.19
'@push.rocks/smartunique':
specifier: ^3.0.9
version: 3.0.9
'@serve.zone/interfaces':
specifier: ^5.0.4
version: 5.0.4
@ -974,6 +983,9 @@ packages:
'@push.rocks/smartjson@5.0.20':
resolution: {integrity: sha512-ogGBLyOTluphZVwBYNyjhm5sziPGuiAwWihW07OSRxD4HQUyqj9Ek6r1pqH07JUG5EbtRYivM1Yt1cCwnu3JVQ==}
'@push.rocks/smartjwt@2.2.1':
resolution: {integrity: sha512-Xwau9o8u7kLfSGi5v+kiyGB/hiDPclZjVEuj69J0LszO9nOh4OexYizKIOgOzKQMqnYQ03Dy35KqP9pdEjccbQ==}
'@push.rocks/smartlog-destination-devtools@1.0.12':
resolution: {integrity: sha512-zvsIkrqByc0JRaBgIyhh+PSz2SY/e/bmhZdUcr/OW6pudgAcqe2sso68EzrKux0w9OMl1P9ZnzF3FpCZPFWD/A==}
@ -1606,6 +1618,9 @@ packages:
'@types/jsonfile@6.1.4':
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
'@types/jsonwebtoken@9.0.9':
resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==}
'@types/mailparser@3.4.6':
resolution: {integrity: sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==}
@ -1886,6 +1901,9 @@ packages:
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -2238,6 +2256,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@ -2884,6 +2905,16 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
keygrip@1.1.0:
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
engines: {node: '>= 0.6'}
@ -2982,15 +3013,36 @@ packages:
lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
lodash.includes@4.3.0:
resolution: {integrity: sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.isarray@3.0.4:
resolution: {integrity: sha512-JwObCrNJuT0Nnbuecmqr5DgtuBppuCvGD9lxjFpAzwnVtdGoDQ1zig+5W8k5/6Gcn0gZ3936HDAlGd28i7sOGQ==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=}
lodash.isinteger@4.0.4:
resolution: {integrity: sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=}
lodash.isnumber@3.0.3:
resolution: {integrity: sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=}
lodash.isstring@4.0.1:
resolution: {integrity: sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=}
lodash.keys@3.1.2:
resolution: {integrity: sha512-CuBsapFjcubOGMn3VD+24HOAPxM79tH+V6ivJL3CHYjtrawauDJHUk//Yew9Hvc6e9rbCrURGk8z6PC+8WJBfQ==}
lodash.once@4.1.1:
resolution: {integrity: sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=}
lodash.restparam@3.6.1:
resolution: {integrity: sha512-L4/arjjuq4noiUJpt3yS6KIKDtJwNe2fIYgMqyYYKoeIfV1iEqvPwhCx23o+R9dzouGihDAPN1dTIRWa7zk8tw==}
@ -5356,10 +5408,8 @@ snapshots:
'@push.rocks/taskbuffer': 3.1.7
transitivePeerDependencies:
- '@nuxt/kit'
- bufferutil
- react
- supports-color
- utf-8-validate
- vue
'@hapi/hoek@9.3.0': {}
@ -5940,6 +5990,15 @@ snapshots:
fast-json-stable-stringify: 2.1.0
lodash.clonedeep: 4.5.0
'@push.rocks/smartjwt@2.2.1':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartguard': 3.1.0
'@push.rocks/smartjson': 5.0.20
'@tsclass/tsclass': 4.4.4
'@types/jsonwebtoken': 9.0.9
jsonwebtoken: 9.0.2
'@push.rocks/smartlog-destination-devtools@1.0.12':
dependencies:
'@push.rocks/smartlog-interfaces': 3.0.2
@ -7045,6 +7104,11 @@ snapshots:
dependencies:
'@types/node': 22.15.30
'@types/jsonwebtoken@9.0.9':
dependencies:
'@types/ms': 2.1.0
'@types/node': 22.15.30
'@types/mailparser@3.4.6':
dependencies:
'@types/node': 22.15.30
@ -7337,6 +7401,8 @@ snapshots:
buffer-crc32@0.2.13: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer-json@2.0.0: {}
@ -7670,6 +7736,10 @@ snapshots:
eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
elliptic@6.6.1:
@ -8463,6 +8533,30 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonwebtoken@9.0.2:
dependencies:
jws: 3.2.2
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.2
jwa@1.4.2:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@3.2.2:
dependencies:
jwa: 1.4.2
safe-buffer: 5.2.1
keygrip@1.1.0:
dependencies:
tsscmp: 1.0.6
@ -8585,16 +8679,30 @@ snapshots:
lodash.clonedeep@4.5.0: {}
lodash.includes@4.3.0: {}
lodash.isarguments@3.1.0: {}
lodash.isarray@3.0.4: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.keys@3.1.2:
dependencies:
lodash._getnative: 3.9.1
lodash.isarguments: 3.1.0
lodash.isarray: 3.0.4
lodash.once@4.1.1: {}
lodash.restparam@3.6.1: {}
lodash@4.17.21: {}

View File

@ -229,11 +229,13 @@ Create modular components in `ts_web/elements/components/`:
### Phase 6: Optional Enhancements
#### 6.1 Authentication (if required)
- [ ] Simple token-based authentication
- [ ] Login component
- [ ] Protected route handling
- [ ] Session management
#### 6.1 Authentication ✓ (Implemented)
- [x] JWT-based authentication using `@push.rocks/smartjwt`
- [x] Guards for identity validation and admin access
- [x] Login/logout endpoints following cloudly pattern
- [ ] Login component (frontend)
- [ ] Protected route handling (frontend)
- [ ] Session persistence (frontend)
#### 6.2 Real-time Updates (future)
- [ ] WebSocket integration for live stats
@ -304,6 +306,13 @@ Create modular components in `ts_web/elements/components/`:
- Implemented mock data responses for all endpoints
- Fixed all TypeScript compilation errors
- VirtualStream used for log streaming with Uint8Array encoding
- **JWT Authentication** - Following cloudly pattern:
- Added `@push.rocks/smartjwt` and `@push.rocks/smartguard` dependencies
- Updated IIdentity interface to match cloudly structure
- Implemented JWT-based authentication with RSA keypairs
- Created validIdentityGuard and adminIdentityGuard
- Added guard helpers for protecting endpoints
- Full test coverage for JWT authentication flows
### Next Steps
- Phase 3: Frontend State Management - Set up Smartstate

130
test/test.jwt-auth.ts Normal file
View File

@ -0,0 +1,130 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let identity: interfaces.data.IIdentity;
tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({
// Minimal config for testing
});
await testDcRouter.start();
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
});
tap.test('should login with admin credentials and receive JWT', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3000/typedrequest',
'adminLoginWithUsernameAndPassword'
);
const response = await loginRequest.fire({
username: 'admin',
password: 'admin'
});
expect(response).toHaveProperty('identity');
expect(response.identity).toHaveProperty('jwt');
expect(response.identity).toHaveProperty('userId');
expect(response.identity).toHaveProperty('name');
expect(response.identity).toHaveProperty('expiresAt');
expect(response.identity).toHaveProperty('role');
expect(response.identity.role).toEqual('admin');
identity = response.identity;
console.log('JWT:', identity.jwt);
});
tap.test('should verify valid JWT identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'verifyIdentity'
);
const response = await verifyRequest.fire({
identity
});
expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue();
expect(response).toHaveProperty('identity');
expect(response.identity.userId).toEqual(identity.userId);
});
tap.test('should reject invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'verifyIdentity'
);
const response = await verifyRequest.fire({
identity: {
...identity,
jwt: 'invalid.jwt.token'
}
});
expect(response).toHaveProperty('valid');
expect(response.valid).toBeFalse();
});
tap.test('should verify JWT matches identity data', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3000/typedrequest',
'verifyIdentity'
);
// The response should contain the same identity data as the JWT
const response = await verifyRequest.fire({
identity
});
expect(response).toHaveProperty('valid');
expect(response.valid).toBeTrue();
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
expect(response.identity.userId).toEqual(identity.userId);
});
tap.test('should handle logout', async () => {
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
'http://localhost:3000/typedrequest',
'adminLogout'
);
const response = await logoutRequest.fire({
identity
});
expect(response).toHaveProperty('success');
expect(response.success).toBeTrue();
});
tap.test('should reject wrong credentials', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3000/typedrequest',
'adminLoginWithUsernameAndPassword'
);
let errorOccurred = false;
try {
await loginRequest.fire({
username: 'admin',
password: 'wrongpassword'
});
} catch (error) {
errorOccurred = true;
// TypedResponseError is thrown
expect(error).toBeTruthy();
}
expect(errorOccurred).toBeTrue();
});
tap.test('should stop DCRouter', async () => {
await testDcRouter.stop();
});
export default tap.start();

View File

@ -0,0 +1,115 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
tap.test('should start DCRouter with OpsServer', async () => {
testDcRouter = new DcRouter({
// Minimal config for testing
});
await testDcRouter.start();
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
});
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3000/typedrequest',
'adminLoginWithUsernameAndPassword'
);
const response = await loginRequest.fire({
username: 'admin',
password: 'admin'
});
expect(response).toHaveProperty('identity');
adminIdentity = response.identity;
console.log('Admin logged in with JWT');
});
tap.test('should allow admin to update configuration', async () => {
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
'http://localhost:3000/typedrequest',
'updateConfiguration'
);
const response = await updateRequest.fire({
identity: adminIdentity,
section: 'security',
config: {
rateLimit: true,
spamDetection: true
}
});
expect(response).toHaveProperty('updated');
expect(response.updated).toBeTrue();
});
tap.test('should reject configuration update without identity', async () => {
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
'http://localhost:3000/typedrequest',
'updateConfiguration'
);
try {
await updateRequest.fire({
section: 'security',
config: {
rateLimit: false
}
});
expect(true).toBeFalse(); // Should not reach here
} catch (error) {
expect(error).toBeTruthy();
console.log('Successfully rejected request without identity');
}
});
tap.test('should reject configuration update with invalid JWT', async () => {
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
'http://localhost:3000/typedrequest',
'updateConfiguration'
);
try {
await updateRequest.fire({
identity: {
...adminIdentity,
jwt: 'invalid.jwt.token'
},
section: 'security',
config: {
rateLimit: false
}
});
expect(true).toBeFalse(); // Should not reach here
} catch (error) {
expect(error).toBeTruthy();
console.log('Successfully rejected request with invalid JWT');
}
});
tap.test('should allow access to public endpoints without auth', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3000/typedrequest',
'getHealthStatus'
);
// No identity provided
const response = await healthRequest.fire({});
expect(response).toHaveProperty('health');
expect(response.health.healthy).toBeTrue();
console.log('Public endpoint accessible without auth');
});
tap.test('should stop DCRouter', async () => {
await testDcRouter.stop();
});
export default tap.start();

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';