diff --git a/src/controllers/auth.ts b/src/controllers/auth.ts new file mode 100644 index 0000000..002d65a --- /dev/null +++ b/src/controllers/auth.ts @@ -0,0 +1,222 @@ +import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa' +import jwt from 'jsonwebtoken' +import bcrypt from 'bcryptjs' +import User from '../model/User' +import { InfoJWT } from '../types' +import { removeTokensInDB, saveTokensInDB } from '../utils' + +@Route('SASjsApi/auth') +@Tags('Auth') +export default class AuthController { + static authCodes: { [key: string]: { [key: string]: string } } = {} + static saveCode = (userId: number, clientId: string, code: string) => { + if (AuthController.authCodes[userId]) + return (AuthController.authCodes[userId][clientId] = code) + + AuthController.authCodes[userId] = { [clientId]: code } + return AuthController.authCodes[userId][clientId] + } + static deleteCode = (userId: number, clientId: string) => + delete AuthController.authCodes[userId][clientId] + + /** + * Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE + * + */ + @Example({ + code: 'someRandomCryptoString' + }) + @Post('/authorize') + public async authorize( + @Body() body: AuthorizePayload + ): Promise { + return authorize(body) + } + + /** + * Accepts client/auth code and returns access/refresh tokens + * + */ + @Example({ + accessToken: 'someRandomCryptoString', + refreshToken: 'someRandomCryptoString' + }) + @Post('/token') + public async token(@Body() body: TokenPayload): Promise { + return token(body) + } + + /** + * Returns new access/refresh tokens + * + */ + @Example({ + accessToken: 'someRandomCryptoString', + refreshToken: 'someRandomCryptoString' + }) + @Security('bearerAuth') + @Post('/refresh') + public async refresh( + @Query() @Hidden() data?: InfoJWT + ): Promise { + return refresh(data!) + } + + /** + * Logout terminate access/refresh tokens and returns nothing + * + */ + @Security('bearerAuth') + @Post('/logout') + public async logout(@Query() @Hidden() data?: InfoJWT) { + return logout(data!) + } +} + +const authorize = async (data: any): Promise => { + const { username, password, clientId } = data + + // Authenticate User + const user = await User.findOne({ username }) + if (!user) throw new Error('Username is not found.') + + const validPass = await bcrypt.compare(password, user.password) + if (!validPass) throw new Error('Invalid password.') + + // generate authorization code against clientId + const userInfo: InfoJWT = { + clientId, + userId: user.id + } + const code = AuthController.saveCode( + user.id, + clientId, + generateAuthCode(userInfo) + ) + + return { code } +} + +const token = async (data: any): Promise => { + const { clientId, code } = data + + const userInfo = await verifyAuthCode(clientId, code) + if (!userInfo) throw new Error('Invalid Auth Code') + + if (AuthController.authCodes[userInfo.userId][clientId] !== code) + throw new Error('Invalid Auth Code') + + AuthController.deleteCode(userInfo.userId, clientId) + + const accessToken = generateAccessToken(userInfo) + const refreshToken = generateRefreshToken(userInfo) + + await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken) + + return { accessToken, refreshToken } +} + +const refresh = async (userInfo: InfoJWT): Promise => { + const accessToken = generateAccessToken(userInfo) + const refreshToken = generateRefreshToken(userInfo) + + await saveTokensInDB( + userInfo.userId, + userInfo.clientId, + accessToken, + refreshToken + ) + + return { accessToken, refreshToken } +} + +const logout = async (userInfo: InfoJWT) => { + await removeTokensInDB(userInfo.userId, userInfo.clientId) +} + +interface AuthorizePayload { + /** + * Username for user + * @example "johnSnow01" + */ + username: string + /** + * Password for user + * @example "secretpassword" + */ + password: string + /** + * Client ID + * @example "someFormattedClientID1234" + */ + clientId: string +} + +interface AuthorizeResponse { + /** + * Authorization code + * @example "someRandomCryptoString" + */ + code: string +} + +interface TokenPayload { + /** + * Client ID + * @example "someFormattedClientID1234" + */ + clientId: string + /** + * Authorization code + * @example "someRandomCryptoString" + */ + code: string +} + +interface TokenResponse { + /** + * Access Token + * @example "someRandomCryptoString" + */ + accessToken: string + /** + * Refresh Token + * @example "someRandomCryptoString" + */ + refreshToken: string +} + +export const generateAuthCode = (data: InfoJWT) => + jwt.sign(data, process.env.AUTH_CODE_SECRET as string, { + expiresIn: '30s' + }) + +export const generateAccessToken = (data: InfoJWT) => + jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, { + expiresIn: '1h' + }) + +export const generateRefreshToken = (data: InfoJWT) => + jwt.sign(data, process.env.REFRESH_TOKEN_SECRET as string, { + expiresIn: '1day' + }) + +const verifyAuthCode = async ( + clientId: string, + code: string +): Promise => { + 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 = { + clientId: data?.clientId, + userId: data?.userId + } + if (clientInfo.clientId === clientId) { + return resolve(clientInfo) + } + return resolve(undefined) + }) + }) +} diff --git a/src/controllers/user.ts b/src/controllers/user.ts index 54e1d26..e6afa5c 100644 --- a/src/controllers/user.ts +++ b/src/controllers/user.ts @@ -16,13 +16,13 @@ import bcrypt from 'bcryptjs' import User, { UserPayload } from '../model/User' -interface userResponse { +interface UserResponse { id: number username: string displayName: string } -interface userDetailsResponse { +interface UserDetailsResponse { id: number displayName: string username: string @@ -38,7 +38,7 @@ export default class UserController { * Get list of all users (username, displayname). All users can request this. * */ - @Example([ + @Example([ { id: 123, username: 'johnusername', @@ -51,7 +51,7 @@ export default class UserController { } ]) @Get('/') - public async getAllUsers(): Promise { + public async getAllUsers(): Promise { return getAllUsers() } @@ -59,7 +59,7 @@ export default class UserController { * Create user with the following attributes: UserId, UserName, Password, isAdmin, isActive. Admin only task. * */ - @Example({ + @Example({ id: 1234, displayName: 'John Snow', username: 'johnSnow01', @@ -69,7 +69,7 @@ export default class UserController { @Post('/') public async createUser( @Body() body: UserPayload - ): Promise { + ): Promise { return createUser(body) } @@ -79,7 +79,7 @@ export default class UserController { * @example userId 1234 */ @Get('{userId}') - public async getUser(@Path() userId: number): Promise { + public async getUser(@Path() userId: number): Promise { return getUser(userId) } @@ -88,7 +88,7 @@ export default class UserController { * @param userId The user's identifier * @example userId "1234" */ - @Example({ + @Example({ id: 1234, displayName: 'John Snow', username: 'johnSnow01', @@ -99,7 +99,7 @@ export default class UserController { public async updateUser( @Path() userId: number, @Body() body: UserPayload - ): Promise { + ): Promise { return updateUser(userId, body) } @@ -118,12 +118,12 @@ export default class UserController { } } -const getAllUsers = async (): Promise => +const getAllUsers = async (): Promise => await User.find({}) .select({ _id: 0, id: 1, username: 1, displayName: 1 }) .exec() -const createUser = async (data: any): Promise => { +const createUser = async (data: any): Promise => { const { displayName, username, password, isAdmin, isActive } = data // Checking if user is already in the database @@ -145,7 +145,13 @@ const createUser = async (data: any): Promise => { const savedUser = await user.save() - return savedUser + return { + id: savedUser.id, + displayName: savedUser.displayName, + username: savedUser.username, + isActive: savedUser.isActive, + isAdmin: savedUser.isAdmin + } } const getUser = async (id: number) => { diff --git a/src/routes/api/auth.ts b/src/routes/api/auth.ts index 08de2cd..8aedd32 100644 --- a/src/routes/api/auth.ts +++ b/src/routes/api/auth.ts @@ -1,10 +1,9 @@ import express from 'express' -import bcrypt from 'bcryptjs' import mongoose from 'mongoose' import jwt from 'jsonwebtoken' +import AuthController from '../../controllers/auth' import Client from '../../model/Client' -import User from '../../model/User' import { authenticateAccessToken, @@ -22,7 +21,6 @@ import { InfoJWT } from '../../types' const authRouter = express.Router() const clientIDs = new Set() -const authCodes: { [key: string]: { [key: string]: string } } = {} export const populateClients = async () => { const result = await Client.find() @@ -46,127 +44,63 @@ export const connectDB = () => { } } -export const saveCode = (userId: number, clientId: string, code: string) => { - if (authCodes[userId]) return (authCodes[userId][clientId] = code) - - authCodes[userId] = { [clientId]: code } - return authCodes[userId][clientId] -} -export const deleteCode = (userId: number, clientId: string) => - delete authCodes[userId][clientId] - authRouter.post('/authorize', async (req, res) => { - const { error, value } = authorizeValidation(req.body) + const { error, value: body } = authorizeValidation(req.body) if (error) return res.status(400).send(error.details[0].message) - const { username, password, clientId } = value + const { clientId } = body // Verify client ID if (!clientIDs.has(clientId)) { return res.status(403).send('Invalid clientId.') } - // Authenticate User - const user = await User.findOne({ username }) - if (!user) return res.status(403).send('Username is not found.') + const controller = new AuthController() + try { + const response = await controller.authorize(body) - const validPass = await bcrypt.compare(password, user.password) - if (!validPass) return res.status(403).send('Invalid password.') - - // generate authorization code against clientId - const userInfo: InfoJWT = { - clientId, - userId: user.id + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) } - - const code = saveCode(user.id, clientId, generateAuthCode(userInfo)) - - res.json({ code }) }) authRouter.post('/token', async (req, res) => { - const { error, value } = tokenValidation(req.body) + const { error, value: body } = tokenValidation(req.body) if (error) return res.status(400).send(error.details[0].message) - const { clientId, code } = value + const controller = new AuthController() + try { + const response = await controller.token(body) - const userInfo = await verifyAuthCode(clientId, code) - if (!userInfo) return res.sendStatus(403) - - if (authCodes[userInfo.userId][clientId] !== code) return res.sendStatus(403) - - deleteCode(userInfo.userId, clientId) - - const accessToken = generateAccessToken(userInfo) - const refreshToken = jwt.sign( - userInfo, - process.env.REFRESH_TOKEN_SECRET as string - ) - - await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken) - - res.json({ accessToken: accessToken, refreshToken: refreshToken }) + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } }) authRouter.post('/refresh', authenticateRefreshToken, async (req: any, res) => { - const { userId, clientId } = req.user - const userInfo = { - userId, - clientId + const userInfo: InfoJWT = req.user + + const controller = new AuthController() + try { + const response = await controller.refresh(userInfo) + + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) } - - const accessToken = generateAccessToken(userInfo) - const refreshToken = jwt.sign( - userInfo, - process.env.REFRESH_TOKEN_SECRET as string - ) - - await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken) - - res.json({ accessToken: accessToken, refreshToken: refreshToken }) }) authRouter.delete('/logout', authenticateAccessToken, async (req: any, res) => { - const { user } = req + const userInfo: InfoJWT = req.user - await removeTokensInDB(user.username, user.clientId) + const controller = new AuthController() + try { + await controller.logout(userInfo) + } catch (e) {} res.sendStatus(204) }) -export const generateAccessToken = (data: InfoJWT) => - jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, { - expiresIn: '1h' - }) - -export const generateRefreshToken = (data: InfoJWT) => - jwt.sign(data, process.env.REFRESH_TOKEN_SECRET as string, { - expiresIn: '1day' - }) - -export const generateAuthCode = (data: InfoJWT) => - jwt.sign(data, process.env.AUTH_CODE_SECRET as string, { - expiresIn: '30s' - }) - -const verifyAuthCode = async ( - clientId: string, - code: string -): Promise => { - 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 = { - clientId: data?.clientId, - userId: data?.userId - } - if (clientInfo.clientId === clientId) { - return resolve(clientInfo) - } - return resolve(undefined) - }) - }) -} - export default authRouter diff --git a/src/routes/api/spec/auth.spec.ts b/src/routes/api/spec/auth.spec.ts index 9fd5161..80a2bca 100644 --- a/src/routes/api/spec/auth.spec.ts +++ b/src/routes/api/spec/auth.spec.ts @@ -4,13 +4,12 @@ import request from 'supertest' import app from '../../../app' import UserController from '../../../controllers/user' import ClientController from '../../../controllers/client' -import { +import AuthController, { generateAccessToken, generateAuthCode, - generateRefreshToken, - populateClients, - saveCode -} from '../auth' + generateRefreshToken +} from '../../../controllers/auth' +import { populateClients } from '../auth' import { InfoJWT } from '../../../types' import { saveTokensInDB, verifyTokenInDB } from '../../../utils' @@ -115,7 +114,7 @@ describe('auth', () => { }) .expect(403) - expect(res.text).toEqual('Username is not found.') + expect(res.text).toEqual('Error: Username is not found.') expect(res.body).toEqual({}) }) @@ -131,7 +130,7 @@ describe('auth', () => { }) .expect(403) - expect(res.text).toEqual('Invalid password.') + expect(res.text).toEqual('Error: Invalid password.') expect(res.body).toEqual({}) }) @@ -167,7 +166,7 @@ describe('auth', () => { }) it('should respond with access and refresh tokens', async () => { - const code = saveCode( + const code = AuthController.saveCode( userInfo.userId, userInfo.clientId, generateAuthCode(userInfo) @@ -198,7 +197,7 @@ describe('auth', () => { }) it('should respond with Bad Request if clientId is missing', async () => { - const code = saveCode( + const code = AuthController.saveCode( userInfo.userId, userInfo.clientId, generateAuthCode(userInfo) @@ -228,7 +227,7 @@ describe('auth', () => { }) it('should respond with Forbidden if clientId is invalid', async () => { - const code = saveCode( + const code = AuthController.saveCode( userInfo.userId, userInfo.clientId, generateAuthCode(userInfo) diff --git a/src/routes/api/spec/client.spec.ts b/src/routes/api/spec/client.spec.ts index b72796f..742097d 100644 --- a/src/routes/api/spec/client.spec.ts +++ b/src/routes/api/spec/client.spec.ts @@ -4,7 +4,7 @@ import request from 'supertest' import app from '../../../app' import UserController from '../../../controllers/user' import ClientController from '../../../controllers/client' -import { generateAccessToken } from '../auth' +import { generateAccessToken } from '../../../controllers/auth' import { saveTokensInDB } from '../../../utils' const client = { diff --git a/src/routes/api/spec/drive.spec.ts b/src/routes/api/spec/drive.spec.ts index bbc0c83..8f1231f 100644 --- a/src/routes/api/spec/drive.spec.ts +++ b/src/routes/api/spec/drive.spec.ts @@ -7,7 +7,7 @@ import UserController from '../../../controllers/user' import { getTmpFilesFolderPath } from '../../../utils/file' import { folderExists, fileExists, readFile, deleteFolder } from '@sasjs/utils' import path from 'path' -import { generateAccessToken } from '../auth' +import { generateAccessToken } from '../../../controllers/auth' import { saveTokensInDB } from '../../../utils' const clientId = 'someclientID' diff --git a/src/routes/api/spec/user.spec.ts b/src/routes/api/spec/user.spec.ts index c26aa97..99c9150 100644 --- a/src/routes/api/spec/user.spec.ts +++ b/src/routes/api/spec/user.spec.ts @@ -3,7 +3,7 @@ import { MongoMemoryServer } from 'mongodb-memory-server' import request from 'supertest' import app from '../../../app' import UserController from '../../../controllers/user' -import { generateAccessToken } from '../auth' +import { generateAccessToken } from '../../../controllers/auth' import { saveTokensInDB } from '../../../utils' const clientId = 'someclientID' diff --git a/src/utils/removeTokensInDB.ts b/src/utils/removeTokensInDB.ts index eadda1b..735fd00 100644 --- a/src/utils/removeTokensInDB.ts +++ b/src/utils/removeTokensInDB.ts @@ -1,7 +1,7 @@ import User from '../model/User' -export const removeTokensInDB = async (username: string, clientId: string) => { - const user = await User.findOne({ username }) +export const removeTokensInDB = async (userId: number, clientId: string) => { + const user = await User.findOne({ id: userId }) if (!user) return const tokenObjIndex = user.tokens.findIndex( diff --git a/tsoa.json b/tsoa.json index e2a56c5..e3b95da 100644 --- a/tsoa.json +++ b/tsoa.json @@ -18,6 +18,10 @@ { "name": "Client", "description": "Operations about clients" + }, + { + "name": "Auth", + "description": "Operations about auth" } ], "specVersion": 3