Merge branch 'dev' into feat/assignment-page

This commit is contained in:
Laure Jablonski 2025-04-21 08:29:47 +02:00 committed by GitHub
commit 9dcc1091cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 148 additions and 53 deletions

2
.gitignore vendored
View file

@ -738,3 +738,5 @@ flycheck_*.el
/network-security.data /network-security.data
docs/.venv docs/.venv
idp_data/h2/keycloakdb.mv.db
idp_data/h2/keycloakdb.trace.db

View file

@ -1,21 +0,0 @@
PORT=3000
DWENGO_DB_HOST=db
DWENGO_DB_PORT=5432
DWENGO_DB_USERNAME=postgres
DWENGO_DB_PASSWORD=postgres
DWENGO_DB_UPDATE=false
DWENGO_AUTH_STUDENT_URL=http://localhost/idp/realms/student
DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo
DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs
DWENGO_AUTH_TEACHER_URL=http://localhost/idp/realms/teacher
DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo
DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/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/,127.0.0.1:80,http://127.0.0.1,http://localhost:80,http://127.0.0.1:80,localhost
DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/*,http://idp:7080,https://idp:7080
# Logging and monitoring
LOKI_HOST=http://logging:3102

View file

@ -1,4 +1,10 @@
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 { envVars, getEnvVar } from '../util/envVars.js';
import { Response } from 'express';
interface FrontendIdpConfig { interface FrontendIdpConfig {
authority: string; authority: string;
@ -15,6 +21,8 @@ interface FrontendAuthConfig {
const SCOPE = 'openid profile email'; const SCOPE = 'openid profile email';
const RESPONSE_TYPE = 'code'; const RESPONSE_TYPE = 'code';
const logger = getLogger();
export function getFrontendAuthConfig(): FrontendAuthConfig { export function getFrontendAuthConfig(): FrontendAuthConfig {
return { return {
student: { student: {
@ -31,3 +39,24 @@ export function getFrontendAuthConfig(): FrontendAuthConfig {
}, },
}; };
} }
export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> {
const auth = req.auth;
if (!auth) {
throw new UnauthorizedException('Cannot say hello when not authenticated.');
}
const userData = {
id: auth.username,
username: auth.username,
firstName: auth.firstName ?? '',
lastName: auth.lastName ?? '',
};
if (auth.accountType === 'student') {
await createOrUpdateStudent(userData);
logger.debug(`Synchronized student ${userData.username} with IDP`);
} else {
await createOrUpdateTeacher(userData);
logger.debug(`Synchronized teacher ${userData.username} with IDP`);
}
res.status(200).send({ message: 'Welcome!' });
}

View file

@ -1,7 +1,9 @@
import { HasStatusCode } from './has-status-code';
/** /**
* Exceptions which are associated with a HTTP error code. * Exceptions which are associated with a HTTP error code.
*/ */
export abstract class ExceptionWithHttpState extends Error { export abstract class ExceptionWithHttpState extends Error implements HasStatusCode {
constructor( constructor(
public status: number, public status: number,
public error: string public error: string

View file

@ -0,0 +1,6 @@
export interface HasStatusCode {
status: number;
}
export function hasStatusCode(err: unknown): err is HasStatusCode {
return typeof err === 'object' && err !== null && 'status' in err && typeof (err as HasStatusCode)?.status === 'number';
}

View file

@ -48,14 +48,14 @@ const idpConfigs = {
const verifyJwtToken = expressjwt({ const verifyJwtToken = expressjwt({
secret: async (_: express.Request, token: jwt.Jwt | undefined) => { secret: async (_: express.Request, token: jwt.Jwt | undefined) => {
if (!token?.payload || !(token.payload as JwtPayload).iss) { if (!token?.payload || !(token.payload as JwtPayload).iss) {
throw new Error('Invalid token'); throw new UnauthorizedException('Invalid token.');
} }
const issuer = (token.payload as JwtPayload).iss; const issuer = (token.payload as JwtPayload).iss;
const idpConfig = Object.values(idpConfigs).find((config) => config.issuer === issuer); const idpConfig = Object.values(idpConfigs).find((config) => config.issuer === issuer);
if (!idpConfig) { if (!idpConfig) {
throw new Error('Issuer not accepted.'); throw new UnauthorizedException('Issuer not accepted.');
} }
const signingKey = await idpConfig.jwksClient.getSigningKey(token.header.kid); const signingKey = await idpConfig.jwksClient.getSigningKey(token.header.kid);

View file

@ -1,11 +1,11 @@
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { getLogger, Logger } from '../../logging/initalize.js'; import { getLogger, Logger } from '../../logging/initalize.js';
import { ExceptionWithHttpState } from '../../exceptions/exception-with-http-state.js'; import { hasStatusCode } from '../../exceptions/has-status-code.js';
const logger: Logger = getLogger(); const logger: Logger = getLogger();
export function errorHandler(err: unknown, _req: Request, res: Response, _: NextFunction): void { export function errorHandler(err: unknown, _req: Request, res: Response, _: NextFunction): void {
if (err instanceof ExceptionWithHttpState) { if (hasStatusCode(err)) {
logger.warn(`An error occurred while handling a request: ${err} (-> HTTP ${err.status})`); logger.warn(`An error occurred while handling a request: ${err} (-> HTTP ${err.status})`);
res.status(err.status).json(err); res.status(err.status).json(err);
} else { } else {

View file

@ -1,5 +1,5 @@
import express from 'express'; import express from 'express';
import { getFrontendAuthConfig } from '../controllers/auth.js'; import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js';
import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js';
const router = express.Router(); const router = express.Router();
@ -23,4 +23,6 @@ router.get('/testTeachersOnly', teachersOnly, (_req, res) => {
res.json({ message: 'If you see this, you should be a teacher!' }); res.json({ message: 'If you see this, you should be a teacher!' });
}); });
router.post('/hello', authenticatedOnly, postHelloHandler);
export default router; export default router;

View file

@ -63,6 +63,16 @@ export async function createStudent(userData: StudentDTO): Promise<StudentDTO> {
const newStudent = mapToStudent(userData); const newStudent = mapToStudent(userData);
await studentRepository.save(newStudent, { preventOverwrite: true }); await studentRepository.save(newStudent, { preventOverwrite: true });
return userData;
}
export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> {
await getStudentRepository().upsert({
username: userData.username,
firstName: userData.firstName,
lastName: userData.lastName,
});
return userData; return userData;
} }

View file

@ -62,9 +62,19 @@ export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO> {
const newTeacher = mapToTeacher(userData); const newTeacher = mapToTeacher(userData);
await teacherRepository.save(newTeacher, { preventOverwrite: true }); await teacherRepository.save(newTeacher, { preventOverwrite: true });
return mapToTeacherDTO(newTeacher); return mapToTeacherDTO(newTeacher);
} }
export async function createOrUpdateTeacher(userData: TeacherDTO): Promise<TeacherDTO> {
await getTeacherRepository().upsert({
username: userData.username,
firstName: userData.firstName,
lastName: userData.lastName,
});
return userData;
}
export async function deleteTeacher(username: string): Promise<TeacherDTO> { export async function deleteTeacher(username: string): Promise<TeacherDTO> {
const teacherRepository: TeacherRepository = getTeacherRepository(); const teacherRepository: TeacherRepository = getTeacherRepository();

View file

@ -10,10 +10,6 @@
} }
const showMenuBar = computed(() => (route.meta as RouteMeta).requiresAuth && auth.authState.user); const showMenuBar = computed(() => (route.meta as RouteMeta).requiresAuth && auth.authState.user);
auth.loadUser().catch((_error) => {
// TODO Could not load user!
});
</script> </script>
<template> <template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import auth from "@/services/auth/auth-service.ts"; import auth from "@/services/auth/auth-service.ts";
@ -10,6 +11,7 @@
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const role = auth.authState.activeRole; const role = auth.authState.activeRole;
const router = useRouter();
const name = ref(auth.authState.user!.profile.name!); const name = ref(auth.authState.user!.profile.name!);
const initials: string = name.value const initials: string = name.value

View file

@ -88,6 +88,9 @@
"sent": "sent", "sent": "sent",
"failed": "fehlgeschlagen", "failed": "fehlgeschlagen",
"wrong": "etwas ist schief gelaufen", "wrong": "etwas ist schief gelaufen",
"created": "erstellt",
"callbackLoading": "Sie werden angemeldet...",
"loginUnexpectedError": "Anmeldung fehlgeschlagen",
"submitSolution": "Lösung einreichen", "submitSolution": "Lösung einreichen",
"submitNewSolution": "Neue Lösung einreichen", "submitNewSolution": "Neue Lösung einreichen",
"markAsDone": "Als fertig markieren", "markAsDone": "Als fertig markieren",
@ -107,8 +110,8 @@
"created": "erstellt", "created": "erstellt",
"remove": "entfernen", "remove": "entfernen",
"students": "Studenten", "students": "Studenten",
"classJoinRequests": "Anfragen verbinden", "classJoinRequests": "Beitrittsanfragen",
"reject": "zurückweisen", "reject": "ablehnen",
"areusure": "Sind Sie sicher?", "areusure": "Sind Sie sicher?",
"yes": "ja", "yes": "ja",
"teachers": "Lehrer", "teachers": "Lehrer",
@ -117,4 +120,3 @@
"enterUsername": "Geben Sie den Benutzernamen der Lehrkraft ein, die Sie einladen möchten", "enterUsername": "Geben Sie den Benutzernamen der Lehrkraft ein, die Sie einladen möchten",
"username": "Nutzername", "username": "Nutzername",
"invite": "einladen" "invite": "einladen"
}

View file

@ -88,6 +88,9 @@
"sent": "sent", "sent": "sent",
"failed": "failed", "failed": "failed",
"wrong": "something went wrong", "wrong": "something went wrong",
"created": "created",
"callbackLoading": "You are being logged in...",
"loginUnexpectedError": "Login failed",
"submitSolution": "Submit solution", "submitSolution": "Submit solution",
"submitNewSolution": "Submit new solution", "submitNewSolution": "Submit new solution",
"markAsDone": "Mark as completed", "markAsDone": "Mark as completed",

View file

@ -89,6 +89,8 @@
"failed": "échoué", "failed": "échoué",
"wrong": "quelque chose n'a pas fonctionné", "wrong": "quelque chose n'a pas fonctionné",
"created": "créé", "created": "créé",
"callbackLoading": "Vous serez connecté...",
"loginUnexpectedError": "La connexion a échoué",
"submitSolution": "Soumettre la solution", "submitSolution": "Soumettre la solution",
"submitNewSolution": "Soumettre une nouvelle solution", "submitNewSolution": "Soumettre une nouvelle solution",
"markAsDone": "Marquer comme terminé", "markAsDone": "Marquer comme terminé",

View file

@ -89,6 +89,8 @@
"failed": "mislukt", "failed": "mislukt",
"wrong": "er ging iets verkeerd", "wrong": "er ging iets verkeerd",
"created": "gecreëerd", "created": "gecreëerd",
"callbackLoading": "Je wordt ingelogd...",
"loginUnexpectedError": "Inloggen mislukt",
"submitSolution": "Oplossing indienen", "submitSolution": "Oplossing indienen",
"submitNewSolution": "Nieuwe oplossing indienen", "submitNewSolution": "Nieuwe oplossing indienen",
"markAsDone": "Markeren als afgewerkt", "markAsDone": "Markeren als afgewerkt",

View file

@ -12,9 +12,11 @@ import App from "./App.vue";
import router from "./router"; import router from "./router";
import { aliases, mdi } from "vuetify/iconsets/mdi"; import { aliases, mdi } from "vuetify/iconsets/mdi";
import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query";
import authService from "./services/auth/auth-service.ts";
const app = createApp(App); const app = createApp(App);
await authService.loadUser();
app.use(router); app.use(router);
const link = document.createElement("link"); const link = document.createElement("link");

View file

@ -142,9 +142,8 @@ const router = createRouter({
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
// Verify if user is logged in before accessing certain routes // Verify if user is logged in before accessing certain routes
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
if (!authState.isLoggedIn.value) { if (!authService.isLoggedIn.value) {
//Next("/login"); next("/login");
next();
} else { } else {
next(); next();
} }

View file

@ -26,7 +26,7 @@ async function getUserManagers(): Promise<UserManagersForRoles> {
const authState = reactive<AuthState>({ const authState = reactive<AuthState>({
user: null, user: null,
accessToken: null, accessToken: null,
activeRole: authStorage.getActiveRole() || null, activeRole: authStorage.getActiveRole() ?? null,
}); });
/** /**
@ -38,18 +38,38 @@ async function loadUser(): Promise<User | null> {
return null; return null;
} }
const user = await (await getUserManagers())[activeRole].getUser(); const user = await (await getUserManagers())[activeRole].getUser();
authState.user = user; setUserAuthInfo(user);
authState.accessToken = user?.access_token || null; authState.activeRole = activeRole ?? null;
authState.activeRole = activeRole || null;
return user; return user;
} }
const isLoggedIn = computed(() => authState.user !== null); const isLoggedIn = computed(() => authState.user !== null);
/**
* Clears all the cached information about the current authentication.
*/
function clearAuthState(): void {
authStorage.deleteActiveRole();
authState.accessToken = null;
authState.user = null;
authState.activeRole = null;
}
/**
* Sets the information about the currently logged-in user in the cache.
*/
function setUserAuthInfo(newUser: User | null): void {
authState.user = newUser;
authState.accessToken = newUser?.access_token ?? null;
}
/** /**
* Redirect the user to the login page where he/she can choose whether to log in as a student or teacher. * Redirect the user to the login page where he/she can choose whether to log in as a student or teacher.
*/ */
async function initiateLogin(): Promise<void> { async function initiateLogin(): Promise<void> {
if (isLoggedIn.value) {
clearAuthState();
}
await router.push(loginRoute); await router.push(loginRoute);
} }
@ -72,6 +92,7 @@ async function handleLoginCallback(): Promise<void> {
throw new Error("Login callback received, but the user is not logging in!"); throw new Error("Login callback received, but the user is not logging in!");
} }
authState.user = (await (await getUserManagers())[activeRole].signinCallback()) || null; authState.user = (await (await getUserManagers())[activeRole].signinCallback()) || null;
await apiClient.post("/auth/hello");
} }
/** /**
@ -80,14 +101,14 @@ async function handleLoginCallback(): Promise<void> {
async function renewToken(): Promise<User | null> { async function renewToken(): Promise<User | null> {
const activeRole = authStorage.getActiveRole(); const activeRole = authStorage.getActiveRole();
if (!activeRole) { if (!activeRole) {
// FIXME console.log("Can't renew the token: Not logged in!");
await initiateLogin(); await initiateLogin();
return null; return null;
} }
try { try {
return await (await getUserManagers())[activeRole].signinSilent(); const userManagerForRole = (await getUserManagers())[activeRole];
} catch (_error) { const user = await userManagerForRole.signinSilent();
// FIXME console.log("Can't renew the token: " + error); setUserAuthInfo(user);
} catch (_error: unknown) {
await initiateLogin(); await initiateLogin();
} }
return null; return null;
@ -101,6 +122,7 @@ async function logout(): Promise<void> {
if (activeRole) { if (activeRole) {
await (await getUserManagers())[activeRole].signoutRedirect(); await (await getUserManagers())[activeRole].signoutRedirect();
authStorage.deleteActiveRole(); authStorage.deleteActiveRole();
clearAuthState();
} }
} }
@ -119,13 +141,15 @@ apiClient.interceptors.request.use(
// Registering interceptor to refresh the token when a request failed because it was expired. // Registering interceptor to refresh the token when a request failed because it was expired.
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError<{ message?: string }>) => { async (error: AxiosError<{ message?: string; inner?: { message?: string } }>) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
if (error.response.data.message === "token_expired") { // If the user should already be logged in, his token is probably just expired.
// FIXME console.log("Access token expired, trying to refresh..."); if (isLoggedIn.value) {
await renewToken(); await renewToken();
return apiClient(error.config!); // Retry the request return apiClient(error.config!); // Retry the request
} // Apparently, the user got a 401 because he was not logged in yet at all. Redirect him to login. }
// Apparently, the user got a 401 because he was not logged in yet at all. Redirect him to login.
await initiateLogin(); await initiateLogin();
} }
return Promise.reject(error); return Promise.reject(error);

View file

@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { onMounted, ref, type Ref } from "vue"; import { onMounted, ref, type Ref } from "vue";
import auth from "../services/auth/auth-service.ts"; import auth from "../services/auth/auth-service.ts";
const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const errorMessage: Ref<string | null> = ref(null); const errorMessage: Ref<string | null> = ref(null);
@ -12,14 +15,34 @@
await auth.handleLoginCallback(); await auth.handleLoginCallback();
await router.replace("/user"); // Redirect to theme page await router.replace("/user"); // Redirect to theme page
} catch (error) { } catch (error) {
errorMessage.value = `OIDC callback error: ${error}`; errorMessage.value = `${t("loginUnexpectedError")}: ${error}`;
} }
}); });
</script> </script>
<template> <template>
<p v-if="!errorMessage">Logging you in...</p> <div class="callback">
<p v-else>{{ errorMessage }}</p> <div
class="callback-loading"
v-if="!errorMessage"
>
<v-progress-circular indeterminate></v-progress-circular>
<p>{{ t("callbackLoading") }}</p>
</div>
<v-alert
icon="mdi-alert-circle"
type="error"
variant="elevated"
v-if="errorMessage"
>
{{ errorMessage }}
</v-alert>
</div>
</template> </template>
<style scoped></style> <style scoped>
.callback {
text-align: center;
margin: 20px;
}
</style>