diff --git a/src/controllers/auth-controller.ts b/src/controllers/auth-controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..f613480e726195eaf0c62dc80a6ea799e72c69ff --- /dev/null +++ b/src/controllers/auth-controller.ts @@ -0,0 +1,47 @@ +import * as AuthService from "../services/auth-service"; +import { NextFunction, Request, Response } from "express"; +import { generateResponse } from "../utils/response"; +import { StatusCodes } from "http-status-codes"; + +const signup = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise<void> => { + try { + const createdUser = await AuthService.signup(req.body); + generateResponse(res, StatusCodes.OK, createdUser); + } catch (err) { + next(err); + } +}; + +// The storage of tokens on the client side follows the recommendations provided by OWASP +// https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html#token-storage-on-client-side +const login = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise<void> => { + try { + const accessTokenAndFingerPrint = await AuthService.login(req.body); + setFingerprintCookie(res, accessTokenAndFingerPrint.fingerprint); + generateResponse(res, StatusCodes.OK); + } catch (err) { + next(err); + } +}; + +const setFingerprintCookie = ( + res: Response, + fingerprint: string, +): void => { + res.cookie("__Secure-fingerprint", fingerprint, { + httpOnly: true, + secure: true, + sameSite: "strict", + maxAge: 60 * 15, // 15 minutes max age (same as access token expiry) + }); +}; + +export { signup, login }; diff --git a/src/cores/app.ts b/src/cores/app.ts index eff68148f16c9d2d86b336cfe875c1fc5ccea232..62f3059dcfdc9da04635c9ccf3ffee983d3edc01 100644 --- a/src/cores/app.ts +++ b/src/cores/app.ts @@ -1,4 +1,5 @@ import express, { Express } from "express"; +import cookieParser from "cookie-parser"; import dotenv from "dotenv"; import apiRouter from "../routers/api"; @@ -8,6 +9,7 @@ export const app: Express = express(); const port: string | undefined = process.env.EXPRESS_PORT; app.use(express.json()); +app.use(cookieParser()); app.use(apiRouter); app.listen(port, () => { diff --git a/src/errors/standard-error.ts b/src/errors/standard-error.ts index 874e89bf1175815707ba24a4ef89607686a92ff1..c7bf854af68d8df4791a0e5473538a27404553f3 100644 --- a/src/errors/standard-error.ts +++ b/src/errors/standard-error.ts @@ -6,10 +6,12 @@ enum ErrorType { WRONG_PASSWORD, PASSWORD_HASH_FAILURE, PASSWORD_VERIFICATION_FAILURE, - TOKEN_GENERATION_FAILURE, - TOKEN_MISSING, - TOKEN_EXPIRED, - TOKEN_NOT_ACTIVE, + ACCESS_TOKEN_GENERATION_FAILURE, + ACCESS_TOKEN_MISSING, + ACCESS_TOKEN_EXPIRED, + ACCESS_TOKEN_NOT_ACTIVE, + AUTHORIZATION_HEADER_NOT_SET, + FINGERPRINT_MISSING, ALBUM_NOT_FOUND, INVALID_API_KEY, } @@ -46,26 +48,36 @@ class StandardError { this.status = StatusCodes.INTERNAL_SERVER_ERROR; break; - case ErrorType.TOKEN_GENERATION_FAILURE: - this.title = "Failed to generate token." + case ErrorType.ACCESS_TOKEN_GENERATION_FAILURE: + this.title = "Failed to generate access token." this.status = StatusCodes.INTERNAL_SERVER_ERROR; break; - case ErrorType.TOKEN_MISSING: + case ErrorType.ACCESS_TOKEN_MISSING: this.title = "Your access token is missing." this.status = StatusCodes.UNAUTHORIZED; break; - case ErrorType.TOKEN_EXPIRED: + case ErrorType.ACCESS_TOKEN_EXPIRED: this.title = "Your access token is expired." this.status = StatusCodes.UNAUTHORIZED; break; - case ErrorType.TOKEN_NOT_ACTIVE: + case ErrorType.ACCESS_TOKEN_NOT_ACTIVE: this.title = "Your access token is not active yet." this.status = StatusCodes.UNAUTHORIZED; break; + case ErrorType.AUTHORIZATION_HEADER_NOT_SET: + this.title = "Authorization header not set." + this.status = StatusCodes.UNAUTHORIZED; + break; + + case ErrorType.FINGERPRINT_MISSING: + this.title = "Fingerprint is missing." + this.status = StatusCodes.UNAUTHORIZED; + break; + case ErrorType.ALBUM_NOT_FOUND: this.title = "Album not found." this.status = StatusCodes.NOT_FOUND; diff --git a/src/middlewares/verify-token.ts b/src/middlewares/verify-token.ts new file mode 100644 index 0000000000000000000000000000000000000000..7273ff9501c563e9516e7c7073a92fde4e78e0c1 --- /dev/null +++ b/src/middlewares/verify-token.ts @@ -0,0 +1,70 @@ +import { NextFunction, Request, Response } from "express"; +import jwt, { NotBeforeError, TokenExpiredError } from "jsonwebtoken"; +import { ErrorType, StandardError } from "../errors/standard-error"; +import { hashFingerprint } from "../utils/token"; + +interface TonalityPayload { + uid: number; + usr: string; + fgp: string; + exp: number; + nbf: number; + iss: string; +} + +// Compare fingerprint in the token payload with +// the fingerprint stored in the cookie +const verifyFingerprint = async ( + fingerprint: string, + d: TonalityPayload, +): Promise<boolean> => { + return (await hashFingerprint(fingerprint)) === d.fgp; +}; + +const verifyToken = async (req: Request, res: Response, next: NextFunction) => { + // Authorization Bearer ${accessToken} + const authHeader = req.headers.authorization; + + if (!authHeader) { + throw new StandardError(ErrorType.AUTHORIZATION_HEADER_NOT_SET); + } + + const accessToken = authHeader.split(" ")[1]; + + if (!accessToken) { + throw new StandardError(ErrorType.ACCESS_TOKEN_MISSING); + } + + const fingerprint = req.cookies["__Secure-fingerprint"]; + + if (!fingerprint) { + throw new StandardError(ErrorType.FINGERPRINT_MISSING); + } + + try { + const decodedPayload = jwt.verify( + accessToken, + process.env.JWT_SHARED_SECRET as string, + { + algorithms: ["HS256"], + issuer: "Tonality REST Service", + }, + ); + + // Will generate run-time error when the decoded payload is not of the type TonalityPayload + // This is a wanted behavior because we want to validate the payload type + await verifyFingerprint(fingerprint, decodedPayload as TonalityPayload); + + next(); // The token is verified, pass to the next middleware + } catch (error) { + if (error instanceof TokenExpiredError) { + throw new StandardError(ErrorType.ACCESS_TOKEN_EXPIRED); + } else if (error instanceof NotBeforeError) { + throw new StandardError(ErrorType.ACCESS_TOKEN_NOT_ACTIVE); + } + + throw error; + } +}; + +export { verifyToken }; diff --git a/src/routers/api.ts b/src/routers/api.ts index 54fdb5e83fa374ef6bf8584866ffacb593cfc927..10eeceb1019d5e6d0f6bd15254a08cd9ef16f770 100644 --- a/src/routers/api.ts +++ b/src/routers/api.ts @@ -1,8 +1,10 @@ import express from "express"; -import premiumAlbumRouter from "./premium-album-router"; +import { premiumAlbumRouter } from "./premium-album-router"; +import { authRouter } from "./auth-router"; const apiRouter = express.Router(); apiRouter.use(premiumAlbumRouter); +apiRouter.use(authRouter); export default apiRouter; diff --git a/src/routers/auth-router.ts b/src/routers/auth-router.ts new file mode 100644 index 0000000000000000000000000000000000000000..baef16a8ba3d68599428da9f1f36117be1e7e7a0 --- /dev/null +++ b/src/routers/auth-router.ts @@ -0,0 +1,11 @@ +import express, { Router } from "express"; +import * as AuthController from "../controllers/auth-controller"; +import { handleStandardError } from "../middlewares/handle-standard-error"; + +const authRouter: Router = express.Router(); + +authRouter.post("/api/signup", AuthController.signup, handleStandardError); + +authRouter.post("/api/login", AuthController.login, handleStandardError); + +export { authRouter }; diff --git a/src/routers/premium-album-router.ts b/src/routers/premium-album-router.ts index f6cb29ae4d75f309e079f5f14feae743b68c9ff5..ad0384d92e8b99f3131a1df96b0f611df07f4309 100644 --- a/src/routers/premium-album-router.ts +++ b/src/routers/premium-album-router.ts @@ -1,31 +1,36 @@ -import express from "express"; +import express, { Router } from "express"; import * as PremiumAlbumController from "../controllers/premium-album-controller"; import { handleStandardError } from "../middlewares/handle-standard-error"; +import { verifyToken } from "../middlewares/verify-token"; -const premiumAlbumRouter = express.Router(); +const premiumAlbumRouter: Router = express.Router(); premiumAlbumRouter.post( "/api/premium-album", + verifyToken, PremiumAlbumController.createPremiumAlbum, handleStandardError, ); premiumAlbumRouter.get( "/api/premium-albums", + verifyToken, PremiumAlbumController.searchPremiumAlbum, handleStandardError, ); premiumAlbumRouter.patch( "/api/premium-album/:premiumAlbumId", + verifyToken, PremiumAlbumController.updatePremiumAlbum, handleStandardError, ); premiumAlbumRouter.delete( "/api/premium-album/:premiumAlbumId", + verifyToken, PremiumAlbumController.deletePremiumAlbum, handleStandardError, ); -export default premiumAlbumRouter; +export { premiumAlbumRouter }; diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b54ea3679ddaf14708c0435dec9ce3ac42a78f6 --- /dev/null +++ b/src/services/auth-service.ts @@ -0,0 +1,68 @@ +import { Prisma } from "@prisma/client"; +import prismaClient from "../cores/db"; + +import { ErrorType, StandardError } from "../errors/standard-error"; +import { hashPassword, isPasswordValid } from "../utils/password"; +import { generateAccessTokenAndFingerprint } from "../utils/token"; + +const signup = async ( + data: Prisma.UserCreateInput, +): Promise<{ userId: number; username: string }> => { + // If username already exists throw error + if ( + (await prismaClient.user.findUnique({ + where: { + username: data.username, + }, + })) !== null + ) { + throw new StandardError(ErrorType.USERNAME_ALREADY_EXISTS); + } + + try { + // Hash the password + const hashedPassword: string = await hashPassword(data.password); + + return await prismaClient.user.create({ + data: { + username: data.username, + password: hashedPassword, + }, + select: { + userId: true, + username: true, + }, + }); + } catch (error) { + throw error; + } +}; + +const login = async (data: { username: string; password: string }) => { + const user = await prismaClient.user.findUnique({ + where: { + username: data.username, + }, + }); + + // If username not found, throw error + if (user === null) { + throw new StandardError(ErrorType.USER_NOT_FOUND); + } + + // If wrong password, throw error + if (!(await isPasswordValid(user.password, data.password))) { + throw new StandardError(ErrorType.WRONG_PASSWORD); + } + + try { + return await generateAccessTokenAndFingerprint({ + userId: user.userId, + username: user.username, + }); + } catch (error) { + throw error; + } +}; + +export { signup, login }; diff --git a/src/utils/password.ts b/src/utils/password.ts new file mode 100644 index 0000000000000000000000000000000000000000..6eda1b60ac08cad019de1794028f24a1f224987a --- /dev/null +++ b/src/utils/password.ts @@ -0,0 +1,23 @@ +import * as argon2 from "argon2"; +import { ErrorType, StandardError } from "../errors/standard-error"; + +const hashPassword = async (plainPassword: string): Promise<string> => { + try { + return await argon2.hash(plainPassword); + } catch (error) { + throw new StandardError(ErrorType.PASSWORD_HASH_FAILURE); + } +}; + +const isPasswordValid = async ( + hashedPassword: string, + plainPassword: string, +): Promise<boolean> => { + try { + return argon2.verify(hashedPassword, plainPassword); + } catch (error) { + throw new StandardError(ErrorType.PASSWORD_VERIFICATION_FAILURE); + } +}; + +export { hashPassword, isPasswordValid }; diff --git a/src/utils/response.ts b/src/utils/response.ts index 653af5c640eea7c32e59681f7d5910032f457ef7..f7195df4c2564fa6b695069c18fcd6a1ef310817 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -5,9 +5,13 @@ import { StandardError } from "../errors/standard-error"; const generateResponse = ( res: Response, status: StatusCodes, - data: any, + data?: any, ): void => { - res.status(status).json(data ? data : null); + if (!data) { + res.status(status); + return; + } + res.status(status).json(data); }; const generateStandardErrorResponse = ( diff --git a/src/utils/token.ts b/src/utils/token.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0afdd8f320d1119c3d83c6c8d887e102bff0250 --- /dev/null +++ b/src/utils/token.ts @@ -0,0 +1,46 @@ +import jwt from "jsonwebtoken"; +import { ErrorType, StandardError } from "../errors/standard-error"; +import * as crypto from "crypto"; + +const generateFingerprint = async (numberOfBytes: number): Promise<string> => { + const randomBytes: Buffer = crypto.randomBytes(numberOfBytes); + return randomBytes.toString("utf8"); +}; + +const hashFingerprint = async (fgp: string): Promise<string> => { + const hash: crypto.Hash = crypto.createHash("sha256"); + hash.update(fgp, "utf8"); + return hash.digest().toString("utf8"); +}; + +const generateAccessTokenAndFingerprint = async (data: { + userId: number, + username: string, +}) => { + try { + const fingerprint: string = await generateFingerprint(64); + const hashedFingerprint: string = await hashFingerprint(fingerprint); + + return { + accessToken: jwt.sign( + { + uid: data.userId, + usr: data.username, + fgp: hashedFingerprint, // User hashed fingerprint + }, + process.env.JWT_SHARED_SECRET as string, + { + algorithm: "HS256", // Only use HS256 to generate JWTs + expiresIn: "15m", // Valid for 15 minutes + notBefore: "0ms", // The token is valid right away + issuer: "Tonality REST Service", + }, + ), + fingerprint: fingerprint, + }; + } catch (error) { + throw new StandardError(ErrorType.ACCESS_TOKEN_GENERATION_FAILURE); + } +}; + +export { generateAccessTokenAndFingerprint, hashFingerprint };