mirror of
https://github.com/sasjs/server.git
synced 2025-12-10 19:34:34 +00:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee4f215d2 | ||
|
|
100f138f98 | ||
| 6ffaa7e9e2 | |||
|
|
a433786011 | ||
|
|
1adff9a783 | ||
| 1435e380be | |||
| e099f2e678 | |||
| ddd155ba01 | |||
| 9936241815 | |||
| 570995e572 | |||
| 462829fd9a | |||
| c1c0554de2 | |||
| bd3aff9a7b | |||
| a1e255e0c7 | |||
| 0dae034f17 | |||
| 89048ce943 | |||
| a82cabb001 | |||
| c4066d32a0 | |||
|
|
6a44cd69d9 | ||
|
|
e607115995 | ||
| edab51c519 | |||
|
|
081cc3102c | ||
|
|
b19aa1eba4 | ||
| 2c31922f58 | |||
|
|
4d7a571a6e | ||
|
|
a373a4eb5f | ||
| 5e3ce8a98f | |||
|
|
737b34567e | ||
|
|
6373442f83 | ||
|
|
3de59ac4f8 | ||
|
|
941988cd7c | ||
| 158f044363 | |||
|
|
02ae041a81 | ||
|
|
c4c84b1537 | ||
| b3402ea80a | |||
|
|
abe942e697 | ||
|
|
faf2edb111 | ||
| 5bec453e89 | |||
| 7f2174dd2c | |||
| 2bae52e307 | |||
|
|
b243e62ece | ||
|
|
88c3056e97 | ||
| 203303b659 | |||
| 835709bd36 | |||
| 69f2576ee6 | |||
|
|
305077f36e | ||
|
|
96eca3a35d | ||
|
|
0f5c815c25 | ||
|
|
acccef1e99 | ||
| abc34ea047 | |||
| 71c429b093 | |||
|
|
c126f2d5d9 | ||
|
|
34dd95d16e | ||
| 1192583843 | |||
|
|
518815acf1 | ||
|
|
80b7e14ed5 | ||
| 23c997b3be | |||
| 39ba995355 | |||
|
|
0e081e024b | ||
|
|
6a84bd0387 | ||
| 98d177a691 | |||
| 4dcee4b3c3 | |||
|
|
4ffc1ec6a9 | ||
|
|
5a1d168e83 | ||
|
|
515c976685 | ||
| 112431a1b7 | |||
| c26485afec | |||
| 1d48f8856b | |||
| 68758aa616 | |||
| 8b8c43c21b | |||
| 4581f32534 | |||
| b47e74a7e1 | |||
| b27d684145 | |||
|
|
6b666d5554 | ||
|
|
b5f0911858 | ||
| b86ba5b8a3 | |||
| 200f6c596a | |||
|
|
1b7ccda6e9 | ||
|
|
532035d835 | ||
|
|
7ae862c5ce | ||
|
|
ab5858b8af | ||
|
|
a39f5dd9f1 | ||
|
|
3ea444756c | ||
|
|
96399ecbbe | ||
| bb054938c5 | |||
|
|
fb6a556630 | ||
|
|
9dbd8e16bd | ||
| fe07c41f5f | |||
| acc25cbd68 | |||
| 4ca61feda6 | |||
| abd5c64b4a | |||
| 2413c05fea | |||
|
|
4c874c2c39 | ||
|
|
d819d79bc9 | ||
| c51b50428f | |||
|
|
e10a0554f0 | ||
|
|
337e2eb2a0 | ||
| 66f8e7840b | |||
| 1c9d167f86 |
166
CHANGELOG.md
166
CHANGELOG.md
@@ -1,3 +1,169 @@
|
|||||||
|
# [0.32.0](https://github.com/sasjs/server/compare/v0.31.0...v0.32.0) (2023-04-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add an api endpoint for admin to get list of client ids ([6ffaa7e](https://github.com/sasjs/server/commit/6ffaa7e9e2a62c083bb9fcc3398dcbed10cebdb1))
|
||||||
|
|
||||||
|
# [0.31.0](https://github.com/sasjs/server/compare/v0.30.3...v0.31.0) (2023-03-30)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* prevent brute force attack by rate limiting login endpoint ([a82cabb](https://github.com/sasjs/server/commit/a82cabb00134c79c5ee77afd1b1628a1f768e050))
|
||||||
|
|
||||||
|
## [0.30.3](https://github.com/sasjs/server/compare/v0.30.2...v0.30.3) (2023-03-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add location.pathname to location.origin conditionally ([edab51c](https://github.com/sasjs/server/commit/edab51c51997f17553e037dc7c2b5e5fa6ea8ffe))
|
||||||
|
|
||||||
|
## [0.30.2](https://github.com/sasjs/server/compare/v0.30.1...v0.30.2) (2023-03-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **web:** add path to base in launch program url ([2c31922](https://github.com/sasjs/server/commit/2c31922f58a8aa20d7fa6bfc95b53a350f90c798))
|
||||||
|
|
||||||
|
## [0.30.1](https://github.com/sasjs/server/compare/v0.30.0...v0.30.1) (2023-03-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **web:** add proper base url in axios.defaults ([5e3ce8a](https://github.com/sasjs/server/commit/5e3ce8a98f1825e14c1d26d8da0c9821beeff7b3))
|
||||||
|
|
||||||
|
# [0.30.0](https://github.com/sasjs/server/compare/v0.29.0...v0.30.0) (2023-02-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* lint + remove default settings ([3de59ac](https://github.com/sasjs/server/commit/3de59ac4f8e3d95cad31f09e6963bd04c4811f26))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add new env config DB_TYPE ([158f044](https://github.com/sasjs/server/commit/158f044363abf2576c8248f0ca9da4bc9cb7e9d8))
|
||||||
|
|
||||||
|
# [0.29.0](https://github.com/sasjs/server/compare/v0.28.7...v0.29.0) (2023-02-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add /SASjsApi endpoint in permissions ([b3402ea](https://github.com/sasjs/server/commit/b3402ea80afb8802eee8b8b6cbbbcc29903424bc))
|
||||||
|
|
||||||
|
## [0.28.7](https://github.com/sasjs/server/compare/v0.28.6...v0.28.7) (2023-02-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add user to all users group on user creation ([2bae52e](https://github.com/sasjs/server/commit/2bae52e307327d7ee4a94b19d843abdc0ccec9d1))
|
||||||
|
|
||||||
|
## [0.28.6](https://github.com/sasjs/server/compare/v0.28.5...v0.28.6) (2023-01-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* show loading spinner on login screen while request is in process ([69f2576](https://github.com/sasjs/server/commit/69f2576ee6d3d7b7f3325922a88656d511e3ac88))
|
||||||
|
|
||||||
|
## [0.28.5](https://github.com/sasjs/server/compare/v0.28.4...v0.28.5) (2023-01-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* adding NOPRNGETLIST system option for faster startup ([96eca3a](https://github.com/sasjs/server/commit/96eca3a35dce4521150257ee019beb4488c8a08f))
|
||||||
|
|
||||||
|
## [0.28.4](https://github.com/sasjs/server/compare/v0.28.3...v0.28.4) (2022-12-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* replace main class with container class ([71c429b](https://github.com/sasjs/server/commit/71c429b093b91e2444ae75d946579dccc2e48636))
|
||||||
|
|
||||||
|
## [0.28.3](https://github.com/sasjs/server/compare/v0.28.2...v0.28.3) (2022-12-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* stringify json file ([1192583](https://github.com/sasjs/server/commit/1192583843d7efd1a6ab6943207f394c3ae966be))
|
||||||
|
|
||||||
|
## [0.28.2](https://github.com/sasjs/server/compare/v0.28.1...v0.28.2) (2022-12-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* execute child process asyncronously ([23c997b](https://github.com/sasjs/server/commit/23c997b3beabeb6b733ae893031d2f1a48f28ad2))
|
||||||
|
* JS / Python / R session folders should be NEW folders, not existing SAS folders ([39ba995](https://github.com/sasjs/server/commit/39ba995355daa24bb7ab22720f8fc57d2dc85f40))
|
||||||
|
|
||||||
|
## [0.28.1](https://github.com/sasjs/server/compare/v0.28.0...v0.28.1) (2022-11-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* update the content type header after the program has been executed ([4dcee4b](https://github.com/sasjs/server/commit/4dcee4b3c3950d402220b8f451c50ad98a317d83))
|
||||||
|
|
||||||
|
# [0.28.0](https://github.com/sasjs/server/compare/v0.27.0...v0.28.0) (2022-11-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* update the response header of request to stp/execute routes ([112431a](https://github.com/sasjs/server/commit/112431a1b7461989c04100418d67d975a2a8f354))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **api:** add the api endpoint for updating user password ([4581f32](https://github.com/sasjs/server/commit/4581f325344eb68c5df5a28492f132312f15bb5c))
|
||||||
|
* ask for updated password on first login ([1d48f88](https://github.com/sasjs/server/commit/1d48f8856b1fbbf3ef868914558333190e04981f))
|
||||||
|
* **web:** add the UI for updating user password ([8b8c43c](https://github.com/sasjs/server/commit/8b8c43c21bde5379825c5ec44ecd81a92425f605))
|
||||||
|
|
||||||
|
# [0.27.0](https://github.com/sasjs/server/compare/v0.26.2...v0.27.0) (2022-11-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* on startup add webout.sas file in sasautos folder ([200f6c5](https://github.com/sasjs/server/commit/200f6c596a6e732d799ed408f1f0fd92f216ba58))
|
||||||
|
|
||||||
|
## [0.26.2](https://github.com/sasjs/server/compare/v0.26.1...v0.26.2) (2022-11-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* comments ([7ae862c](https://github.com/sasjs/server/commit/7ae862c5ce720e9483d4728f4295dede4f849436))
|
||||||
|
|
||||||
|
## [0.26.1](https://github.com/sasjs/server/compare/v0.26.0...v0.26.1) (2022-11-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* change the expiration of access/refresh tokens from days to seconds ([bb05493](https://github.com/sasjs/server/commit/bb054938c5bd0535ae6b9da93ba0b14f9b80ddcd))
|
||||||
|
|
||||||
|
# [0.26.0](https://github.com/sasjs/server/compare/v0.25.1...v0.26.0) (2022-11-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **web:** dispose monaco editor actions in return of useEffect ([acc25cb](https://github.com/sasjs/server/commit/acc25cbd686952d3f1c65e57aefcebe1cb859cc7))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* make access token duration configurable when creating client/secret ([2413c05](https://github.com/sasjs/server/commit/2413c05fea3960f7e5c3c8b7b2f85d61314f08db))
|
||||||
|
* make refresh token duration configurable ([abd5c64](https://github.com/sasjs/server/commit/abd5c64b4a726e3f17594a98111b6aa269b71fee))
|
||||||
|
|
||||||
|
## [0.25.1](https://github.com/sasjs/server/compare/v0.25.0...v0.25.1) (2022-11-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **web:** use mui treeView instead of custom implementation ([c51b504](https://github.com/sasjs/server/commit/c51b50428f32608bc46438e9d7964429b2d595da))
|
||||||
|
|
||||||
|
# [0.25.0](https://github.com/sasjs/server/compare/v0.24.0...v0.25.0) (2022-11-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Enable DRIVE_LOCATION setting for deploying multiple instances of SASjs Server ([1c9d167](https://github.com/sasjs/server/commit/1c9d167f86bbbb108b96e9bc30efaf8de65d82ff))
|
||||||
|
|
||||||
# [0.24.0](https://github.com/sasjs/server/compare/v0.23.4...v0.24.0) (2022-10-28)
|
# [0.24.0](https://github.com/sasjs/server/compare/v0.23.4...v0.24.0) (2022-10-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -93,6 +93,10 @@ R_PATH=/usr/bin/Rscript
|
|||||||
SASJS_ROOT=./sasjs_root
|
SASJS_ROOT=./sasjs_root
|
||||||
|
|
||||||
|
|
||||||
|
# This location is for files, sasjs packages and appStreamConfig.json
|
||||||
|
DRIVE_LOCATION=./sasjs_root/drive
|
||||||
|
|
||||||
|
|
||||||
# options: [http|https] default: http
|
# options: [http|https] default: http
|
||||||
PROTOCOL=
|
PROTOCOL=
|
||||||
|
|
||||||
@@ -133,6 +137,9 @@ CA_ROOT=fullchain.pem (optional)
|
|||||||
## ENV variables required for MODE: `server`
|
## ENV variables required for MODE: `server`
|
||||||
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||||
|
|
||||||
|
# options: [mongodb|cosmos_mongodb] default: mongodb
|
||||||
|
DB_TYPE=
|
||||||
|
|
||||||
# AUTH_PROVIDERS options: [ldap] default: ``
|
# AUTH_PROVIDERS options: [ldap] default: ``
|
||||||
AUTH_PROVIDERS=
|
AUTH_PROVIDERS=
|
||||||
|
|
||||||
@@ -168,6 +175,19 @@ HELMET_COEP=
|
|||||||
# }
|
# }
|
||||||
HELMET_CSP_CONFIG_PATH=./csp.config.json
|
HELMET_CSP_CONFIG_PATH=./csp.config.json
|
||||||
|
|
||||||
|
# To prevent brute force attack on login route we have implemented rate limiter
|
||||||
|
# Only valid for MODE: server
|
||||||
|
# Following are configurable env variable rate limiter
|
||||||
|
|
||||||
|
# After this, access is blocked for 1 day
|
||||||
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY = <number> default: 100;
|
||||||
|
|
||||||
|
|
||||||
|
# After this, access is blocked for an hour
|
||||||
|
# Store number for 90 days since first fail
|
||||||
|
# Once a successful login is attempted, it resets
|
||||||
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP = <number> default: 10;
|
||||||
|
|
||||||
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
|
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
|
||||||
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
|
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
|
||||||
LOG_FORMAT_MORGAN=
|
LOG_FORMAT_MORGAN=
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used
|
|||||||
HELMET_COEP=[true|false] if omitted HELMET default will be used
|
HELMET_COEP=[true|false] if omitted HELMET default will be used
|
||||||
|
|
||||||
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||||
|
DB_TYPE=[mongodb|cosmos_mongodb] default considered as mongodb
|
||||||
|
|
||||||
AUTH_PROVIDERS=[ldap]
|
AUTH_PROVIDERS=[ldap]
|
||||||
|
|
||||||
@@ -23,6 +24,12 @@ LDAP_BIND_PASSWORD = <password>
|
|||||||
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
|
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
|
||||||
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
||||||
|
|
||||||
|
#default value is 100
|
||||||
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
|
||||||
|
|
||||||
|
#default value is 10
|
||||||
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10
|
||||||
|
|
||||||
RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas
|
RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas
|
||||||
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
|
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
|
||||||
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
||||||
@@ -30,6 +37,7 @@ PYTHON_PATH=/usr/bin/python
|
|||||||
R_PATH=/usr/bin/Rscript
|
R_PATH=/usr/bin/Rscript
|
||||||
|
|
||||||
SASJS_ROOT=./sasjs_root
|
SASJS_ROOT=./sasjs_root
|
||||||
|
DRIVE_LOCATION=./sasjs_root/drive
|
||||||
|
|
||||||
LOG_FORMAT_MORGAN=common
|
LOG_FORMAT_MORGAN=common
|
||||||
LOG_LOCATION=./sasjs_root/logs
|
LOG_LOCATION=./sasjs_root/logs
|
||||||
3328
api/package-lock.json
generated
3328
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,7 @@
|
|||||||
"author": "4GL Ltd",
|
"author": "4GL Ltd",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/core": "^4.40.1",
|
"@sasjs/core": "^4.40.1",
|
||||||
"@sasjs/utils": "2.48.1",
|
"@sasjs/utils": "3.2.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"connect-mongo": "^4.6.0",
|
"connect-mongo": "^4.6.0",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
"mongoose-sequence": "^5.3.1",
|
"mongoose-sequence": "^5.3.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"rate-limiter-flexible": "2.4.1",
|
||||||
"rotating-file-stream": "^3.0.4",
|
"rotating-file-stream": "^3.0.4",
|
||||||
"swagger-ui-express": "4.3.0",
|
"swagger-ui-express": "4.3.0",
|
||||||
"unzipper": "^0.10.11",
|
"unzipper": "^0.10.11",
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
"dotenv": "^10.0.0",
|
"dotenv": "^10.0.0",
|
||||||
"http-headers-validation": "^0.0.1",
|
"http-headers-validation": "^0.0.1",
|
||||||
"jest": "^27.0.6",
|
"jest": "^27.0.6",
|
||||||
"mongodb-memory-server": "^8.0.0",
|
"mongodb-memory-server": "8.11.4",
|
||||||
"nodejs-file-downloader": "4.10.2",
|
"nodejs-file-downloader": "4.10.2",
|
||||||
"nodemon": "^2.0.7",
|
"nodemon": "^2.0.7",
|
||||||
"pkg": "5.6.0",
|
"pkg": "5.6.0",
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ components:
|
|||||||
- userId
|
- userId
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
UpdatePasswordPayload:
|
||||||
|
properties:
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
description: 'Current Password'
|
||||||
|
example: currentPasswordString
|
||||||
|
newPassword:
|
||||||
|
type: string
|
||||||
|
description: 'New Password'
|
||||||
|
example: newPassword
|
||||||
|
required:
|
||||||
|
- currentPassword
|
||||||
|
- newPassword
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
ClientPayload:
|
ClientPayload:
|
||||||
properties:
|
properties:
|
||||||
clientId:
|
clientId:
|
||||||
@@ -57,6 +72,16 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: 'Client Secret'
|
description: 'Client Secret'
|
||||||
example: someRandomCryptoString
|
example: someRandomCryptoString
|
||||||
|
accessTokenExpiration:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
description: 'Number of seconds after which access token will expire. Default is 86400 (1 day)'
|
||||||
|
example: 86400
|
||||||
|
refreshTokenExpiration:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
description: 'Number of seconds after which access token will expire. Default is 2592000 (30 days)'
|
||||||
|
example: 2592000
|
||||||
required:
|
required:
|
||||||
- clientId
|
- clientId
|
||||||
- clientSecret
|
- clientSecret
|
||||||
@@ -509,6 +534,27 @@ components:
|
|||||||
- setting
|
- setting
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
SessionResponse:
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
isAdmin:
|
||||||
|
type: boolean
|
||||||
|
needsToUpdatePassword:
|
||||||
|
type: boolean
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- username
|
||||||
|
- displayName
|
||||||
|
- isAdmin
|
||||||
|
- needsToUpdatePassword
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
ExecutePostRequestPayload:
|
ExecutePostRequestPayload:
|
||||||
properties:
|
properties:
|
||||||
_program:
|
_program:
|
||||||
@@ -622,6 +668,25 @@ paths:
|
|||||||
-
|
-
|
||||||
bearerAuth: []
|
bearerAuth: []
|
||||||
parameters: []
|
parameters: []
|
||||||
|
/SASjsApi/auth/updatePassword:
|
||||||
|
patch:
|
||||||
|
operationId: UpdatePassword
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: 'No content'
|
||||||
|
summary: 'Update user''s password.'
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
security:
|
||||||
|
-
|
||||||
|
bearerAuth: []
|
||||||
|
parameters: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdatePasswordPayload'
|
||||||
/SASjsApi/authConfig:
|
/SASjsApi/authConfig:
|
||||||
get:
|
get:
|
||||||
operationId: GetDetail
|
operationId: GetDetail
|
||||||
@@ -679,8 +744,8 @@ paths:
|
|||||||
$ref: '#/components/schemas/ClientPayload'
|
$ref: '#/components/schemas/ClientPayload'
|
||||||
examples:
|
examples:
|
||||||
'Example 1':
|
'Example 1':
|
||||||
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString}
|
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}
|
||||||
summary: 'Create client with the following attributes: ClientId, ClientSecret. Admin only task.'
|
summary: "Admin only task. Create client with the following attributes:\nClientId,\nClientSecret,\naccessTokenExpiration (optional),\nrefreshTokenExpiration (optional)"
|
||||||
tags:
|
tags:
|
||||||
- Client
|
- Client
|
||||||
security:
|
security:
|
||||||
@@ -693,6 +758,27 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ClientPayload'
|
$ref: '#/components/schemas/ClientPayload'
|
||||||
|
get:
|
||||||
|
operationId: GetAllClients
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Ok
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ClientPayload'
|
||||||
|
type: array
|
||||||
|
examples:
|
||||||
|
'Example 1':
|
||||||
|
value: [{clientId: someClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}, {clientId: someOtherClientID, clientSecret: someOtherRandomCryptoString, accessTokenExpiration: 86400}]
|
||||||
|
summary: 'Admin only task. Returns the list of all the clients *'
|
||||||
|
tags:
|
||||||
|
- Client
|
||||||
|
security:
|
||||||
|
-
|
||||||
|
bearerAuth: []
|
||||||
|
parameters: []
|
||||||
/SASjsApi/code/execute:
|
/SASjsApi/code/execute:
|
||||||
post:
|
post:
|
||||||
operationId: ExecuteCode
|
operationId: ExecuteCode
|
||||||
@@ -1680,7 +1766,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/UserResponse'
|
$ref: '#/components/schemas/SessionResponse'
|
||||||
examples:
|
examples:
|
||||||
'Example 1':
|
'Example 1':
|
||||||
value: {id: 123, username: johnusername, displayName: John, isAdmin: false}
|
value: {id: 123, username: johnusername, displayName: John, isAdmin: false}
|
||||||
@@ -1777,7 +1863,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
properties:
|
properties:
|
||||||
user: {properties: {isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [isAdmin, displayName, username, id], type: object}
|
user: {properties: {needsToUpdatePassword: {type: boolean}, isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [needsToUpdatePassword, isAdmin, displayName, username, id], type: object}
|
||||||
loggedIn: {type: boolean}
|
loggedIn: {type: boolean}
|
||||||
required:
|
required:
|
||||||
- user
|
- user
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const configureCors = (app: Express) => {
|
|||||||
whiteList.push(url.replace(/\/$/, ''))
|
whiteList.push(url.replace(/\/$/, ''))
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('All CORS Requests are enabled for:', whiteList)
|
process.logger.info('All CORS Requests are enabled for:', whiteList)
|
||||||
app.use(cors({ credentials: true, origin: whiteList }))
|
app.use(cors({ credentials: true, origin: whiteList }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,27 @@ import mongoose from 'mongoose'
|
|||||||
import session from 'express-session'
|
import session from 'express-session'
|
||||||
import MongoStore from 'connect-mongo'
|
import MongoStore from 'connect-mongo'
|
||||||
|
|
||||||
import { ModeType, ProtocolType } from '../utils'
|
import { DatabaseType, ModeType, ProtocolType } from '../utils'
|
||||||
|
|
||||||
export const configureExpressSession = (app: Express) => {
|
export const configureExpressSession = (app: Express) => {
|
||||||
const { MODE } = process.env
|
const { MODE, DB_TYPE } = process.env
|
||||||
|
|
||||||
if (MODE === ModeType.Server) {
|
if (MODE === ModeType.Server) {
|
||||||
let store: MongoStore | undefined
|
let store: MongoStore | undefined
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
store = MongoStore.create({
|
if (DB_TYPE === DatabaseType.COSMOS_MONGODB) {
|
||||||
client: mongoose.connection!.getClient() as any,
|
// COSMOS DB requires specific connection options (compatibility mode)
|
||||||
collectionName: 'sessions'
|
// See: https://www.npmjs.com/package/connect-mongo#set-the-compatibility-mode
|
||||||
})
|
store = MongoStore.create({
|
||||||
|
client: mongoose.connection!.getClient() as any,
|
||||||
|
autoRemove: 'interval'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
store = MongoStore.create({
|
||||||
|
client: mongoose.connection!.getClient() as any
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { PROTOCOL, ALLOWED_DOMAIN } = process.env
|
const { PROTOCOL, ALLOWED_DOMAIN } = process.env
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const configureLogger = (app: Express) => {
|
|||||||
path: logsFolder
|
path: logsFolder
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Writing Logs to :', path.join(logsFolder, filename))
|
process.logger.info('Writing Logs to :', path.join(logsFolder, filename))
|
||||||
|
|
||||||
options = { stream: accessLogStream }
|
options = { stream: accessLogStream }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import dotenv from 'dotenv'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
copySASjsCore,
|
copySASjsCore,
|
||||||
|
createWeboutSasFile,
|
||||||
|
getFilesFolder,
|
||||||
|
getPackagesFolder,
|
||||||
getWebBuildFolder,
|
getWebBuildFolder,
|
||||||
instantiateLogger,
|
instantiateLogger,
|
||||||
loadAppStreamConfig,
|
loadAppStreamConfig,
|
||||||
ReturnCode,
|
ReturnCode,
|
||||||
setProcessVariables,
|
setProcessVariables,
|
||||||
setupFolders,
|
setupFilesFolder,
|
||||||
|
setupPackagesFolder,
|
||||||
|
setupUserAutoExec,
|
||||||
verifyEnvVariables
|
verifyEnvVariables
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +24,7 @@ import {
|
|||||||
configureLogger,
|
configureLogger,
|
||||||
configureSecurity
|
configureSecurity
|
||||||
} from './app-modules'
|
} from './app-modules'
|
||||||
|
import { folderExists } from '@sasjs/utils'
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
@@ -29,7 +35,7 @@ if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
|
|||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
const onError: ErrorRequestHandler = (err, req, res, next) => {
|
const onError: ErrorRequestHandler = (err, req, res, next) => {
|
||||||
console.error(err.stack)
|
process.logger.error(err.stack)
|
||||||
res.status(500).send('Something broke!')
|
res.status(500).send('Something broke!')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +68,21 @@ export default setProcessVariables().then(async () => {
|
|||||||
// Currently only place we use it is SAS9 Mock - POST /SASLogon/login
|
// Currently only place we use it is SAS9 Mock - POST /SASLogon/login
|
||||||
app.use(express.urlencoded({ extended: true }))
|
app.use(express.urlencoded({ extended: true }))
|
||||||
|
|
||||||
await setupFolders()
|
await setupUserAutoExec()
|
||||||
await copySASjsCore()
|
|
||||||
|
if (!(await folderExists(getFilesFolder()))) await setupFilesFolder()
|
||||||
|
|
||||||
|
if (!(await folderExists(getPackagesFolder()))) await setupPackagesFolder()
|
||||||
|
|
||||||
|
const sasautosPath = path.join(process.driveLoc, 'sas', 'sasautos')
|
||||||
|
if (await folderExists(sasautosPath)) {
|
||||||
|
process.logger.warn(
|
||||||
|
`SASAUTOS was not refreshed. To force a refresh, delete the ${sasautosPath} folder`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await copySASjsCore()
|
||||||
|
await createWeboutSasFile()
|
||||||
|
}
|
||||||
|
|
||||||
// loading these modules after setting up variables due to
|
// loading these modules after setting up variables due to
|
||||||
// multer's usage of process var process.driveLoc
|
// multer's usage of process var process.driveLoc
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa'
|
import express from 'express'
|
||||||
|
import {
|
||||||
|
Security,
|
||||||
|
Route,
|
||||||
|
Tags,
|
||||||
|
Example,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Request,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
Hidden
|
||||||
|
} from 'tsoa'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import { InfoJWT } from '../types'
|
import { InfoJWT } from '../types'
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +20,8 @@ import {
|
|||||||
removeTokensInDB,
|
removeTokensInDB,
|
||||||
saveTokensInDB
|
saveTokensInDB
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
|
import Client from '../model/Client'
|
||||||
|
import User from '../model/User'
|
||||||
|
|
||||||
@Route('SASjsApi/auth')
|
@Route('SASjsApi/auth')
|
||||||
@Tags('Auth')
|
@Tags('Auth')
|
||||||
@@ -61,6 +75,18 @@ export class AuthController {
|
|||||||
public async logout(@Query() @Hidden() data?: InfoJWT) {
|
public async logout(@Query() @Hidden() data?: InfoJWT) {
|
||||||
return logout(data!)
|
return logout(data!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update user's password.
|
||||||
|
*/
|
||||||
|
@Security('bearerAuth')
|
||||||
|
@Patch('updatePassword')
|
||||||
|
public async updatePassword(
|
||||||
|
@Request() req: express.Request,
|
||||||
|
@Body() body: UpdatePasswordPayload
|
||||||
|
) {
|
||||||
|
return updatePassword(req, body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = async (data: any): Promise<TokenResponse> => {
|
const token = async (data: any): Promise<TokenResponse> => {
|
||||||
@@ -83,8 +109,17 @@ const token = async (data: any): Promise<TokenResponse> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = generateAccessToken(userInfo)
|
const client = await Client.findOne({ clientId })
|
||||||
const refreshToken = generateRefreshToken(userInfo)
|
if (!client) throw new Error('Invalid clientId.')
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(
|
||||||
|
userInfo,
|
||||||
|
client.accessTokenExpiration
|
||||||
|
)
|
||||||
|
const refreshToken = generateRefreshToken(
|
||||||
|
userInfo,
|
||||||
|
client.refreshTokenExpiration
|
||||||
|
)
|
||||||
|
|
||||||
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
|
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
|
||||||
|
|
||||||
@@ -92,8 +127,17 @@ const token = async (data: any): Promise<TokenResponse> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
|
const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
|
||||||
const accessToken = generateAccessToken(userInfo)
|
const client = await Client.findOne({ clientId: userInfo.clientId })
|
||||||
const refreshToken = generateRefreshToken(userInfo)
|
if (!client) throw new Error('Invalid clientId.')
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(
|
||||||
|
userInfo,
|
||||||
|
client.accessTokenExpiration
|
||||||
|
)
|
||||||
|
const refreshToken = generateRefreshToken(
|
||||||
|
userInfo,
|
||||||
|
client.refreshTokenExpiration
|
||||||
|
)
|
||||||
|
|
||||||
await saveTokensInDB(
|
await saveTokensInDB(
|
||||||
userInfo.userId,
|
userInfo.userId,
|
||||||
@@ -109,6 +153,40 @@ const logout = async (userInfo: InfoJWT) => {
|
|||||||
await removeTokensInDB(userInfo.userId, userInfo.clientId)
|
await removeTokensInDB(userInfo.userId, userInfo.clientId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatePassword = async (
|
||||||
|
req: express.Request,
|
||||||
|
data: UpdatePasswordPayload
|
||||||
|
) => {
|
||||||
|
const { currentPassword, newPassword } = data
|
||||||
|
const userId = req.user?.userId
|
||||||
|
const dbUser = await User.findOne({ id: userId })
|
||||||
|
|
||||||
|
if (!dbUser)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
message: `User not found!`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbUser?.authProvider) {
|
||||||
|
throw {
|
||||||
|
code: 405,
|
||||||
|
message:
|
||||||
|
'Can not update password of user that is created by an external auth provider.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPass = dbUser.comparePassword(currentPassword)
|
||||||
|
if (!validPass)
|
||||||
|
throw {
|
||||||
|
code: 403,
|
||||||
|
message: `Invalid current password!`
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser.password = User.hashPassword(newPassword)
|
||||||
|
dbUser.needsToUpdatePassword = false
|
||||||
|
await dbUser.save()
|
||||||
|
}
|
||||||
|
|
||||||
interface TokenPayload {
|
interface TokenPayload {
|
||||||
/**
|
/**
|
||||||
* Client ID
|
* Client ID
|
||||||
@@ -135,6 +213,19 @@ interface TokenResponse {
|
|||||||
refreshToken: string
|
refreshToken: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UpdatePasswordPayload {
|
||||||
|
/**
|
||||||
|
* Current Password
|
||||||
|
* @example "currentPasswordString"
|
||||||
|
*/
|
||||||
|
currentPassword: string
|
||||||
|
/**
|
||||||
|
* New Password
|
||||||
|
* @example "newPassword"
|
||||||
|
*/
|
||||||
|
newPassword: string
|
||||||
|
}
|
||||||
|
|
||||||
const verifyAuthCode = async (
|
const verifyAuthCode = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
code: string
|
code: string
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ const synchroniseWithLDAP = async () => {
|
|||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
password: hashPassword,
|
password: hashPassword,
|
||||||
authProvider: AuthProviderType.LDAP
|
authProvider: AuthProviderType.LDAP,
|
||||||
|
needsToUpdatePassword: false
|
||||||
})
|
})
|
||||||
|
|
||||||
importedUsers.push(user)
|
importedUsers.push(user)
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import { Security, Route, Tags, Example, Post, Body } from 'tsoa'
|
import { Security, Route, Tags, Example, Post, Body, Get } from 'tsoa'
|
||||||
|
|
||||||
import Client, { ClientPayload } from '../model/Client'
|
import Client, {
|
||||||
|
ClientPayload,
|
||||||
|
NUMBER_OF_SECONDS_IN_A_DAY
|
||||||
|
} from '../model/Client'
|
||||||
|
|
||||||
@Security('bearerAuth')
|
@Security('bearerAuth')
|
||||||
@Route('SASjsApi/client')
|
@Route('SASjsApi/client')
|
||||||
@Tags('Client')
|
@Tags('Client')
|
||||||
export class ClientController {
|
export class ClientController {
|
||||||
/**
|
/**
|
||||||
* @summary Create client with the following attributes: ClientId, ClientSecret. Admin only task.
|
* @summary Admin only task. Create client with the following attributes:
|
||||||
|
* ClientId,
|
||||||
|
* ClientSecret,
|
||||||
|
* accessTokenExpiration (optional),
|
||||||
|
* refreshTokenExpiration (optional)
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@Example<ClientPayload>({
|
@Example<ClientPayload>({
|
||||||
clientId: 'someFormattedClientID1234',
|
clientId: 'someFormattedClientID1234',
|
||||||
clientSecret: 'someRandomCryptoString'
|
clientSecret: 'someRandomCryptoString',
|
||||||
|
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||||
|
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
})
|
})
|
||||||
@Post('/')
|
@Post('/')
|
||||||
public async createClient(
|
public async createClient(
|
||||||
@@ -20,10 +29,37 @@ export class ClientController {
|
|||||||
): Promise<ClientPayload> {
|
): Promise<ClientPayload> {
|
||||||
return createClient(body)
|
return createClient(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Admin only task. Returns the list of all the clients
|
||||||
|
*/
|
||||||
|
@Example<ClientPayload[]>([
|
||||||
|
{
|
||||||
|
clientId: 'someClientID1234',
|
||||||
|
clientSecret: 'someRandomCryptoString',
|
||||||
|
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||||
|
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clientId: 'someOtherClientID',
|
||||||
|
clientSecret: 'someOtherRandomCryptoString',
|
||||||
|
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||||
|
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
|
}
|
||||||
|
])
|
||||||
|
@Get('/')
|
||||||
|
public async getAllClients(): Promise<ClientPayload[]> {
|
||||||
|
return getAllClients()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createClient = async (data: any): Promise<ClientPayload> => {
|
const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
|
||||||
const { clientId, clientSecret } = data
|
const {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
accessTokenExpiration,
|
||||||
|
refreshTokenExpiration
|
||||||
|
} = data
|
||||||
|
|
||||||
// Checking if client is already in the database
|
// Checking if client is already in the database
|
||||||
const clientExist = await Client.findOne({ clientId })
|
const clientExist = await Client.findOne({ clientId })
|
||||||
@@ -32,13 +68,27 @@ const createClient = async (data: any): Promise<ClientPayload> => {
|
|||||||
// Create a new client
|
// Create a new client
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret
|
clientSecret,
|
||||||
|
accessTokenExpiration,
|
||||||
|
refreshTokenExpiration
|
||||||
})
|
})
|
||||||
|
|
||||||
const savedClient = await client.save()
|
const savedClient = await client.save()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clientId: savedClient.clientId,
|
clientId: savedClient.clientId,
|
||||||
clientSecret: savedClient.clientSecret
|
clientSecret: savedClient.clientSecret,
|
||||||
|
accessTokenExpiration: savedClient.accessTokenExpiration,
|
||||||
|
refreshTokenExpiration: savedClient.refreshTokenExpiration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAllClients = async (): Promise<ClientPayload[]> => {
|
||||||
|
return Client.find({}).select({
|
||||||
|
_id: 0,
|
||||||
|
clientId: 1,
|
||||||
|
clientSecret: 1,
|
||||||
|
accessTokenExpiration: 1,
|
||||||
|
refreshTokenExpiration: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export class ExecutionController {
|
|||||||
|
|
||||||
const logPath = path.join(session.path, 'log.log')
|
const logPath = path.join(session.path, 'log.log')
|
||||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
|
|
||||||
const weboutPath = path.join(session.path, 'webout.txt')
|
const weboutPath = path.join(session.path, 'webout.txt')
|
||||||
const tokenFile = path.join(session.path, 'reqHeaders.txt')
|
const tokenFile = path.join(session.path, 'reqHeaders.txt')
|
||||||
|
|
||||||
@@ -105,6 +106,11 @@ export class ExecutionController {
|
|||||||
? await readFile(headersPath)
|
? await readFile(headersPath)
|
||||||
: ''
|
: ''
|
||||||
const httpHeaders: HTTPHeaders = extractHeaders(headersContent)
|
const httpHeaders: HTTPHeaders = extractHeaders(headersContent)
|
||||||
|
|
||||||
|
if (isDebugOn(vars)) {
|
||||||
|
httpHeaders['content-type'] = 'text/plain'
|
||||||
|
}
|
||||||
|
|
||||||
const fileResponse: boolean = httpHeaders.hasOwnProperty('content-type')
|
const fileResponse: boolean = httpHeaders.hasOwnProperty('content-type')
|
||||||
|
|
||||||
const webout = (await fileExists(weboutPath))
|
const webout = (await fileExists(weboutPath))
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
await createFile(headersPath, 'Content-type: text/plain')
|
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
|
||||||
|
|
||||||
this.sessions.push(session)
|
this.sessions.push(session)
|
||||||
return session
|
return session
|
||||||
@@ -94,7 +94,7 @@ export class SASSessionController extends SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
await createFile(headersPath, 'Content-type: text/plain')
|
await createFile(headersPath, 'content-type: text/html; charset=utf-8\n')
|
||||||
|
|
||||||
// we do not want to leave sessions running forever
|
// we do not want to leave sessions running forever
|
||||||
// we clean them up after a predefined period, if unused
|
// we clean them up after a predefined period, if unused
|
||||||
@@ -140,17 +140,18 @@ ${autoExecContent}`
|
|||||||
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
|
||||||
process.sasLoc!.endsWith('sas.exe') ? '-noterminal' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-noterminal' : '',
|
||||||
process.sasLoc!.endsWith('sas.exe') ? '-nostatuswin' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-nostatuswin' : '',
|
||||||
|
process.sasLoc!.endsWith('sas.exe') ? '-NOPRNGETLIST' : '',
|
||||||
process.sasLoc!.endsWith('sas.exe') ? '-SASINITIALFOLDER' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-SASINITIALFOLDER' : '',
|
||||||
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
||||||
])
|
])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
session.completed = true
|
session.completed = true
|
||||||
console.log('session completed', session)
|
process.logger.info('session completed', session)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
session.completed = true
|
session.completed = true
|
||||||
session.crashed = err.toString()
|
session.crashed = err.toString()
|
||||||
console.log('session crashed', session.id, session.crashed)
|
process.logger.error('session crashed', session.id, session.crashed)
|
||||||
})
|
})
|
||||||
|
|
||||||
// we have a triggered session - add to array
|
// we have a triggered session - add to array
|
||||||
@@ -170,7 +171,10 @@ ${autoExecContent}`
|
|||||||
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
||||||
|
|
||||||
if (session.crashed)
|
if (session.crashed)
|
||||||
console.log('session crashed! while waiting to be ready', session.crashed)
|
process.logger.error(
|
||||||
|
'session crashed! while waiting to be ready',
|
||||||
|
session.crashed
|
||||||
|
)
|
||||||
|
|
||||||
session.ready = true
|
session.ready = true
|
||||||
}
|
}
|
||||||
@@ -203,12 +207,15 @@ ${autoExecContent}`
|
|||||||
export const getSessionController = (
|
export const getSessionController = (
|
||||||
runTime: RunTimeType
|
runTime: RunTimeType
|
||||||
): SessionController => {
|
): SessionController => {
|
||||||
if (process.sessionController) return process.sessionController
|
if (runTime === RunTimeType.SAS) {
|
||||||
|
process.sasSessionController =
|
||||||
|
process.sasSessionController || new SASSessionController()
|
||||||
|
|
||||||
|
return process.sasSessionController
|
||||||
|
}
|
||||||
|
|
||||||
process.sessionController =
|
process.sessionController =
|
||||||
runTime === RunTimeType.SAS
|
process.sessionController || new SessionController()
|
||||||
? new SASSessionController()
|
|
||||||
: new SessionController()
|
|
||||||
|
|
||||||
return process.sessionController
|
return process.sessionController
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import { WriteStream, createWriteStream } from 'fs'
|
||||||
import { execFileSync } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { once } from 'stream'
|
import { once } from 'stream'
|
||||||
import { createFile, moveFile } from '@sasjs/utils'
|
import { createFile, moveFile } from '@sasjs/utils'
|
||||||
import { PreProgramVars, Session } from '../../types'
|
import { PreProgramVars, Session } from '../../types'
|
||||||
@@ -105,26 +105,58 @@ export const processProgram = async (
|
|||||||
throw new Error('Invalid runtime!')
|
throw new Error('Invalid runtime!')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await createFile(codePath, program)
|
||||||
await createFile(codePath, program)
|
|
||||||
|
|
||||||
// create a stream that will write to console outputs to log file
|
// create a stream that will write to console outputs to log file
|
||||||
const writeStream = fs.createWriteStream(logPath)
|
const writeStream = createWriteStream(logPath)
|
||||||
// waiting for the open event so that we can have underlying file descriptor
|
// waiting for the open event so that we can have underlying file descriptor
|
||||||
await once(writeStream, 'open')
|
await once(writeStream, 'open')
|
||||||
execFileSync(executablePath, [codePath], {
|
|
||||||
stdio: ['ignore', writeStream, writeStream]
|
await execFilePromise(executablePath, [codePath], writeStream)
|
||||||
|
.then(() => {
|
||||||
|
session.completed = true
|
||||||
|
process.logger.info('session completed', session)
|
||||||
})
|
})
|
||||||
// copy the code file to log and end write stream
|
.catch((err) => {
|
||||||
writeStream.end(program)
|
session.completed = true
|
||||||
session.completed = true
|
session.crashed = err.toString()
|
||||||
console.log('session completed', session)
|
process.logger.error('session crashed', session.id, session.crashed)
|
||||||
} catch (err: any) {
|
})
|
||||||
session.completed = true
|
|
||||||
session.crashed = err.toString()
|
// copy the code file to log and end write stream
|
||||||
console.log('session crashed', session.id, session.crashed)
|
writeStream.end(program)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promisified child_process.execFile
|
||||||
|
*
|
||||||
|
* @param file - The name or path of the executable file to run.
|
||||||
|
* @param args - List of string arguments.
|
||||||
|
* @param writeStream - Child process stdout and stderr will be piped to it.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{ stdout: string, stderr: string }>}
|
||||||
|
*/
|
||||||
|
const execFilePromise = (
|
||||||
|
file: string,
|
||||||
|
args: string[],
|
||||||
|
writeStream: WriteStream
|
||||||
|
): Promise<{ stdout: string; stderr: string }> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = execFile(file, args, (err, stdout, stderr) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
|
||||||
|
resolve({ stdout, stderr })
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stdout?.on('data', (data) => {
|
||||||
|
writeStream.write(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stderr?.on('data', (data) => {
|
||||||
|
writeStream.write(data)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export class MockSas9Controller {
|
|||||||
content: result.result as string
|
content: result.result as string
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('err', err)
|
process.logger.error('err', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -168,7 +168,7 @@ export class MockSas9Controller {
|
|||||||
content: result.result as string
|
content: result.result as string
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('err', err)
|
process.logger.error('err', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -269,7 +269,7 @@ const getMockResponseFromFile = async (
|
|||||||
|
|
||||||
let file = await readFile(filePathParsed).catch((err: any) => {
|
let file = await readFile(filePathParsed).catch((err: any) => {
|
||||||
const errMsg = `Error reading mocked file on path: ${filePathParsed}\nError: ${err}`
|
const errMsg = `Error reading mocked file on path: ${filePathParsed}\nError: ${err}`
|
||||||
console.error(errMsg)
|
process.logger.error(errMsg)
|
||||||
|
|
||||||
error = true
|
error = true
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import express from 'express'
|
|||||||
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
||||||
import { UserResponse } from './user'
|
import { UserResponse } from './user'
|
||||||
|
|
||||||
|
interface SessionResponse extends UserResponse {
|
||||||
|
needsToUpdatePassword: boolean
|
||||||
|
}
|
||||||
|
|
||||||
@Security('bearerAuth')
|
@Security('bearerAuth')
|
||||||
@Route('SASjsApi/session')
|
@Route('SASjsApi/session')
|
||||||
@Tags('Session')
|
@Tags('Session')
|
||||||
@@ -19,7 +23,7 @@ export class SessionController {
|
|||||||
@Get('/')
|
@Get('/')
|
||||||
public async session(
|
public async session(
|
||||||
@Request() request: express.Request
|
@Request() request: express.Request
|
||||||
): Promise<UserResponse> {
|
): Promise<SessionResponse> {
|
||||||
return session(request)
|
return session(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,5 +32,6 @@ const session = (req: express.Request) => ({
|
|||||||
id: req.user!.userId,
|
id: req.user!.userId,
|
||||||
username: req.user!.username,
|
username: req.user!.username,
|
||||||
displayName: req.user!.displayName,
|
displayName: req.user!.displayName,
|
||||||
isAdmin: req.user!.isAdmin
|
isAdmin: req.user!.isAdmin,
|
||||||
|
needsToUpdatePassword: req.user!.needsToUpdatePassword
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ const execute = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
req.res?.header(httpHeaders)
|
||||||
|
|
||||||
if (result instanceof Buffer) {
|
if (result instanceof Buffer) {
|
||||||
;(req as any).sasHeaders = httpHeaders
|
;(req as any).sasHeaders = httpHeaders
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ import {
|
|||||||
getUserAutoExec,
|
getUserAutoExec,
|
||||||
updateUserAutoExec,
|
updateUserAutoExec,
|
||||||
ModeType,
|
ModeType,
|
||||||
AuthProviderType
|
ALL_USERS_GROUP
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { GroupResponse } from './group'
|
import { GroupController, GroupResponse } from './group'
|
||||||
|
|
||||||
export interface UserResponse {
|
export interface UserResponse {
|
||||||
id: number
|
id: number
|
||||||
@@ -237,6 +237,15 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
|||||||
|
|
||||||
const savedUser = await user.save()
|
const savedUser = await user.save()
|
||||||
|
|
||||||
|
const groupController = new GroupController()
|
||||||
|
const allUsersGroup = await groupController
|
||||||
|
.getGroupByGroupName(ALL_USERS_GROUP.name)
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
if (allUsersGroup) {
|
||||||
|
await groupController.addUserToGroup(allUsersGroup.groupId, savedUser.id)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: savedUser.id,
|
id: savedUser.id,
|
||||||
displayName: savedUser.displayName,
|
displayName: savedUser.displayName,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { Request, Route, Tags, Post, Body, Get, Example } from 'tsoa'
|
import { Request, Route, Tags, Post, Body, Get, Example } from 'tsoa'
|
||||||
import { readFile } from '@sasjs/utils'
|
import { readFile, convertSecondsToHms } from '@sasjs/utils'
|
||||||
|
|
||||||
import User from '../model/User'
|
import User from '../model/User'
|
||||||
import Client from '../model/Client'
|
import Client from '../model/Client'
|
||||||
import {
|
import {
|
||||||
getWebBuildFolder,
|
getWebBuildFolder,
|
||||||
generateAuthCode,
|
generateAuthCode,
|
||||||
|
RateLimiter,
|
||||||
AuthProviderType,
|
AuthProviderType,
|
||||||
LDAPClient
|
LDAPClient
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
@@ -83,19 +84,38 @@ const login = async (
|
|||||||
) => {
|
) => {
|
||||||
// Authenticate User
|
// Authenticate User
|
||||||
const user = await User.findOne({ username })
|
const user = await User.findOne({ username })
|
||||||
if (!user) throw new Error('Username is not found.')
|
|
||||||
|
|
||||||
if (
|
let validPass = false
|
||||||
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
|
|
||||||
user.authProvider === AuthProviderType.LDAP
|
if (user) {
|
||||||
) {
|
if (
|
||||||
const ldapClient = await LDAPClient.init()
|
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
|
||||||
await ldapClient.verifyUser(username, password)
|
user.authProvider === AuthProviderType.LDAP
|
||||||
} else {
|
) {
|
||||||
const validPass = user.comparePassword(password)
|
const ldapClient = await LDAPClient.init()
|
||||||
if (!validPass) throw new Error('Invalid password.')
|
validPass = await ldapClient
|
||||||
|
.verifyUser(username, password)
|
||||||
|
.catch(() => false)
|
||||||
|
} else {
|
||||||
|
validPass = user.comparePassword(password)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// code to prevent brute force attack
|
||||||
|
|
||||||
|
const rateLimiter = RateLimiter.getInstance()
|
||||||
|
|
||||||
|
if (!validPass) {
|
||||||
|
const retrySecs = await rateLimiter.consume(req.ip, user?.username)
|
||||||
|
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) throw errors.userNotFound
|
||||||
|
if (!validPass) throw errors.invalidPassword
|
||||||
|
|
||||||
|
// Reset on successful authorization
|
||||||
|
rateLimiter.resetOnSuccess(req.ip, user.username)
|
||||||
|
|
||||||
req.session.loggedIn = true
|
req.session.loggedIn = true
|
||||||
req.session.user = {
|
req.session.user = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -104,7 +124,8 @@ const login = async (
|
|||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
isActive: user.isActive,
|
isActive: user.isActive,
|
||||||
autoExec: user.autoExec
|
autoExec: user.autoExec,
|
||||||
|
needsToUpdatePassword: user.needsToUpdatePassword
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -113,7 +134,8 @@ const login = async (
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
isAdmin: user.isAdmin
|
isAdmin: user.isAdmin,
|
||||||
|
needsToUpdatePassword: user.needsToUpdatePassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,3 +192,18 @@ interface AuthorizeResponse {
|
|||||||
*/
|
*/
|
||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const errors = {
|
||||||
|
invalidPassword: {
|
||||||
|
code: 401,
|
||||||
|
message: 'Invalid Password.'
|
||||||
|
},
|
||||||
|
userNotFound: {
|
||||||
|
code: 401,
|
||||||
|
message: 'Username is not found.'
|
||||||
|
},
|
||||||
|
tooManyRequests: (seconds: number) => ({
|
||||||
|
code: 429,
|
||||||
|
message: `Too Many Requests! Retry after ${convertSecondsToHms(seconds)}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ const authenticateToken = async (
|
|||||||
username: 'desktopModeUsername',
|
username: 'desktopModeUsername',
|
||||||
displayName: 'desktopModeDisplayName',
|
displayName: 'desktopModeDisplayName',
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
isActive: true
|
isActive: true,
|
||||||
|
needsToUpdatePassword: false
|
||||||
}
|
}
|
||||||
req.accessToken = 'desktopModeAccessToken'
|
req.accessToken = 'desktopModeAccessToken'
|
||||||
return next()
|
return next()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
PermissionSettingForRoute,
|
PermissionSettingForRoute,
|
||||||
PermissionType
|
PermissionType
|
||||||
} from '../controllers/permission'
|
} from '../controllers/permission'
|
||||||
import { getPath, isPublicRoute } from '../utils'
|
import { getPath, isPublicRoute, TopLevelRoutes } from '../utils'
|
||||||
|
|
||||||
export const authorize: RequestHandler = async (req, res, next) => {
|
export const authorize: RequestHandler = async (req, res, next) => {
|
||||||
const { user } = req
|
const { user } = req
|
||||||
@@ -22,6 +22,9 @@ export const authorize: RequestHandler = async (req, res, next) => {
|
|||||||
if (!dbUser) return res.sendStatus(401)
|
if (!dbUser) return res.sendStatus(401)
|
||||||
|
|
||||||
const path = getPath(req)
|
const path = getPath(req)
|
||||||
|
const { baseUrl } = req
|
||||||
|
const topLevelRoute =
|
||||||
|
TopLevelRoutes.find((route) => baseUrl.startsWith(route)) || baseUrl
|
||||||
|
|
||||||
// find permission w.r.t user
|
// find permission w.r.t user
|
||||||
const permission = await Permission.findOne({
|
const permission = await Permission.findOne({
|
||||||
@@ -35,6 +38,21 @@ export const authorize: RequestHandler = async (req, res, next) => {
|
|||||||
else return res.sendStatus(401)
|
else return res.sendStatus(401)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// find permission w.r.t user on top level
|
||||||
|
const topLevelPermission = await Permission.findOne({
|
||||||
|
path: topLevelRoute,
|
||||||
|
type: PermissionType.route,
|
||||||
|
user: dbUser._id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (topLevelPermission) {
|
||||||
|
if (topLevelPermission.setting === PermissionSettingForRoute.grant)
|
||||||
|
return next()
|
||||||
|
else return res.sendStatus(401)
|
||||||
|
}
|
||||||
|
|
||||||
|
let isPermissionDenied = false
|
||||||
|
|
||||||
// find permission w.r.t user's groups
|
// find permission w.r.t user's groups
|
||||||
for (const group of dbUser.groups) {
|
for (const group of dbUser.groups) {
|
||||||
const groupPermission = await Permission.findOne({
|
const groupPermission = await Permission.findOne({
|
||||||
@@ -42,8 +60,28 @@ export const authorize: RequestHandler = async (req, res, next) => {
|
|||||||
type: PermissionType.route,
|
type: PermissionType.route,
|
||||||
group
|
group
|
||||||
})
|
})
|
||||||
if (groupPermission?.setting === PermissionSettingForRoute.grant)
|
|
||||||
return next()
|
if (groupPermission) {
|
||||||
|
if (groupPermission.setting === PermissionSettingForRoute.grant) {
|
||||||
|
return next()
|
||||||
|
} else {
|
||||||
|
isPermissionDenied = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isPermissionDenied) {
|
||||||
|
// find permission w.r.t user's groups on top level
|
||||||
|
for (const group of dbUser.groups) {
|
||||||
|
const groupPermission = await Permission.findOne({
|
||||||
|
path: topLevelRoute,
|
||||||
|
type: PermissionType.route,
|
||||||
|
group
|
||||||
|
})
|
||||||
|
if (groupPermission?.setting === PermissionSettingForRoute.grant)
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.sendStatus(401)
|
return res.sendStatus(401)
|
||||||
}
|
}
|
||||||
|
|||||||
22
api/src/middlewares/bruteForceProtection.ts
Normal file
22
api/src/middlewares/bruteForceProtection.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { RequestHandler } from 'express'
|
||||||
|
import { convertSecondsToHms } from '@sasjs/utils'
|
||||||
|
import { RateLimiter } from '../utils'
|
||||||
|
|
||||||
|
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
|
||||||
|
const ip = req.ip
|
||||||
|
const username = req.body.username
|
||||||
|
|
||||||
|
const rateLimiter = RateLimiter.getInstance()
|
||||||
|
|
||||||
|
const retrySecs = await rateLimiter.check(ip, username)
|
||||||
|
|
||||||
|
if (retrySecs > 0) {
|
||||||
|
res
|
||||||
|
.status(429)
|
||||||
|
.send(`Too Many Requests! Retry after ${convertSecondsToHms(retrySecs)}`)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
@@ -33,5 +33,6 @@ export const desktopUser: RequestUser = {
|
|||||||
username: userInfo().username,
|
username: userInfo().username,
|
||||||
displayName: userInfo().username,
|
displayName: userInfo().username,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
isActive: true
|
isActive: true,
|
||||||
|
needsToUpdatePassword: false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from './csrfProtection'
|
|||||||
export * from './desktop'
|
export * from './desktop'
|
||||||
export * from './verifyAdmin'
|
export * from './verifyAdmin'
|
||||||
export * from './verifyAdminIfNeeded'
|
export * from './verifyAdminIfNeeded'
|
||||||
|
export * from './bruteForceProtection'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import mongoose, { Schema } from 'mongoose'
|
import mongoose, { Schema } from 'mongoose'
|
||||||
|
|
||||||
|
export const NUMBER_OF_SECONDS_IN_A_DAY = 86400
|
||||||
export interface ClientPayload {
|
export interface ClientPayload {
|
||||||
/**
|
/**
|
||||||
* Client ID
|
* Client ID
|
||||||
@@ -11,6 +12,16 @@ export interface ClientPayload {
|
|||||||
* @example "someRandomCryptoString"
|
* @example "someRandomCryptoString"
|
||||||
*/
|
*/
|
||||||
clientSecret: string
|
clientSecret: string
|
||||||
|
/**
|
||||||
|
* Number of seconds after which access token will expire. Default is 86400 (1 day)
|
||||||
|
* @example 86400
|
||||||
|
*/
|
||||||
|
accessTokenExpiration?: number
|
||||||
|
/**
|
||||||
|
* Number of seconds after which access token will expire. Default is 2592000 (30 days)
|
||||||
|
* @example 2592000
|
||||||
|
*/
|
||||||
|
refreshTokenExpiration?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClientSchema = new Schema<ClientPayload>({
|
const ClientSchema = new Schema<ClientPayload>({
|
||||||
@@ -21,6 +32,14 @@ const ClientSchema = new Schema<ClientPayload>({
|
|||||||
clientSecret: {
|
clientSecret: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
accessTokenExpiration: {
|
||||||
|
type: Number,
|
||||||
|
default: NUMBER_OF_SECONDS_IN_A_DAY
|
||||||
|
},
|
||||||
|
refreshTokenExpiration: {
|
||||||
|
type: Number,
|
||||||
|
default: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface IUserDocument extends UserPayload, Document {
|
|||||||
id: number
|
id: number
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
|
needsToUpdatePassword: boolean
|
||||||
autoExec: string
|
autoExec: string
|
||||||
groups: Schema.Types.ObjectId[]
|
groups: Schema.Types.ObjectId[]
|
||||||
tokens: [{ [key: string]: string }]
|
tokens: [{ [key: string]: string }]
|
||||||
@@ -81,6 +82,10 @@ const userSchema = new Schema<IUserDocument>({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
needsToUpdatePassword: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
autoExec: {
|
autoExec: {
|
||||||
type: String
|
type: String
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,12 +7,28 @@ import {
|
|||||||
authenticateRefreshToken
|
authenticateRefreshToken
|
||||||
} from '../../middlewares'
|
} from '../../middlewares'
|
||||||
|
|
||||||
import { tokenValidation } from '../../utils'
|
import { tokenValidation, updatePasswordValidation } from '../../utils'
|
||||||
import { InfoJWT } from '../../types'
|
import { InfoJWT } from '../../types'
|
||||||
|
|
||||||
const authRouter = express.Router()
|
const authRouter = express.Router()
|
||||||
const controller = new AuthController()
|
const controller = new AuthController()
|
||||||
|
|
||||||
|
authRouter.patch(
|
||||||
|
'/updatePassword',
|
||||||
|
authenticateAccessToken,
|
||||||
|
async (req, res) => {
|
||||||
|
const { error, value: body } = updatePasswordValidation(req.body)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.updatePassword(req, body)
|
||||||
|
res.sendStatus(204)
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(err.code).send(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
authRouter.post('/token', async (req, res) => {
|
authRouter.post('/token', async (req, res) => {
|
||||||
const { error, value: body } = tokenValidation(req.body)
|
const { error, value: body } = tokenValidation(req.body)
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { ClientController } from '../../controllers'
|
import { ClientController } from '../../controllers'
|
||||||
import { registerClientValidation } from '../../utils'
|
import { registerClientValidation } from '../../utils'
|
||||||
|
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
||||||
|
|
||||||
const clientRouter = express.Router()
|
const clientRouter = express.Router()
|
||||||
|
|
||||||
@@ -17,4 +18,19 @@ clientRouter.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
clientRouter.get(
|
||||||
|
'/',
|
||||||
|
authenticateAccessToken,
|
||||||
|
verifyAdmin,
|
||||||
|
async (req, res) => {
|
||||||
|
const controller = new ClientController()
|
||||||
|
try {
|
||||||
|
const response = await controller.getAllClients()
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(403).send(err.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export default clientRouter
|
export default clientRouter
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import request from 'supertest'
|
|||||||
import appPromise from '../../../app'
|
import appPromise from '../../../app'
|
||||||
import { UserController, ClientController } from '../../../controllers/'
|
import { UserController, ClientController } from '../../../controllers/'
|
||||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||||
|
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../../../model/Client'
|
||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
clientId: 'someclientID',
|
clientId: 'someclientID',
|
||||||
@@ -26,6 +27,7 @@ describe('client', () => {
|
|||||||
let app: Express
|
let app: Express
|
||||||
let con: Mongoose
|
let con: Mongoose
|
||||||
let mongoServer: MongoMemoryServer
|
let mongoServer: MongoMemoryServer
|
||||||
|
let adminAccessToken: string
|
||||||
const userController = new UserController()
|
const userController = new UserController()
|
||||||
const clientController = new ClientController()
|
const clientController = new ClientController()
|
||||||
|
|
||||||
@@ -34,6 +36,18 @@ describe('client', () => {
|
|||||||
|
|
||||||
mongoServer = await MongoMemoryServer.create()
|
mongoServer = await MongoMemoryServer.create()
|
||||||
con = await mongoose.connect(mongoServer.getUri())
|
con = await mongoose.connect(mongoServer.getUri())
|
||||||
|
|
||||||
|
const dbUser = await userController.createUser(adminUser)
|
||||||
|
adminAccessToken = generateAccessToken({
|
||||||
|
clientId: client.clientId,
|
||||||
|
userId: dbUser.id
|
||||||
|
})
|
||||||
|
await saveTokensInDB(
|
||||||
|
dbUser.id,
|
||||||
|
client.clientId,
|
||||||
|
adminAccessToken,
|
||||||
|
'refreshToken'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -43,22 +57,6 @@ describe('client', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
let adminAccessToken: string
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const dbUser = await userController.createUser(adminUser)
|
|
||||||
adminAccessToken = generateAccessToken({
|
|
||||||
clientId: client.clientId,
|
|
||||||
userId: dbUser.id
|
|
||||||
})
|
|
||||||
await saveTokensInDB(
|
|
||||||
dbUser.id,
|
|
||||||
client.clientId,
|
|
||||||
adminAccessToken,
|
|
||||||
'refreshToken'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
const collections = mongoose.connection.collections
|
const collections = mongoose.connection.collections
|
||||||
const collection = collections['clients']
|
const collection = collections['clients']
|
||||||
@@ -157,4 +155,80 @@ describe('client', () => {
|
|||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('get', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
const collections = mongoose.connection.collections
|
||||||
|
const collection = collections['clients']
|
||||||
|
await collection.deleteMany({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with an array of all clients', async () => {
|
||||||
|
await clientController.createClient(newClient)
|
||||||
|
await clientController.createClient({
|
||||||
|
clientId: 'clientID',
|
||||||
|
clientSecret: 'clientSecret'
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/SASjsApi/client')
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
clientId: 'newClientID',
|
||||||
|
clientSecret: 'newClientSecret',
|
||||||
|
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||||
|
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clientId: 'clientID',
|
||||||
|
clientSecret: 'clientSecret',
|
||||||
|
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||||
|
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(res.body).toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Unauthorized if access token is not present', async () => {
|
||||||
|
const res = await request(app).get('/SASjsApi/client').send().expect(401)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Unauthorized')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Forbideen if access token is not of an admin account', async () => {
|
||||||
|
const user = {
|
||||||
|
displayName: 'User 2',
|
||||||
|
username: 'username2',
|
||||||
|
password: '12345678',
|
||||||
|
isAdmin: false,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
const dbUser = await userController.createUser(user)
|
||||||
|
const accessToken = generateAccessToken({
|
||||||
|
clientId: client.clientId,
|
||||||
|
userId: dbUser.id
|
||||||
|
})
|
||||||
|
await saveTokensInDB(
|
||||||
|
dbUser.id,
|
||||||
|
client.clientId,
|
||||||
|
accessToken,
|
||||||
|
'refreshToken'
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/SASjsApi/client')
|
||||||
|
.auth(accessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(401)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Admin account required')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -77,10 +77,85 @@ describe('web', () => {
|
|||||||
id: expect.any(Number),
|
id: expect.any(Number),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
isAdmin: user.isAdmin
|
isAdmin: user.isAdmin,
|
||||||
|
needsToUpdatePassword: true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should respond with too many requests when attempting with invalid password for a same user too many times', async () => {
|
||||||
|
await userController.createUser(user)
|
||||||
|
|
||||||
|
const promises: request.Test[] = []
|
||||||
|
|
||||||
|
const maxConsecutiveFailsByUsernameAndIp = Number(
|
||||||
|
process.env.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||||
|
)
|
||||||
|
|
||||||
|
Array(maxConsecutiveFailsByUsernameAndIp + 1)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => {
|
||||||
|
promises.push(
|
||||||
|
request(app)
|
||||||
|
.post('/SASLogon/login')
|
||||||
|
.set('x-xsrf-token', csrfToken)
|
||||||
|
.send({
|
||||||
|
username: user.username,
|
||||||
|
password: 'invalid-password'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASLogon/login')
|
||||||
|
.set('x-xsrf-token', csrfToken)
|
||||||
|
.send({
|
||||||
|
username: user.username,
|
||||||
|
password: user.password
|
||||||
|
})
|
||||||
|
.expect(429)
|
||||||
|
|
||||||
|
expect(res.text).toContain('Too Many Requests!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with too many requests when attempting with invalid credentials for different users but with same ip too many times', async () => {
|
||||||
|
await userController.createUser(user)
|
||||||
|
|
||||||
|
const promises: request.Test[] = []
|
||||||
|
|
||||||
|
const maxWrongAttemptsByIpPerDay = Number(
|
||||||
|
process.env.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY
|
||||||
|
)
|
||||||
|
|
||||||
|
Array(maxWrongAttemptsByIpPerDay + 1)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => {
|
||||||
|
promises.push(
|
||||||
|
request(app)
|
||||||
|
.post('/SASLogon/login')
|
||||||
|
.set('x-xsrf-token', csrfToken)
|
||||||
|
.send({
|
||||||
|
username: `user${i}`,
|
||||||
|
password: 'invalid-password'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASLogon/login')
|
||||||
|
.set('x-xsrf-token', csrfToken)
|
||||||
|
.send({
|
||||||
|
username: user.username,
|
||||||
|
password: user.password
|
||||||
|
})
|
||||||
|
.expect(429)
|
||||||
|
|
||||||
|
expect(res.text).toContain('Too Many Requests!')
|
||||||
|
})
|
||||||
|
|
||||||
it('should respond with Bad Request if CSRF Token is not present', async () => {
|
it('should respond with Bad Request if CSRF Token is not present', async () => {
|
||||||
await userController.createUser(user)
|
await userController.createUser(user)
|
||||||
|
|
||||||
@@ -118,6 +193,7 @@ describe('web', () => {
|
|||||||
let authCookies: string
|
let authCookies: string
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
await deleteDocumentsFromLimitersCollections()
|
||||||
;({ csrfToken } = await getCSRF(app))
|
;({ csrfToken } = await getCSRF(app))
|
||||||
|
|
||||||
await userController.createUser(user)
|
await userController.createUser(user)
|
||||||
@@ -209,3 +285,12 @@ const extractCSRF = (text: string) =>
|
|||||||
/<script>document.cookie = 'XSRF-TOKEN=(.*); Max-Age=86400; SameSite=Strict; Path=\/;'<\/script>/.exec(
|
/<script>document.cookie = 'XSRF-TOKEN=(.*); Max-Age=86400; SameSite=Strict; Path=\/;'<\/script>/.exec(
|
||||||
text
|
text
|
||||||
)![1]
|
)![1]
|
||||||
|
|
||||||
|
const deleteDocumentsFromLimitersCollections = async () => {
|
||||||
|
const { collections } = mongoose.connection
|
||||||
|
const login_fail_ip_per_day_collection = collections['login_fail_ip_per_day']
|
||||||
|
await login_fail_ip_per_day_collection.deleteMany({})
|
||||||
|
const login_fail_consecutive_username_and_ip_collection =
|
||||||
|
collections['login_fail_consecutive_username_and_ip']
|
||||||
|
await login_fail_consecutive_username_and_ip_collection.deleteMany({})
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const publishAppStream = async (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const sasJsPort = process.env.PORT || 5000
|
const sasJsPort = process.env.PORT || 5000
|
||||||
console.log(
|
process.logger.info(
|
||||||
'Serving Stream App: ',
|
'Serving Stream App: ',
|
||||||
`http://localhost:${sasJsPort}/AppStream/${streamServiceName}`
|
`http://localhost:${sasJsPort}/AppStream/${streamServiceName}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { generateCSRFToken } from '../../middlewares'
|
import { generateCSRFToken } from '../../middlewares'
|
||||||
import { WebController } from '../../controllers/web'
|
import { WebController } from '../../controllers/web'
|
||||||
import { authenticateAccessToken, desktopRestrict } from '../../middlewares'
|
import {
|
||||||
|
authenticateAccessToken,
|
||||||
|
bruteForceProtection,
|
||||||
|
desktopRestrict
|
||||||
|
} from '../../middlewares'
|
||||||
import { authorizeValidation, loginWebValidation } from '../../utils'
|
import { authorizeValidation, loginWebValidation } from '../../utils'
|
||||||
|
|
||||||
const webRouter = express.Router()
|
const webRouter = express.Router()
|
||||||
@@ -27,17 +31,26 @@ webRouter.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
webRouter.post('/SASLogon/login', desktopRestrict, async (req, res) => {
|
webRouter.post(
|
||||||
const { error, value: body } = loginWebValidation(req.body)
|
'/SASLogon/login',
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
desktopRestrict,
|
||||||
|
bruteForceProtection,
|
||||||
|
async (req, res) => {
|
||||||
|
const { error, value: body } = loginWebValidation(req.body)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await controller.login(req, body)
|
const response = await controller.login(req, body)
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(403).send(err.toString())
|
if (err instanceof Error) {
|
||||||
|
res.status(500).send(err.toString())
|
||||||
|
} else {
|
||||||
|
res.status(err.code).send(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
webRouter.post(
|
webRouter.post(
|
||||||
'/SASLogon/authorize',
|
'/SASLogon/authorize',
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ appPromise.then(async (app) => {
|
|||||||
const protocol = process.env.PROTOCOL || 'http'
|
const protocol = process.env.PROTOCOL || 'http'
|
||||||
const sasJsPort = process.env.PORT || 5000
|
const sasJsPort = process.env.PORT || 5000
|
||||||
|
|
||||||
console.log('PROTOCOL: ', protocol)
|
process.logger.info('PROTOCOL: ', protocol)
|
||||||
|
|
||||||
if (protocol !== 'https') {
|
if (protocol !== 'https') {
|
||||||
app.listen(sasJsPort, () => {
|
app.listen(sasJsPort, () => {
|
||||||
console.log(
|
process.logger.info(
|
||||||
`⚡️[server]: Server is running at http://localhost:${sasJsPort}`
|
`⚡️[server]: Server is running at http://localhost:${sasJsPort}`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -20,7 +20,7 @@ appPromise.then(async (app) => {
|
|||||||
|
|
||||||
const httpsServer = createServer({ key, cert, ca }, app)
|
const httpsServer = createServer({ key, cert, ca }, app)
|
||||||
httpsServer.listen(sasJsPort, () => {
|
httpsServer.listen(sasJsPort, () => {
|
||||||
console.log(
|
process.logger.info(
|
||||||
`⚡️[server]: Server is running at https://localhost:${sasJsPort}`
|
`⚡️[server]: Server is running at https://localhost:${sasJsPort}`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export interface RequestUser {
|
|||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
|
needsToUpdatePassword: boolean
|
||||||
autoExec?: string
|
autoExec?: string
|
||||||
}
|
}
|
||||||
|
|||||||
2
api/src/types/system/process.d.ts
vendored
2
api/src/types/system/process.d.ts
vendored
@@ -5,9 +5,11 @@ declare namespace NodeJS {
|
|||||||
pythonLoc?: string
|
pythonLoc?: string
|
||||||
rLoc?: string
|
rLoc?: string
|
||||||
driveLoc: string
|
driveLoc: string
|
||||||
|
sasjsRoot: string
|
||||||
logsLoc: string
|
logsLoc: string
|
||||||
logsUUID: string
|
logsUUID: string
|
||||||
sessionController?: import('../../controllers/internal').SessionController
|
sessionController?: import('../../controllers/internal').SessionController
|
||||||
|
sasSessionController?: import('../../controllers/internal').SASSessionController
|
||||||
appStreamConfig: import('../').AppStreamConfig
|
appStreamConfig: import('../').AppStreamConfig
|
||||||
logger: import('@sasjs/utils/logger').Logger
|
logger: import('@sasjs/utils/logger').Logger
|
||||||
runTimes: import('../../utils').RunTimeType[]
|
runTimes: import('../../utils').RunTimeType[]
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const loadAppStreamConfig = async () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('App Stream Config loaded!')
|
process.logger.info('App Stream Config loaded!')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addEntryToAppStreamConfig = (
|
export const addEntryToAppStreamConfig = (
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ export const connectDB = async () => {
|
|||||||
throw new Error('Unable to connect to DB!')
|
throw new Error('Unable to connect to DB!')
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Connected to DB!')
|
process.logger.success('Connected to DB!')
|
||||||
return seedDB()
|
return seedDB()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getMacrosFolder, sasJSCoreMacros, sasJSCoreMacrosInfo } from '.'
|
|||||||
export const copySASjsCore = async () => {
|
export const copySASjsCore = async () => {
|
||||||
if (process.env.NODE_ENV === 'test') return
|
if (process.env.NODE_ENV === 'test') return
|
||||||
|
|
||||||
console.log('Copying Macros from container to drive(tmp).')
|
process.logger.log('Copying Macros from container to drive.')
|
||||||
|
|
||||||
const macrosDrivePath = getMacrosFolder()
|
const macrosDrivePath = getMacrosFolder()
|
||||||
|
|
||||||
@@ -30,5 +30,5 @@ export const copySASjsCore = async () => {
|
|||||||
await createFile(macroFileDestPath, macroContent)
|
await createFile(macroFileDestPath, macroContent)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Macros Drive Path:', macrosDrivePath)
|
process.logger.info('Macros Drive Path:', macrosDrivePath)
|
||||||
}
|
}
|
||||||
|
|||||||
18
api/src/utils/createWeboutSasFile.ts
Normal file
18
api/src/utils/createWeboutSasFile.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { createFile } from '@sasjs/utils'
|
||||||
|
import { getMacrosFolder } from './file'
|
||||||
|
|
||||||
|
const fileContent = `%macro webout(action,ds,dslabel=,fmt=,missing=NULL,showmeta=NO,maxobs=MAX);
|
||||||
|
%ms_webout(&action,ds=&ds,dslabel=&dslabel,fmt=&fmt
|
||||||
|
,missing=&missing
|
||||||
|
,showmeta=&showmeta
|
||||||
|
,maxobs=&maxobs
|
||||||
|
)
|
||||||
|
%mend;`
|
||||||
|
|
||||||
|
export const createWeboutSasFile = async () => {
|
||||||
|
const macrosDrivePath = getMacrosFolder()
|
||||||
|
process.logger.log(`Creating webout.sas at ${macrosDrivePath}`)
|
||||||
|
const filePath = path.join(macrosDrivePath, 'webout.sas')
|
||||||
|
await createFile(filePath, fileContent)
|
||||||
|
}
|
||||||
@@ -20,22 +20,24 @@ export const getSasjsHomeFolder = () => path.join(homedir(), '.sasjs-server')
|
|||||||
export const getDesktopUserAutoExecPath = () =>
|
export const getDesktopUserAutoExecPath = () =>
|
||||||
path.join(getSasjsHomeFolder(), 'user-autoexec.sas')
|
path.join(getSasjsHomeFolder(), 'user-autoexec.sas')
|
||||||
|
|
||||||
export const getSasjsRootFolder = () => process.driveLoc
|
export const getSasjsRootFolder = () => process.sasjsRoot
|
||||||
|
|
||||||
|
export const getSasjsDriveFolder = () => process.driveLoc
|
||||||
|
|
||||||
export const getLogFolder = () => process.logsLoc
|
export const getLogFolder = () => process.logsLoc
|
||||||
|
|
||||||
export const getAppStreamConfigPath = () =>
|
export const getAppStreamConfigPath = () =>
|
||||||
path.join(getSasjsRootFolder(), 'appStreamConfig.json')
|
path.join(getSasjsDriveFolder(), 'appStreamConfig.json')
|
||||||
|
|
||||||
export const getMacrosFolder = () =>
|
export const getMacrosFolder = () =>
|
||||||
path.join(getSasjsRootFolder(), 'sas', 'sasautos')
|
path.join(getSasjsDriveFolder(), 'sas', 'sasautos')
|
||||||
|
|
||||||
export const getPackagesFolder = () =>
|
export const getPackagesFolder = () =>
|
||||||
path.join(getSasjsRootFolder(), 'sas', 'sas_packages')
|
path.join(getSasjsDriveFolder(), 'sas', 'sas_packages')
|
||||||
|
|
||||||
export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
|
export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
|
||||||
|
|
||||||
export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files')
|
export const getFilesFolder = () => path.join(getSasjsDriveFolder(), 'files')
|
||||||
|
|
||||||
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')
|
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import { InfoJWT } from '../types'
|
import { InfoJWT } from '../types'
|
||||||
|
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../model/Client'
|
||||||
|
|
||||||
export const generateAccessToken = (data: InfoJWT) =>
|
export const generateAccessToken = (data: InfoJWT, expiry?: number) =>
|
||||||
jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
|
jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
|
||||||
expiresIn: '1day'
|
expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import { InfoJWT } from '../types'
|
import { InfoJWT } from '../types'
|
||||||
|
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../model/Client'
|
||||||
|
|
||||||
export const generateRefreshToken = (data: InfoJWT) =>
|
export const generateRefreshToken = (data: InfoJWT, expiry?: number) =>
|
||||||
jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
|
jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
|
||||||
expiresIn: '30 days'
|
expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Request } from 'express'
|
import { Request } from 'express'
|
||||||
|
|
||||||
|
export const TopLevelRoutes = ['/AppStream', '/SASjsApi']
|
||||||
|
|
||||||
const StaticAuthorizedRoutes = [
|
const StaticAuthorizedRoutes = [
|
||||||
'/AppStream',
|
|
||||||
'/SASjsApi/code/execute',
|
'/SASjsApi/code/execute',
|
||||||
'/SASjsApi/stp/execute',
|
'/SASjsApi/stp/execute',
|
||||||
'/SASjsApi/drive/deploy',
|
'/SASjsApi/drive/deploy',
|
||||||
@@ -15,7 +16,7 @@ const StaticAuthorizedRoutes = [
|
|||||||
export const getAuthorizedRoutes = () => {
|
export const getAuthorizedRoutes = () => {
|
||||||
const streamingApps = Object.keys(process.appStreamConfig)
|
const streamingApps = Object.keys(process.appStreamConfig)
|
||||||
const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`)
|
const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`)
|
||||||
return [...StaticAuthorizedRoutes, ...streamingAppsRoutes]
|
return [...TopLevelRoutes, ...StaticAuthorizedRoutes, ...streamingAppsRoutes]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPath = (req: Request) => {
|
export const getPath = (req: Request) => {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export const getCertificates = async () => {
|
|||||||
const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)'))
|
const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)'))
|
||||||
const caPath = CA_ROOT
|
const caPath = CA_ROOT
|
||||||
|
|
||||||
console.log('keyPath: ', keyPath)
|
process.logger.info('keyPath: ', keyPath)
|
||||||
console.log('certPath: ', certPath)
|
process.logger.info('certPath: ', certPath)
|
||||||
if (caPath) console.log('caPath: ', caPath)
|
if (caPath) process.logger.info('caPath: ', caPath)
|
||||||
|
|
||||||
const key = await readFile(keyPath)
|
const key = await readFile(keyPath)
|
||||||
const cert = await readFile(certPath)
|
const cert = await readFile(certPath)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './appStreamConfig'
|
export * from './appStreamConfig'
|
||||||
export * from './connectDB'
|
export * from './connectDB'
|
||||||
export * from './copySASjsCore'
|
export * from './copySASjsCore'
|
||||||
|
export * from './createWeboutSasFile'
|
||||||
export * from './desktopAutoExec'
|
export * from './desktopAutoExec'
|
||||||
export * from './extractHeaders'
|
export * from './extractHeaders'
|
||||||
export * from './extractName'
|
export * from './extractName'
|
||||||
@@ -19,14 +20,16 @@ export * from './instantiateLogger'
|
|||||||
export * from './isDebugOn'
|
export * from './isDebugOn'
|
||||||
export * from './isPublicRoute'
|
export * from './isPublicRoute'
|
||||||
export * from './ldapClient'
|
export * from './ldapClient'
|
||||||
export * from './zipped'
|
|
||||||
export * from './parseLogToArray'
|
export * from './parseLogToArray'
|
||||||
|
export * from './rateLimiter'
|
||||||
export * from './removeTokensInDB'
|
export * from './removeTokensInDB'
|
||||||
export * from './saveTokensInDB'
|
export * from './saveTokensInDB'
|
||||||
export * from './seedDB'
|
export * from './seedDB'
|
||||||
export * from './setProcessVariables'
|
export * from './setProcessVariables'
|
||||||
export * from './setupFolders'
|
export * from './setupFolders'
|
||||||
|
export * from './setupUserAutoExec'
|
||||||
export * from './upload'
|
export * from './upload'
|
||||||
export * from './validation'
|
export * from './validation'
|
||||||
export * from './verifyEnvVariables'
|
export * from './verifyEnvVariables'
|
||||||
export * from './verifyTokenInDB'
|
export * from './verifyTokenInDB'
|
||||||
|
export * from './zipped'
|
||||||
|
|||||||
@@ -27,5 +27,6 @@ export const publicUser: RequestUser = {
|
|||||||
username: 'publicUser',
|
username: 'publicUser',
|
||||||
displayName: 'Public User',
|
displayName: 'Public User',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isActive: true
|
isActive: true,
|
||||||
|
needsToUpdatePassword: false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ export const getEnvCSPDirectives = (
|
|||||||
try {
|
try {
|
||||||
cspConfigJson = JSON.parse(file)
|
cspConfigJson = JSON.parse(file)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
process.logger.error(
|
||||||
'Parsing Content Security Policy JSON config failed. Make sure it is valid json'
|
'Parsing Content Security Policy JSON config failed. Make sure it is valid json'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error reading HELMET CSP config file', e)
|
process.logger.error('Error reading HELMET CSP config file', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
126
api/src/utils/rateLimiter.ts
Normal file
126
api/src/utils/rateLimiter.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
import { RateLimiterMongo } from 'rate-limiter-flexible'
|
||||||
|
|
||||||
|
export class RateLimiter {
|
||||||
|
private static instance: RateLimiter
|
||||||
|
private limiterSlowBruteByIP: RateLimiterMongo
|
||||||
|
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMongo
|
||||||
|
private maxWrongAttemptsByIpPerDay: number
|
||||||
|
private maxConsecutiveFailsByUsernameAndIp: number
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
const {
|
||||||
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
|
||||||
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||||
|
} = process.env
|
||||||
|
|
||||||
|
this.maxWrongAttemptsByIpPerDay = Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY)
|
||||||
|
this.maxConsecutiveFailsByUsernameAndIp = Number(
|
||||||
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||||
|
)
|
||||||
|
|
||||||
|
this.limiterSlowBruteByIP = new RateLimiterMongo({
|
||||||
|
storeClient: mongoose.connection,
|
||||||
|
keyPrefix: 'login_fail_ip_per_day',
|
||||||
|
points: this.maxWrongAttemptsByIpPerDay,
|
||||||
|
duration: 60 * 60 * 24,
|
||||||
|
blockDuration: 60 * 60 * 24 // Block for 1 day
|
||||||
|
})
|
||||||
|
|
||||||
|
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMongo({
|
||||||
|
storeClient: mongoose.connection,
|
||||||
|
keyPrefix: 'login_fail_consecutive_username_and_ip',
|
||||||
|
points: this.maxConsecutiveFailsByUsernameAndIp,
|
||||||
|
duration: 60 * 60 * 24 * 90, // Store number for 90 days since first fail
|
||||||
|
blockDuration: 60 * 60 // Block for 1 hour
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance() {
|
||||||
|
if (!RateLimiter.instance) {
|
||||||
|
RateLimiter.instance = new RateLimiter()
|
||||||
|
}
|
||||||
|
return RateLimiter.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUsernameIPKey(ip: string, username: string) {
|
||||||
|
return `${username}_${ip}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method checks for brute force attack
|
||||||
|
* If attack is detected then returns the number of seconds after which user can make another request
|
||||||
|
* Else returns 0
|
||||||
|
*/
|
||||||
|
public async check(ip: string, username: string) {
|
||||||
|
const usernameIPkey = this.getUsernameIPKey(ip, username)
|
||||||
|
|
||||||
|
const [resSlowByIP, resUsernameAndIP] = await Promise.all([
|
||||||
|
this.limiterSlowBruteByIP.get(ip),
|
||||||
|
this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
|
||||||
|
])
|
||||||
|
|
||||||
|
// NOTE: To make use of blockDuration option from RateLimiterMongo
|
||||||
|
// comparison in both following if statements should have greater than symbol
|
||||||
|
// otherwise, blockDuration option will not work
|
||||||
|
// For more info see: https://github.com/animir/node-rate-limiter-flexible/wiki/Options#blockduration
|
||||||
|
|
||||||
|
// Check if IP or Username + IP is already blocked
|
||||||
|
if (
|
||||||
|
resSlowByIP !== null &&
|
||||||
|
resSlowByIP.consumedPoints > this.maxWrongAttemptsByIpPerDay
|
||||||
|
) {
|
||||||
|
return Math.ceil(resSlowByIP.msBeforeNext / 1000)
|
||||||
|
} else if (
|
||||||
|
resUsernameAndIP !== null &&
|
||||||
|
resUsernameAndIP.consumedPoints > this.maxConsecutiveFailsByUsernameAndIp
|
||||||
|
) {
|
||||||
|
return Math.ceil(resUsernameAndIP.msBeforeNext / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume 1 point from limiters on wrong attempt and block if limits reached
|
||||||
|
* If limit is reached, return the number of seconds after which user can make another request
|
||||||
|
* Else return 0
|
||||||
|
*/
|
||||||
|
public async consume(ip: string, username?: string) {
|
||||||
|
try {
|
||||||
|
const promises = [this.limiterSlowBruteByIP.consume(ip)]
|
||||||
|
if (username) {
|
||||||
|
const usernameIPkey = this.getUsernameIPKey(ip, username)
|
||||||
|
|
||||||
|
// Count failed attempts by Username + IP only for registered users
|
||||||
|
promises.push(
|
||||||
|
this.limiterConsecutiveFailsByUsernameAndIP.consume(usernameIPkey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
} catch (rlRejected: any) {
|
||||||
|
if (rlRejected instanceof Error) {
|
||||||
|
throw rlRejected
|
||||||
|
} else {
|
||||||
|
// based upon the implementation of consume method of RateLimiterMongo
|
||||||
|
// we are sure that rlRejected will contain msBeforeNext
|
||||||
|
// for further reference,
|
||||||
|
// see https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#login-endpoint-protection
|
||||||
|
return Math.ceil(rlRejected.msBeforeNext / 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resetOnSuccess(ip: string, username: string) {
|
||||||
|
const usernameIPkey = this.getUsernameIPKey(ip, username)
|
||||||
|
const resUsernameAndIP =
|
||||||
|
await this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
|
||||||
|
|
||||||
|
if (resUsernameAndIP !== null && resUsernameAndIP.consumedPoints > 0) {
|
||||||
|
await this.limiterConsecutiveFailsByUsernameAndIP.delete(usernameIPkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,16 +19,16 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
|||||||
const client = new Client(CLIENT)
|
const client = new Client(CLIENT)
|
||||||
await client.save()
|
await client.save()
|
||||||
|
|
||||||
console.log(`DB Seed - client created: ${CLIENT.clientId}`)
|
process.logger.success(`DB Seed - client created: ${CLIENT.clientId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checking if 'AllUsers' Group is already in the database
|
// Checking if 'AllUsers' Group is already in the database
|
||||||
let groupExist = await Group.findOne({ name: GROUP.name })
|
let groupExist = await Group.findOne({ name: ALL_USERS_GROUP.name })
|
||||||
if (!groupExist) {
|
if (!groupExist) {
|
||||||
const group = new Group(GROUP)
|
const group = new Group(ALL_USERS_GROUP)
|
||||||
groupExist = await group.save()
|
groupExist = await group.save()
|
||||||
|
|
||||||
console.log(`DB Seed - Group created: ${GROUP.name}`)
|
process.logger.success(`DB Seed - Group created: ${ALL_USERS_GROUP.name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checking if 'Public' Group is already in the database
|
// Checking if 'Public' Group is already in the database
|
||||||
@@ -37,7 +37,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
|||||||
const group = new Group(PUBLIC_GROUP)
|
const group = new Group(PUBLIC_GROUP)
|
||||||
await group.save()
|
await group.save()
|
||||||
|
|
||||||
console.log(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
|
process.logger.success(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checking if user is already in the database
|
// Checking if user is already in the database
|
||||||
@@ -46,13 +46,15 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
|||||||
const user = new User(ADMIN_USER)
|
const user = new User(ADMIN_USER)
|
||||||
usernameExist = await user.save()
|
usernameExist = await user.save()
|
||||||
|
|
||||||
console.log(`DB Seed - admin account created: ${ADMIN_USER.username}`)
|
process.logger.success(
|
||||||
|
`DB Seed - admin account created: ${ADMIN_USER.username}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!groupExist.hasUser(usernameExist)) {
|
if (!groupExist.hasUser(usernameExist)) {
|
||||||
groupExist.addUser(usernameExist)
|
groupExist.addUser(usernameExist)
|
||||||
console.log(
|
process.logger.success(
|
||||||
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${GROUP.name}'`
|
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${ALL_USERS_GROUP.name}'`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
|||||||
const configuration = new Configuration(SECRETS)
|
const configuration = new Configuration(SECRETS)
|
||||||
configExist = await configuration.save()
|
configExist = await configuration.save()
|
||||||
|
|
||||||
console.log('DB Seed - configuration added')
|
process.logger.success('DB Seed - configuration added')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -73,7 +75,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP = {
|
export const ALL_USERS_GROUP = {
|
||||||
name: 'AllUsers',
|
name: 'AllUsers',
|
||||||
description: 'Group contains all users'
|
description: 'Group contains all users'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export const setProcessVariables = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
process.driveLoc = path.join(process.cwd(), 'sasjs_root')
|
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
|
||||||
|
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,11 +42,19 @@ export const setProcessVariables = async () => {
|
|||||||
const { SASJS_ROOT } = process.env
|
const { SASJS_ROOT } = process.env
|
||||||
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
|
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
|
||||||
await createFolder(absPath)
|
await createFolder(absPath)
|
||||||
process.driveLoc = getRealPath(absPath)
|
process.sasjsRoot = getRealPath(absPath)
|
||||||
|
|
||||||
|
const { DRIVE_LOCATION } = process.env
|
||||||
|
const absDrivePath = getAbsolutePath(
|
||||||
|
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
|
||||||
|
process.cwd()
|
||||||
|
)
|
||||||
|
await createFolder(absDrivePath)
|
||||||
|
process.driveLoc = getRealPath(absDrivePath)
|
||||||
|
|
||||||
const { LOG_LOCATION } = process.env
|
const { LOG_LOCATION } = process.env
|
||||||
const absLogsPath = getAbsolutePath(
|
const absLogsPath = getAbsolutePath(
|
||||||
LOG_LOCATION ?? `sasjs_root${path.sep}logs`,
|
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
|
||||||
process.cwd()
|
process.cwd()
|
||||||
)
|
)
|
||||||
await createFolder(absLogsPath)
|
await createFolder(absLogsPath)
|
||||||
@@ -53,8 +62,8 @@ export const setProcessVariables = async () => {
|
|||||||
|
|
||||||
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||||
|
|
||||||
console.log('sasLoc: ', process.sasLoc)
|
process.logger.info('sasLoc: ', process.sasLoc)
|
||||||
console.log('sasDrive: ', process.driveLoc)
|
process.logger.info('sasDrive: ', process.driveLoc)
|
||||||
console.log('sasLogs: ', process.logsLoc)
|
process.logger.info('sasLogs: ', process.logsLoc)
|
||||||
console.log('runTimes: ', process.runTimes)
|
process.logger.info('runTimes: ', process.runTimes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import { createFile, createFolder, fileExists } from '@sasjs/utils'
|
import { createFolder } from '@sasjs/utils'
|
||||||
import {
|
import { getFilesFolder, getPackagesFolder } from './file'
|
||||||
getDesktopUserAutoExecPath,
|
|
||||||
getFilesFolder,
|
|
||||||
getPackagesFolder
|
|
||||||
} from './file'
|
|
||||||
import { ModeType } from './verifyEnvVariables'
|
|
||||||
|
|
||||||
export const setupFolders = async () => {
|
export const setupFilesFolder = async () => await createFolder(getFilesFolder())
|
||||||
const drivePath = getFilesFolder()
|
|
||||||
await createFolder(drivePath)
|
export const setupPackagesFolder = async () =>
|
||||||
await createFolder(getPackagesFolder())
|
await createFolder(getPackagesFolder())
|
||||||
|
|
||||||
if (process.env.MODE === ModeType.Desktop) {
|
|
||||||
if (!(await fileExists(getDesktopUserAutoExecPath()))) {
|
|
||||||
await createFile(getDesktopUserAutoExecPath(), '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
11
api/src/utils/setupUserAutoExec.ts
Normal file
11
api/src/utils/setupUserAutoExec.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFile, fileExists } from '@sasjs/utils'
|
||||||
|
import { getDesktopUserAutoExecPath } from './file'
|
||||||
|
import { ModeType } from './verifyEnvVariables'
|
||||||
|
|
||||||
|
export const setupUserAutoExec = async () => {
|
||||||
|
if (process.env.MODE === ModeType.Desktop) {
|
||||||
|
if (!(await fileExists(getDesktopUserAutoExecPath()))) {
|
||||||
|
await createFile(getDesktopUserAutoExecPath(), '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,10 +85,18 @@ export const updateUserValidation = (
|
|||||||
return Joi.object(validationChecks).validate(data)
|
return Joi.object(validationChecks).validate(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const updatePasswordValidation = (data: any): Joi.ValidationResult =>
|
||||||
|
Joi.object({
|
||||||
|
currentPassword: Joi.string().required(),
|
||||||
|
newPassword: passwordSchema.required()
|
||||||
|
}).validate(data)
|
||||||
|
|
||||||
export const registerClientValidation = (data: any): Joi.ValidationResult =>
|
export const registerClientValidation = (data: any): Joi.ValidationResult =>
|
||||||
Joi.object({
|
Joi.object({
|
||||||
clientId: Joi.string().required(),
|
clientId: Joi.string().required(),
|
||||||
clientSecret: Joi.string().required()
|
clientSecret: Joi.string().required(),
|
||||||
|
accessTokenExpiration: Joi.number(),
|
||||||
|
refreshTokenExpiration: Joi.number()
|
||||||
}).validate(data)
|
}).validate(data)
|
||||||
|
|
||||||
export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
|
export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export enum ReturnCode {
|
|||||||
InvalidEnv
|
InvalidEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum DatabaseType {
|
||||||
|
MONGO = 'mongodb',
|
||||||
|
COSMOS_MONGODB = 'cosmos_mongodb'
|
||||||
|
}
|
||||||
|
|
||||||
export const verifyEnvVariables = (): ReturnCode => {
|
export const verifyEnvVariables = (): ReturnCode => {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
@@ -70,6 +75,10 @@ export const verifyEnvVariables = (): ReturnCode => {
|
|||||||
|
|
||||||
errors.push(...verifyLDAPVariables())
|
errors.push(...verifyLDAPVariables())
|
||||||
|
|
||||||
|
errors.push(...verifyDbType())
|
||||||
|
|
||||||
|
errors.push(...verifyRateLimiter())
|
||||||
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
process.logger?.error(
|
process.logger?.error(
|
||||||
`Invalid environment variable(s) provided: \n${errors.join('\n')}`
|
`Invalid environment variable(s) provided: \n${errors.join('\n')}`
|
||||||
@@ -342,11 +351,76 @@ const verifyLDAPVariables = () => {
|
|||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verifyDbType = () => {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
const { MODE, DB_TYPE } = process.env
|
||||||
|
|
||||||
|
if (MODE === ModeType.Server) {
|
||||||
|
if (DB_TYPE) {
|
||||||
|
const dbTypes = Object.values(DatabaseType)
|
||||||
|
if (!dbTypes.includes(DB_TYPE as DatabaseType))
|
||||||
|
errors.push(`- DB_TYPE '${DB_TYPE}'\n - valid options ${dbTypes}`)
|
||||||
|
} else {
|
||||||
|
process.env.DB_TYPE = DEFAULTS.DB_TYPE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyRateLimiter = () => {
|
||||||
|
const errors: string[] = []
|
||||||
|
const {
|
||||||
|
MODE,
|
||||||
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
|
||||||
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||||
|
} = process.env
|
||||||
|
if (MODE === ModeType.Server) {
|
||||||
|
if (MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) {
|
||||||
|
if (
|
||||||
|
!isNumeric(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) ||
|
||||||
|
Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) < 1
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`- Invalid value for 'MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY' - Only positive number is acceptable`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
process.env.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY =
|
||||||
|
DEFAULTS.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) {
|
||||||
|
if (
|
||||||
|
!isNumeric(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) ||
|
||||||
|
Number(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) < 1
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`- Invalid value for 'MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP' - Only positive number is acceptable`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
process.env.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP =
|
||||||
|
DEFAULTS.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNumeric = (val: string): boolean => {
|
||||||
|
return !isNaN(Number(val))
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
MODE: ModeType.Desktop,
|
MODE: ModeType.Desktop,
|
||||||
PROTOCOL: ProtocolType.HTTP,
|
PROTOCOL: ProtocolType.HTTP,
|
||||||
PORT: '5000',
|
PORT: '5000',
|
||||||
HELMET_COEP: HelmetCoepType.TRUE,
|
HELMET_COEP: HelmetCoepType.TRUE,
|
||||||
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common,
|
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common,
|
||||||
RUN_TIMES: RunTimeType.SAS
|
RUN_TIMES: RunTimeType.SAS,
|
||||||
|
DB_TYPE: DatabaseType.MONGO,
|
||||||
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY: '100',
|
||||||
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP: '10'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const fetchLatestAutoExec = async (
|
|||||||
displayName: dbUser.displayName,
|
displayName: dbUser.displayName,
|
||||||
isAdmin: dbUser.isAdmin,
|
isAdmin: dbUser.isAdmin,
|
||||||
isActive: dbUser.isActive,
|
isActive: dbUser.isActive,
|
||||||
|
needsToUpdatePassword: dbUser.needsToUpdatePassword,
|
||||||
autoExec: dbUser.autoExec
|
autoExec: dbUser.autoExec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +42,7 @@ export const verifyTokenInDB = async (
|
|||||||
displayName: dbUser.displayName,
|
displayName: dbUser.displayName,
|
||||||
isAdmin: dbUser.isAdmin,
|
isAdmin: dbUser.isAdmin,
|
||||||
isActive: dbUser.isActive,
|
isActive: dbUser.isActive,
|
||||||
|
needsToUpdatePassword: dbUser.needsToUpdatePassword,
|
||||||
autoExec: dbUser.autoExec
|
autoExec: dbUser.autoExec
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
680
web/package-lock.json
generated
680
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import Header from './components/header'
|
|||||||
import Home from './components/home'
|
import Home from './components/home'
|
||||||
import Studio from './containers/Studio'
|
import Studio from './containers/Studio'
|
||||||
import Settings from './containers/Settings'
|
import Settings from './containers/Settings'
|
||||||
|
import UpdatePassword from './components/updatePassword'
|
||||||
|
|
||||||
import { AppContext } from './context/appContext'
|
import { AppContext } from './context/appContext'
|
||||||
import AuthCode from './containers/AuthCode'
|
import AuthCode from './containers/AuthCode'
|
||||||
@@ -29,6 +30,20 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appContext.needsToUpdatePassword) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<HashRouter>
|
||||||
|
<Header />
|
||||||
|
<Routes>
|
||||||
|
<Route path="*" element={<UpdatePassword />} />
|
||||||
|
</Routes>
|
||||||
|
<ToastContainer />
|
||||||
|
</HashRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
|
|||||||
@@ -31,14 +31,24 @@ const DeleteConfirmationModal = ({
|
|||||||
message,
|
message,
|
||||||
_delete
|
_delete
|
||||||
}: DeleteConfirmationModalProps) => {
|
}: DeleteConfirmationModalProps) => {
|
||||||
|
const handleDeleteClick = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
_delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = (event: any) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
|
<BootstrapDialog onClose={handleClose} open={open}>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Typography gutterBottom>{message}</Typography>
|
<Typography gutterBottom>{message}</Typography>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
<Button onClick={handleClose}>Cancel</Button>
|
||||||
<Button color="error" onClick={() => _delete()}>
|
<Button color="error" onClick={handleDeleteClick}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Box from '@mui/material/Box'
|
|||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
return (
|
return (
|
||||||
<Box className="main">
|
<Box className="container">
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<h2>Welcome to SASjs Server!</h2>
|
<h2>Welcome to SASjs Server!</h2>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import axios from 'axios'
|
|||||||
import React, { useState, useContext } from 'react'
|
import React, { useState, useContext } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
|
|
||||||
import { CssBaseline, Box, TextField, Button } from '@mui/material'
|
import {
|
||||||
|
Backdrop,
|
||||||
|
CircularProgress,
|
||||||
|
CssBaseline,
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Button
|
||||||
|
} from '@mui/material'
|
||||||
import { AppContext } from '../context/appContext'
|
import { AppContext } from '../context/appContext'
|
||||||
|
|
||||||
const login = async (payload: { username: string; password: string }) =>
|
const login = async (payload: { username: string; password: string }) =>
|
||||||
@@ -10,21 +17,27 @@ const login = async (payload: { username: string; password: string }) =>
|
|||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const appContext = useContext(AppContext)
|
const appContext = useContext(AppContext)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
const handleSubmit = async (e: any) => {
|
const handleSubmit = async (e: any) => {
|
||||||
|
setIsLoading(true)
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const { loggedIn, user } = await login({
|
const { loggedIn, user } = await login({
|
||||||
username,
|
username,
|
||||||
password
|
password
|
||||||
}).catch((err: any) => {
|
|
||||||
setErrorMessage(err.response?.data || err.toString())
|
|
||||||
return {}
|
|
||||||
})
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
setErrorMessage(err.response?.data || err.toString())
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
appContext.setUserId?.(user.id)
|
appContext.setUserId?.(user.id)
|
||||||
@@ -32,46 +45,56 @@ const Login = () => {
|
|||||||
appContext.setDisplayName?.(user.displayName)
|
appContext.setDisplayName?.(user.displayName)
|
||||||
appContext.setIsAdmin?.(user.isAdmin)
|
appContext.setIsAdmin?.(user.isAdmin)
|
||||||
appContext.setLoggedIn?.(loggedIn)
|
appContext.setLoggedIn?.(loggedIn)
|
||||||
|
appContext.setNeedsToUpdatePassword?.(user.needsToUpdatePassword)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<>
|
||||||
className="main"
|
<Backdrop
|
||||||
component="form"
|
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||||
onSubmit={handleSubmit}
|
open={isLoading}
|
||||||
sx={{
|
|
||||||
'& > :not(style)': { m: 1, width: '25ch' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CssBaseline />
|
|
||||||
<br />
|
|
||||||
<h2 style={{ width: 'auto' }}>Welcome to SASjs Server!</h2>
|
|
||||||
<TextField
|
|
||||||
id="username"
|
|
||||||
label="Username"
|
|
||||||
type="text"
|
|
||||||
variant="outlined"
|
|
||||||
onChange={(e: any) => setUsername(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
id="password"
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
variant="outlined"
|
|
||||||
onChange={(e: any) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{errorMessage && <span>{errorMessage}</span>}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outlined"
|
|
||||||
disabled={!appContext.setLoggedIn}
|
|
||||||
>
|
>
|
||||||
Submit
|
<CircularProgress color="inherit" />
|
||||||
</Button>
|
</Backdrop>
|
||||||
</Box>
|
|
||||||
|
<Box
|
||||||
|
className="container"
|
||||||
|
component="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
sx={{
|
||||||
|
'& > :not(style)': { m: 1, width: '25ch' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CssBaseline />
|
||||||
|
<br />
|
||||||
|
<h2 style={{ width: 'auto' }}>Welcome to SASjs Server!</h2>
|
||||||
|
<TextField
|
||||||
|
id="username"
|
||||||
|
label="Username"
|
||||||
|
type="text"
|
||||||
|
variant="outlined"
|
||||||
|
onChange={(e: any) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="password"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
onChange={(e: any) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errorMessage && <span>{errorMessage}</span>}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={!appContext.setLoggedIn}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,18 @@ const NameInputModal = ({
|
|||||||
action(name)
|
action(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleActionClick = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
action(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = (event: any) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BootstrapDialog fullWidth onClose={() => setOpen(false)} open={open}>
|
<BootstrapDialog fullWidth onClose={handleClose} open={open}>
|
||||||
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
|
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
|
||||||
{title}
|
{title}
|
||||||
</BootstrapDialogTitle>
|
</BootstrapDialogTitle>
|
||||||
@@ -91,12 +101,12 @@ const NameInputModal = ({
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button variant="contained" onClick={() => setOpen(false)}>
|
<Button variant="contained" onClick={handleClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => action(name)}
|
onClick={handleActionClick}
|
||||||
disabled={hasError || !name}
|
disabled={hasError || !name}
|
||||||
>
|
>
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
|
|||||||
145
web/src/components/passwordModal.tsx
Normal file
145
web/src/components/passwordModal.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Grid,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
OutlinedInput,
|
||||||
|
InputAdornment,
|
||||||
|
IconButton,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
FormHelperText
|
||||||
|
} from '@mui/material'
|
||||||
|
import Visibility from '@mui/icons-material/Visibility'
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff'
|
||||||
|
|
||||||
|
import { BootstrapDialogTitle } from './dialogTitle'
|
||||||
|
import { BootstrapDialog } from './modal'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
title: string
|
||||||
|
updatePassword: (currentPassword: string, newPassword: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdatePasswordModal = (props: Props) => {
|
||||||
|
const { open, setOpen, title, updatePassword } = props
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [hasError, setHasError] = useState(false)
|
||||||
|
const [errorText, setErrorText] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
currentPassword.length > 0 &&
|
||||||
|
newPassword.length > 0 &&
|
||||||
|
newPassword === currentPassword
|
||||||
|
) {
|
||||||
|
setErrorText('New password should be different to current password.')
|
||||||
|
setHasError(true)
|
||||||
|
} else if (newPassword.length >= 6) {
|
||||||
|
setErrorText('')
|
||||||
|
setHasError(false)
|
||||||
|
}
|
||||||
|
}, [currentPassword, newPassword])
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setErrorText('Password length should be at least 6 characters.')
|
||||||
|
setHasError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
|
||||||
|
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
|
||||||
|
{title}
|
||||||
|
</BootstrapDialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<PasswordInput
|
||||||
|
label="Current Password"
|
||||||
|
password={currentPassword}
|
||||||
|
setPassword={setCurrentPassword}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<PasswordInput
|
||||||
|
label="New Password"
|
||||||
|
password={newPassword}
|
||||||
|
setPassword={setNewPassword}
|
||||||
|
hasError={hasError}
|
||||||
|
errorText={errorText}
|
||||||
|
handleBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="contained" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => updatePassword(currentPassword, newPassword)}
|
||||||
|
disabled={hasError || !currentPassword || !newPassword}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</BootstrapDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdatePasswordModal
|
||||||
|
|
||||||
|
type PasswordInputProps = {
|
||||||
|
label: string
|
||||||
|
password: string
|
||||||
|
setPassword: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
hasError?: boolean
|
||||||
|
errorText?: string
|
||||||
|
handleBlur?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PasswordInput = ({
|
||||||
|
label,
|
||||||
|
password,
|
||||||
|
setPassword,
|
||||||
|
hasError,
|
||||||
|
errorText,
|
||||||
|
handleBlur
|
||||||
|
}: PasswordInputProps) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl sx={{ width: '100%' }} variant="outlined" error={hasError}>
|
||||||
|
<InputLabel htmlFor="outlined-adornment-password">{label}</InputLabel>
|
||||||
|
<OutlinedInput
|
||||||
|
id="outlined-adornment-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
label={label}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
endAdornment={
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowPassword((val) => !val)}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errorText && <FormHelperText>{errorText}</FormHelperText>}
|
||||||
|
</FormControl>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,67 +1,79 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Menu, MenuItem } from '@mui/material'
|
import { Menu, MenuItem, Typography } from '@mui/material'
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
||||||
|
import MuiTreeView from '@mui/lab/TreeView'
|
||||||
|
import MuiTreeItem from '@mui/lab/TreeItem'
|
||||||
|
|
||||||
import DeleteConfirmationModal from './deleteConfirmationModal'
|
import DeleteConfirmationModal from './deleteConfirmationModal'
|
||||||
import NameInputModal from './nameInputModal'
|
import NameInputModal from './nameInputModal'
|
||||||
|
|
||||||
import { TreeNode } from '../utils/types'
|
import { TreeNode } from '../utils/types'
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
node: TreeNode
|
node: TreeNode
|
||||||
selectedFilePath: string
|
|
||||||
handleSelect: (filePath: string) => void
|
handleSelect: (filePath: string) => void
|
||||||
deleteNode: (path: string, isFolder: boolean) => void
|
deleteNode: (path: string, isFolder: boolean) => void
|
||||||
addFile: (path: string) => void
|
addFile: (path: string) => void
|
||||||
addFolder: (path: string) => void
|
addFolder: (path: string) => void
|
||||||
rename: (oldPath: string, newPath: string) => void
|
rename: (oldPath: string, newPath: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeViewProps extends Props {
|
||||||
defaultExpanded?: string[]
|
defaultExpanded?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const TreeView = ({
|
const TreeView = ({
|
||||||
node,
|
node,
|
||||||
selectedFilePath,
|
|
||||||
handleSelect,
|
handleSelect,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
addFile,
|
addFile,
|
||||||
addFolder,
|
addFolder,
|
||||||
rename,
|
rename,
|
||||||
defaultExpanded
|
defaultExpanded
|
||||||
}: Props) => {
|
}: TreeViewProps) => {
|
||||||
return (
|
const renderTree = (nodes: TreeNode) => (
|
||||||
<ul
|
<MuiTreeItem
|
||||||
style={{
|
key={nodes.relativePath}
|
||||||
listStyle: 'none',
|
nodeId={nodes.relativePath}
|
||||||
padding: '0.25rem 0.85rem',
|
label={
|
||||||
width: 'max-content'
|
<TreeItemWithContextMenu
|
||||||
}}
|
node={nodes}
|
||||||
|
handleSelect={handleSelect}
|
||||||
|
deleteNode={deleteNode}
|
||||||
|
addFile={addFile}
|
||||||
|
addFolder={addFolder}
|
||||||
|
rename={rename}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<TreeViewNode
|
{Array.isArray(nodes.children)
|
||||||
node={node}
|
? nodes.children.map((node) => renderTree(node))
|
||||||
selectedFilePath={selectedFilePath}
|
: null}
|
||||||
handleSelect={handleSelect}
|
</MuiTreeItem>
|
||||||
deleteNode={deleteNode}
|
)
|
||||||
addFile={addFile}
|
|
||||||
addFolder={addFolder}
|
return (
|
||||||
rename={rename}
|
<MuiTreeView
|
||||||
defaultExpanded={defaultExpanded}
|
defaultCollapseIcon={<ExpandMoreIcon />}
|
||||||
/>
|
defaultExpandIcon={<ChevronRightIcon />}
|
||||||
</ul>
|
defaultExpanded={defaultExpanded}
|
||||||
|
sx={{ flexGrow: 1, maxWidth: 400, overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
{renderTree(node)}
|
||||||
|
</MuiTreeView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TreeView
|
export default TreeView
|
||||||
|
|
||||||
const TreeViewNode = ({
|
const TreeItemWithContextMenu = ({
|
||||||
node,
|
node,
|
||||||
selectedFilePath,
|
|
||||||
handleSelect,
|
handleSelect,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
addFile,
|
addFile,
|
||||||
addFolder,
|
addFolder,
|
||||||
rename,
|
rename
|
||||||
defaultExpanded
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] =
|
const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] =
|
||||||
useState(false)
|
useState(false)
|
||||||
@@ -72,18 +84,19 @@ const TreeViewNode = ({
|
|||||||
const [nameInputModalTitle, setNameInputModalTitle] = useState('')
|
const [nameInputModalTitle, setNameInputModalTitle] = useState('')
|
||||||
const [nameInputModalActionLabel, setNameInputModalActionLabel] = useState('')
|
const [nameInputModalActionLabel, setNameInputModalActionLabel] = useState('')
|
||||||
const [nameInputModalForFolder, setNameInputModalForFolder] = useState(false)
|
const [nameInputModalForFolder, setNameInputModalForFolder] = useState(false)
|
||||||
const [childVisible, setChildVisibility] = useState(false)
|
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
mouseX: number
|
mouseX: number
|
||||||
mouseY: number
|
mouseY: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const launchProgram = () => {
|
const launchProgram = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
const baseUrl = window.location.origin
|
const baseUrl = window.location.origin
|
||||||
window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${node.relativePath}`)
|
window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${node.relativePath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const launchProgramWithDebug = () => {
|
const launchProgramWithDebug = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
const baseUrl = window.location.origin
|
const baseUrl = window.location.origin
|
||||||
window.open(
|
window.open(
|
||||||
`${baseUrl}/SASjsApi/stp/execute?_program=${node.relativePath}&_debug=131`
|
`${baseUrl}/SASjsApi/stp/execute?_program=${node.relativePath}&_debug=131`
|
||||||
@@ -103,25 +116,18 @@ const TreeViewNode = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasChild = node.children.length ? true : false
|
const handleClose = (event: any) => {
|
||||||
|
event.stopPropagation()
|
||||||
const handleItemClick = () => {
|
setContextMenu(null)
|
||||||
if (node.children.length) {
|
}
|
||||||
setChildVisibility((v) => !v)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const handleItemClick = (event: React.MouseEvent) => {
|
||||||
|
if (node.children.length) return
|
||||||
handleSelect(node.relativePath)
|
handleSelect(node.relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const handleDeleteItemClick = (event: React.MouseEvent) => {
|
||||||
if (defaultExpanded && defaultExpanded[0] === node.relativePath) {
|
event.stopPropagation()
|
||||||
setChildVisibility(true)
|
|
||||||
defaultExpanded.shift()
|
|
||||||
}
|
|
||||||
}, [defaultExpanded, node.relativePath])
|
|
||||||
|
|
||||||
const handleDeleteItemClick = () => {
|
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
setDeleteConfirmationModalOpen(true)
|
setDeleteConfirmationModalOpen(true)
|
||||||
setDeleteConfirmationModalMessage(
|
setDeleteConfirmationModalMessage(
|
||||||
@@ -136,7 +142,8 @@ const TreeViewNode = ({
|
|||||||
deleteNode(node.relativePath, node.isFolder)
|
deleteNode(node.relativePath, node.isFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNewFolderItemClick = () => {
|
const handleNewFolderItemClick = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
setNameInputModalOpen(true)
|
setNameInputModalOpen(true)
|
||||||
setNameInputModalTitle('Add Folder')
|
setNameInputModalTitle('Add Folder')
|
||||||
@@ -145,7 +152,8 @@ const TreeViewNode = ({
|
|||||||
setDefaultInputModalName('')
|
setDefaultInputModalName('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNewFileItemClick = () => {
|
const handleNewFileItemClick = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
setNameInputModalOpen(true)
|
setNameInputModalOpen(true)
|
||||||
setNameInputModalTitle('Add File')
|
setNameInputModalTitle('Add File')
|
||||||
@@ -161,7 +169,8 @@ const TreeViewNode = ({
|
|||||||
else addFile(path)
|
else addFile(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRenameItemClick = () => {
|
const handleRenameItemClick = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
setNameInputModalOpen(true)
|
setNameInputModalOpen(true)
|
||||||
setNameInputModalTitle('Rename')
|
setNameInputModalTitle('Rename')
|
||||||
@@ -181,34 +190,7 @@ const TreeViewNode = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div onContextMenu={handleContextMenu} style={{ cursor: 'context-menu' }}>
|
<div onContextMenu={handleContextMenu} style={{ cursor: 'context-menu' }}>
|
||||||
<li style={{ display: 'list-item' }}>
|
<Typography onClick={handleItemClick}>{node.name}</Typography>
|
||||||
<div
|
|
||||||
className={`tree-item-label ${
|
|
||||||
selectedFilePath === node.relativePath ? 'selected' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => handleItemClick()}
|
|
||||||
>
|
|
||||||
{hasChild &&
|
|
||||||
(childVisible ? <ExpandMoreIcon /> : <ChevronRightIcon />)}
|
|
||||||
<div>{node.name}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasChild &&
|
|
||||||
childVisible &&
|
|
||||||
node.children.map((child, index) => (
|
|
||||||
<TreeView
|
|
||||||
key={node.relativePath + '-' + index}
|
|
||||||
node={child}
|
|
||||||
selectedFilePath={selectedFilePath}
|
|
||||||
handleSelect={handleSelect}
|
|
||||||
deleteNode={deleteNode}
|
|
||||||
addFile={addFile}
|
|
||||||
addFolder={addFolder}
|
|
||||||
rename={rename}
|
|
||||||
defaultExpanded={defaultExpanded}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</li>
|
|
||||||
<DeleteConfirmationModal
|
<DeleteConfirmationModal
|
||||||
open={deleteConfirmationModalOpen}
|
open={deleteConfirmationModalOpen}
|
||||||
setOpen={setDeleteConfirmationModalOpen}
|
setOpen={setDeleteConfirmationModalOpen}
|
||||||
@@ -228,7 +210,7 @@ const TreeViewNode = ({
|
|||||||
/>
|
/>
|
||||||
<Menu
|
<Menu
|
||||||
open={contextMenu !== null}
|
open={contextMenu !== null}
|
||||||
onClose={() => setContextMenu(null)}
|
onClose={handleClose}
|
||||||
anchorReference="anchorPosition"
|
anchorReference="anchorPosition"
|
||||||
anchorPosition={
|
anchorPosition={
|
||||||
contextMenu !== null
|
contextMenu !== null
|
||||||
|
|||||||
109
web/src/components/updatePassword.tsx
Normal file
109
web/src/components/updatePassword.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { useState, useEffect, useContext } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { Box, CssBaseline, Button, CircularProgress } from '@mui/material'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { PasswordInput } from './passwordModal'
|
||||||
|
|
||||||
|
import { AppContext } from '../context/appContext'
|
||||||
|
|
||||||
|
const UpdatePassword = () => {
|
||||||
|
const appContext = useContext(AppContext)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [hasError, setHasError] = useState(false)
|
||||||
|
const [errorText, setErrorText] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
currentPassword.length > 0 &&
|
||||||
|
newPassword.length > 0 &&
|
||||||
|
newPassword === currentPassword
|
||||||
|
) {
|
||||||
|
setErrorText('New password should be different to current password.')
|
||||||
|
setHasError(true)
|
||||||
|
} else if (newPassword.length >= 6) {
|
||||||
|
setErrorText('')
|
||||||
|
setHasError(false)
|
||||||
|
}
|
||||||
|
}, [currentPassword, newPassword])
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setErrorText('Password length should be at least 6 characters.')
|
||||||
|
setHasError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: any) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (hasError || !currentPassword || !newPassword) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
axios
|
||||||
|
.patch(`/SASjsApi/auth/updatePassword`, {
|
||||||
|
currentPassword,
|
||||||
|
newPassword
|
||||||
|
})
|
||||||
|
.then((res: any) => {
|
||||||
|
appContext.setNeedsToUpdatePassword?.(false)
|
||||||
|
toast.success('Password updated', {
|
||||||
|
theme: 'dark',
|
||||||
|
position: toast.POSITION.BOTTOM_RIGHT
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error('Failed: ' + err.response?.data || err.text, {
|
||||||
|
theme: 'dark',
|
||||||
|
position: toast.POSITION.BOTTOM_RIGHT
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
<CircularProgress
|
||||||
|
style={{ position: 'absolute', left: '50%', top: '50%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
className="container"
|
||||||
|
component="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
sx={{
|
||||||
|
'& > :not(style)': { m: 1, width: '25ch' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CssBaseline />
|
||||||
|
<h2>Welcome to SASjs Server!</h2>
|
||||||
|
<p style={{ width: 'auto' }}>
|
||||||
|
This is your first time login to SASjs server. Therefore, you need to
|
||||||
|
update your password.
|
||||||
|
</p>
|
||||||
|
<PasswordInput
|
||||||
|
label="Current Password"
|
||||||
|
password={currentPassword}
|
||||||
|
setPassword={setCurrentPassword}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="New Password"
|
||||||
|
password={newPassword}
|
||||||
|
setPassword={setNewPassword}
|
||||||
|
hasError={hasError}
|
||||||
|
errorText={errorText}
|
||||||
|
handleBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={hasError || !currentPassword || !newPassword}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdatePassword
|
||||||
@@ -47,7 +47,7 @@ const AuthCode = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="main">
|
<Box className="container">
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<br />
|
<br />
|
||||||
<h2>Authorization Code</h2>
|
<h2>Authorization Code</h2>
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import {
|
|||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
|
|
||||||
import { AppContext, ModeType } from '../../context/appContext'
|
import { AppContext, ModeType } from '../../context/appContext'
|
||||||
|
import UpdatePasswordModal from '../../components/passwordModal'
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const appContext = useContext(AppContext)
|
const appContext = useContext(AppContext)
|
||||||
const [user, setUser] = useState({} as any)
|
const [user, setUser] = useState({} as any)
|
||||||
|
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -36,7 +38,7 @@ const Profile = () => {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [appContext.userId])
|
||||||
|
|
||||||
const handleChange = (event: any) => {
|
const handleChange = (event: any) => {
|
||||||
const { name, value } = event.target
|
const { name, value } = event.target
|
||||||
@@ -68,82 +70,124 @@ const Profile = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatePassword = (currentPassword: string, newPassword: string) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setIsPasswordModalOpen(false)
|
||||||
|
axios
|
||||||
|
.patch(`/SASjsApi/auth/updatePassword`, {
|
||||||
|
currentPassword,
|
||||||
|
newPassword
|
||||||
|
})
|
||||||
|
.then((res: any) => {
|
||||||
|
toast.success('Password updated', {
|
||||||
|
theme: 'dark',
|
||||||
|
position: toast.POSITION.BOTTOM_RIGHT
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error('Failed: ' + err.response?.data || err.text, {
|
||||||
|
theme: 'dark',
|
||||||
|
position: toast.POSITION.BOTTOM_RIGHT
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return isLoading ? (
|
return isLoading ? (
|
||||||
<CircularProgress
|
<CircularProgress
|
||||||
style={{ position: 'absolute', left: '50%', top: '50%' }}
|
style={{ position: 'absolute', left: '50%', top: '50%' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<>
|
||||||
<CardHeader title="Profile Information" />
|
<Card>
|
||||||
<Divider />
|
<CardHeader title="Profile Information" />
|
||||||
<CardContent>
|
<Divider />
|
||||||
<Grid container spacing={4}>
|
<CardContent>
|
||||||
<Grid item md={6} xs={12}>
|
<Grid container spacing={4}>
|
||||||
<TextField
|
<Grid item md={6} xs={12}>
|
||||||
fullWidth
|
<TextField
|
||||||
error={user.displayName?.length === 0}
|
fullWidth
|
||||||
helperText="Please specify display name"
|
error={user.displayName?.length === 0}
|
||||||
label="Display Name"
|
helperText="Please specify display name"
|
||||||
name="displayName"
|
label="Display Name"
|
||||||
onChange={handleChange}
|
name="displayName"
|
||||||
required
|
onChange={handleChange}
|
||||||
value={user.displayName}
|
required
|
||||||
variant="outlined"
|
value={user.displayName}
|
||||||
disabled={appContext.mode === ModeType.Desktop}
|
variant="outlined"
|
||||||
/>
|
disabled={appContext.mode === ModeType.Desktop}
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item md={6} xs={12}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
error={user.username?.length === 0}
|
|
||||||
helperText="Please specify username"
|
|
||||||
label="Username"
|
|
||||||
name="username"
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
value={user.username}
|
|
||||||
variant="outlined"
|
|
||||||
disabled={appContext.mode === ModeType.Desktop}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item lg={6} md={8} sm={12} xs={12}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="autoExec"
|
|
||||||
name="autoExec"
|
|
||||||
onChange={handleChange}
|
|
||||||
multiline
|
|
||||||
rows="10"
|
|
||||||
value={user.autoExec}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={6}>
|
|
||||||
<FormGroup row>
|
|
||||||
<FormControlLabel
|
|
||||||
disabled
|
|
||||||
control={<Checkbox checked={user.isActive} />}
|
|
||||||
label="isActive"
|
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
</Grid>
|
||||||
disabled
|
|
||||||
control={<Checkbox checked={user.isAdmin} />}
|
<Grid item md={6} xs={12}>
|
||||||
label="isAdmin"
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
error={user.username?.length === 0}
|
||||||
|
helperText="Please specify username"
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
value={user.username}
|
||||||
|
variant="outlined"
|
||||||
|
disabled={appContext.mode === ModeType.Desktop}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item lg={6} md={8} sm={12} xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="autoExec"
|
||||||
|
name="autoExec"
|
||||||
|
onChange={handleChange}
|
||||||
|
multiline
|
||||||
|
rows="10"
|
||||||
|
value={user.autoExec}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<FormGroup row>
|
||||||
|
<FormControlLabel
|
||||||
|
disabled
|
||||||
|
control={<Checkbox checked={user.isActive} />}
|
||||||
|
label="isActive"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
disabled
|
||||||
|
control={<Checkbox checked={user.isAdmin} />}
|
||||||
|
label="isAdmin"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setIsPasswordModalOpen(true)}
|
||||||
|
>
|
||||||
|
Update Password
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</CardContent>
|
||||||
</CardContent>
|
<Divider />
|
||||||
<Divider />
|
<CardActions>
|
||||||
<CardActions>
|
<Button type="submit" variant="contained" onClick={handleSubmit}>
|
||||||
<Button type="submit" variant="contained" onClick={handleSubmit}>
|
Save Changes
|
||||||
Save Changes
|
</Button>
|
||||||
</Button>
|
</CardActions>
|
||||||
</CardActions>
|
</Card>
|
||||||
</Card>
|
<UpdatePasswordModal
|
||||||
|
open={isPasswordModalOpen}
|
||||||
|
setOpen={setIsPasswordModalOpen}
|
||||||
|
title="Update Password"
|
||||||
|
updatePassword={updatePassword}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,10 @@ const SASjsEditor = ({
|
|||||||
<StyledTabPanel value="log">
|
<StyledTabPanel value="log">
|
||||||
<div>
|
<div>
|
||||||
<h2>Log</h2>
|
<h2>Log</h2>
|
||||||
<pre id="log" style={{ overflow: 'auto', height: '75vh' }}>
|
<pre
|
||||||
|
id="log"
|
||||||
|
style={{ overflow: 'auto', height: 'calc(100vh - 220px)' }}
|
||||||
|
>
|
||||||
{log}
|
{log}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ const RunMenu = ({
|
|||||||
handleRunBtnClick
|
handleRunBtnClick
|
||||||
}: RunMenuProps) => {
|
}: RunMenuProps) => {
|
||||||
const launchProgram = () => {
|
const launchProgram = () => {
|
||||||
const baseUrl = window.location.origin
|
const pathName =
|
||||||
|
window.location.pathname === '/' ? '' : window.location.pathname
|
||||||
|
const baseUrl = window.location.origin + pathName
|
||||||
|
|
||||||
window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${selectedFilePath}`)
|
window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${selectedFilePath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const useEditor = ({
|
|||||||
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
|
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
|
||||||
const [showDiff, setShowDiff] = useState(false)
|
const [showDiff, setShowDiff] = useState(false)
|
||||||
|
|
||||||
const editorRef = useRef(null as any)
|
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null)
|
||||||
|
|
||||||
const handleEditorDidMount: EditorDidMount = (editor) => {
|
const handleEditorDidMount: EditorDidMount = (editor) => {
|
||||||
editorRef.current = editor
|
editorRef.current = editor
|
||||||
@@ -199,7 +199,7 @@ const useEditor = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
editorRef.current.addAction({
|
const saveFileAction = editorRef.current?.addAction({
|
||||||
// An unique identifier of the contributed action.
|
// An unique identifier of the contributed action.
|
||||||
id: 'save-file',
|
id: 'save-file',
|
||||||
|
|
||||||
@@ -209,6 +209,8 @@ const useEditor = ({
|
|||||||
// An optional array of keybindings for the action.
|
// An optional array of keybindings for the action.
|
||||||
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
|
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
|
||||||
|
|
||||||
|
contextMenuGroupId: '9_cutcopypaste',
|
||||||
|
|
||||||
// Method that will be executed when the action is triggered.
|
// Method that will be executed when the action is triggered.
|
||||||
// @param editor The editor instance is passed in as a convenience
|
// @param editor The editor instance is passed in as a convenience
|
||||||
run: () => {
|
run: () => {
|
||||||
@@ -217,7 +219,7 @@ const useEditor = ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
editorRef.current.addAction({
|
const runCodeAction = editorRef.current?.addAction({
|
||||||
// An unique identifier of the contributed action.
|
// An unique identifier of the contributed action.
|
||||||
id: 'run-code',
|
id: 'run-code',
|
||||||
|
|
||||||
@@ -229,14 +231,17 @@ const useEditor = ({
|
|||||||
|
|
||||||
contextMenuGroupId: 'navigation',
|
contextMenuGroupId: 'navigation',
|
||||||
|
|
||||||
contextMenuOrder: 1,
|
|
||||||
|
|
||||||
// Method that will be executed when the action is triggered.
|
// Method that will be executed when the action is triggered.
|
||||||
// @param editor The editor instance is passed in as a convenience
|
// @param editor The editor instance is passed in as a convenience
|
||||||
run: function () {
|
run: function () {
|
||||||
runCode(getSelection(editorRef.current as any) || fileContent)
|
runCode(getSelection(editorRef.current as any) || fileContent)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
saveFileAction?.dispose()
|
||||||
|
runCodeAction?.dispose()
|
||||||
|
}
|
||||||
}, [fileContent, prevFileContent, selectedFilePath, saveFile, runCode])
|
}, [fileContent, prevFileContent, selectedFilePath, saveFile, runCode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -256,8 +261,10 @@ const useEditor = ({
|
|||||||
axios
|
axios
|
||||||
.get(`/SASjsApi/drive/file?_filePath=${selectedFilePath}`)
|
.get(`/SASjsApi/drive/file?_filePath=${selectedFilePath}`)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
setPrevFileContent(res.data)
|
const content =
|
||||||
setFileContent(res.data)
|
typeof res.data === 'object' ? JSON.stringify(res.data) : res.data
|
||||||
|
setPrevFileContent(content)
|
||||||
|
setFileContent(content)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setModalTitle('Abort')
|
setModalTitle('Abort')
|
||||||
|
|||||||
@@ -180,7 +180,6 @@ const SideBar = ({
|
|||||||
{directoryData && (
|
{directoryData && (
|
||||||
<TreeView
|
<TreeView
|
||||||
node={directoryData}
|
node={directoryData}
|
||||||
selectedFilePath={selectedFilePath}
|
|
||||||
handleSelect={handleFileSelect}
|
handleSelect={handleFileSelect}
|
||||||
deleteNode={deleteNode}
|
deleteNode={deleteNode}
|
||||||
addFile={addFile}
|
addFile={addFile}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface AppContextProps {
|
|||||||
checkingSession: boolean
|
checkingSession: boolean
|
||||||
loggedIn: boolean
|
loggedIn: boolean
|
||||||
setLoggedIn: Dispatch<SetStateAction<boolean>> | null
|
setLoggedIn: Dispatch<SetStateAction<boolean>> | null
|
||||||
|
needsToUpdatePassword: boolean
|
||||||
|
setNeedsToUpdatePassword: Dispatch<SetStateAction<boolean>> | null
|
||||||
userId: number
|
userId: number
|
||||||
setUserId: Dispatch<SetStateAction<number>> | null
|
setUserId: Dispatch<SetStateAction<number>> | null
|
||||||
username: string
|
username: string
|
||||||
@@ -42,6 +44,8 @@ export const AppContext = createContext<AppContextProps>({
|
|||||||
checkingSession: false,
|
checkingSession: false,
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
setLoggedIn: null,
|
setLoggedIn: null,
|
||||||
|
needsToUpdatePassword: false,
|
||||||
|
setNeedsToUpdatePassword: null,
|
||||||
userId: 0,
|
userId: 0,
|
||||||
setUserId: null,
|
setUserId: null,
|
||||||
username: '',
|
username: '',
|
||||||
@@ -59,6 +63,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
|||||||
const { children } = props
|
const { children } = props
|
||||||
const [checkingSession, setCheckingSession] = useState(false)
|
const [checkingSession, setCheckingSession] = useState(false)
|
||||||
const [loggedIn, setLoggedIn] = useState(false)
|
const [loggedIn, setLoggedIn] = useState(false)
|
||||||
|
const [needsToUpdatePassword, setNeedsToUpdatePassword] = useState(false)
|
||||||
const [userId, setUserId] = useState(0)
|
const [userId, setUserId] = useState(0)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [displayName, setDisplayName] = useState('')
|
const [displayName, setDisplayName] = useState('')
|
||||||
@@ -79,6 +84,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
|||||||
setDisplayName(data.displayName)
|
setDisplayName(data.displayName)
|
||||||
setIsAdmin(data.isAdmin)
|
setIsAdmin(data.isAdmin)
|
||||||
setLoggedIn(true)
|
setLoggedIn(true)
|
||||||
|
setNeedsToUpdatePassword(data.needsToUpdatePassword)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setLoggedIn(false)
|
setLoggedIn(false)
|
||||||
@@ -120,6 +126,8 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
|||||||
checkingSession,
|
checkingSession,
|
||||||
loggedIn,
|
loggedIn,
|
||||||
setLoggedIn,
|
setLoggedIn,
|
||||||
|
needsToUpdatePassword,
|
||||||
|
setNeedsToUpdatePassword,
|
||||||
userId,
|
userId,
|
||||||
setUserId,
|
setUserId,
|
||||||
username,
|
username,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ code {
|
|||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.container {
|
||||||
margin: 50px 10px 0 10px;
|
margin: 50px 10px 0 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -25,15 +25,3 @@ code {
|
|||||||
padding: '5px 10px';
|
padding: '5px 10px';
|
||||||
margin-top: '10px';
|
margin-top: '10px';
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-item-label {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item-label.selected {
|
|
||||||
background: lightgoldenrodyellow;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item-label:hover {
|
|
||||||
background: lightgray;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import axios from 'axios'
|
|||||||
const NODE_ENV = process.env.NODE_ENV
|
const NODE_ENV = process.env.NODE_ENV
|
||||||
const PORT_API = process.env.PORT_API
|
const PORT_API = process.env.PORT_API
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
|
NODE_ENV === 'development'
|
||||||
|
? `http://localhost:${PORT_API ?? 5000}`
|
||||||
|
: window.location.origin + window.location.pathname
|
||||||
|
|
||||||
axios.defaults = Object.assign(axios.defaults, {
|
axios.defaults = Object.assign(axios.defaults, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user