Merge pull request #205 from SELab-2/fix/verschillende-authenticatieproblemen
fix: Verschillende authenticatie-gerelateerde problemen
This commit is contained in:
		
						commit
						1b65eee38f
					
				
					 20 changed files with 149 additions and 55 deletions
				
			
		
							
								
								
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -738,3 +738,5 @@ flycheck_*.el | |||
| /network-security.data | ||||
| 
 | ||||
| docs/.venv | ||||
| idp_data/h2/keycloakdb.mv.db | ||||
| idp_data/h2/keycloakdb.trace.db | ||||
|  |  | |||
|  | @ -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 | ||||
|  | @ -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 { Response } from 'express'; | ||||
| 
 | ||||
| interface FrontendIdpConfig { | ||||
|     authority: string; | ||||
|  | @ -15,6 +21,8 @@ interface FrontendAuthConfig { | |||
| const SCOPE = 'openid profile email'; | ||||
| const RESPONSE_TYPE = 'code'; | ||||
| 
 | ||||
| const logger = getLogger(); | ||||
| 
 | ||||
| export function getFrontendAuthConfig(): FrontendAuthConfig { | ||||
|     return { | ||||
|         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!' }); | ||||
| } | ||||
|  |  | |||
|  | @ -1,7 +1,9 @@ | |||
| import { HasStatusCode } from './has-status-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( | ||||
|         public status: number, | ||||
|         public error: string | ||||
|  |  | |||
							
								
								
									
										6
									
								
								backend/src/exceptions/has-status-code.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								backend/src/exceptions/has-status-code.ts
									
										
									
									
									
										Normal 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'; | ||||
| } | ||||
|  | @ -48,14 +48,14 @@ const idpConfigs = { | |||
| const verifyJwtToken = expressjwt({ | ||||
|     secret: async (_: express.Request, token: jwt.Jwt | undefined) => { | ||||
|         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 idpConfig = Object.values(idpConfigs).find((config) => config.issuer === issuer); | ||||
|         if (!idpConfig) { | ||||
|             throw new Error('Issuer not accepted.'); | ||||
|             throw new UnauthorizedException('Issuer not accepted.'); | ||||
|         } | ||||
| 
 | ||||
|         const signingKey = await idpConfig.jwksClient.getSigningKey(token.header.kid); | ||||
|  |  | |||
|  | @ -1,11 +1,11 @@ | |||
| import { NextFunction, Request, Response } from 'express'; | ||||
| 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(); | ||||
| 
 | ||||
| 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})`); | ||||
|         res.status(err.status).json(err); | ||||
|     } else { | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| 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'; | ||||
| 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!' }); | ||||
| }); | ||||
| 
 | ||||
| router.post('/hello', authenticatedOnly, postHelloHandler); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -63,6 +63,16 @@ export async function createStudent(userData: StudentDTO): Promise<StudentDTO> { | |||
| 
 | ||||
|     const newStudent = mapToStudent(userData); | ||||
|     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; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -62,9 +62,19 @@ export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO> { | |||
| 
 | ||||
|     const newTeacher = mapToTeacher(userData); | ||||
|     await teacherRepository.save(newTeacher, { preventOverwrite: true }); | ||||
| 
 | ||||
|     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> { | ||||
|     const teacherRepository: TeacherRepository = getTeacherRepository(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,10 +10,6 @@ | |||
|     } | ||||
| 
 | ||||
|     const showMenuBar = computed(() => (route.meta as RouteMeta).requiresAuth && auth.authState.user); | ||||
| 
 | ||||
|     auth.loadUser().catch((_error) => { | ||||
|         // TODO Could not load user! | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| <script setup lang="ts"> | ||||
|     import { ref } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useRouter } from "vue-router"; | ||||
| 
 | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
| 
 | ||||
|  | @ -10,6 +11,7 @@ | |||
|     const { t, locale } = useI18n(); | ||||
| 
 | ||||
|     const role = auth.authState.activeRole; | ||||
|     const router = useRouter(); | ||||
| 
 | ||||
|     const name: string = auth.authState.user!.profile.name!; | ||||
|     const initials: string = name | ||||
|  |  | |||
|  | @ -75,6 +75,9 @@ | |||
|     "sent": "sent", | ||||
|     "failed": "fehlgeschlagen", | ||||
|     "wrong": "etwas ist schief gelaufen", | ||||
|     "created": "erstellt", | ||||
|     "callbackLoading": "Sie werden angemeldet...", | ||||
|     "loginUnexpectedError": "Anmeldung fehlgeschlagen", | ||||
|     "submitSolution": "Lösung einreichen", | ||||
|     "submitNewSolution": "Neue Lösung einreichen", | ||||
|     "markAsDone": "Als fertig markieren", | ||||
|  | @ -86,11 +89,10 @@ | |||
|     "noSubmissionsYet": "Noch keine Lösungen eingereicht.", | ||||
|     "viewAsGroup": "Fortschritt ansehen von Gruppe...", | ||||
|     "assignLearningPath": "Als Aufgabe geben", | ||||
|     "created": "erstellt", | ||||
|     "remove": "entfernen", | ||||
|     "students": "Studenten", | ||||
|     "classJoinRequests": "Anfragen verbinden", | ||||
|     "reject": "zurückweisen", | ||||
|     "classJoinRequests": "Beitrittsanfragen", | ||||
|     "reject": "ablehnen", | ||||
|     "areusure": "Sind Sie sicher?", | ||||
|     "yes": "ja", | ||||
|     "teachers": "Lehrer", | ||||
|  |  | |||
|  | @ -75,6 +75,9 @@ | |||
|     "sent": "sent", | ||||
|     "failed": "failed", | ||||
|     "wrong": "something went wrong", | ||||
|     "created": "created", | ||||
|     "callbackLoading": "You are being logged in...", | ||||
|     "loginUnexpectedError": "Login failed", | ||||
|     "submitSolution": "Submit solution", | ||||
|     "submitNewSolution": "Submit new solution", | ||||
|     "markAsDone": "Mark as completed", | ||||
|  | @ -86,7 +89,6 @@ | |||
|     "noSubmissionsYet": "No submissions yet.", | ||||
|     "viewAsGroup": "View progress of group...", | ||||
|     "assignLearningPath": "assign", | ||||
|     "created": "created", | ||||
|     "remove": "remove", | ||||
|     "students": "students", | ||||
|     "classJoinRequests": "join requests", | ||||
|  |  | |||
|  | @ -76,6 +76,8 @@ | |||
|     "failed": "échoué", | ||||
|     "wrong": "quelque chose n'a pas fonctionné", | ||||
|     "created": "créé", | ||||
|     "callbackLoading": "Vous serez connecté...", | ||||
|     "loginUnexpectedError": "La connexion a échoué", | ||||
|     "submitSolution": "Soumettre la solution", | ||||
|     "submitNewSolution": "Soumettre une nouvelle solution", | ||||
|     "markAsDone": "Marquer comme terminé", | ||||
|  |  | |||
|  | @ -76,6 +76,8 @@ | |||
|     "failed": "mislukt", | ||||
|     "wrong": "er ging iets verkeerd", | ||||
|     "created": "gecreëerd", | ||||
|     "callbackLoading": "Je wordt ingelogd...", | ||||
|     "loginUnexpectedError": "Inloggen mislukt", | ||||
|     "submitSolution": "Oplossing indienen", | ||||
|     "submitNewSolution": "Nieuwe oplossing indienen", | ||||
|     "markAsDone": "Markeren als afgewerkt", | ||||
|  |  | |||
|  | @ -12,9 +12,11 @@ import App from "./App.vue"; | |||
| import router from "./router"; | ||||
| import { aliases, mdi } from "vuetify/iconsets/mdi"; | ||||
| import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; | ||||
| import authService from "./services/auth/auth-service.ts"; | ||||
| 
 | ||||
| const app = createApp(App); | ||||
| 
 | ||||
| await authService.loadUser(); | ||||
| app.use(router); | ||||
| 
 | ||||
| const link = document.createElement("link"); | ||||
|  |  | |||
|  | @ -8,7 +8,7 @@ import CreateDiscussion from "@/views/discussions/CreateDiscussion.vue"; | |||
| import CallbackPage from "@/views/CallbackPage.vue"; | ||||
| import UserClasses from "@/views/classes/UserClasses.vue"; | ||||
| import UserAssignments from "@/views/classes/UserAssignments.vue"; | ||||
| import authState from "@/services/auth/auth-service.ts"; | ||||
| import authService from "@/services/auth/auth-service.ts"; | ||||
| import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue"; | ||||
| import LearningPathSearchPage from "@/views/learning-paths/LearningPathSearchPage.vue"; | ||||
| import UserHomePage from "@/views/homepage/UserHomePage.vue"; | ||||
|  | @ -138,9 +138,8 @@ const router = createRouter({ | |||
| router.beforeEach(async (to, _from, next) => { | ||||
|     // Verify if user is logged in before accessing certain routes
 | ||||
|     if (to.meta.requiresAuth) { | ||||
|         if (!authState.isLoggedIn.value) { | ||||
|             //Next("/login");
 | ||||
|             next(); | ||||
|         if (!authService.isLoggedIn.value) { | ||||
|             next("/login"); | ||||
|         } else { | ||||
|             next(); | ||||
|         } | ||||
|  |  | |||
|  | @ -26,7 +26,7 @@ async function getUserManagers(): Promise<UserManagersForRoles> { | |||
| const authState = reactive<AuthState>({ | ||||
|     user: null, | ||||
|     accessToken: null, | ||||
|     activeRole: authStorage.getActiveRole() || null, | ||||
|     activeRole: authStorage.getActiveRole() ?? null, | ||||
| }); | ||||
| 
 | ||||
| /** | ||||
|  | @ -38,18 +38,38 @@ async function loadUser(): Promise<User | null> { | |||
|         return null; | ||||
|     } | ||||
|     const user = await (await getUserManagers())[activeRole].getUser(); | ||||
|     authState.user = user; | ||||
|     authState.accessToken = user?.access_token || null; | ||||
|     authState.activeRole = activeRole || null; | ||||
|     setUserAuthInfo(user); | ||||
|     authState.activeRole = activeRole ?? null; | ||||
|     return user; | ||||
| } | ||||
| 
 | ||||
| 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. | ||||
|  */ | ||||
| async function initiateLogin(): Promise<void> { | ||||
|     if (isLoggedIn.value) { | ||||
|         clearAuthState(); | ||||
|     } | ||||
|     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!"); | ||||
|     } | ||||
|     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> { | ||||
|     const activeRole = authStorage.getActiveRole(); | ||||
|     if (!activeRole) { | ||||
|         // FIXME console.log("Can't renew the token: Not logged in!");
 | ||||
|         await initiateLogin(); | ||||
|         return null; | ||||
|     } | ||||
|     try { | ||||
|         return await (await getUserManagers())[activeRole].signinSilent(); | ||||
|     } catch (_error) { | ||||
|         // FIXME console.log("Can't renew the token: " + error);
 | ||||
|         const userManagerForRole = (await getUserManagers())[activeRole]; | ||||
|         const user = await userManagerForRole.signinSilent(); | ||||
|         setUserAuthInfo(user); | ||||
|     } catch (_error: unknown) { | ||||
|         await initiateLogin(); | ||||
|     } | ||||
|     return null; | ||||
|  | @ -101,6 +122,7 @@ async function logout(): Promise<void> { | |||
|     if (activeRole) { | ||||
|         await (await getUserManagers())[activeRole].signoutRedirect(); | ||||
|         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.
 | ||||
| apiClient.interceptors.response.use( | ||||
|     (response) => response, | ||||
|     async (error: AxiosError<{ message?: string }>) => { | ||||
|     async (error: AxiosError<{ message?: string; inner?: { message?: string } }>) => { | ||||
|         if (error.response?.status === 401) { | ||||
|             if (error.response.data.message === "token_expired") { | ||||
|                 // FIXME console.log("Access token expired, trying to refresh...");
 | ||||
|             // If the user should already be logged in, his token is probably just expired.
 | ||||
|             if (isLoggedIn.value) { | ||||
|                 await renewToken(); | ||||
|                 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(); | ||||
|         } | ||||
|         return Promise.reject(error); | ||||
|  |  | |||
|  | @ -1,8 +1,11 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useRouter } from "vue-router"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { onMounted, ref, type Ref } from "vue"; | ||||
|     import auth from "../services/auth/auth-service.ts"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const router = useRouter(); | ||||
| 
 | ||||
|     const errorMessage: Ref<string | null> = ref(null); | ||||
|  | @ -12,14 +15,34 @@ | |||
|             await auth.handleLoginCallback(); | ||||
|             await router.replace("/user"); // Redirect to theme page | ||||
|         } catch (error) { | ||||
|             errorMessage.value = `OIDC callback error: ${error}`; | ||||
|             errorMessage.value = `${t("loginUnexpectedError")}: ${error}`; | ||||
|         } | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <p v-if="!errorMessage">Logging you in...</p> | ||||
|     <p v-else>{{ errorMessage }}</p> | ||||
|     <div class="callback"> | ||||
|         <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> | ||||
| 
 | ||||
| <style scoped></style> | ||||
| <style scoped> | ||||
|     .callback { | ||||
|         text-align: center; | ||||
|         margin: 20px; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
		Reference in a new issue
	
	 Gerald Schmittinger
						Gerald Schmittinger