diff --git a/src/model/User.ts b/src/model/User.ts index e53a474..db3be92 100644 --- a/src/model/User.ts +++ b/src/model/User.ts @@ -1,4 +1,5 @@ -import mongoose from 'mongoose' +import { string } from 'joi' +import mongoose, { Schema } from 'mongoose' const userSchema = new mongoose.Schema({ displayname: { @@ -20,7 +21,23 @@ const userSchema = new mongoose.Schema({ isactive: { type: Boolean, default: true - } + }, + tokens: [ + { + clientid: { + type: String, + required: true + }, + accesstoken: { + type: String, + required: true + }, + refreshtoken: { + type: String, + required: true + } + } + ] }) export default mongoose.model('User', userSchema) diff --git a/src/routes/api/auth.ts b/src/routes/api/auth.ts index 10070b1..63ff9e1 100644 --- a/src/routes/api/auth.ts +++ b/src/routes/api/auth.ts @@ -1,18 +1,24 @@ import express from 'express' import bcrypt from 'bcryptjs' -import mongoose, { Mongoose } from 'mongoose' +import mongoose from 'mongoose' import jwt from 'jsonwebtoken' import Client from '../../model/Client' import User from '../../model/User' -import { authorizeValidation, tokenValidation } from '../../utils' +import { + authenticateToken, + authorizeValidation, + removeTokensInDB, + saveTokensInDB, + 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 } = {} +const authCodes: { [key: string]: { [key: string]: string } } = {} export const populateClients = async () => { const result = await Client.find() @@ -37,11 +43,14 @@ export const connectDB = () => { } } -export const saveCode = (client_id: string, code: string) => - (authCodes[client_id] = code) -export const deleteCode = (client_id: string) => delete authCodes[client_id] +export const saveCode = (username: string, client_id: string, code: string) => { + if (authCodes[username]) return (authCodes[username][client_id] = code) -const refreshTokens: string[] = [] + authCodes[username] = { [client_id]: code } + return authCodes[username][client_id] +} +export const deleteCode = (username: string, client_id: string) => + delete authCodes[username][client_id] authRouter.post('/authorize', async (req, res) => { const { error, value } = authorizeValidation(req.body) @@ -49,6 +58,11 @@ authRouter.post('/authorize', async (req, res) => { const { username, password, client_id } = value + // Verify client ID + if (!clientIDs.has(client_id)) { + return res.status(403).send('Invalid client_id.') + } + // Authenticate User const user = await User.findOne({ username }) if (!user) return res.status(403).send('Username is not found.') @@ -56,20 +70,13 @@ authRouter.post('/authorize', async (req, res) => { const validPass = await bcrypt.compare(password, user.password) if (!validPass) return res.status(403).send('Invalid password.') - // Verify client ID - if (!clientIDs.has(client_id)) { - return res.status(403).send('Invalid client_id.') - } - // generate authorization code against client_id const userInfo: InfoJWT = { client_id, - username, - isadmin: user.isadmin, - isactive: user.isactive + username } - const code = saveCode(client_id, generateAuthCode(userInfo)) + const code = saveCode(username, client_id, generateAuthCode(userInfo)) res.json({ code }) }) @@ -78,22 +85,23 @@ 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 { client_id, code } = value - const userInfo = await verifyAuthCode(client_id, client_secret, code) + const userInfo = await verifyAuthCode(client_id, code) + if (!userInfo) return res.sendStatus(403) - if (!userInfo) { + if (authCodes[userInfo.username][client_id] !== code) return res.sendStatus(403) - } + + deleteCode(userInfo.username, client_id) const accessToken = generateAccessToken(userInfo) const refreshToken = jwt.sign( userInfo, process.env.REFRESH_TOKEN_SECRET as string ) - refreshTokens.push(refreshToken) - deleteCode(client_id) + await saveTokensInDB(userInfo.username, client_id, accessToken, refreshToken) res.json({ accessToken: accessToken, refreshToken: refreshToken }) }) @@ -113,9 +121,10 @@ authRouter.post('/token', async (req, res) => { // ) // }) -authRouter.delete('/logout', (req, res) => { - const index = refreshTokens.findIndex(req.body.token) - if (index > -1) refreshTokens.splice(index, 1) +authRouter.delete('/logout', authenticateToken, async (req: any, res) => { + const { user } = req + + await removeTokensInDB(user.username, user.client_id) res.sendStatus(204) }) @@ -132,7 +141,6 @@ export const generateAuthCode = (data: InfoJWT) => const verifyAuthCode = async ( client_id: string, - client_secret: string, code: string ): Promise => { return new Promise((resolve, reject) => { @@ -141,15 +149,9 @@ const verifyAuthCode = async ( const clientInfo: InfoJWT = { client_id: data?.client_id, - username: data?.username, - isadmin: data?.isadmin, - isactive: data?.isactive + username: data?.username } - if ( - clientInfo.client_id === client_id && - clients[client_id] === client_secret && - authCodes[client_id] === code - ) { + if (clientInfo.client_id === client_id) { return resolve(clientInfo) } return resolve(undefined) diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 1e313aa..42f99e2 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -1,13 +1,12 @@ import express from 'express' -import jwt from 'jsonwebtoken' import dotenv from 'dotenv' -import { InfoJWT } from '../../types' import driveRouter from './drive' import stpRouter from './stp' import userRouter from './user' import clientRouter from './client' import authRouter, { connectDB } from './auth' +import { authenticateToken } from '../../utils' dotenv.config() connectDB() @@ -20,32 +19,9 @@ router.use('/user', authenticateToken, verifyAdmin, userRouter) router.use('/client', authenticateToken, verifyAdmin, clientRouter) 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') + if (!user?.isadmin) return res.status(403).send('Admin account required') next() } diff --git a/src/routes/api/spec/auth.spec.ts b/src/routes/api/spec/auth.spec.ts index 26683d4..de6d6dc 100644 --- a/src/routes/api/spec/auth.spec.ts +++ b/src/routes/api/spec/auth.spec.ts @@ -4,13 +4,17 @@ import request from 'supertest' import app from '../../../app' import { createUser } from '../../../controllers/createUser' import { createClient } from '../../../controllers/createClient' -import { generateAuthCode, populateClients, saveCode } from '../auth' +import { + generateAccessToken, + generateAuthCode, + populateClients, + saveCode +} from '../auth' import { InfoJWT } from '../../../types' +import { saveTokensInDB, verifyTokenInDB } from '../../../utils' -const client = { - client_id: 'someclientID', - client_secret: 'someclientSecret' -} +const client_id = 'someclientID' +const client_secret = 'someclientSecret' const user = { displayname: 'Test User', username: 'testUsername', @@ -26,7 +30,7 @@ describe('auth', () => { beforeAll(async () => { mongoServer = await MongoMemoryServer.create() con = await mongoose.connect(mongoServer.getUri()) - await createClient(client) + await createClient({ client_id, client_secret }) await populateClients() }) @@ -51,7 +55,7 @@ describe('auth', () => { .send({ username: user.username, password: user.password, - client_id: client.client_id + client_id }) .expect(200) @@ -63,7 +67,7 @@ describe('auth', () => { .post('/SASjsApi/auth/authorize') .send({ password: user.password, - client_id: client.client_id + client_id }) .expect(400) @@ -76,7 +80,7 @@ describe('auth', () => { .post('/SASjsApi/auth/authorize') .send({ username: user.username, - client_id: client.client_id + client_id }) .expect(400) @@ -103,7 +107,7 @@ describe('auth', () => { .send({ username: user.username, password: user.password, - client_id: client.client_id + client_id }) .expect(403) @@ -119,7 +123,7 @@ describe('auth', () => { .send({ username: user.username, password: 'WrongPassword', - client_id: client.client_id + client_id }) .expect(403) @@ -146,10 +150,8 @@ describe('auth', () => { describe('token', () => { const userInfo: InfoJWT = { - client_id: client.client_id, - username: user.username, - isadmin: user.isadmin, - isactive: user.isactive + client_id, + username: user.username } beforeAll(async () => { await createUser(user) @@ -161,13 +163,16 @@ describe('auth', () => { }) it('should respond with access and refresh tokens', async () => { - const code = saveCode(userInfo.client_id, generateAuthCode(userInfo)) + const code = saveCode( + userInfo.username, + userInfo.client_id, + generateAuthCode(userInfo) + ) const res = await request(app) .post('/SASjsApi/auth/token') .send({ - client_id: client.client_id, - client_secret: client.client_secret, + client_id, code }) .expect(200) @@ -180,8 +185,7 @@ describe('auth', () => { const res = await request(app) .post('/SASjsApi/auth/token') .send({ - client_id: client.client_id, - client_secret: client.client_secret + client_id }) .expect(400) @@ -190,12 +194,15 @@ describe('auth', () => { }) it('should respond with Bad Request if client_id is missing', async () => { - const code = saveCode(userInfo.client_id, generateAuthCode(userInfo)) + const code = saveCode( + userInfo.username, + userInfo.client_id, + generateAuthCode(userInfo) + ) const res = await request(app) .post('/SASjsApi/auth/token') .send({ - client_secret: client.client_secret, code }) .expect(400) @@ -204,27 +211,11 @@ describe('auth', () => { expect(res.body).toEqual({}) }) - it('should respond with Bad Request if client_secret is missing', async () => { - const code = saveCode(userInfo.client_id, generateAuthCode(userInfo)) - - const res = await request(app) - .post('/SASjsApi/auth/token') - .send({ - client_id: client.client_id, - code - }) - .expect(400) - - expect(res.text).toEqual(`"client_secret" is required`) - expect(res.body).toEqual({}) - }) - it('should respond with Forbidden if code is invalid', async () => { const res = await request(app) .post('/SASjsApi/auth/token') .send({ - client_id: client.client_id, - client_secret: client.client_secret, + client_id, code: 'InvalidCode' }) .expect(403) @@ -233,28 +224,16 @@ describe('auth', () => { }) it('should respond with Forbidden if client_id is invalid', async () => { - const code = saveCode(userInfo.client_id, generateAuthCode(userInfo)) + const code = saveCode( + userInfo.username, + userInfo.client_id, + generateAuthCode(userInfo) + ) const res = await request(app) .post('/SASjsApi/auth/token') .send({ client_id: 'WrongClientID', - client_secret: client.client_secret, - code - }) - .expect(403) - - expect(res.body).toEqual({}) - }) - - it('should respond with Forbidden if client_secret is invalid', async () => { - const code = saveCode(userInfo.client_id, generateAuthCode(userInfo)) - - const res = await request(app) - .post('/SASjsApi/auth/token') - .send({ - client_id: client.client_id, - client_secret: 'WrongClientSecret', code }) .expect(403) @@ -262,4 +241,47 @@ describe('auth', () => { expect(res.body).toEqual({}) }) }) + + describe('logout', () => { + const accessToken = generateAccessToken({ + client_id, + username: user.username + }) + + beforeEach(async () => { + await createUser(user) + await saveTokensInDB( + user.username, + client_id, + accessToken, + 'refreshToken' + ) + }) + + afterEach(async () => { + const collections = mongoose.connection.collections + const collection = collections['users'] + await collection.deleteMany({}) + }) + + afterAll(async () => { + const collections = mongoose.connection.collections + const collection = collections['users'] + await collection.deleteMany({}) + }) + + it('should respond no content and remove access/refresh tokens from DB', async () => { + const res = await request(app) + .delete('/SASjsApi/auth/logout') + .auth(accessToken, { type: 'bearer' }) + .send() + .expect(204) + + expect(res.body).toEqual({}) + + expect( + await verifyTokenInDB(user.username, client_id, accessToken) + ).toBeUndefined() + }) + }) }) diff --git a/src/routes/api/spec/client.spec.ts b/src/routes/api/spec/client.spec.ts index a06cce8..91ce4fd 100644 --- a/src/routes/api/spec/client.spec.ts +++ b/src/routes/api/spec/client.spec.ts @@ -4,8 +4,21 @@ import request from 'supertest' import app from '../../../app' import { createClient } from '../../../controllers/createClient' import { generateAccessToken } from '../auth' +import { createUser } from '../../../controllers/createUser' +import { saveTokensInDB } from '../../../utils' const client = { + client_id: 'someclientID', + client_secret: 'someclientSecret' +} +const adminUser = { + displayname: 'Test Admin', + username: 'testAdminUsername', + password: '12345678', + isadmin: true, + isactive: true +} +const newClient = { client_id: 'newClientID', client_secret: 'newClientSecret' } @@ -27,10 +40,18 @@ describe('user', () => { describe('create', () => { const adminAccessToken = generateAccessToken({ - client_id: 'someClientID', - username: 'someAdminUsername', - isadmin: true, - isactive: true + client_id: client.client_id, + username: adminUser.username + }) + + beforeAll(async () => { + await createUser(adminUser) + await saveTokensInDB( + adminUser.username, + client.client_id, + adminAccessToken, + 'refreshToken' + ) }) afterEach(async () => { @@ -43,17 +64,17 @@ describe('user', () => { const res = await request(app) .post('/SASjsApi/client') .auth(adminAccessToken, { type: 'bearer' }) - .send(client) + .send(newClient) .expect(200) - expect(res.body.client_id).toEqual(client.client_id) - expect(res.body.client_secret).toEqual(client.client_secret) + expect(res.body.client_id).toEqual(newClient.client_id) + expect(res.body.client_secret).toEqual(newClient.client_secret) }) it('should respond with Unauthorized if access token is not present', async () => { const res = await request(app) .post('/SASjsApi/client') - .send(client) + .send(newClient) .expect(401) expect(res.text).toEqual('Unauthorized') @@ -61,17 +82,29 @@ describe('user', () => { }) it('should respond with Forbideen if access token is not of an admin account', async () => { - const accessToken = generateAccessToken({ - client_id: 'someClientID', - username: 'someUsername', + const user = { + displayname: 'User 1', + username: 'username1', + password: '12345678', isadmin: false, isactive: true + } + const accessToken = generateAccessToken({ + client_id: client.client_id, + username: user.username }) + await createUser(user) + await saveTokensInDB( + user.username, + client.client_id, + accessToken, + 'refreshToken' + ) const res = await request(app) .post('/SASjsApi/client') .auth(accessToken, { type: 'bearer' }) - .send(client) + .send(newClient) .expect(403) expect(res.text).toEqual('Admin account required') @@ -79,12 +112,12 @@ describe('user', () => { }) it('should respond with Forbidden if client_id is already present', async () => { - await createClient(client) + await createClient(newClient) const res = await request(app) .post('/SASjsApi/client') .auth(adminAccessToken, { type: 'bearer' }) - .send(client) + .send(newClient) .expect(403) expect(res.text).toEqual('Error: Client ID already exists.') @@ -96,7 +129,7 @@ describe('user', () => { .post('/SASjsApi/client') .auth(adminAccessToken, { type: 'bearer' }) .send({ - ...client, + ...newClient, client_id: undefined }) .expect(400) @@ -110,7 +143,7 @@ describe('user', () => { .post('/SASjsApi/client') .auth(adminAccessToken, { type: 'bearer' }) .send({ - ...client, + ...newClient, client_secret: undefined }) .expect(400) diff --git a/src/routes/api/spec/drive.spec.ts b/src/routes/api/spec/drive.spec.ts index 78f723e..39e528f 100644 --- a/src/routes/api/spec/drive.spec.ts +++ b/src/routes/api/spec/drive.spec.ts @@ -1,3 +1,5 @@ +import mongoose, { Mongoose } from 'mongoose' +import { MongoMemoryServer } from 'mongodb-memory-server' import request from 'supertest' import app from '../../../app' import { getTreeExample } from '../../../controllers/deploy' @@ -5,15 +7,50 @@ import { getTmpFilesFolderPath } from '../../../utils/file' import { folderExists, fileExists, readFile, deleteFolder } from '@sasjs/utils' import path from 'path' import { generateAccessToken } from '../auth' +import { createUser } from '../../../controllers/createUser' +import { saveTokensInDB } from '../../../utils' + +const client = { + clientid: 'someclientID', + clientsecret: 'someclientSecret' +} +const user = { + displayname: 'Test User', + username: 'testUsername', + password: '87654321', + isadmin: false, + isactive: true +} describe('files', () => { - const accessToken = generateAccessToken({ - client_id: 'someClientID', - username: 'username', - isadmin: false, - isactive: true + let con: Mongoose + let mongoServer: MongoMemoryServer + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create() + con = await mongoose.connect(mongoServer.getUri()) + }) + + afterAll(async () => { + await con.connection.dropDatabase() + await con.connection.close() + await mongoServer.stop() }) describe('deploy', () => { + const accessToken = generateAccessToken({ + client_id: client.clientid, + username: user.username + }) + + beforeAll(async () => { + await createUser(user) + await saveTokensInDB( + user.username, + client.clientid, + accessToken, + 'refreshToken' + ) + }) const shouldFailAssertion = async (payload: any) => { const res = await request(app) .post('/SASjsApi/drive/deploy') diff --git a/src/routes/api/spec/user.spec.ts b/src/routes/api/spec/user.spec.ts index 8855875..19ea780 100644 --- a/src/routes/api/spec/user.spec.ts +++ b/src/routes/api/spec/user.spec.ts @@ -4,6 +4,7 @@ import request from 'supertest' import app from '../../../app' import { createUser } from '../../../controllers/createUser' import { generateAccessToken } from '../auth' +import { saveTokensInDB } from '../../../utils' const client = { clientid: 'someclientID', @@ -42,9 +43,17 @@ describe('user', () => { describe('create', () => { const adminAccessToken = generateAccessToken({ client_id: client.clientid, - username: adminUser.username, - isadmin: adminUser.isadmin, - isactive: adminUser.isactive + username: adminUser.username + }) + + beforeEach(async () => { + await createUser(adminUser) + await saveTokensInDB( + adminUser.username, + client.clientid, + adminAccessToken, + 'refreshToken' + ) }) afterEach(async () => { @@ -79,10 +88,15 @@ describe('user', () => { it('should respond with Forbideen if access token is not of an admin account', async () => { const accessToken = generateAccessToken({ client_id: client.clientid, - username: user.username, - isadmin: user.isadmin, - isactive: user.isactive + username: user.username }) + await createUser(user) + await saveTokensInDB( + user.username, + client.clientid, + accessToken, + 'refreshToken' + ) const res = await request(app) .post('/SASjsApi/user') diff --git a/src/routes/api/user.ts b/src/routes/api/user.ts index 7fa712b..4f362b7 100644 --- a/src/routes/api/user.ts +++ b/src/routes/api/user.ts @@ -14,7 +14,8 @@ userRouter.post('/', async (req, res) => { displayname: savedUser.displayname, username: savedUser.username, isadmin: savedUser.isadmin, - isactive: savedUser.isactive + isactive: savedUser.isactive, + tokens: [] }) } catch (err: any) { res.status(403).send(err.toString()) diff --git a/src/types/InfoJWT.ts b/src/types/InfoJWT.ts index 154a48b..4d5b546 100644 --- a/src/types/InfoJWT.ts +++ b/src/types/InfoJWT.ts @@ -1,6 +1,4 @@ export interface InfoJWT { client_id: string username: string - isadmin: boolean - isactive: boolean } diff --git a/src/utils/authenticateToken.ts b/src/utils/authenticateToken.ts new file mode 100644 index 0000000..f572f05 --- /dev/null +++ b/src/utils/authenticateToken.ts @@ -0,0 +1,25 @@ +import jwt from 'jsonwebtoken' +import { verifyTokenInDB } from './verifyTokenInDB' + +export const authenticateToken = (req: any, res: any, next: any) => { + const authHeader = req.headers['authorization'] + const token = authHeader?.split(' ')[1] + if (!token) return res.sendStatus(401) + + jwt.verify( + token, + process.env.ACCESS_TOKEN_SECRET as string, + async (err: any, data: any) => { + if (err) return res.sendStatus(403) + + // verify this valid token's entry in DB + const user = await verifyTokenInDB(data?.username, data?.client_id, token) + + if (user) { + req.user = user + return next() + } + return res.sendStatus(403) + } + ) +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 58f9b73..cb8ef98 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,8 @@ +export * from './authenticateToken' export * from './file' +export * from './removeTokensInDB' +export * from './saveTokensInDB' export * from './sleep' export * from './upload' export * from './validation' +export * from './verifyTokenInDB' diff --git a/src/utils/removeTokensInDB.ts b/src/utils/removeTokensInDB.ts new file mode 100644 index 0000000..b1c6a02 --- /dev/null +++ b/src/utils/removeTokensInDB.ts @@ -0,0 +1,14 @@ +import User from '../model/User' + +export const removeTokensInDB = async (username: string, client_id: string) => { + const user = await User.findOne({ username }) + + const tokenObjIndex = user.tokens.findIndex( + (tokenObj: any) => tokenObj.clientid === client_id + ) + + if (tokenObjIndex > -1) { + user.tokens.splice(tokenObjIndex, 1) + await user.save() + } +} diff --git a/src/utils/saveTokensInDB.ts b/src/utils/saveTokensInDB.ts new file mode 100644 index 0000000..002c730 --- /dev/null +++ b/src/utils/saveTokensInDB.ts @@ -0,0 +1,25 @@ +import User from '../model/User' + +export const saveTokensInDB = async ( + username: string, + client_id: string, + accessToken: string, + refreshToken: string +) => { + const user = await User.findOne({ username }) + + const currentTokenObj = user.tokens.find( + (tokenObj: any) => tokenObj.clientid === client_id + ) + if (currentTokenObj) { + currentTokenObj.accesstoken = accessToken + currentTokenObj.refreshtoken = refreshToken + } else { + user.tokens.push({ + clientid: client_id, + accesstoken: accessToken, + refreshtoken: refreshToken + }) + } + await user.save() +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts index f4f5491..b97f8ff 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -13,7 +13,6 @@ export const authorizeValidation = (data: any): Joi.ValidationResult => 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) diff --git a/src/utils/verifyTokenInDB.ts b/src/utils/verifyTokenInDB.ts new file mode 100644 index 0000000..083f021 --- /dev/null +++ b/src/utils/verifyTokenInDB.ts @@ -0,0 +1,22 @@ +import User from '../model/User' + +export const verifyTokenInDB = async ( + username: string, + client_id: string, + token: string +) => { + const dbUser = await User.findOne({ username }) + + const currentTokenObj = dbUser.tokens.find( + (tokenObj: any) => tokenObj.clientid === client_id + ) + + return currentTokenObj?.accesstoken === token + ? { + client_id, + username, + isadmin: dbUser.isadmin, + isactive: dbUser.isactive + } + : undefined +}