mirror of
https://github.com/sasjs/server.git
synced 2026-01-03 21:10:05 +00:00
feat: authentication with jwt
This commit is contained in:
13520
package-lock.json
generated
13520
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,15 +22,22 @@
|
|||||||
"author": "Analytium Ltd",
|
"author": "Analytium Ltd",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/utils": "^2.23.3",
|
"@sasjs/utils": "^2.23.3",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
|
"joi": "^17.4.2",
|
||||||
|
"jsonwebtoken": "^8.5.1",
|
||||||
|
"mongoose": "^6.0.12",
|
||||||
"multer": "^1.4.3"
|
"multer": "^1.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/express": "^4.17.12",
|
"@types/express": "^4.17.12",
|
||||||
"@types/jest": "^26.0.24",
|
"@types/jest": "^26.0.24",
|
||||||
|
"@types/jsonwebtoken": "^8.5.5",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^15.12.2",
|
"@types/node": "^15.12.2",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
|
"dotenv": "^10.0.0",
|
||||||
"jest": "^27.0.6",
|
"jest": "^27.0.6",
|
||||||
"nodemon": "^2.0.7",
|
"nodemon": "^2.0.7",
|
||||||
"prettier": "^2.3.1",
|
"prettier": "^2.3.1",
|
||||||
|
|||||||
36
routes.rest
Normal file
36
routes.rest
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
###
|
||||||
|
POST http://localhost:5000/SASjsApi/drive/deploy
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDc2LCJleHAiOjE2MzU4OTA0NzZ9.Cx1F54ILgAUtnkit0Wg1K1YVO2RdNjOnTKdPhUtDm5I
|
||||||
|
|
||||||
|
###
|
||||||
|
POST http://localhost:5000/SASjsApi/user
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InNlY3JldHVzZXIiLCJpc2FkbWluIjp0cnVlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODAzOTc3LCJleHAiOjE2MzU4OTAzNzd9.f-FLgLwryKvB5XrihdzaGZajO3d5E5OHEEuJI_03GRI
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"displayname": "User 2",
|
||||||
|
"username": "username2",
|
||||||
|
"password": "some password"
|
||||||
|
}
|
||||||
|
###
|
||||||
|
POST http://localhost:5000/SASjsApi/auth/authorize
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"username": "username1",
|
||||||
|
"password": "some password",
|
||||||
|
"client_id": "clientID1"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
POST http://localhost:5000/SASjsApi/auth/token
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"client_id": "clientID1",
|
||||||
|
"client_secret": "clientID1secret",
|
||||||
|
"code": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDYxLCJleHAiOjE2MzU4MDQwOTF9.jV7DpBWG7XAGODs22zAW_kWOqVLZvOxmmYJGpSNQ-KM"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
DELETE http://localhost:5000/SASjsApi/auth/logout
|
||||||
14
src/model/Client.ts
Normal file
14
src/model/Client.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const clientSchema = new mongoose.Schema({
|
||||||
|
clientid: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
clientsecret: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default mongoose.model('Client', clientSchema)
|
||||||
26
src/model/User.ts
Normal file
26
src/model/User.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
const userSchema = new mongoose.Schema({
|
||||||
|
displayname: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isadmin: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isactive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default mongoose.model('User', userSchema)
|
||||||
144
src/routes/api/auth.ts
Normal file
144
src/routes/api/auth.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
|
import Client from '../../model/Client'
|
||||||
|
import User from '../../model/User'
|
||||||
|
import { authorizeValidation, tokenValidation } from '../../utils'
|
||||||
|
import { InfoJWT } from '../../types'
|
||||||
|
|
||||||
|
const authRouter = express.Router()
|
||||||
|
|
||||||
|
const clients: { [key: string]: string } = {}
|
||||||
|
const clientIDs = new Set()
|
||||||
|
const authCodes: { [key: string]: string } = {}
|
||||||
|
|
||||||
|
// connect to DB
|
||||||
|
mongoose.connect(process.env.DB_CONNECT as string, async (err) => {
|
||||||
|
if (err) throw err
|
||||||
|
|
||||||
|
console.log('Connected to db!')
|
||||||
|
|
||||||
|
const result = await Client.find()
|
||||||
|
result.forEach((r) => {
|
||||||
|
clients[r.clientid] = r.clientsecret
|
||||||
|
clientIDs.add(r.clientid)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshTokens: string[] = []
|
||||||
|
|
||||||
|
authRouter.post('/authorize', async (req, res) => {
|
||||||
|
const { error, value } = authorizeValidation(req.body)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
|
const { username, password, client_id } = value
|
||||||
|
|
||||||
|
// Authenticate User
|
||||||
|
const user = await User.findOne({ username })
|
||||||
|
if (!user) return res.status(400).send('Username is not found.')
|
||||||
|
|
||||||
|
const validPass = await bcrypt.compare(password, user.password)
|
||||||
|
if (!validPass) return res.status(400).send('Invalid password.')
|
||||||
|
|
||||||
|
// Verify client ID
|
||||||
|
if (!clientIDs.has(client_id)) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate authorization code against client_id
|
||||||
|
const userInfo: InfoJWT = {
|
||||||
|
client_id,
|
||||||
|
username,
|
||||||
|
isadmin: user.isadmin,
|
||||||
|
isactive: user.isactive
|
||||||
|
}
|
||||||
|
authCodes[client_id] = generateAuthCode(userInfo)
|
||||||
|
|
||||||
|
res.json({ code: authCodes[client_id] })
|
||||||
|
})
|
||||||
|
|
||||||
|
authRouter.post('/token', async (req, res) => {
|
||||||
|
const { error, value } = tokenValidation(req.body)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
|
const { client_id, client_secret, code } = value
|
||||||
|
|
||||||
|
const userInfo = await verifyAuthCode(client_id, client_secret, code)
|
||||||
|
if (userInfo) {
|
||||||
|
const accessToken = generateAccessToken(userInfo)
|
||||||
|
const refreshToken = jwt.sign(
|
||||||
|
userInfo,
|
||||||
|
process.env.REFRESH_TOKEN_SECRET as string
|
||||||
|
)
|
||||||
|
refreshTokens.push(refreshToken)
|
||||||
|
|
||||||
|
delete authCodes[client_id]
|
||||||
|
|
||||||
|
res.json({ accessToken: accessToken, refreshToken: refreshToken })
|
||||||
|
} else {
|
||||||
|
res.sendStatus(403)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// authRouter.post('/refresh', (req, res) => {
|
||||||
|
// const refreshToken = req.body.token
|
||||||
|
// if (refreshToken == null) return res.sendStatus(401)
|
||||||
|
// if (!refreshTokens.includes(refreshToken)) return res.sendStatus(403)
|
||||||
|
// jwt.verify(
|
||||||
|
// refreshToken,
|
||||||
|
// process.env.REFRESH_TOKEN_SECRET as string,
|
||||||
|
// (err: any, user: any) => {
|
||||||
|
// if (err) return res.sendStatus(403)
|
||||||
|
// const accessToken = generateAccessToken({ name: user.name })
|
||||||
|
// res.json({ accessToken: accessToken })
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
// })
|
||||||
|
|
||||||
|
authRouter.delete('/logout', (req, res) => {
|
||||||
|
const index = refreshTokens.findIndex(req.body.token)
|
||||||
|
if (index > -1) refreshTokens.splice(index, 1)
|
||||||
|
|
||||||
|
res.sendStatus(204)
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateAccessToken = (data: InfoJWT) =>
|
||||||
|
jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, {
|
||||||
|
expiresIn: '1day'
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateAuthCode = (data: InfoJWT) =>
|
||||||
|
jwt.sign(data, process.env.AUTH_CODE_SECRET as string, {
|
||||||
|
expiresIn: '30s'
|
||||||
|
})
|
||||||
|
|
||||||
|
const verifyAuthCode = async (
|
||||||
|
client_id: string,
|
||||||
|
client_secret: string,
|
||||||
|
code: string
|
||||||
|
): Promise<InfoJWT | undefined> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
jwt.verify(code, process.env.AUTH_CODE_SECRET as string, (err, data) => {
|
||||||
|
if (err) return resolve(undefined)
|
||||||
|
|
||||||
|
const clientInfo: InfoJWT = {
|
||||||
|
client_id: data?.client_id,
|
||||||
|
username: data?.username,
|
||||||
|
isadmin: data?.isadmin,
|
||||||
|
isactive: data?.isactive
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
clientInfo.client_id === client_id &&
|
||||||
|
clients[client_id] === client_secret &&
|
||||||
|
authCodes[client_id] === code
|
||||||
|
) {
|
||||||
|
return resolve(clientInfo)
|
||||||
|
}
|
||||||
|
return resolve(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default authRouter
|
||||||
@@ -1,10 +1,49 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import { InfoJWT } from '../../types'
|
||||||
|
|
||||||
import driveRouter from './drive'
|
import driveRouter from './drive'
|
||||||
import stpRouter from './stp'
|
import stpRouter from './stp'
|
||||||
|
import userRouter from './user'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
import authRouter from './auth'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
router.use('/drive', driveRouter)
|
router.use('/drive', authenticateToken, driveRouter)
|
||||||
router.use('/stp', stpRouter)
|
router.use('/stp', authenticateToken, stpRouter)
|
||||||
|
router.use('/user', authenticateToken, verifyAdmin, userRouter)
|
||||||
|
router.use('/auth', authRouter)
|
||||||
|
|
||||||
|
function authenticateToken(req: any, res: any, next: any) {
|
||||||
|
const authHeader = req.headers['authorization']
|
||||||
|
const token = authHeader && authHeader.split(' ')[1]
|
||||||
|
if (token == null) return res.sendStatus(401)
|
||||||
|
|
||||||
|
jwt.verify(
|
||||||
|
token,
|
||||||
|
process.env.ACCESS_TOKEN_SECRET as string,
|
||||||
|
(err: any, data: any) => {
|
||||||
|
if (err) return res.sendStatus(403)
|
||||||
|
|
||||||
|
const user: InfoJWT = {
|
||||||
|
client_id: data?.client_id,
|
||||||
|
username: data?.username,
|
||||||
|
isadmin: data?.isadmin,
|
||||||
|
isactive: data?.isactive
|
||||||
|
}
|
||||||
|
req.user = user
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAdmin(req: any, res: any, next: any) {
|
||||||
|
const { user } = req
|
||||||
|
if (!user.isadmin) return res.status(403).send('Admin account required')
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
44
src/routes/api/user.ts
Normal file
44
src/routes/api/user.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import User from '../../model/User'
|
||||||
|
import { registerValidation } from '../../utils'
|
||||||
|
|
||||||
|
const userRouter = express.Router()
|
||||||
|
|
||||||
|
userRouter.post('/', async (req, res) => {
|
||||||
|
const { error, value } = registerValidation(req.body)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
|
const { displayname, username, password, isadmin, isactive } = value
|
||||||
|
|
||||||
|
// Checking if user is already in the database
|
||||||
|
const usernameExist = await User.findOne({ username })
|
||||||
|
if (usernameExist) return res.status(400).send('Username already exists.')
|
||||||
|
|
||||||
|
// Hash passwords
|
||||||
|
const salt = await bcrypt.genSalt(10)
|
||||||
|
const hashPassword = await bcrypt.hash(password, salt)
|
||||||
|
|
||||||
|
// Create a new user
|
||||||
|
const user = new User({
|
||||||
|
displayname,
|
||||||
|
username,
|
||||||
|
password: hashPassword,
|
||||||
|
isadmin,
|
||||||
|
isactive
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedUser = await user.save()
|
||||||
|
res.send({
|
||||||
|
displayname: savedUser.displayname,
|
||||||
|
username: savedUser.username,
|
||||||
|
isadmin: savedUser.isadmin,
|
||||||
|
isactive: savedUser.isactive
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).send(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default userRouter
|
||||||
6
src/types/InfoJWT.ts
Normal file
6
src/types/InfoJWT.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface InfoJWT {
|
||||||
|
client_id: string
|
||||||
|
username: string
|
||||||
|
isadmin: boolean
|
||||||
|
isactive: boolean
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export * from './Execution'
|
|||||||
export * from './Request'
|
export * from './Request'
|
||||||
export * from './FileTree'
|
export * from './FileTree'
|
||||||
export * from './Session'
|
export * from './Session'
|
||||||
|
export * from './InfoJWT'
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './file'
|
export * from './file'
|
||||||
export * from './sleep'
|
export * from './sleep'
|
||||||
export * from './upload'
|
export * from './upload'
|
||||||
|
export * from './validation'
|
||||||
|
|||||||
27
src/utils/validation.ts
Normal file
27
src/utils/validation.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Joi from 'joi'
|
||||||
|
|
||||||
|
const usernameSchema = Joi.string().alphanum().min(6).max(20).required()
|
||||||
|
const passwordSchema = Joi.string().min(6).max(1024).required()
|
||||||
|
|
||||||
|
export const authorizeValidation = (data: any): Joi.ValidationResult =>
|
||||||
|
Joi.object({
|
||||||
|
username: usernameSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
client_id: Joi.string().required()
|
||||||
|
}).validate(data)
|
||||||
|
|
||||||
|
export const tokenValidation = (data: any): Joi.ValidationResult =>
|
||||||
|
Joi.object({
|
||||||
|
client_id: Joi.string().required(),
|
||||||
|
client_secret: Joi.string().required(),
|
||||||
|
code: Joi.string().required()
|
||||||
|
}).validate(data)
|
||||||
|
|
||||||
|
export const registerValidation = (data: any): Joi.ValidationResult =>
|
||||||
|
Joi.object({
|
||||||
|
displayname: Joi.string().min(6).required(),
|
||||||
|
username: usernameSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
isadmin: Joi.boolean(),
|
||||||
|
isactive: Joi.boolean()
|
||||||
|
}).validate(data)
|
||||||
Reference in New Issue
Block a user