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..105a1654 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,11 +1,22 @@ -# -# Basic configuration -# +DWENGO_PORT=3000 # The port the backend will listen on +DWENGO_DB_HOST=domain-or-ip-of-database +DWENGO_DB_PORT=5432 -PORT=3000 # The port the backend will listen on +# Change this to the actual credentials of the user Dwengo should use in the backend +DWENGO_DB_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres -# -# Advanced configuration -# +# 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 # LOKI_HOST=http://localhost:3102 # The address of the Loki instance, used for logging diff --git a/backend/package.json b/backend/package.json index 930932fb..37d21532 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,15 +18,20 @@ "@mikro-orm/postgresql": "6.4.6", "@mikro-orm/reflection": "6.4.6", "@mikro-orm/sqlite": "6.4.6", - "axios": "^1.8.1", + "@types/js-yaml": "^4.0.9", + "axios": "^1.8.2", "dotenv": "^16.4.7", "express": "^5.0.1", + "express-jwt": "^8.5.1", + "jwks-rsa": "^3.1.0", + "uuid": "^11.1.0", "js-yaml": "^4.1.0", "loki-logger-ts": "^1.0.2", "response-time": "^2.3.3", - "uuid": "^11.1.0", "winston": "^3.17.0", - "winston-loki": "^6.1.3" + "winston-loki": "^6.1.3", + "cors": "^2.8.5", + "@types/cors": "^2.8.17" }, "devDependencies": { "@mikro-orm/cli": "6.4.6", diff --git a/backend/src/app.ts b/backend/src/app.ts index 5769e360..61d173ec 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'; @@ -22,8 +24,10 @@ const logger: Logger = getLogger(); const app: Express = express(); const port: string | number = getNumericEnvVar(EnvVars.Port); +app.use(cors); app.use(express.json()); app.use(responseTime(responseTimeLogger)); +app.use(authenticateUser); // TODO Replace with Express routes app.get('/', (_, res: Response) => { @@ -39,8 +43,7 @@ app.use('/assignment', assignmentRouter); app.use('/submission', submissionRouter); app.use('/class', classRouter); app.use('/question', questionRouter); -app.use('/login', loginRouter); - +app.use('/auth', authRouter); app.use('/theme', themeRoutes); app.use('/learningPath', learningPathRoutes); app.use('/learningObject', learningObjectRoutes); diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts new file mode 100644 index 00000000..409ead0c --- /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..a76e2b72 --- /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..ca64b3b3 --- /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'); + } + + const issuer = (token.payload as JwtPayload).iss; + + const 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; + } + const 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) => + (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..9737fa7e --- /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..4b060dfa --- /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..3d2c9be0 --- /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..942a997a --- /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 6d10e296..43940244 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 06e882ca..11e3cb3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,6 +89,30 @@ services: # networks: # - dwengo-1 + idp: # Based on: 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 + volumes: dwengo_postgres_data: dwengo_letsencrypt: diff --git a/frontend/package.json b/frontend/package.json index 2c2c2612..b056c9f3 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.2" }, "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 @@ - +