diff --git a/backend/.env.development.example b/backend/.env.development.example index 58694df4..247ff054 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -4,3 +4,13 @@ DWENGO_DB_PORT=5431 DWENGO_DB_USERNAME=postgres DWENGO_DB_PASSWORD=postgres DWENGO_DB_UPDATE=true + +DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs +DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher +DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs + +# Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production! +DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173 diff --git a/backend/.env.example b/backend/.env.example index 165b7a29..fd193c89 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,10 +2,31 @@ # Basic configuration # -PORT=3000 # The port the backend will listen on +# The port the backend will listen on +DWENGO_PORT=3000 +DWENGO_DB_HOST=domain-or-ip-of-database +DWENGO_DB_PORT=5432 + +# Change this to the actual credentials of the user Dwengo should use in the backend +DWENGO_DB_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres + +# Set this to true when the database scheme needs to be updated. In that case, take a backup first. +DWENGO_DB_UPDATE=false + +# Data for the identity provider via which the students authenticate. +DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs + +# Data for the identity provider via which the teachers authenticate. +DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher +DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs # # Advanced configuration # -# LOKI_HOST=http://localhost:3102 # The address of the Loki instance, used for logging +# The address of the Lokiinstance, used for logging +# LOKI_HOST=http://localhost:3102 diff --git a/backend/package.json b/backend/package.json index b4dc9c1e..7d7b87a1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,11 +18,13 @@ "@mikro-orm/postgresql": "^6.4.6", "@mikro-orm/reflection": "^6.4.6", "@mikro-orm/sqlite": "6.4.6", - "@types/js-yaml": "^4.0.9", "axios": "^1.8.1", "dotenv": "^16.4.7", "express": "^5.0.1", + "express-jwt": "^8.5.1", + "jwks-rsa": "^3.1.0", "js-yaml": "^4.1.0", + "cors": "^2.8.5", "loki-logger-ts": "^1.0.2", "response-time": "^2.3.3", "swagger-ui-express": "^5.0.1", @@ -31,11 +33,13 @@ "winston-loki": "^6.1.3" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@mikro-orm/cli": "^6.4.6", "@types/express": "^5.0.0", "@types/node": "^22.13.4", "@types/response-time": "^2.3.8", "@types/swagger-ui-express": "^4.1.8", + "@types/cors": "^2.8.17", "globals": "^15.15.0", "ts-node": "^10.9.2", "tsx": "^4.19.3", diff --git a/backend/src/app.ts b/backend/src/app.ts index f76d72d6..15304ec7 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,7 +11,9 @@ import assignmentRouter from './routes/assignment.js'; import submissionRouter from './routes/submission.js'; import classRouter from './routes/class.js'; import questionRouter from './routes/question.js'; -import loginRouter from './routes/login.js'; +import authRouter from './routes/auth.js'; +import {authenticateUser} from './middleware/auth/auth.js'; +import cors from './middleware/cors.js'; import { getLogger, Logger } from './logging/initalize.js'; import { responseTimeLogger } from './logging/responseTimeLogger.js'; import responseTime from 'response-time'; @@ -25,6 +27,10 @@ const app: Express = express(); const port: string | number = getNumericEnvVar(EnvVars.Port); app.use(express.json()); +app.use(cors); +app.use(authenticateUser); +// Add response time logging +app.use(responseTime(responseTimeLogger)); // TODO Replace with Express routes app.get('/', (_, res: Response) => { @@ -41,15 +47,12 @@ app.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */); app.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */); app.use('/class', classRouter /* #swagger.tags = ['Class'] */); app.use('/question', questionRouter /* #swagger.tags = ['Question'] */); -app.use('/login', loginRouter /* #swagger.tags = ['Login'] */); - +app.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); app.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); + app.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); app.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); -// Add response time loggin -app.use(responseTime(responseTimeLogger)); - // Swagger UI for API documentation app.use('/api-docs', swaggerUi.serve, swaggerMiddleware); diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts new file mode 100644 index 00000000..14c614a5 --- /dev/null +++ b/backend/src/controllers/auth.ts @@ -0,0 +1,33 @@ +import {EnvVars, getEnvVar} from "../util/envvars.js"; + +type FrontendIdpConfig = { + authority: string, + clientId: string, + scope: string, + responseType: string +} + +type FrontendAuthConfig = { + student: FrontendIdpConfig, + teacher: FrontendIdpConfig +} + +const SCOPE = "openid profile email"; +const RESPONSE_TYPE = "code"; + +export function getFrontendAuthConfig(): FrontendAuthConfig { + return { + student: { + authority: getEnvVar(EnvVars.IdpStudentUrl), + clientId: getEnvVar(EnvVars.IdpStudentClientId), + scope: SCOPE, + responseType: RESPONSE_TYPE + }, + teacher: { + authority: getEnvVar(EnvVars.IdpTeacherUrl), + clientId: getEnvVar(EnvVars.IdpTeacherClientId), + scope: SCOPE, + responseType: RESPONSE_TYPE + }, + }; +} diff --git a/backend/src/exceptions.ts b/backend/src/exceptions.ts new file mode 100644 index 00000000..2b6e6d3c --- /dev/null +++ b/backend/src/exceptions.ts @@ -0,0 +1,13 @@ +export class UnauthorizedException extends Error { + status = 401; + constructor(message: string = "Unauthorized") { + super(message); + } +} + +export class ForbiddenException extends Error { + status = 403; + constructor(message: string = "Forbidden") { + super(message); + } +} diff --git a/backend/src/middleware/auth/auth.ts b/backend/src/middleware/auth/auth.ts new file mode 100644 index 00000000..9c988146 --- /dev/null +++ b/backend/src/middleware/auth/auth.ts @@ -0,0 +1,141 @@ +import {EnvVars, getEnvVar} from "../../util/envvars.js"; +import {expressjwt} from 'express-jwt'; +import {JwtPayload} from 'jsonwebtoken' +import jwksClient from 'jwks-rsa'; +import * as express from "express"; +import * as jwt from "jsonwebtoken"; +import {AuthenticatedRequest} from "./authenticated-request.js"; +import {AuthenticationInfo} from "./authentication-info.js"; +import {ForbiddenException, UnauthorizedException} from "../../exceptions"; + +const JWKS_CACHE = true; +const JWKS_RATE_LIMIT = true; +const REQUEST_PROPERTY_FOR_JWT_PAYLOAD = "jwtPayload"; +const JWT_ALGORITHM = "RS256"; // Not configurable via env vars since supporting other algorithms would + // require additional libraries to be added. + +const JWT_PROPERTY_NAMES = { + username: "preferred_username", + firstName: "given_name", + lastName: "family_name", + name: "name", + email: "email" +}; + +function createJwksClient(uri: string): jwksClient.JwksClient { + return jwksClient({ + cache: JWKS_CACHE, + rateLimit: JWKS_RATE_LIMIT, + jwksUri: uri, + }); +} + +const idpConfigs = { + student: { + issuer: getEnvVar(EnvVars.IdpStudentUrl), + jwksClient: createJwksClient(getEnvVar(EnvVars.IdpStudentJwksEndpoint)), + }, + teacher: { + issuer: getEnvVar(EnvVars.IdpTeacherUrl), + jwksClient: createJwksClient(getEnvVar(EnvVars.IdpTeacherJwksEndpoint)), + } +}; + +/** + * Express middleware which verifies the JWT Bearer token if one is given in the request. + */ +const verifyJwtToken = expressjwt({ + secret: async (_: express.Request, token: jwt.Jwt | undefined) => { + if (!token?.payload || !(token.payload as JwtPayload).iss) { + throw new Error("Invalid token"); + } + + let issuer = (token.payload as JwtPayload).iss; + + let idpConfig = Object.values(idpConfigs).find(config => config.issuer === issuer); + if (!idpConfig) { + throw new Error("Issuer not accepted."); + } + + const signingKey = await idpConfig.jwksClient.getSigningKey(token.header.kid); + if (!signingKey) { + throw new Error("Signing key not found."); + } + return signingKey.getPublicKey(); + }, + audience: getEnvVar(EnvVars.IdpAudience), + algorithms: [JWT_ALGORITHM], + credentialsRequired: false, + requestProperty: REQUEST_PROPERTY_FOR_JWT_PAYLOAD +}); + +/** + * Get an object with information about the authenticated user from a given authenticated request. + */ +function getAuthenticationInfo(req: AuthenticatedRequest): AuthenticationInfo | undefined { + if (!req.jwtPayload) { + return; + } + let issuer = req.jwtPayload.iss; + let accountType: "student" | "teacher"; + + if (issuer === idpConfigs.student.issuer) { + accountType = "student"; + } else if (issuer === idpConfigs.teacher.issuer) { + accountType = "teacher"; + } else { + return; + } + return { + accountType: accountType, + username: req.jwtPayload[JWT_PROPERTY_NAMES.username]!, + name: req.jwtPayload[JWT_PROPERTY_NAMES.name], + firstName: req.jwtPayload[JWT_PROPERTY_NAMES.firstName], + lastName: req.jwtPayload[JWT_PROPERTY_NAMES.lastName], + email: req.jwtPayload[JWT_PROPERTY_NAMES.email], + } +} + +/** + * Add the AuthenticationInfo object with the information about the current authentication to the request in order + * to avoid that the routers have to deal with the JWT token. + */ +const addAuthenticationInfo = (req: AuthenticatedRequest, res: express.Response, next: express.NextFunction) => { + req.auth = getAuthenticationInfo(req); + next(); +}; + +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 const 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 new file mode 100644 index 00000000..275b2d19 --- /dev/null +++ b/backend/src/middleware/auth/authenticated-request.d.ts @@ -0,0 +1,9 @@ +import { Request } from "express"; +import { JwtPayload } from "jsonwebtoken"; +import {AuthenticationInfo} from "./authentication-info.js"; + +export interface AuthenticatedRequest extends Request { + // Properties are optional since the user is not necessarily authenticated. + jwtPayload?: JwtPayload; + auth?: AuthenticationInfo; +} diff --git a/backend/src/middleware/auth/authentication-info.d.ts b/backend/src/middleware/auth/authentication-info.d.ts new file mode 100644 index 00000000..6711edd0 --- /dev/null +++ b/backend/src/middleware/auth/authentication-info.d.ts @@ -0,0 +1,11 @@ +/** + * Object with information about the user who is currently logged in. + */ +export type AuthenticationInfo = { + accountType: "student" | "teacher", + username: string, + name?: string, + firstName?: string, + lastName?: string, + email?: string +}; diff --git a/backend/src/middleware/cors.ts b/backend/src/middleware/cors.ts new file mode 100644 index 00000000..88104fb5 --- /dev/null +++ b/backend/src/middleware/cors.ts @@ -0,0 +1,7 @@ +import cors from "cors"; +import {EnvVars, getEnvVar} from "../util/envvars.js"; + +export default cors({ + origin: getEnvVar(EnvVars.CorsAllowedOrigins).split(','), + allowedHeaders: getEnvVar(EnvVars.CorsAllowedHeaders).split(',') +}); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 00000000..57af3a7d --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,23 @@ +import express from 'express' +import {getFrontendAuthConfig} from "../controllers/auth.js"; +import {authenticatedOnly, studentsOnly, teachersOnly} from "../middleware/auth/auth.js"; +const router = express.Router(); + +// returns auth configuration for frontend +router.get('/config', (req, res) => { + res.json(getFrontendAuthConfig()); +}); + +router.get('/testAuthenticatedOnly', authenticatedOnly, (req, res) => { + res.json({message: "If you see this, you should be authenticated!"}); +}); + +router.get('/testStudentsOnly', studentsOnly, (req, res) => { + res.json({message: "If you see this, you should be a student!"}); +}); + +router.get('/testTeachersOnly', teachersOnly, (req, res) => { + res.json({message: "If you see this, you should be a teacher!"}); +}); + +export default router; diff --git a/backend/src/routes/login.ts b/backend/src/routes/login.ts deleted file mode 100644 index 33d5e6c3..00000000 --- a/backend/src/routes/login.ts +++ /dev/null @@ -1,14 +0,0 @@ -import express from 'express'; -const router = express.Router(); - -// Returns login paths for IDP -router.get('/', (req, res) => { - res.json({ - // Dummy variables, needs to be changed - // With IDP endpoints - leerkracht: '/login-leerkracht', - leerling: '/login-leerling', - }); -}); - -export default router; diff --git a/backend/src/util/envvars.ts b/backend/src/util/envvars.ts index 5a06ac22..b5142e58 100644 --- a/backend/src/util/envvars.ts +++ b/backend/src/util/envvars.ts @@ -1,5 +1,9 @@ const PREFIX = 'DWENGO_'; const DB_PREFIX = PREFIX + 'DB_'; +const IDP_PREFIX = PREFIX + 'AUTH_'; +const STUDENT_IDP_PREFIX = IDP_PREFIX + 'STUDENT_'; +const TEACHER_IDP_PREFIX = IDP_PREFIX + 'TEACHER_'; +const CORS_PREFIX = PREFIX + 'CORS_'; type EnvVar = { key: string; required?: boolean; defaultValue?: any }; @@ -11,6 +15,15 @@ export const EnvVars: { [key: string]: EnvVar } = { DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false }, + IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true }, + IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true }, + IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, + IdpTeacherUrl: { key: TEACHER_IDP_PREFIX + 'URL', required: true }, + IdpTeacherClientId: { key: TEACHER_IDP_PREFIX + 'CLIENT_ID', required: true }, + IdpTeacherJwksEndpoint: { key: TEACHER_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, + IdpAudience: { key: IDP_PREFIX + 'AUDIENCE', defaultValue: 'account' }, + CorsAllowedOrigins: { key: CORS_PREFIX + 'ALLOWED_ORIGINS', defaultValue: ''}, + CorsAllowedHeaders: { key: CORS_PREFIX + 'ALLOWED_HEADERS', defaultValue: 'Authorization,Content-Type'} } as const; /** diff --git a/docker-compose.yml b/docker-compose.yml index 0f8219af..f43cdf4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,35 @@ services: volumes: - dwengo_postgres_data:/var/lib/postgresql/data + idp: # Bron: https://medium.com/@fingervinicius/easy-running-keycloak-with-docker-compose-b0d7a4ee2358 + image: quay.io/keycloak/keycloak:latest + volumes: + - ./idp:/opt/keycloak/data/import + environment: + KC_HOSTNAME: localhost + KC_HOSTNAME_PORT: 7080 + KC_HOSTNAME_STRICT_BACKCHANNEL: 'true' + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: 'true' + KC_LOG_LEVEL: info + healthcheck: + test: [ 'CMD', 'curl', '-f', 'http://localhost:7080/health/ready' ] + interval: 15s + timeout: 2s + retries: 15 + command: [ + 'start-dev', + '--http-port', '7080', + '--https-port', '7443', + '--import-realm' + ] + ports: + - '7080:7080' + - '7443:7443' + depends_on: + - db + logging: image: grafana/loki:latest ports: diff --git a/frontend/package.json b/frontend/package.json index 2c2c2612..8c6f81d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,9 @@ "dependencies": { "vue": "^3.5.13", "vue-router": "^4.5.0", - "vuetify": "^3.7.12" + "vuetify": "^3.7.12", + "oidc-client-ts": "^3.1.0", + "axios": "^1.8.1" }, "devDependencies": { "@playwright/test": "^1.50.1", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 50d132d6..d355c43d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,4 +1,7 @@ - +