Merge branch 'dev' into feat/endpoints-in-backend-om-eigen-leerpaden-en-leerobjecten-toe-te-voegen-aan-de-databank-#248
This commit is contained in:
commit
f05994fa5e
70 changed files with 904 additions and 357 deletions
|
@ -7,8 +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';
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
const JWKS_CACHE = true;
|
||||
const JWKS_RATE_LIMIT = true;
|
||||
|
@ -109,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, req: AuthenticatedRequest) => boolean | Promise<boolean>): RequestHandler {
|
||||
return async (req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): Promise<void> => {
|
||||
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 === 'student');
|
||||
|
||||
/**
|
||||
* Middleware which rejects requests from unauthenticated users or users that aren't teachers.
|
||||
*/
|
||||
export const teachersOnly = authorize((auth) => auth.accountType === 'teacher');
|
||||
|
|
|
@ -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<string, unknown> = Record<string, unknown>,
|
||||
> extends Request<P, ResBody, ReqBody, ReqQuery, Locals> {
|
||||
// Properties are optional since the user is not necessarily authenticated.
|
||||
jwtPayload?: JwtPayload;
|
||||
auth?: AuthenticationInfo;
|
||||
|
|
21
backend/src/middleware/auth/checks/assignment-auth-checks.ts
Normal file
21
backend/src/middleware/auth/checks/assignment-auth-checks.ts
Normal file
|
@ -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));
|
||||
});
|
61
backend/src/middleware/auth/checks/auth-checks.ts
Normal file
61
backend/src/middleware/auth/checks/auth-checks.ts
Normal file
|
@ -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<P, ResBody, ReqBody, ReqQuery, Locals extends Record<string, unknown>>(
|
||||
accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>) => boolean | Promise<boolean>
|
||||
): RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals> {
|
||||
// Bypass authentication during testing
|
||||
if (getEnvVar(envVars.RunMode) === 'test') {
|
||||
return async (
|
||||
_req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>,
|
||||
_res: express.Response,
|
||||
next: express.NextFunction
|
||||
): Promise<void> => {
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
return async (
|
||||
req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>,
|
||||
_res: express.Response,
|
||||
next: express.NextFunction
|
||||
): Promise<void> => {
|
||||
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);
|
70
backend/src/middleware/auth/checks/class-auth-checks.ts
Normal file
70
backend/src/middleware/auth/checks/class-auth-checks.ts
Normal file
|
@ -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<boolean> {
|
||||
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);
|
||||
});
|
26
backend/src/middleware/auth/checks/group-auth-checker.ts
Normal file
26
backend/src/middleware/auth/checks/group-auth-checker.ts
Normal file
|
@ -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);
|
||||
});
|
|
@ -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;
|
||||
});
|
|
@ -1,8 +1,8 @@
|
|||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import learningObjectService from '../../../services/learning-objects/learning-object-service.js';
|
||||
import { authorize } from '../auth.js';
|
||||
import { AuthenticatedRequest } from '../authenticated-request.js';
|
||||
import { AuthenticationInfo } from '../authentication-info.js';
|
||||
import { authorize } from './auth-checks.js';
|
||||
|
||||
export const onlyAdminsForLearningObject = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
|
||||
const { hruid } = req.params;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import learningPathService from '../../../services/learning-paths/learning-path-service.js';
|
||||
import { authorize } from '../auth.js';
|
||||
import { AuthenticatedRequest } from '../authenticated-request.js';
|
||||
import { AuthenticationInfo } from '../authentication-info.js';
|
||||
import { authorize } from './auth-checks.js';
|
||||
|
||||
export const onlyAdminsForLearningPath = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
|
||||
const adminsForLearningPath = await learningPathService.getAdmins({
|
||||
|
|
66
backend/src/middleware/auth/checks/question-checks.ts
Normal file
66
backend/src/middleware/auth/checks/question-checks.ts
Normal file
|
@ -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);
|
||||
});
|
28
backend/src/middleware/auth/checks/submission-checks.ts
Normal file
28
backend/src/middleware/auth/checks/submission-checks.ts
Normal file
|
@ -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);
|
||||
});
|
|
@ -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
|
||||
);
|
8
backend/src/middleware/auth/checks/user-auth-checks.ts
Normal file
8
backend/src/middleware/auth/checks/user-auth-checks.ts
Normal file
|
@ -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);
|
Loading…
Add table
Add a link
Reference in a new issue