From b91ecacb60fc1c2305da868de781da0fdb6da7d2 Mon Sep 17 00:00:00 2001 From: ajdj100 Date: Sat, 13 Dec 2025 17:01:50 -0500 Subject: [PATCH] implemented role and state based authorization --- api/src/middleware/auth.ts | 39 +++++++++++++++++++++++-- api/src/routes/applications.ts | 24 ++++----------- api/src/routes/{auth.js => auth.ts} | 45 +++++++++++++++++++---------- api/src/routes/loa.ts | 10 +++---- api/src/routes/members.js | 6 ++-- api/src/services/memberService.ts | 15 ++++------ ui/src/api/application.ts | 4 ++- ui/src/api/trainingReport.ts | 18 ++++++++---- 8 files changed, 101 insertions(+), 60 deletions(-) rename api/src/routes/{auth.js => auth.ts} (76%) diff --git a/api/src/middleware/auth.ts b/api/src/middleware/auth.ts index 371acb7..19240ab 100644 --- a/api/src/middleware/auth.ts +++ b/api/src/middleware/auth.ts @@ -1,4 +1,6 @@ import { NextFunction, Request, Response } from "express"; +import { MemberState } from "../services/memberService"; +import { stat } from "fs"; export const requireLogin = function (req: Request, res: Response, next: NextFunction) { if (req.user?.id) @@ -7,8 +9,41 @@ export const requireLogin = function (req: Request, res: Response, next: NextFun res.sendStatus(401) } -function requireRole(roleName: string) { +export function requireMemberState(state: MemberState) { return function (req: Request, res: Response, next: NextFunction) { - + if (req.user?.state === state) + next(); + else + res.status(403).send("You must be a member of the 17th RBN to access this resource"); } +} + +export function requireRole(requiredRoles: string | string[]) { + // Normalize the input to always be an array of lowercase required roles + const normalizedRequiredRoles: string[] = Array.isArray(requiredRoles) + ? requiredRoles.map(role => role.toLowerCase()) + : [requiredRoles.toLowerCase()]; + + const DEV_ROLE = 'dev'; + + return function (req: Request, res: Response, next: NextFunction) { + if (!req.user || !req.user.roles) { + // User is not authenticated or has no roles array + return res.sendStatus(401); + } + + const userRolesLowercase = req.user.roles.map(role => role.name.toLowerCase()); + + // Check if the user has *any* of the required roles OR the 'dev' role + const hasAccess = userRolesLowercase.some(userRole => + userRole === DEV_ROLE || normalizedRequiredRoles.includes(userRole) + ); + + if (hasAccess) { + return next(); + } else { + // User is authenticated but does not have the necessary permissions + return res.sendStatus(403); + } + }; } \ No newline at end of file diff --git a/api/src/routes/applications.ts b/api/src/routes/applications.ts index 585f199..412fb2c 100644 --- a/api/src/routes/applications.ts +++ b/api/src/routes/applications.ts @@ -9,7 +9,7 @@ import { ApplicationFull, CommentRow } from "@app/shared/types/application" import { assignUserToStatus } from '../services/statusService'; import { Request, response, Response } from 'express'; import { getUserRoles } from '../services/rolesService'; -import { requireLogin } from '../middleware/auth'; +import { requireLogin, requireRole } from '../middleware/auth'; //get CoC router.get('/coc', async (req: Request, res: Response) => { @@ -48,7 +48,7 @@ router.post('/', [requireLogin], async (req, res) => { }); // GET /application/all -router.get('/all', [requireLogin], async (req, res) => { +router.get('/all', [requireLogin, requireRole("Recruiter")], async (req, res) => { try { const rows = await getApplicationList(); res.status(200).json(rows); @@ -124,22 +124,10 @@ router.get('/me/:id', [requireLogin], async (req: Request, res: Response) => { }); // GET /application/:id -router.get('/:id', [requireLogin], async (req: Request, res: Response) => { +router.get('/:id', [requireLogin, requireRole("Recruiter")], async (req: Request, res: Response) => { let appID = Number(req.params.id); let asAdmin = !!req.query.admin || false; - let user = req.user.id; - //TODO: Replace this with bigger authorization system eventually - if (asAdmin) { - let allowed = (await getUserRoles(user)).some((role) => - role.name.toLowerCase() === 'dev' || - role.name.toLowerCase() === 'recruiter' || - role.name.toLowerCase() === 'administrator') - console.log(allowed) - if (!allowed) { - return res.sendStatus(403) - } - } try { const application = await getApplicationByID(appID); if (application === undefined) @@ -160,7 +148,7 @@ router.get('/:id', [requireLogin], async (req: Request, res: Response) => { }); // POST /application/approve/:id -router.post('/approve/:id', [requireLogin], async (req: Request, res: Response) => { +router.post('/approve/:id', [requireLogin, requireRole("Recruiter")], async (req: Request, res: Response) => { const appID = Number(req.params.id); const approved_by = req.user.id; @@ -189,7 +177,7 @@ router.post('/approve/:id', [requireLogin], async (req: Request, res: Response) }); // POST /application/deny/:id -router.post('/deny/:id', [requireLogin], async (req, res) => { +router.post('/deny/:id', [requireLogin, requireRole("Recruiter")], async (req, res) => { const appID = req.params.id; try { @@ -247,7 +235,7 @@ VALUES(?, ?, ?);` }); // POST /application/:id/comment -router.post('/:id/adminComment', [requireLogin], async (req: Request, res: Response) => { +router.post('/:id/adminComment', [requireLogin, requireRole("Recruiter")], async (req: Request, res: Response) => { const appID = req.params.id; const data = req.body.message; const user = req.user; diff --git a/api/src/routes/auth.js b/api/src/routes/auth.ts similarity index 76% rename from api/src/routes/auth.js rename to api/src/routes/auth.ts index 8b0c379..dafc2e0 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.ts @@ -6,8 +6,11 @@ dotenv.config(); const express = require('express'); const { param } = require('./applications'); const router = express.Router(); +import { Role } from '@app/shared/types/roles'; import pool from '../db'; import { requireLogin } from '../middleware/auth'; +import { getUserRoles } from '../services/rolesService'; +import { getUserState, MemberState } from '../services/memberService'; const querystring = require('querystring'); @@ -22,13 +25,13 @@ passport.use(new OpenIDConnectStrategy({ scope: ['openid', 'profile'] }, async function verify(issuer, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) { - console.log('--- OIDC verify() called ---'); - console.log('issuer:', issuer); - console.log('sub:', sub); - // console.log('profile:', JSON.stringify(profile, null, 2)); - console.log('profile:', profile); - console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2)); - console.log('preferred_username:', jwtClaims?.preferred_username); + // console.log('--- OIDC verify() called ---'); + // console.log('issuer:', issuer); + // console.log('sub:', sub); + // // console.log('profile:', JSON.stringify(profile, null, 2)); + // console.log('profile:', profile); + // console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2)); + // console.log('preferred_username:', jwtClaims?.preferred_username); const con = await pool.getConnection(); try { @@ -67,12 +70,6 @@ router.get('/login', (req, res, next) => { next(); }, passport.authenticate('openidconnect')); -// router.get('/callback', (req, res, next) => { -// passport.authenticate('openidconnect', { -// successRedirect: req.session.redirectTo, -// failureRedirect: process.env.CLIENT_URL -// }) -// }); router.get('/callback', (req, res, next) => { const redirectURI = req.session.redirectTo; @@ -111,15 +108,17 @@ passport.serializeUser(function (user, cb) { passport.deserializeUser(function (user, cb) { process.nextTick(async function () { - const memberID = user.memberId; + const memberID = user.memberId as number; const con = await pool.getConnection(); - var userData; + var userData: { id: number, name: string, roles: Role[], state: MemberState }; try { let userResults = await con.query(`SELECT id, name FROM members WHERE id = ?;`, [memberID]) userData = userResults[0]; - + let userRoles = await getUserRoles(memberID); + userData.roles = userRoles; + userData.state = await getUserState(memberID); } catch (error) { console.error(error) } finally { @@ -129,5 +128,19 @@ passport.deserializeUser(function (user, cb) { }); }); +declare global { + namespace Express { + interface Request { + user: { + id: number; + name: string; + roles: Role[]; + state: MemberState; + }; + } + } +} + + module.exports = router; \ No newline at end of file diff --git a/api/src/routes/loa.ts b/api/src/routes/loa.ts index d86bf47..6c984b1 100644 --- a/api/src/routes/loa.ts +++ b/api/src/routes/loa.ts @@ -5,7 +5,7 @@ import { Request, Response } from 'express'; import pool from '../db'; import { closeLOA, createNewLOA, getAllLOA, getLOAbyID, getLoaTypes, getUserLOA, setLOAExtension } from '../services/loaService'; import { LOARequest } from '@app/shared/types/loa'; -import { requireLogin } from '../middleware/auth'; +import { requireLogin, requireRole } from '../middleware/auth'; router.use(requireLogin); @@ -26,7 +26,7 @@ router.post("/", async (req: Request, res: Response) => { }); //admin posts LOA -router.post("/admin", async (req: Request, res: Response) => { +router.post("/admin", [requireRole("17th Administrator")], async (req: Request, res: Response) => { let LOARequest = req.body as LOARequest; LOARequest.created_by = req.user.id; LOARequest.filed_date = new Date(); @@ -66,7 +66,7 @@ router.get("/history", async (req: Request, res: Response) => { } }) -router.get('/all', async (req, res) => { +router.get('/all', [requireRole("17th Administrator")], async (req, res) => { try { const result = await getAllLOA(); res.status(200).json(result) @@ -104,7 +104,7 @@ router.post('/cancel/:id', async (req: Request, res: Response) => { }) //TODO: enforce admin only -router.post('/adminCancel/:id', async (req: Request, res: Response) => { +router.post('/adminCancel/:id', [requireRole("17th Administrator")], async (req: Request, res: Response) => { let closer = req.user.id; try { await closeLOA(Number(req.params.id), closer); @@ -116,7 +116,7 @@ router.post('/adminCancel/:id', async (req: Request, res: Response) => { }) // TODO: Enforce admin only -router.post('/extend/:id', async (req: Request, res: Response) => { +router.post('/extend/:id', [requireRole("17th Administrator")], async (req: Request, res: Response) => { const to: Date = req.body.to; if (!to) { diff --git a/api/src/routes/members.js b/api/src/routes/members.js index cb16001..24b75c3 100644 --- a/api/src/routes/members.js +++ b/api/src/routes/members.js @@ -2,15 +2,15 @@ const express = require('express'); const router = express.Router(); import pool from '../db'; -import { requireLogin } from '../middleware/auth'; +import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; import { getUserActiveLOA } from '../services/loaService'; -import { getUserData } from '../services/memberService'; +import { getUserData, MemberState } from '../services/memberService'; import { getUserRoles } from '../services/rolesService'; router.use(requireLogin); //get all users -router.get('/', async (req, res) => { +router.get('/', [requireMemberState(MemberState.Member)], async (req, res) => { try { const result = await pool.query( `SELECT diff --git a/api/src/services/memberService.ts b/api/src/services/memberService.ts index 4563a6a..8a467dd 100644 --- a/api/src/services/memberService.ts +++ b/api/src/services/memberService.ts @@ -22,13 +22,8 @@ export async function setUserState(userID: number, state: MemberState) { return await pool.query(sql, [state, userID]); } -declare global { - namespace Express { - interface Request { - user: { - id: number; - name: string; - }; - } - } -} +export async function getUserState(user: number): Promise { + let out = await pool.query(`SELECT state FROM members WHERE id = ?`, [user]); + console.log('hi') + return (out[0].state as MemberState); +} \ No newline at end of file diff --git a/ui/src/api/application.ts b/ui/src/api/application.ts index ec05982..f814b15 100644 --- a/ui/src/api/application.ts +++ b/ui/src/api/application.ts @@ -59,7 +59,9 @@ export async function postAdminChatMessage(message: any, post_id: number) { } export async function getAllApplications(): Promise { - const res = await fetch(`${addr}/application/all`) + const res = await fetch(`${addr}/application/all`, { + credentials: 'include', + }) if (res.ok) { return res.json() diff --git a/ui/src/api/trainingReport.ts b/ui/src/api/trainingReport.ts index a7a04b3..c1b8adc 100644 --- a/ui/src/api/trainingReport.ts +++ b/ui/src/api/trainingReport.ts @@ -4,7 +4,9 @@ import { Course, CourseAttendeeRole, CourseEventDetails, CourseEventSummary } fr const addr = import.meta.env.VITE_APIHOST; export async function getTrainingReports(sortMode: string, search: string): Promise { - const res = await fetch(`${addr}/courseEvent?sort=${sortMode}&search=${search}`); + const res = await fetch(`${addr}/courseEvent?sort=${sortMode}&search=${search}`, { + credentials: 'include', + }); if (res.ok) { return await res.json() as Promise; @@ -15,7 +17,9 @@ export async function getTrainingReports(sortMode: string, search: string): Prom } export async function getTrainingReport(id: number): Promise { - const res = await fetch(`${addr}/courseEvent/${id}`); + const res = await fetch(`${addr}/courseEvent/${id}`, { + credentials: 'include', + }); if (res.ok) { return await res.json() as Promise; @@ -26,10 +30,12 @@ export async function getTrainingReport(id: number): Promise } export async function getAllTrainings(): Promise { - const res = await fetch(`${addr}/course`); + const res = await fetch(`${addr}/course`, { + credentials: 'include', + }); if (res.ok) { - return await res.json() as Promise; + return await res.json() as Promise; } else { console.error("Something went wrong"); throw new Error("Failed to load training list"); @@ -37,7 +43,9 @@ export async function getAllTrainings(): Promise { } export async function getAllAttendeeRoles(): Promise { - const res = await fetch(`${addr}/course/roles`); + const res = await fetch(`${addr}/course/roles`, { + credentials: 'include', + }); if (res.ok) { return await res.json() as Promise;