feat(auth): implement JWT-based authentication with admin access controls
This commit is contained in:
@ -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
112
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
@ -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
130
test/test.jwt-auth.ts
Normal 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();
|
115
test/test.protected-endpoint.ts
Normal file
115
test/test.protected-endpoint.ts
Normal 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();
|
@ -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);
|
||||
|
@ -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'],
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Store session
|
||||
this.sessions.set(token, {
|
||||
identity,
|
||||
createdAt: Date.now(),
|
||||
lastAccess: Date.now(),
|
||||
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,
|
||||
});
|
||||
|
||||
// Clean up old sessions
|
||||
this.cleanupSessions();
|
||||
|
||||
return {
|
||||
identity,
|
||||
identity: {
|
||||
jwt,
|
||||
userId: user.id,
|
||||
name: user.username,
|
||||
expiresAt: expiresAtTimestamp,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
} 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);
|
||||
async (dataArg) => {
|
||||
// In a real implementation, you might want to blacklist the JWT
|
||||
// For now, just return success
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -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
|
||||
public validIdentityGuard = new plugins.smartguard.Guard<{
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [token, session] of this.sessions.entries()) {
|
||||
if (now - session.lastAccess > maxAge) {
|
||||
this.sessions.delete(token);
|
||||
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 authentication
|
||||
* This can be used by other handlers to protect endpoints
|
||||
* Create a guard for admin identity (matching cloudly pattern)
|
||||
*/
|
||||
public createAuthGuard() {
|
||||
return async (dataArg: { identity?: interfaces.data.IIdentity }) => {
|
||||
if (!dataArg.identity?.token) {
|
||||
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;
|
||||
}
|
||||
|
||||
const session = this.sessions.get(dataArg.identity.token);
|
||||
if (session && session.identity.expiresAt > Date.now()) {
|
||||
// Update last access
|
||||
session.lastAccess = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
// Check if user has admin role
|
||||
return dataArg.identity.role === 'admin';
|
||||
},
|
||||
{
|
||||
failedHint: 'user is not admin',
|
||||
name: 'adminIdentityGuard',
|
||||
}
|
||||
);
|
||||
}
|
@ -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,
|
||||
|
56
ts/opsserver/helpers/guards.ts
Normal file
56
ts/opsserver/helpers/guards.ts
Normal 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');
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
Reference in New Issue
Block a user