diff --git a/backend/.env.test b/backend/.env.test index fb94aa09..2d928db0 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -8,6 +8,7 @@ ### Dwengo ### DWENGO_PORT=3000 +DWENGO_RUN_MODE=test DWENGO_DB_NAME=":memory:" DWENGO_DB_UPDATE=true diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 49e2159b..0a249c5b 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -1,10 +1,11 @@ import { UnauthorizedException } from '../exceptions/unauthorized-exception.js'; import { getLogger } from '../logging/initalize.js'; import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; -import { createOrUpdateStudent } from '../services/students.js'; -import { createOrUpdateTeacher } from '../services/teachers.js'; import { envVars, getEnvVar } from '../util/envVars.js'; -import { Response } from 'express'; +import { createOrUpdateStudent } from '../services/students.js'; +import { Request, Response } from 'express'; +import { createOrUpdateTeacher } from '../services/teachers.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; interface FrontendIdpConfig { authority: string; @@ -40,6 +41,10 @@ export function getFrontendAuthConfig(): FrontendAuthConfig { }; } +export function handleGetFrontendAuthConfig(_req: Request, res: Response): void { + res.json(getFrontendAuthConfig()); +} + export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise { const auth = req.auth; if (!auth) { @@ -51,7 +56,7 @@ export async function postHelloHandler(req: AuthenticatedRequest, res: Response) firstName: auth.firstName ?? '', lastName: auth.lastName ?? '', }; - if (auth.accountType === 'student') { + if (auth.accountType === AccountType.Student) { await createOrUpdateStudent(userData); logger.debug(`Synchronized student ${userData.username} with IDP`); } else { diff --git a/backend/src/controllers/teacher-invitations.ts b/backend/src/controllers/teacher-invitations.ts index 932bb1af..9e8eee6e 100644 --- a/backend/src/controllers/teacher-invitations.ts +++ b/backend/src/controllers/teacher-invitations.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import { requireFields } from './error-helper.js'; import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { ConflictException } from '../exceptions/conflict-exception.js'; export async function getAllInvitationsHandler(req: Request, res: Response): Promise { const username = req.params.username; @@ -30,6 +31,10 @@ export async function createInvitationHandler(req: Request, res: Response): Prom const classId = req.body.class; requireFields({ sender, receiver, classId }); + if (sender === receiver) { + throw new ConflictException('Cannot send an invitation to yourself'); + } + const data = req.body as TeacherInvitationData; const invitation = await createInvitation(data); diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index b9935b16..f681eebb 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -3,9 +3,9 @@ import { Question } from '../../entities/questions/question.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Student } from '../../entities/users/student.entity.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { Group } from '../../entities/assignments/group.entity.js'; import { Assignment } from '../../entities/assignments/assignment.entity.js'; import { Loaded } from '@mikro-orm/core'; -import { Group } from '../../entities/assignments/group.entity'; export class QuestionRepository extends DwengoEntityRepository { public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise { diff --git a/backend/src/interfaces/user.ts b/backend/src/interfaces/user.ts index f4413b5e..3084c494 100644 --- a/backend/src/interfaces/user.ts +++ b/backend/src/interfaces/user.ts @@ -10,6 +10,10 @@ export function mapToUserDTO(user: User): UserDTO { }; } +export function mapToUsername(user: { username: string }): string { + return user.username; +} + export function mapToUser(userData: UserDTO, userInstance: T): T { userInstance.username = userData.username; userInstance.firstName = userData.firstName; diff --git a/backend/src/middleware/auth/auth.ts b/backend/src/middleware/auth/auth.ts index 73a65b9a..24be4825 100644 --- a/backend/src/middleware/auth/auth.ts +++ b/backend/src/middleware/auth/auth.ts @@ -7,7 +7,6 @@ import * as express from 'express'; import { AuthenticatedRequest } from './authenticated-request.js'; import { AuthenticationInfo } from './authentication-info.js'; import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js'; -import { ForbiddenException } from '../../exceptions/forbidden-exception.js'; const JWKS_CACHE = true; const JWKS_RATE_LIMIT = true; @@ -108,36 +107,3 @@ function addAuthenticationInfo(req: AuthenticatedRequest, _res: express.Response } export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; - -/** - * Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill - * the given access condition. - * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates - * to true. - */ -export function authorize(accessCondition: (auth: AuthenticationInfo) => boolean) { - return (req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): void => { - if (!req.auth) { - throw new UnauthorizedException(); - } else if (!accessCondition(req.auth)) { - throw new ForbiddenException(); - } else { - next(); - } - }; -} - -/** - * Middleware which rejects all unauthenticated users, but accepts all authenticated users. - */ -export const authenticatedOnly = authorize((_) => true); - -/** - * Middleware which rejects requests from unauthenticated users or users that aren't students. - */ -export const studentsOnly = authorize((auth) => auth.accountType === 'student'); - -/** - * Middleware which rejects requests from unauthenticated users or users that aren't teachers. - */ -export const teachersOnly = authorize((auth) => auth.accountType === 'teacher'); diff --git a/backend/src/middleware/auth/authenticated-request.d.ts b/backend/src/middleware/auth/authenticated-request.d.ts index 9737fa7e..af7630af 100644 --- a/backend/src/middleware/auth/authenticated-request.d.ts +++ b/backend/src/middleware/auth/authenticated-request.d.ts @@ -1,8 +1,15 @@ import { Request } from 'express'; import { JwtPayload } from 'jsonwebtoken'; import { AuthenticationInfo } from './authentication-info.js'; +import * as core from 'express-serve-static-core'; -export interface AuthenticatedRequest extends Request { +export interface AuthenticatedRequest< + P = core.ParamsDictionary, + ResBody = unknown, + ReqBody = unknown, + ReqQuery = core.Query, + Locals extends Record = Record, +> extends Request { // Properties are optional since the user is not necessarily authenticated. jwtPayload?: JwtPayload; auth?: AuthenticationInfo; diff --git a/backend/src/middleware/auth/checks/assignment-auth-checks.ts b/backend/src/middleware/auth/checks/assignment-auth-checks.ts new file mode 100644 index 00000000..bd9f51d7 --- /dev/null +++ b/backend/src/middleware/auth/checks/assignment-auth-checks.ts @@ -0,0 +1,21 @@ +import { authorize } from './auth-checks.js'; +import { fetchClass } from '../../../services/classes.js'; +import { fetchAllGroups } from '../../../services/groups.js'; +import { mapToUsername } from '../../../interfaces/user.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +/** + * Expects the path to contain the path parameters 'classId' and 'id' (meaning the ID of the assignment). + * Only allows requests from users who are + * - either teachers of the class the assignment was posted in, + * - or students in a group of the assignment. + */ +export const onlyAllowIfHasAccessToAssignment = authorize(async (auth, req) => { + const { classid: classId, id: assignmentId } = req.params as { classid: string; id: number }; + if (auth.accountType === AccountType.Teacher) { + const clazz = await fetchClass(classId); + return clazz.teachers.map(mapToUsername).includes(auth.username); + } + const groups = await fetchAllGroups(classId, assignmentId); + return groups.some((group) => group.members.map((member) => member.username).includes(auth.username)); +}); diff --git a/backend/src/middleware/auth/checks/auth-checks.ts b/backend/src/middleware/auth/checks/auth-checks.ts new file mode 100644 index 00000000..bf4891a3 --- /dev/null +++ b/backend/src/middleware/auth/checks/auth-checks.ts @@ -0,0 +1,61 @@ +import { AuthenticationInfo } from '../authentication-info.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; +import * as express from 'express'; +import { RequestHandler } from 'express'; +import { UnauthorizedException } from '../../../exceptions/unauthorized-exception.js'; +import { ForbiddenException } from '../../../exceptions/forbidden-exception.js'; +import { envVars, getEnvVar } from '../../../util/envVars.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +/** + * Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill + * the given access condition. + * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates + * to true. + */ +export function authorize>( + accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest) => boolean | Promise +): RequestHandler { + // Bypass authentication during testing + if (getEnvVar(envVars.RunMode) === 'test') { + return async ( + _req: AuthenticatedRequest, + _res: express.Response, + next: express.NextFunction + ): Promise => { + next(); + }; + } + + return async ( + req: AuthenticatedRequest, + _res: express.Response, + next: express.NextFunction + ): Promise => { + if (!req.auth) { + throw new UnauthorizedException(); + } else if (!(await accessCondition(req.auth, req))) { + throw new ForbiddenException(); + } else { + next(); + } + }; +} + +/** + * Middleware which rejects all unauthenticated users, but accepts all authenticated users. + */ +export const authenticatedOnly = authorize((_) => true); +/** + * Middleware which rejects requests from unauthenticated users or users that aren't students. + */ +export const studentsOnly = authorize((auth) => auth.accountType === AccountType.Student); +/** + * Middleware which rejects requests from unauthenticated users or users that aren't teachers. + */ +export const teachersOnly = authorize((auth) => auth.accountType === AccountType.Teacher); +/** + * Middleware which is to be used on requests no normal user should be able to execute. + * Since there is no concept of administrator accounts yet, currently, those requests will always be blocked. + */ +export const adminOnly = authorize(() => false); diff --git a/backend/src/middleware/auth/checks/class-auth-checks.ts b/backend/src/middleware/auth/checks/class-auth-checks.ts new file mode 100644 index 00000000..ea75d21d --- /dev/null +++ b/backend/src/middleware/auth/checks/class-auth-checks.ts @@ -0,0 +1,70 @@ +import { authorize } from './auth-checks.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; +import { fetchClass } from '../../../services/classes.js'; +import { mapToUsername } from '../../../interfaces/user.js'; +import { getAllInvitations } from '../../../services/teacher-invitations.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +async function teaches(teacherUsername: string, classId: string): Promise { + const clazz = await fetchClass(classId); + return clazz.teachers.map(mapToUsername).includes(teacherUsername); +} + +/** + * To be used on a request with path parameters username and classId. + * Only allows requests whose username parameter is equal to the username of the user who is logged in and requests + * whose classId parameter references a class the logged-in user is a teacher of. + */ +export const onlyAllowStudentHimselfAndTeachersOfClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + if (req.params.username === auth.username) { + return true; + } else if (auth.accountType === AccountType.Teacher) { + return teaches(auth.username, req.params.classId); + } + return false; +}); + +/** + * Only let the request pass through if its path parameter "username" is the username of the currently logged-in + * teacher and the path parameter "classId" refers to a class the teacher teaches. + */ +export const onlyAllowTeacherOfClass = authorize( + async (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username && teaches(auth.username, req.params.classId) +); + +/** + * Only let the request pass through if the class id in it refers to a class the current user is in (as a student + * or teacher) + */ +export const onlyAllowIfInClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const classId = req.params.classId ?? req.params.classid ?? req.params.id; + const clazz = await fetchClass(classId); + if (auth.accountType === AccountType.Teacher) { + return clazz.teachers.map(mapToUsername).includes(auth.username); + } + return clazz.students.map(mapToUsername).includes(auth.username); +}); + +export const onlyAllowIfInClassOrInvited = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const classId = req.params.classId ?? req.params.classid ?? req.params.id; + const clazz = await fetchClass(classId); + if (auth.accountType === AccountType.Teacher) { + const invitations = await getAllInvitations(auth.username, false); + return clazz.teachers.map(mapToUsername).includes(auth.username) || invitations.some((invitation) => invitation.classId === classId); + } + return clazz.students.map(mapToUsername).includes(auth.username); +}); + +/** + * Only allows the request to pass if the 'class' property in its body is a class the current user is a member of. + */ +export const onlyAllowOwnClassInBody = authorize(async (auth, req) => { + const classId = (req.body as { class: string })?.class; + const clazz = await fetchClass(classId); + + if (auth.accountType === AccountType.Teacher) { + return clazz.teachers.map(mapToUsername).includes(auth.username); + } + return clazz.students.map(mapToUsername).includes(auth.username); +}); diff --git a/backend/src/middleware/auth/checks/group-auth-checker.ts b/backend/src/middleware/auth/checks/group-auth-checker.ts new file mode 100644 index 00000000..563edf57 --- /dev/null +++ b/backend/src/middleware/auth/checks/group-auth-checker.ts @@ -0,0 +1,26 @@ +import { authorize } from './auth-checks.js'; +import { fetchClass } from '../../../services/classes.js'; +import { fetchGroup } from '../../../services/groups.js'; +import { mapToUsername } from '../../../interfaces/user.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +/** + * Expects the path to contain the path parameters 'classid', 'assignmentid' and 'groupid'. + * Only allows requests from users who are + * - either teachers of the class the assignment for the group was posted in, + * - or students in the group + */ +export const onlyAllowIfHasAccessToGroup = authorize(async (auth, req) => { + const { + classid: classId, + assignmentid: assignmentId, + groupid: groupId, + } = req.params as { classid: string; assignmentid: number; groupid: number }; + + if (auth.accountType === AccountType.Teacher) { + const clazz = await fetchClass(classId); + return clazz.teachers.map(mapToUsername).includes(auth.username); + } // User is student + const group = await fetchGroup(classId, assignmentId, groupId); + return group.members.map(mapToUsername).includes(auth.username); +}); diff --git a/backend/src/middleware/auth/checks/learning-content-auth-checks.ts b/backend/src/middleware/auth/checks/learning-content-auth-checks.ts new file mode 100644 index 00000000..6942b425 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-content-auth-checks.ts @@ -0,0 +1,21 @@ +import { authorize } from './auth-checks'; +import { AuthenticationInfo } from '../authentication-info'; +import { AuthenticatedRequest } from '../authenticated-request'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +/** + * Only allows requests whose learning path personalization query parameters ('forGroup' / 'assignmentNo' / 'classId') + * are + * - either not set + * - or set to a group the user is in, + * - or set to anything if the user is a teacher. + */ +export const onlyAllowPersonalizationForOwnGroup = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const { forGroup, assignmentNo, classId } = req.params; + if (auth.accountType === AccountType.Student && forGroup && assignmentNo && classId) { + // TODO: groupNumber? + // Const group = await fetchGroup(Number(classId), Number(assignmentNo), ) + return false; + } + return true; +}); diff --git a/backend/src/middleware/auth/checks/question-checks.ts b/backend/src/middleware/auth/checks/question-checks.ts new file mode 100644 index 00000000..76ede049 --- /dev/null +++ b/backend/src/middleware/auth/checks/question-checks.ts @@ -0,0 +1,66 @@ +import { authorize } from './auth-checks.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; +import { requireFields } from '../../../controllers/error-helper.js'; +import { getLearningObjectId, getQuestionId } from '../../../controllers/questions.js'; +import { fetchQuestion } from '../../../services/questions.js'; +import { FALLBACK_SEQ_NUM } from '../../../config.js'; +import { fetchAnswer } from '../../../services/answers.js'; +import { mapToUsername } from '../../../interfaces/user.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +export const onlyAllowAuthor = authorize( + (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { author: string }).author === auth.username +); + +export const onlyAllowAuthorRequest = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const question = await fetchQuestion(questionId); + + return question.author.username === auth.username; +}); + +export const onlyAllowAuthorRequestAnswer = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + const seqAnswer = req.params.seqAnswer; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM; + const answer = await fetchAnswer(questionId, sequenceNumber); + + return answer.author.username === auth.username; +}); + +export const onlyAllowIfHasAccessToQuestion = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const question = await fetchQuestion(questionId); + const group = question.inGroup; + + if (auth.accountType === AccountType.Teacher) { + const cls = group.assignment.within; // TODO check if contains full objects + return cls.teachers.map(mapToUsername).includes(auth.username); + } // User is student + return group.members.map(mapToUsername).includes(auth.username); +}); diff --git a/backend/src/middleware/auth/checks/submission-checks.ts b/backend/src/middleware/auth/checks/submission-checks.ts new file mode 100644 index 00000000..893371c2 --- /dev/null +++ b/backend/src/middleware/auth/checks/submission-checks.ts @@ -0,0 +1,28 @@ +import { languageMap } from '@dwengo-1/common/util/language'; +import { LearningObjectIdentifier } from '../../../entities/content/learning-object-identifier.js'; +import { fetchSubmission } from '../../../services/submissions.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { authorize } from './auth-checks.js'; +import { FALLBACK_LANG } from '../../../config.js'; +import { mapToUsername } from '../../../interfaces/user.js'; +import { AccountType } from '@dwengo-1/common/util/account-types'; + +export const onlyAllowSubmitter = authorize( + (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { submitter: string }).submitter === auth.username +); + +export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const { hruid: lohruid, id: submissionNumber } = req.params; + const { language: lang, version: version } = req.query; + + const loId = new LearningObjectIdentifier(lohruid, languageMap[lang as string] ?? FALLBACK_LANG, Number(version)); + const submission = await fetchSubmission(loId, Number(submissionNumber)); + + if (auth.accountType === AccountType.Teacher) { + // Dit kan niet werken om dat al deze objecten niet gepopulate zijn. + return submission.onBehalfOf.assignment.within.teachers.map(mapToUsername).includes(auth.username); + } + + return submission.onBehalfOf.members.map(mapToUsername).includes(auth.username); +}); diff --git a/backend/src/middleware/auth/checks/teacher-invitation-checks.ts b/backend/src/middleware/auth/checks/teacher-invitation-checks.ts new file mode 100644 index 00000000..0c6a790f --- /dev/null +++ b/backend/src/middleware/auth/checks/teacher-invitation-checks.ts @@ -0,0 +1,17 @@ +import { authorize } from './auth-checks.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; + +export const onlyAllowSenderOrReceiver = authorize( + (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username || req.params.receiver === auth.username +); + +export const onlyAllowSender = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username); + +export const onlyAllowSenderBody = authorize( + (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { sender: string }).sender === auth.username +); + +export const onlyAllowReceiverBody = authorize( + (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { receiver: string }).receiver === auth.username +); diff --git a/backend/src/middleware/auth/checks/user-auth-checks.ts b/backend/src/middleware/auth/checks/user-auth-checks.ts new file mode 100644 index 00000000..27228369 --- /dev/null +++ b/backend/src/middleware/auth/checks/user-auth-checks.ts @@ -0,0 +1,8 @@ +import { authorize } from './auth-checks.js'; +import { AuthenticationInfo } from '../authentication-info.js'; +import { AuthenticatedRequest } from '../authenticated-request.js'; + +/** + * Only allow the user whose username is in the path parameter "username" to access the endpoint. + */ +export const preventImpersonation = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username); diff --git a/backend/src/routes/answers.ts b/backend/src/routes/answers.ts index b74f76a0..58179197 100644 --- a/backend/src/routes/answers.ts +++ b/backend/src/routes/answers.ts @@ -1,16 +1,18 @@ import express from 'express'; import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js'; +import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; +import { onlyAllowAuthor, onlyAllowAuthorRequestAnswer, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js'; const router = express.Router({ mergeParams: true }); -router.get('/', getAllAnswersHandler); +router.get('/', authenticatedOnly, getAllAnswersHandler); -router.post('/', createAnswerHandler); +router.post('/', teachersOnly, onlyAllowAuthor, createAnswerHandler); -router.get('/:seqAnswer', getAnswerHandler); +router.get('/:seqAnswer', onlyAllowIfHasAccessToQuestion, getAnswerHandler); -router.delete('/:seqAnswer', deleteAnswerHandler); +router.delete('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, deleteAnswerHandler); -router.put('/:seqAnswer', updateAnswerHandler); +router.put('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, updateAnswerHandler); export default router; diff --git a/backend/src/routes/assignments.ts b/backend/src/routes/assignments.ts index 4503414d..f0250550 100644 --- a/backend/src/routes/assignments.ts +++ b/backend/src/routes/assignments.ts @@ -9,22 +9,25 @@ import { putAssignmentHandler, } from '../controllers/assignments.js'; import groupRouter from './groups.js'; +import { teachersOnly } from '../middleware/auth/checks/auth-checks.js'; +import { onlyAllowIfInClass } from '../middleware/auth/checks/class-auth-checks.js'; +import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js'; const router = express.Router({ mergeParams: true }); -router.get('/', getAllAssignmentsHandler); +router.get('/', teachersOnly, onlyAllowIfInClass, getAllAssignmentsHandler); -router.post('/', createAssignmentHandler); +router.post('/', teachersOnly, onlyAllowIfInClass, createAssignmentHandler); -router.get('/:id', getAssignmentHandler); +router.get('/:id', onlyAllowIfHasAccessToAssignment, getAssignmentHandler); -router.put('/:id', putAssignmentHandler); +router.put('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, putAssignmentHandler); -router.delete('/:id', deleteAssignmentHandler); +router.delete('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteAssignmentHandler); -router.get('/:id/submissions', getAssignmentsSubmissionsHandler); +router.get('/:id/submissions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentsSubmissionsHandler); -router.get('/:id/questions', getAssignmentQuestionsHandler); +router.get('/:id/questions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentQuestionsHandler); router.use('/:assignmentid/groups', groupRouter); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 6f153836..ce9ee866 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,28 +1,35 @@ import express from 'express'; -import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; -import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; +import { handleGetFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; +import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; + const router = express.Router(); // Returns auth configuration for frontend -router.get('/config', (_req, res) => { - res.json(getFrontendAuthConfig()); -}); +router.get('/config', handleGetFrontendAuthConfig); router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => { - /* #swagger.security = [{ "student": [ ] }, { "teacher": [ ] }] */ + /* #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ res.json({ message: 'If you see this, you should be authenticated!' }); }); router.get('/testStudentsOnly', studentsOnly, (_req, res) => { - /* #swagger.security = [{ "student": [ ] }] */ + /* #swagger.security = [{ "studentProduction": [ ] }, { "studentStaging": [ ] }, { "studentDev": [ ] }] */ res.json({ message: 'If you see this, you should be a student!' }); }); router.get('/testTeachersOnly', teachersOnly, (_req, res) => { - /* #swagger.security = [{ "teacher": [ ] }] */ + /* #swagger.security = [{ "teacherProduction": [ ] }, { "teacherStaging": [ ] }, { "teacherDev": [ ] }] */ res.json({ message: 'If you see this, you should be a teacher!' }); }); -router.post('/hello', authenticatedOnly, postHelloHandler); +// This endpoint is called by the client when the user has just logged in. +// It creates or updates the user entity based on the authentication data the endpoint was called with. +router.post( + '/hello', + authenticatedOnly, + /* + #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] +*/ postHelloHandler +); export default router; diff --git a/backend/src/routes/classes.ts b/backend/src/routes/classes.ts index cef6fd72..8a35eb2a 100644 --- a/backend/src/routes/classes.ts +++ b/backend/src/routes/classes.ts @@ -14,33 +14,35 @@ import { putClassHandler, } from '../controllers/classes.js'; import assignmentRouter from './assignments.js'; +import { adminOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; +import { onlyAllowIfInClass, onlyAllowIfInClassOrInvited } from '../middleware/auth/checks/class-auth-checks.js'; const router = express.Router(); -// Root endpoint used to search objects -router.get('/', getAllClassesHandler); +router.get('/', adminOnly, getAllClassesHandler); -router.post('/', createClassHandler); +router.post('/', teachersOnly, createClassHandler); -router.get('/:id', getClassHandler); +router.get('/:id', onlyAllowIfInClassOrInvited, getClassHandler); -router.put('/:id', putClassHandler); +router.put('/:id', teachersOnly, onlyAllowIfInClass, putClassHandler); -router.delete('/:id', deleteClassHandler); +router.delete('/:id', teachersOnly, onlyAllowIfInClass, deleteClassHandler); -router.get('/:id/teacher-invitations', getTeacherInvitationsHandler); +router.get('/:id/teacher-invitations', teachersOnly, onlyAllowIfInClass, getTeacherInvitationsHandler); -router.get('/:id/students', getClassStudentsHandler); +router.get('/:id/students', onlyAllowIfInClass, getClassStudentsHandler); -router.post('/:id/students', addClassStudentHandler); +router.post('/:id/students', teachersOnly, onlyAllowIfInClass, addClassStudentHandler); -router.delete('/:id/students/:username', deleteClassStudentHandler); +router.delete('/:id/students/:username', teachersOnly, onlyAllowIfInClass, deleteClassStudentHandler); -router.get('/:id/teachers', getClassTeachersHandler); +router.get('/:id/teachers', onlyAllowIfInClass, getClassTeachersHandler); -router.post('/:id/teachers', addClassTeacherHandler); +// De combinatie van deze POST en DELETE endpoints kan lethal zijn +router.post('/:id/teachers', teachersOnly, onlyAllowIfInClass, addClassTeacherHandler); -router.delete('/:id/teachers/:username', deleteClassTeacherHandler); +router.delete('/:id/teachers/:username', teachersOnly, onlyAllowIfInClass, deleteClassTeacherHandler); router.use('/:classid/assignments', assignmentRouter); diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts index 3043c23b..e8cb4c2d 100644 --- a/backend/src/routes/groups.ts +++ b/backend/src/routes/groups.ts @@ -8,22 +8,24 @@ import { getGroupSubmissionsHandler, putGroupHandler, } from '../controllers/groups.js'; +import { onlyAllowIfHasAccessToGroup } from '../middleware/auth/checks/group-auth-checker.js'; +import { teachersOnly } from '../middleware/auth/checks/auth-checks.js'; +import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js'; const router = express.Router({ mergeParams: true }); -// Root endpoint used to search objects -router.get('/', getAllGroupsHandler); +router.get('/', onlyAllowIfHasAccessToAssignment, getAllGroupsHandler); -router.post('/', createGroupHandler); +router.post('/', teachersOnly, onlyAllowIfHasAccessToAssignment, createGroupHandler); -router.get('/:groupid', getGroupHandler); +router.get('/:groupid', onlyAllowIfHasAccessToAssignment, getGroupHandler); -router.put('/:groupid', putGroupHandler); +router.put('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, putGroupHandler); -router.delete('/:groupid', deleteGroupHandler); +router.delete('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteGroupHandler); -router.get('/:groupid/submissions', getGroupSubmissionsHandler); +router.get('/:groupid/submissions', onlyAllowIfHasAccessToGroup, getGroupSubmissionsHandler); -router.get('/:groupid/questions', getGroupQuestionsHandler); +router.get('/:groupid/questions', onlyAllowIfHasAccessToGroup, getGroupQuestionsHandler); export default router; diff --git a/backend/src/routes/learning-objects.ts b/backend/src/routes/learning-objects.ts index 7532765b..f53f208a 100644 --- a/backend/src/routes/learning-objects.ts +++ b/backend/src/routes/learning-objects.ts @@ -1,8 +1,8 @@ import express from 'express'; import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; - import submissionRoutes from './submissions.js'; import questionRoutes from './questions.js'; +import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js'; const router = express.Router(); @@ -16,13 +16,13 @@ const router = express.Router(); // Route 2: list of object data // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie -router.get('/', getAllLearningObjects); +router.get('/', authenticatedOnly, getAllLearningObjects); // Parameter: hruid of learning object // Query: language // Route to fetch data of one learning object based on its hruid // Example: http://localhost:3000/learningObject/un_ai7 -router.get('/:hruid', getLearningObject); +router.get('/:hruid', authenticatedOnly, getLearningObject); router.use('/:hruid/submissions', submissionRoutes); @@ -32,12 +32,12 @@ router.use('/:hruid/:version/questions', questionRoutes); // Query: language, version (optional) // Route to fetch the HTML rendering of one learning object based on its hruid. // Example: http://localhost:3000/learningObject/un_ai7/html -router.get('/:hruid/html', getLearningObjectHTML); +router.get('/:hruid/html', authenticatedOnly, getLearningObjectHTML); // Parameter: hruid of learning object, name of attachment. // Query: language, version (optional). // Route to get the raw data of the attachment for one learning object based on its hruid. // Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png -router.get('/:hruid/html/:attachmentName', getAttachment); +router.get('/:hruid/html/:attachmentName', authenticatedOnly, getAttachment); export default router; diff --git a/backend/src/routes/learning-paths.ts b/backend/src/routes/learning-paths.ts index efe17312..59b85e62 100644 --- a/backend/src/routes/learning-paths.ts +++ b/backend/src/routes/learning-paths.ts @@ -1,5 +1,6 @@ import express from 'express'; import { getLearningPaths } from '../controllers/learning-paths.js'; +import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js'; const router = express.Router(); @@ -22,6 +23,6 @@ const router = express.Router(); // Route to fetch learning paths based on a theme // Example: http://localhost:3000/learningPath?theme=kiks -router.get('/', getLearningPaths); +router.get('/', authenticatedOnly, getLearningPaths); export default router; diff --git a/backend/src/routes/questions.ts b/backend/src/routes/questions.ts index 5135c197..6cad3c01 100644 --- a/backend/src/routes/questions.ts +++ b/backend/src/routes/questions.ts @@ -1,20 +1,25 @@ import express from 'express'; import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js'; import answerRoutes from './answers.js'; +import { authenticatedOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js'; +import { updateAnswerHandler } from '../controllers/answers.js'; +import { onlyAllowAuthor, onlyAllowAuthorRequest, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js'; const router = express.Router({ mergeParams: true }); // Query language // Root endpoint used to search objects -router.get('/', getAllQuestionsHandler); +router.get('/', authenticatedOnly, getAllQuestionsHandler); -router.post('/', createQuestionHandler); - -router.delete('/:seq', deleteQuestionHandler); +router.post('/', studentsOnly, onlyAllowAuthor, createQuestionHandler); // Information about a question with id -router.get('/:seq', getQuestionHandler); +router.get('/:seq', onlyAllowIfHasAccessToQuestion, getQuestionHandler); + +router.delete('/:seq', studentsOnly, onlyAllowAuthorRequest, deleteQuestionHandler); + +router.put('/:seq', studentsOnly, onlyAllowAuthorRequest, updateAnswerHandler); router.use('/:seq/answers', answerRoutes); diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts index 99d4312c..ae141913 100644 --- a/backend/src/routes/router.ts +++ b/backend/src/routes/router.ts @@ -18,12 +18,30 @@ router.get('/', (_, res: Response) => { }); }); -router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); -router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */); -router.use('/class', classRouter /* #swagger.tags = ['Class'] */); router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); -router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); -router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); -router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); +router.use( + '/class', + classRouter /* #swagger.tags = ['Class'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); +router.use( + '/learningObject', + learningObjectRoutes /* #swagger.tags = ['Learning Object'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); +router.use( + '/learningPath', + learningPathRoutes /* #swagger.tags = ['Learning Path'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); +router.use( + '/student', + studentRouter /* #swagger.tags = ['Student'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); +router.use( + '/teacher', + teacherRouter /* #swagger.tags = ['Teacher'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); +router.use( + '/theme', + themeRoutes /* #swagger.tags = ['Theme'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ +); export default router; diff --git a/backend/src/routes/student-join-requests.ts b/backend/src/routes/student-join-requests.ts index daf79f09..a49984c7 100644 --- a/backend/src/routes/student-join-requests.ts +++ b/backend/src/routes/student-join-requests.ts @@ -5,15 +5,19 @@ import { getStudentRequestHandler, getStudentRequestsHandler, } from '../controllers/students.js'; +import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; +import { onlyAllowStudentHimselfAndTeachersOfClass } from '../middleware/auth/checks/class-auth-checks.js'; + +// Under /:username/joinRequests/ const router = express.Router({ mergeParams: true }); -router.get('/', getStudentRequestsHandler); +router.get('/', preventImpersonation, getStudentRequestsHandler); -router.post('/', createStudentRequestHandler); +router.post('/', preventImpersonation, createStudentRequestHandler); -router.get('/:classId', getStudentRequestHandler); +router.get('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, getStudentRequestHandler); -router.delete('/:classId', deleteClassJoinRequestHandler); +router.delete('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, deleteClassJoinRequestHandler); export default router; diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts index 0f5d5349..9ecf4688 100644 --- a/backend/src/routes/students.ts +++ b/backend/src/routes/students.ts @@ -11,33 +11,37 @@ import { getStudentSubmissionsHandler, } from '../controllers/students.js'; import joinRequestRouter from './student-join-requests.js'; +import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; +import { adminOnly } from '../middleware/auth/checks/auth-checks.js'; const router = express.Router(); // Root endpoint used to search objects -router.get('/', getAllStudentsHandler); +router.get('/', adminOnly, getAllStudentsHandler); -router.post('/', createStudentHandler); +// Users will be created automatically when some resource is created for them. Therefore, this endpoint +// Can only be used by an administrator. +router.post('/', adminOnly, createStudentHandler); -router.delete('/:username', deleteStudentHandler); +router.delete('/:username', preventImpersonation, deleteStudentHandler); // Information about a student's profile -router.get('/:username', getStudentHandler); +router.get('/:username', preventImpersonation, getStudentHandler); // The list of classes a student is in -router.get('/:username/classes', getStudentClassesHandler); +router.get('/:username/classes', preventImpersonation, getStudentClassesHandler); // The list of submissions a student has made -router.get('/:username/submissions', getStudentSubmissionsHandler); +router.get('/:username/submissions', preventImpersonation, getStudentSubmissionsHandler); // The list of assignments a student has -router.get('/:username/assignments', getStudentAssignmentsHandler); +router.get('/:username/assignments', preventImpersonation, getStudentAssignmentsHandler); // The list of groups a student is in -router.get('/:username/groups', getStudentGroupsHandler); +router.get('/:username/groups', preventImpersonation, getStudentGroupsHandler); // A list of questions a user has created -router.get('/:username/questions', getStudentQuestionsHandler); +router.get('/:username/questions', preventImpersonation, getStudentQuestionsHandler); router.use('/:username/joinRequests', joinRequestRouter); diff --git a/backend/src/routes/submissions.ts b/backend/src/routes/submissions.ts index fc0aa7c6..88309ce8 100644 --- a/backend/src/routes/submissions.ts +++ b/backend/src/routes/submissions.ts @@ -1,15 +1,15 @@ import express from 'express'; import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js'; +import { onlyAllowIfHasAccessToSubmission, onlyAllowSubmitter } from '../middleware/auth/checks/submission-checks.js'; +import { adminOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js'; const router = express.Router({ mergeParams: true }); -// Root endpoint used to search objects -router.get('/', getSubmissionsHandler); +router.get('/', adminOnly, getSubmissionsHandler); -router.post('/', createSubmissionHandler); +router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler); -// Information about an submission with id 'id' -router.get('/:id', getSubmissionHandler); +router.get('/:id', onlyAllowIfHasAccessToSubmission, getSubmissionHandler); -router.delete('/:id', deleteSubmissionHandler); +router.delete('/:id', onlyAllowIfHasAccessToSubmission, deleteSubmissionHandler); export default router; diff --git a/backend/src/routes/teacher-invitations.ts b/backend/src/routes/teacher-invitations.ts index 23b943d0..90117088 100644 --- a/backend/src/routes/teacher-invitations.ts +++ b/backend/src/routes/teacher-invitations.ts @@ -6,17 +6,24 @@ import { getInvitationHandler, updateInvitationHandler, } from '../controllers/teacher-invitations.js'; +import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; +import { + onlyAllowReceiverBody, + onlyAllowSender, + onlyAllowSenderBody, + onlyAllowSenderOrReceiver, +} from '../middleware/auth/checks/teacher-invitation-checks.js'; const router = express.Router({ mergeParams: true }); -router.get('/:username', getAllInvitationsHandler); +router.get('/:username', preventImpersonation, getAllInvitationsHandler); -router.get('/:sender/:receiver/:classId', getInvitationHandler); +router.get('/:sender/:receiver/:classId', onlyAllowSenderOrReceiver, getInvitationHandler); -router.post('/', createInvitationHandler); +router.post('/', onlyAllowSenderBody, createInvitationHandler); -router.put('/', updateInvitationHandler); +router.put('/', onlyAllowReceiverBody, updateInvitationHandler); -router.delete('/:sender/:receiver/:classId', deleteInvitationHandler); +router.delete('/:sender/:receiver/:classId', onlyAllowSender, deleteInvitationHandler); export default router; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts index b858102d..cb2405aa 100644 --- a/backend/src/routes/teachers.ts +++ b/backend/src/routes/teachers.ts @@ -10,25 +10,27 @@ import { updateStudentJoinRequestHandler, } from '../controllers/teachers.js'; import invitationRouter from './teacher-invitations.js'; - +import { adminOnly } from '../middleware/auth/checks/auth-checks.js'; +import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; +import { onlyAllowTeacherOfClass } from '../middleware/auth/checks/class-auth-checks.js'; const router = express.Router(); // Root endpoint used to search objects -router.get('/', getAllTeachersHandler); +router.get('/', adminOnly, getAllTeachersHandler); -router.post('/', createTeacherHandler); +router.post('/', adminOnly, createTeacherHandler); -router.get('/:username', getTeacherHandler); +router.get('/:username', preventImpersonation, getTeacherHandler); -router.delete('/:username', deleteTeacherHandler); +router.delete('/:username', preventImpersonation, deleteTeacherHandler); -router.get('/:username/classes', getTeacherClassHandler); +router.get('/:username/classes', preventImpersonation, getTeacherClassHandler); -router.get('/:username/students', getTeacherStudentHandler); +router.get('/:username/students', preventImpersonation, getTeacherStudentHandler); -router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); +router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler); -router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler); +router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler); // Invitations to other classes a teacher received router.use('/invitations', invitationRouter); diff --git a/backend/src/routes/themes.ts b/backend/src/routes/themes.ts index b135d44f..6310c2ab 100644 --- a/backend/src/routes/themes.ts +++ b/backend/src/routes/themes.ts @@ -1,14 +1,15 @@ import express from 'express'; import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; +import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js'; const router = express.Router(); // Query: language // Route to fetch list of {key, title, description, image} themes in their respective language -router.get('/', getThemesHandler); +router.get('/', authenticatedOnly, getThemesHandler); // Arg: theme (key) // Route to fetch list of hruids based on theme -router.get('/:theme', getHruidsByThemeHandler); +router.get('/:theme', authenticatedOnly, getHruidsByThemeHandler); export default router; diff --git a/backend/src/services/answers.ts b/backend/src/services/answers.ts index ab603883..7ec5773a 100644 --- a/backend/src/services/answers.ts +++ b/backend/src/services/answers.ts @@ -34,7 +34,7 @@ export async function createAnswer(questionId: QuestionId, answerData: AnswerDat return mapToAnswerDTO(answer); } -async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise { +export async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise { const answerRepository = getAnswerRepository(); const question = await fetchQuestion(questionId); const answer = await answerRepository.findAnswer(question, sequenceNumber); diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts index b75fe82f..e5026020 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -34,6 +34,15 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou return group; } +export async function fetchAllGroups(classId: string, assignmentNumber: number): Promise { + const assignment = await fetchAssignment(classId, assignmentNumber); + + const groupRepository = getGroupRepository(); + const groups = await groupRepository.findAllGroupsForAssignment(assignment); + + return groups; +} + export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { const group = await fetchGroup(classId, assignmentNumber, groupNumber); return mapToGroupDTO(group, group.assignment.within); diff --git a/backend/src/services/questions.ts b/backend/src/services/questions.ts index 09643cd2..c6d978d8 100644 --- a/backend/src/services/questions.ts +++ b/backend/src/services/questions.ts @@ -13,6 +13,7 @@ import { fetchStudent } from './students.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { FALLBACK_VERSION_NUM } from '../config.js'; import { fetchAssignment } from './assignments.js'; +import { ConflictException } from '../exceptions/conflict-exception.js'; export async function getQuestionsAboutLearningObjectInAssignment( loId: LearningObjectIdentifier, @@ -99,10 +100,18 @@ export async function createQuestion(loId: LearningObjectIdentifier, questionDat const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber); + if (!inGroup) { + throw new NotFoundException('Group with id and assignment not found'); + } + + if (!inGroup.members.contains(author)) { + throw new ConflictException('Author is not part of this group'); + } + const question = await questionRepository.createQuestion({ loId, author, - inGroup: inGroup!, + inGroup: inGroup, content, }); diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts index 809b23e4..3ccd2dba 100644 --- a/backend/src/services/students.ts +++ b/backend/src/services/students.ts @@ -24,7 +24,8 @@ import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/subm import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; import { ConflictException } from '../exceptions/conflict-exception.js'; -import { Submission } from '../entities/assignments/submission.entity'; +import { Submission } from '../entities/assignments/submission.entity.js'; +import { mapToUsername } from '../interfaces/user.js'; export async function getAllStudents(full: boolean): Promise { const studentRepository = getStudentRepository(); @@ -34,7 +35,7 @@ export async function getAllStudents(full: boolean): Promise user.username); + return users.map(mapToUsername); } export async function fetchStudent(username: string): Promise { @@ -64,7 +65,7 @@ export async function createStudent(userData: StudentDTO): Promise { const newStudent = mapToStudent(userData); await studentRepository.save(newStudent, { preventOverwrite: true }); - return userData; + return mapToStudentDTO(newStudent); } export async function createOrUpdateStudent(userData: StudentDTO): Promise { diff --git a/backend/src/services/teacher-invitations.ts b/backend/src/services/teacher-invitations.ts index aead8715..0457496f 100644 --- a/backend/src/services/teacher-invitations.ts +++ b/backend/src/services/teacher-invitations.ts @@ -32,6 +32,10 @@ export async function createInvitation(data: TeacherInvitationData): Promise { const teacherRepository: TeacherRepository = getTeacherRepository(); @@ -26,7 +27,7 @@ export async function getAllTeachers(full: boolean): Promise user.username); + return users.map(mapToUsername); } export async function fetchTeacher(username: string): Promise { @@ -45,7 +46,8 @@ export async function getTeacher(username: string): Promise { return mapToTeacherDTO(user); } -export async function createTeacher(userData: TeacherDTO): Promise { +// TODO update parameter +export async function createTeacher(userData: TeacherDTO, _update?: boolean): Promise { const teacherRepository: TeacherRepository = getTeacherRepository(); const newTeacher = mapToTeacher(userData); @@ -98,7 +100,9 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro const classIds: string[] = classes.map((cls) => cls.id); - const students: StudentDTO[] = (await Promise.all(classIds.map(async (username) => await getClassStudentsDTO(username)))).flat(); + const students: StudentDTO[] = (await Promise.all(classIds.map(async (classId) => await getClassStudentsDTO(classId)))) + .flat() + .filter((student, index, self) => self.findIndex((s) => s.username === student.username) === index); if (full) { return students; diff --git a/backend/tests/controllers/teachers.test.ts b/backend/tests/controllers/teachers.test.ts index 720365a4..fcf80d90 100644 --- a/backend/tests/controllers/teachers.test.ts +++ b/backend/tests/controllers/teachers.test.ts @@ -15,7 +15,6 @@ import { import { BadRequestException } from '../../src/exceptions/bad-request-exception.js'; import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; import { getStudentRequestsHandler } from '../../src/controllers/students.js'; -import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; import { getClassHandler } from '../../src/controllers/classes'; import { getClass02 } from '../test_assets/classes/classes.testdata'; @@ -97,7 +96,7 @@ describe('Teacher controllers', () => { }); it('Teacher list', async () => { - req = { query: { full: 'true' } }; + req = { query: { full: 'false' } }; await getAllTeachersHandler(req as Request, res as Response); @@ -105,8 +104,7 @@ describe('Teacher controllers', () => { const result = jsonMock.mock.lastCall?.[0]; - const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username); - expect(teacherUsernames).toContain('testleerkracht1'); + expect(result.teachers).toContain('testleerkracht1'); expect(result.teachers).toHaveLength(5); }); diff --git a/common/src/interfaces/question.ts b/common/src/interfaces/question.ts index 172d14b7..2d681fc0 100644 --- a/common/src/interfaces/question.ts +++ b/common/src/interfaces/question.ts @@ -13,8 +13,8 @@ export interface QuestionDTO { export interface QuestionData { author?: string; - content: string; inGroup: GroupDTO; + content: string; } export interface QuestionId { diff --git a/common/src/util/account-types.ts b/common/src/util/account-types.ts new file mode 100644 index 00000000..f0957019 --- /dev/null +++ b/common/src/util/account-types.ts @@ -0,0 +1,4 @@ +export enum AccountType { + Student = 'student', + Teacher = 'teacher', +} diff --git a/docs/api/generate.ts b/docs/api/generate.ts index 796369d1..07523a32 100644 --- a/docs/api/generate.ts +++ b/docs/api/generate.ts @@ -26,7 +26,59 @@ const doc = { ], components: { securitySchemes: { - student: { + studentDev: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost:7080/realms/student/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + teacherDev: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost:7080/realms/teacher/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + studentStaging: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost/idp/realms/student/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + teacherStaging: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost/idp/realms/teacher/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + studentProduction: { type: 'oauth2', flows: { implicit: { @@ -39,7 +91,7 @@ const doc = { }, }, }, - teacher: { + teacherProduction: { type: 'oauth2', flows: { implicit: { diff --git a/frontend/package.json b/frontend/package.json index 0826edae..86232620 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "test:e2e": "playwright test" }, "dependencies": { + "@dwengo-1/common": "^0.2.0", "@tanstack/react-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0", "@vueuse/core": "^13.1.0", diff --git a/frontend/src/components/SingleQuestion.vue b/frontend/src/components/SingleQuestion.vue index 8d53b6e1..2a600d23 100644 --- a/frontend/src/components/SingleQuestion.vue +++ b/frontend/src/components/SingleQuestion.vue @@ -6,6 +6,7 @@ import type { AnswersResponse } from "@/controllers/answers"; import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer"; import authService from "@/services/auth/auth-service"; + import { AccountType } from "@dwengo-1/common/util/account-types"; const props = defineProps<{ question: QuestionDTO; @@ -80,7 +81,7 @@ {{ question.content }}
{ - await auth.loginAs("student"); + await auth.loginAs(AccountType.Student); } async function loginAsTeacher(): Promise { - await auth.loginAs("teacher"); + await auth.loginAs(AccountType.Teacher); } diff --git a/frontend/src/views/assignments/CreateAssignment.vue b/frontend/src/views/assignments/CreateAssignment.vue index 50dad4d1..02ce3c15 100644 --- a/frontend/src/views/assignments/CreateAssignment.vue +++ b/frontend/src/views/assignments/CreateAssignment.vue @@ -14,6 +14,7 @@ import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; import { useCreateAssignmentMutation } from "@/queries/assignments.ts"; import { useRoute } from "vue-router"; + import { AccountType } from "@dwengo-1/common/util/account-types"; const route = useRoute(); const router = useRouter(); @@ -23,7 +24,7 @@ onMounted(async () => { // Redirect student - if (role.value === "student") { + if (role.value === AccountType.Student) { await router.push("/user"); } diff --git a/frontend/src/views/assignments/SingleAssignment.vue b/frontend/src/views/assignments/SingleAssignment.vue index 3d9f7f0a..b77130ee 100644 --- a/frontend/src/views/assignments/SingleAssignment.vue +++ b/frontend/src/views/assignments/SingleAssignment.vue @@ -8,9 +8,10 @@ import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; + import { AccountType } from "@dwengo-1/common/util/account-types"; const role = auth.authState.activeRole; - const isTeacher = computed(() => role === "teacher"); + const isTeacher = computed(() => role === AccountType.Teacher); const route = useRoute(); const classId = ref(route.params.classId as string); diff --git a/frontend/src/views/assignments/UserAssignments.vue b/frontend/src/views/assignments/UserAssignments.vue index 476ee197..2974d34e 100644 --- a/frontend/src/views/assignments/UserAssignments.vue +++ b/frontend/src/views/assignments/UserAssignments.vue @@ -9,6 +9,7 @@ import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; import { asyncComputed } from "@vueuse/core"; import { useDeleteAssignmentMutation } from "@/queries/assignments.ts"; + import { AccountType } from "@dwengo-1/common/util/account-types"; import "../../assets/common.css"; const { t, locale } = useI18n(); @@ -17,7 +18,7 @@ const role = ref(auth.authState.activeRole); const username = ref(""); - const isTeacher = computed(() => role.value === "teacher"); + const isTeacher = computed(() => role.value === AccountType.Teacher); // Fetch and store all the teacher's classes let classesQueryResults = undefined; diff --git a/frontend/src/views/classes/ClassDisplay.vue b/frontend/src/views/classes/ClassDisplay.vue new file mode 100644 index 00000000..96bc8ea1 --- /dev/null +++ b/frontend/src/views/classes/ClassDisplay.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/views/classes/SingleClass.vue b/frontend/src/views/classes/SingleClass.vue index 9cdfe4f3..f40b1b7b 100644 --- a/frontend/src/views/classes/SingleClass.vue +++ b/frontend/src/views/classes/SingleClass.vue @@ -77,7 +77,7 @@ }, onError: (e) => { dialog.value = false; - showSnackbar(t("failed") + ": " + e.message, "error"); + showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error"); }, }, ); @@ -105,7 +105,7 @@ } }, onError: (e) => { - showSnackbar(t("failed") + ": " + e.message, "error"); + showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error"); }, }, ); @@ -126,7 +126,7 @@ usernameTeacher.value = ""; }, onError: (e) => { - showSnackbar(t("failed") + ": " + e.message, "error"); + showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error"); }, }); } diff --git a/frontend/src/views/classes/StudentClasses.vue b/frontend/src/views/classes/StudentClasses.vue index 0b5bb0ac..27cdde73 100644 --- a/frontend/src/views/classes/StudentClasses.vue +++ b/frontend/src/views/classes/StudentClasses.vue @@ -100,7 +100,7 @@ showSnackbar(t("sent"), "success"); }, onError: (e) => { - showSnackbar(t("failed") + ": " + e.message, "error"); + showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error"); }, }, ); diff --git a/frontend/src/views/classes/TeacherClasses.vue b/frontend/src/views/classes/TeacherClasses.vue index 3ca32264..c818a046 100644 --- a/frontend/src/views/classes/TeacherClasses.vue +++ b/frontend/src/views/classes/TeacherClasses.vue @@ -8,7 +8,7 @@ import { useTeacherClassesQuery } from "@/queries/teachers"; import type { ClassesResponse } from "@/controllers/classes"; import UsingQueryResult from "@/components/UsingQueryResult.vue"; - import { useClassesQuery, useCreateClassMutation } from "@/queries/classes"; + import { useCreateClassMutation } from "@/queries/classes"; import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations"; import { useRespondTeacherInvitationMutation, @@ -16,6 +16,7 @@ } from "@/queries/teacher-invitations"; import { useDisplay } from "vuetify"; import "../../assets/common.css"; + import ClassDisplay from "@/views/classes/ClassDisplay.vue"; const { t } = useI18n(); @@ -41,7 +42,6 @@ // Fetch all classes of the logged in teacher const classesQuery = useTeacherClassesQuery(username, true); - const allClassesQuery = useClassesQuery(); const { mutate } = useCreateClassMutation(); const getInvitationsQuery = useTeacherInvitationsReceivedQuery(username); const { mutate: respondToInvitation } = useRespondTeacherInvitationMutation(); @@ -70,7 +70,7 @@ await getInvitationsQuery.refetch(); }, onError: (e) => { - showSnackbar(t("failed") + ": " + e.message, "error"); + showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error"); }, }); } @@ -170,6 +170,7 @@ // Code display dialog logic const viewCodeDialog = ref(false); const selectedCode = ref(""); + function openCodeDialog(codeToView: string): void { selectedCode.value = codeToView; viewCodeDialog.value = true; @@ -231,7 +232,7 @@ variant="text" > {{ c.displayName }} - mdi-menu-right + mdi-menu-right @@ -300,8 +301,8 @@ type="submit" @click="createClass" block - >{{ t("create") }} + >{{ t("create") }} + @@ -368,85 +369,75 @@ :query-result="getInvitationsQuery" v-slot="invitationsResponse: { data: TeacherInvitationsResponse }" > - - + - - + + {{ t("no-invitations-found") }} + + + diff --git a/frontend/src/views/classes/UserClasses.vue b/frontend/src/views/classes/UserClasses.vue index 566bd02a..bab2177a 100644 --- a/frontend/src/views/classes/UserClasses.vue +++ b/frontend/src/views/classes/UserClasses.vue @@ -2,6 +2,7 @@ import authState from "@/services/auth/auth-service.ts"; import TeacherClasses from "./TeacherClasses.vue"; import StudentClasses from "./StudentClasses.vue"; + import { AccountType } from "@dwengo-1/common/util/account-types"; // Determine if role is student or teacher to render correct view const role: string = authState.authState.activeRole!; @@ -9,7 +10,7 @@ diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index dc444156..8ebf7e1a 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -22,6 +22,7 @@ import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; import QuestionNotification from "@/components/QuestionNotification.vue"; + import { AccountType } from "@dwengo-1/common/util/account-types"; const router = useRouter(); const route = useRoute(); @@ -235,8 +236,10 @@

- - +