diff --git a/package.json b/package.json index 5358523..a4a4896 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ef0ce0..dc937de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/readme.opsserver.md b/readme.opsserver.md index dda5b2c..faf98b1 100644 --- a/readme.opsserver.md +++ b/readme.opsserver.md @@ -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 diff --git a/test/test.jwt-auth.ts b/test/test.jwt-auth.ts new file mode 100644 index 0000000..b3630a0 --- /dev/null +++ b/test/test.jwt-auth.ts @@ -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( + '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( + '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( + '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( + '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( + '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( + '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(); \ No newline at end of file diff --git a/test/test.protected-endpoint.ts b/test/test.protected-endpoint.ts new file mode 100644 index 0000000..b8db665 --- /dev/null +++ b/test/test.protected-endpoint.ts @@ -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( + '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( + '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( + '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( + '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( + '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(); \ No newline at end of file diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index 2423908..17e2706 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -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 { // 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); diff --git a/ts/opsserver/handlers/admin.handler.ts b/ts/opsserver/handlers/admin.handler.ts index adcd29f..aa8692d 100644 --- a/ts/opsserver/handlers/admin.handler.ts +++ b/ts/opsserver/handlers/admin.handler.ts @@ -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; + + // Simple in-memory user storage (in production, use proper database) + private users = new Map(); constructor(private opsServerRef: OpsServer) { // Add this handler's router to the parent this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + } + + public async initialize(): Promise { + await this.initializeJwt(); + this.initializeDefaultUsers(); this.registerHandlers(); } + private async initializeJwt(): Promise { + 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( '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( '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( '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', + } + ); } \ No newline at end of file diff --git a/ts/opsserver/handlers/config.handler.ts b/ts/opsserver/handlers/config.handler.ts index 87cad89..3ac93b9 100644 --- a/ts/opsserver/handlers/config.handler.ts +++ b/ts/opsserver/handlers/config.handler.ts @@ -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, diff --git a/ts/opsserver/helpers/guards.ts b/ts/opsserver/helpers/guards.ts new file mode 100644 index 0000000..565a1fc --- /dev/null +++ b/ts/opsserver/helpers/guards.ts @@ -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( + toolsArg: any, + guard: plugins.smartguard.Guard, + dataArg: T +): Promise { + 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( + adminHandler: AdminHandler, + dataArg: T +): Promise { + 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( + adminHandler: AdminHandler, + dataArg: T +): Promise { + 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'); + } +} \ No newline at end of file diff --git a/ts/plugins.ts b/ts/plugins.ts index c37f8e7..efd7ecc 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -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';