mirror of
https://github.com/sasjs/server.git
synced 2026-01-04 21:30:05 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c44ec35b3d | ||
|
|
77fac663c5 | ||
| 7df9588e66 | |||
| 6a520f5b26 | |||
|
|
70c3834022 | ||
|
|
dbf6c7de08 | ||
|
|
d49ea47bd7 | ||
|
|
be4951d112 | ||
|
|
c116b263d9 | ||
|
|
b4436bad0d | ||
|
|
5e325522f4 | ||
|
|
e576fad8f4 | ||
| eda8e56bb0 | |||
|
|
bee4f215d2 | ||
|
|
100f138f98 | ||
| 6ffaa7e9e2 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,3 +1,38 @@
|
|||||||
|
## [0.33.3](https://github.com/sasjs/server/compare/v0.33.2...v0.33.3) (2023-04-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* use RateLimiterMemory instead of RateLimiterMongo ([6a520f5](https://github.com/sasjs/server/commit/6a520f5b26a3e2ed6345721b30ff4e3d9bfa903d))
|
||||||
|
|
||||||
|
## [0.33.2](https://github.com/sasjs/server/compare/v0.33.1...v0.33.2) (2023-04-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* removing print redirection pending full [#274](https://github.com/sasjs/server/issues/274) fix ([d49ea47](https://github.com/sasjs/server/commit/d49ea47bd7a2add42bdb9a717082201f29e16597))
|
||||||
|
|
||||||
|
## [0.33.1](https://github.com/sasjs/server/compare/v0.33.0...v0.33.1) (2023-04-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* applying nologo only for sas.exe ([b4436ba](https://github.com/sasjs/server/commit/b4436bad0d24d5b5a402272632db1739b1018c90)), closes [#352](https://github.com/sasjs/server/issues/352)
|
||||||
|
|
||||||
|
# [0.33.0](https://github.com/sasjs/server/compare/v0.32.0...v0.33.0) (2023-04-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* option to reset admin password on startup ([eda8e56](https://github.com/sasjs/server/commit/eda8e56bb0ea20fdaacabbbe7dcf1e3ea7bd215a))
|
||||||
|
|
||||||
|
# [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)
|
# [0.31.0](https://github.com/sasjs/server/compare/v0.30.3...v0.31.0) (2023-03-30)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -184,10 +184,23 @@ MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY = <number> default: 100;
|
|||||||
|
|
||||||
|
|
||||||
# After this, access is blocked for an hour
|
# After this, access is blocked for an hour
|
||||||
# Store number for 90 days since first fail
|
# Store number for 24 days since first fail
|
||||||
# Once a successful login is attempted, it resets
|
# Once a successful login is attempted, it resets
|
||||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP = <number> default: 10;
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP = <number> default: 10;
|
||||||
|
|
||||||
|
# Name of the admin user that will be created on startup if not exists already
|
||||||
|
# Default is `secretuser`
|
||||||
|
ADMIN_USERNAME=secretuser
|
||||||
|
|
||||||
|
# Temporary password for the ADMIN_USERNAME, which is in place until the first login
|
||||||
|
# Default is `secretpassword`
|
||||||
|
ADMIN_PASSWORD_INITIAL=secretpassword
|
||||||
|
|
||||||
|
# Specify whether app has to reset the ADMIN_USERNAME's password or not
|
||||||
|
# Default is NO. Possible options are YES and NO
|
||||||
|
# If ADMIN_PASSWORD_RESET is YES then the ADMIN_USERNAME will be prompted to change the password from ADMIN_PASSWORD_INITIAL on their next login. This will repeat on every server restart, unless the option is removed / set to NO.
|
||||||
|
ADMIN_PASSWORD_RESET=NO
|
||||||
|
|
||||||
# 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=
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
|
|||||||
#default value is 10
|
#default value is 10
|
||||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10
|
||||||
|
|
||||||
|
ADMIN_USERNAME=secretuser
|
||||||
|
ADMIN_PASSWORD_INITIAL=secretpassword
|
||||||
|
ADMIN_PASSWORD_RESET=NO
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -758,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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Security, Route, Tags, Example, Post, Body } from 'tsoa'
|
import { Security, Route, Tags, Example, Post, Body, Get } from 'tsoa'
|
||||||
|
|
||||||
import Client, {
|
import Client, {
|
||||||
ClientPayload,
|
ClientPayload,
|
||||||
@@ -29,6 +29,28 @@ 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: ClientPayload): Promise<ClientPayload> => {
|
const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
|
||||||
@@ -60,3 +82,13 @@ const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
|
|||||||
refreshTokenExpiration: savedClient.refreshTokenExpiration
|
refreshTokenExpiration: savedClient.refreshTokenExpiration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAllClients = async (): Promise<ClientPayload[]> => {
|
||||||
|
return Client.find({}).select({
|
||||||
|
_id: 0,
|
||||||
|
clientId: 1,
|
||||||
|
clientSecret: 1,
|
||||||
|
accessTokenExpiration: 1,
|
||||||
|
refreshTokenExpiration: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ ${autoExecContent}`
|
|||||||
session.path,
|
session.path,
|
||||||
'-AUTOEXEC',
|
'-AUTOEXEC',
|
||||||
autoExecPath,
|
autoExecPath,
|
||||||
isWindows() ? '-nologo' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-nologo' : '',
|
||||||
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
|
||||||
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
|
||||||
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ export const createSASProgram = async (
|
|||||||
%mend;
|
%mend;
|
||||||
%_sasjs_server_init()
|
%_sasjs_server_init()
|
||||||
|
|
||||||
proc printto print="%sysfunc(getoption(log))";
|
|
||||||
run;
|
|
||||||
`
|
`
|
||||||
|
|
||||||
program = `
|
program = `
|
||||||
|
|||||||
@@ -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({})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,6 +47,77 @@ describe('web', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('SASLogon/authorize', () => {
|
||||||
|
let csrfToken: string
|
||||||
|
let authCookies: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
;({ csrfToken } = await getCSRF(app))
|
||||||
|
|
||||||
|
await userController.createUser(user)
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
username: user.username,
|
||||||
|
password: user.password
|
||||||
|
}
|
||||||
|
|
||||||
|
;({ authCookies } = await performLogin(app, credentials, csrfToken))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const collections = mongoose.connection.collections
|
||||||
|
const collection = collections['users']
|
||||||
|
await collection.deleteMany({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with authorization code', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASLogon/authorize')
|
||||||
|
.set('Cookie', [authCookies].join('; '))
|
||||||
|
.set('x-xsrf-token', csrfToken)
|
||||||
|
.send({ clientId })
|
||||||
|
|
||||||
|
expect(res.body).toHaveProperty('code')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Bad Request if CSRF Token is missing', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASLogon/authorize')
|
||||||
|
.set('Cookie', [authCookies].join('; '))
|
||||||
|
.send({ clientId })
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Invalid CSRF token!')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Bad Request if clientId is missing', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASLogon/authorize')
|
||||||
|
.set('Cookie', [authCookies].join('; '))
|
||||||
|
.set('x-xsrf-token', csrfToken)
|
||||||
|
.send({})
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(res.text).toEqual(`"clientId" is required`)
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Forbidden if clientId is incorrect', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASLogon/authorize')
|
||||||
|
.set('Cookie', [authCookies].join('; '))
|
||||||
|
.set('x-xsrf-token', csrfToken)
|
||||||
|
.send({
|
||||||
|
clientId: 'WrongClientID'
|
||||||
|
})
|
||||||
|
.expect(403)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Error: Invalid clientId.')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('SASLogon/login', () => {
|
describe('SASLogon/login', () => {
|
||||||
let csrfToken: string
|
let csrfToken: string
|
||||||
|
|
||||||
@@ -187,78 +258,6 @@ describe('web', () => {
|
|||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('SASLogon/authorize', () => {
|
|
||||||
let csrfToken: string
|
|
||||||
let authCookies: string
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await deleteDocumentsFromLimitersCollections()
|
|
||||||
;({ csrfToken } = await getCSRF(app))
|
|
||||||
|
|
||||||
await userController.createUser(user)
|
|
||||||
|
|
||||||
const credentials = {
|
|
||||||
username: user.username,
|
|
||||||
password: user.password
|
|
||||||
}
|
|
||||||
|
|
||||||
;({ authCookies } = await performLogin(app, credentials, csrfToken))
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
const collections = mongoose.connection.collections
|
|
||||||
const collection = collections['users']
|
|
||||||
await collection.deleteMany({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with authorization code', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASLogon/authorize')
|
|
||||||
.set('Cookie', [authCookies].join('; '))
|
|
||||||
.set('x-xsrf-token', csrfToken)
|
|
||||||
.send({ clientId })
|
|
||||||
|
|
||||||
expect(res.body).toHaveProperty('code')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Bad Request if CSRF Token is missing', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASLogon/authorize')
|
|
||||||
.set('Cookie', [authCookies].join('; '))
|
|
||||||
.send({ clientId })
|
|
||||||
.expect(400)
|
|
||||||
|
|
||||||
expect(res.text).toEqual('Invalid CSRF token!')
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Bad Request if clientId is missing', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASLogon/authorize')
|
|
||||||
.set('Cookie', [authCookies].join('; '))
|
|
||||||
.set('x-xsrf-token', csrfToken)
|
|
||||||
.send({})
|
|
||||||
.expect(400)
|
|
||||||
|
|
||||||
expect(res.text).toEqual(`"clientId" is required`)
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Forbidden if clientId is incorrect', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASLogon/authorize')
|
|
||||||
.set('Cookie', [authCookies].join('; '))
|
|
||||||
.set('x-xsrf-token', csrfToken)
|
|
||||||
.send({
|
|
||||||
clientId: 'WrongClientID'
|
|
||||||
})
|
|
||||||
.expect(403)
|
|
||||||
|
|
||||||
expect(res.text).toEqual('Error: Invalid clientId.')
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const getCSRF = async (app: Express) => {
|
const getCSRF = async (app: Express) => {
|
||||||
@@ -285,12 +284,3 @@ 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({})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import mongoose from 'mongoose'
|
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||||
import { RateLimiterMongo } from 'rate-limiter-flexible'
|
|
||||||
|
|
||||||
export class RateLimiter {
|
export class RateLimiter {
|
||||||
private static instance: RateLimiter
|
private static instance: RateLimiter
|
||||||
private limiterSlowBruteByIP: RateLimiterMongo
|
private limiterSlowBruteByIP: RateLimiterMemory
|
||||||
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMongo
|
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMemory
|
||||||
private maxWrongAttemptsByIpPerDay: number
|
private maxWrongAttemptsByIpPerDay: number
|
||||||
private maxConsecutiveFailsByUsernameAndIp: number
|
private maxConsecutiveFailsByUsernameAndIp: number
|
||||||
|
|
||||||
@@ -19,19 +18,17 @@ export class RateLimiter {
|
|||||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||||
)
|
)
|
||||||
|
|
||||||
this.limiterSlowBruteByIP = new RateLimiterMongo({
|
this.limiterSlowBruteByIP = new RateLimiterMemory({
|
||||||
storeClient: mongoose.connection,
|
|
||||||
keyPrefix: 'login_fail_ip_per_day',
|
keyPrefix: 'login_fail_ip_per_day',
|
||||||
points: this.maxWrongAttemptsByIpPerDay,
|
points: this.maxWrongAttemptsByIpPerDay,
|
||||||
duration: 60 * 60 * 24,
|
duration: 60 * 60 * 24,
|
||||||
blockDuration: 60 * 60 * 24 // Block for 1 day
|
blockDuration: 60 * 60 * 24 // Block for 1 day
|
||||||
})
|
})
|
||||||
|
|
||||||
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMongo({
|
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMemory({
|
||||||
storeClient: mongoose.connection,
|
|
||||||
keyPrefix: 'login_fail_consecutive_username_and_ip',
|
keyPrefix: 'login_fail_consecutive_username_and_ip',
|
||||||
points: this.maxConsecutiveFailsByUsernameAndIp,
|
points: this.maxConsecutiveFailsByUsernameAndIp,
|
||||||
duration: 60 * 60 * 24 * 90, // Store number for 90 days since first fail
|
duration: 60 * 60 * 24 * 24, // Store number for 24 days since first fail
|
||||||
blockDuration: 60 * 60 // Block for 1 hour
|
blockDuration: 60 * 60 // Block for 1 hour
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -60,8 +57,7 @@ export class RateLimiter {
|
|||||||
this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
|
this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
|
||||||
])
|
])
|
||||||
|
|
||||||
// NOTE: To make use of blockDuration option from RateLimiterMongo
|
// NOTE: To make use of blockDuration option, comparison in both following if statements should have greater than symbol
|
||||||
// comparison in both following if statements should have greater than symbol
|
|
||||||
// otherwise, blockDuration option will not work
|
// otherwise, blockDuration option will not work
|
||||||
// For more info see: https://github.com/animir/node-rate-limiter-flexible/wiki/Options#blockduration
|
// For more info see: https://github.com/animir/node-rate-limiter-flexible/wiki/Options#blockduration
|
||||||
|
|
||||||
@@ -103,10 +99,11 @@ export class RateLimiter {
|
|||||||
if (rlRejected instanceof Error) {
|
if (rlRejected instanceof Error) {
|
||||||
throw rlRejected
|
throw rlRejected
|
||||||
} else {
|
} else {
|
||||||
// based upon the implementation of consume method of RateLimiterMongo
|
// based upon the implementation of consume method of RateLimiterMemory
|
||||||
// we are sure that rlRejected will contain msBeforeNext
|
// we are sure that rlRejected will contain msBeforeNext
|
||||||
// for further reference,
|
// for further reference,
|
||||||
// see https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#login-endpoint-protection
|
// see https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#login-endpoint-protection
|
||||||
|
// or see https://github.com/animir/node-rate-limiter-flexible#ratelimiterres-object
|
||||||
return Math.ceil(rlRejected.msBeforeNext / 1000)
|
return Math.ceil(rlRejected.msBeforeNext / 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import bcrypt from 'bcryptjs'
|
||||||
import Client from '../model/Client'
|
import Client from '../model/Client'
|
||||||
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
|
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
|
||||||
import User from '../model/User'
|
import User, { IUser } from '../model/User'
|
||||||
import Configuration, { ConfigurationType } from '../model/Configuration'
|
import Configuration, { ConfigurationType } from '../model/Configuration'
|
||||||
|
import { ResetAdminPasswordType } from './verifyEnvVariables'
|
||||||
|
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
|
|
||||||
@@ -40,9 +42,13 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
|||||||
process.logger.success(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
|
process.logger.success(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ADMIN_USER = getAdminUser()
|
||||||
|
|
||||||
// Checking if user is already in the database
|
// Checking if user is already in the database
|
||||||
let usernameExist = await User.findOne({ username: ADMIN_USER.username })
|
let usernameExist = await User.findOne({ username: ADMIN_USER.username })
|
||||||
if (!usernameExist) {
|
if (usernameExist) {
|
||||||
|
usernameExist = await resetAdminPassword(usernameExist, ADMIN_USER.password)
|
||||||
|
} else {
|
||||||
const user = new User(ADMIN_USER)
|
const user = new User(ADMIN_USER)
|
||||||
usernameExist = await user.save()
|
usernameExist = await user.save()
|
||||||
|
|
||||||
@@ -51,7 +57,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!groupExist.hasUser(usernameExist)) {
|
if (usernameExist.isAdmin && !groupExist.hasUser(usernameExist)) {
|
||||||
groupExist.addUser(usernameExist)
|
groupExist.addUser(usernameExist)
|
||||||
process.logger.success(
|
process.logger.success(
|
||||||
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${ALL_USERS_GROUP.name}'`
|
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${ALL_USERS_GROUP.name}'`
|
||||||
@@ -90,11 +96,52 @@ const CLIENT = {
|
|||||||
clientId: 'clientID1',
|
clientId: 'clientID1',
|
||||||
clientSecret: 'clientSecret'
|
clientSecret: 'clientSecret'
|
||||||
}
|
}
|
||||||
const ADMIN_USER = {
|
|
||||||
id: 1,
|
const getAdminUser = () => {
|
||||||
displayName: 'Super Admin',
|
const { ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL } = process.env
|
||||||
username: 'secretuser',
|
|
||||||
password: '$2a$10$hKvcVEZdhEQZCcxt6npazO6mY4jJkrzWvfQ5stdBZi8VTTwVMCVXO',
|
const salt = bcrypt.genSaltSync(10)
|
||||||
isAdmin: true,
|
const hashedPassword = bcrypt.hashSync(ADMIN_PASSWORD_INITIAL as string, salt)
|
||||||
isActive: true
|
|
||||||
|
return {
|
||||||
|
displayName: 'Super Admin',
|
||||||
|
username: ADMIN_USERNAME,
|
||||||
|
password: hashedPassword,
|
||||||
|
isAdmin: true,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAdminPassword = async (user: IUser, password: string) => {
|
||||||
|
const { ADMIN_PASSWORD_RESET } = process.env
|
||||||
|
|
||||||
|
if (ADMIN_PASSWORD_RESET === ResetAdminPasswordType.YES) {
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
process.logger.error(
|
||||||
|
`Can not reset the password of non-admin user (${user.username}) on startup.`
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.authProvider) {
|
||||||
|
process.logger.error(
|
||||||
|
`Can not reset the password of admin (${user.username}) with ${user.authProvider} as authentication mechanism.`
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
process.logger.info(
|
||||||
|
`DB Seed - resetting password for admin user: ${user.username}`
|
||||||
|
)
|
||||||
|
|
||||||
|
user.password = password
|
||||||
|
user.needsToUpdatePassword = true
|
||||||
|
user = await user.save()
|
||||||
|
|
||||||
|
process.logger.success(`DB Seed - successfully reset the password`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export enum DatabaseType {
|
|||||||
COSMOS_MONGODB = 'cosmos_mongodb'
|
COSMOS_MONGODB = 'cosmos_mongodb'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ResetAdminPasswordType {
|
||||||
|
YES = 'YES',
|
||||||
|
NO = 'NO'
|
||||||
|
}
|
||||||
|
|
||||||
export const verifyEnvVariables = (): ReturnCode => {
|
export const verifyEnvVariables = (): ReturnCode => {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
@@ -79,6 +84,8 @@ export const verifyEnvVariables = (): ReturnCode => {
|
|||||||
|
|
||||||
errors.push(...verifyRateLimiter())
|
errors.push(...verifyRateLimiter())
|
||||||
|
|
||||||
|
errors.push(...verifyAdminUserConfig())
|
||||||
|
|
||||||
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')}`
|
||||||
@@ -409,6 +416,38 @@ const verifyRateLimiter = () => {
|
|||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verifyAdminUserConfig = () => {
|
||||||
|
const errors: string[] = []
|
||||||
|
const { MODE, ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL, ADMIN_PASSWORD_RESET } =
|
||||||
|
process.env
|
||||||
|
if (MODE === ModeType.Server) {
|
||||||
|
if (ADMIN_USERNAME) {
|
||||||
|
process.env.ADMIN_USERNAME = ADMIN_USERNAME.toLowerCase()
|
||||||
|
} else {
|
||||||
|
process.env.ADMIN_USERNAME = DEFAULTS.ADMIN_USERNAME
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ADMIN_PASSWORD_INITIAL)
|
||||||
|
process.env.ADMIN_PASSWORD_INITIAL = DEFAULTS.ADMIN_PASSWORD_INITIAL
|
||||||
|
|
||||||
|
if (ADMIN_PASSWORD_RESET) {
|
||||||
|
const resetPasswordTypes = Object.values(ResetAdminPasswordType)
|
||||||
|
if (
|
||||||
|
!resetPasswordTypes.includes(
|
||||||
|
ADMIN_PASSWORD_RESET as ResetAdminPasswordType
|
||||||
|
)
|
||||||
|
)
|
||||||
|
errors.push(
|
||||||
|
`- ADMIN_PASSWORD_RESET '${ADMIN_PASSWORD_RESET}'\n - valid options ${resetPasswordTypes}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
process.env.ADMIN_PASSWORD_RESET = DEFAULTS.ADMIN_PASSWORD_RESET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
const isNumeric = (val: string): boolean => {
|
const isNumeric = (val: string): boolean => {
|
||||||
return !isNaN(Number(val))
|
return !isNaN(Number(val))
|
||||||
}
|
}
|
||||||
@@ -422,5 +461,8 @@ const DEFAULTS = {
|
|||||||
RUN_TIMES: RunTimeType.SAS,
|
RUN_TIMES: RunTimeType.SAS,
|
||||||
DB_TYPE: DatabaseType.MONGO,
|
DB_TYPE: DatabaseType.MONGO,
|
||||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY: '100',
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY: '100',
|
||||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP: '10'
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP: '10',
|
||||||
|
ADMIN_USERNAME: 'secretuser',
|
||||||
|
ADMIN_PASSWORD_INITIAL: 'secretpassword',
|
||||||
|
ADMIN_PASSWORD_RESET: ResetAdminPasswordType.NO
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user