|
| 1 | +--- |
| 2 | +title: 'Verifisering med Feide' |
| 3 | +--- |
| 4 | + |
| 5 | +Når du oppretter en bruker hos TIHLDE så må du først registrere en bruker med gitt informasjon, og så må en fra HS eller Index godkjenne at din bruker faktisk tilhører et av studiene til TIHLDE. |
| 6 | + |
| 7 | +Dette er en metode vi har benyttet oss av i flere år, men som vi anser som tungvint og lite tidseffektivt. Dermed har det vært et behov for å automatisere denne prosessen. Dette kan man gjøre ganske enkelt ved hjelp av [Feide sitt api](https://docs.feide.no/reference/apis/feide-api/index.html). |
| 8 | + |
| 9 | +Siden vi kun ønsker å verifisere om brukeren tilhører et av TIHLDE sine studier, men ikke bruke det som innloggingsmetode, så er et oppsett av dette ganske fort gjort. |
| 10 | + |
| 11 | +## Bruker har to valg |
| 12 | +Vi ønsker å gi en bruker som ønsker å registere en bruker, to valg. Enten kan man opprette en bruker automatisk ved å logge seg inn med Feide, og så vil Lepton ta hånd om resten. Dette er det vi ønsker at brukere gjør. Men hva hvis det skjer en feil med innloggingen med Feide eller kanskje en ny student ikke har fått Feide konto enda? Dermed må vi fortsatt beholde vår gamle registreringsmetode. |
| 13 | + |
| 14 | +## Hvordan bruke Feide som verifisering |
| 15 | +Denne dokumentasjonen er basert på Feide sin egen dokumentasjon som du kan lese mer om [her](https://docs.feide.no/service_providers/openid_connect/feide_obtaining_tokens.html#registering-your-application). Hvis det skal utbedres til å integrere selv innloggingsprosessen med Feide, krever det en bedre kjennskap med dokumentasjonen til Feide, enn det vi går gjennom her. |
| 16 | + |
| 17 | + |
| 18 | +### Inlogging |
| 19 | +Det første steget for å benytte seg av Feide, er å gi brukeren mulighet til å logge inn med Feide. Dette er en ganske enkel prosess, ved å la bruker trykke på en knapp som sender brukeren til følgende url: |
| 20 | + |
| 21 | +``` |
| 22 | +https://auth.dataporten.no/oauth/authorization? |
| 23 | +client_id=<our_feide_client_id>& |
| 24 | +response_type=code& |
| 25 | +redirect_uri=https://tihlde.org/ny-bruker/feide& |
| 26 | +scope=openid& |
| 27 | +state=whatever |
| 28 | +``` |
| 29 | + |
| 30 | +Dette vil redirecte brukeren til Feide sin egen innloggingsside som du mest sannsynlig er kjent med. |
| 31 | + |
| 32 | + |
| 33 | + |
| 34 | +Etter at bruker har logget inn med riktig Feide brukernavn og passord, blir brukeren sendt tilbake til vår redirect_url: |
| 35 | + |
| 36 | +``` |
| 37 | +HTTP/1.1 302 Found |
| 38 | +Location: https://tihlde.org/ny-bruker/feide? |
| 39 | +code=0f8cf5fa-dc3f-4c9d-a60c-b6016c4134fa& |
| 40 | +state=f47282ec-0a8b-450a-b0da-dddb393fbeca |
| 41 | +``` |
| 42 | + |
| 43 | +Her ser vi at **code** er en token som varer i 10 min (dette er maks tid, og er usikkert om Feide bruker denne tiden eller mindre). |
| 44 | + |
| 45 | + |
| 46 | +### Autentisering med Lepton |
| 47 | +Det er nå på tide å koble inn Lepton. Ved å benytte oss av **code** parameteret har vi mulighet til å hente ut en access_token for brukeren. Dermed sender vi en POST request til LEPTON API'et til **/feide/** med code som en del av body. |
| 48 | + |
| 49 | + |
| 50 | +```python |
| 51 | +@api_view(["POST"]) |
| 52 | +def register_with_feide(request): |
| 53 | + """Register user with Feide credentials""" |
| 54 | + try: |
| 55 | + serializer = FeideUserCreateSerializer(data=request.data) |
| 56 | + |
| 57 | + if serializer.is_valid(): |
| 58 | + user = serializer.create(serializer.data) |
| 59 | + return Response( |
| 60 | + {"detail": DefaultUserSerializer(user).data}, |
| 61 | + status=status.HTTP_201_CREATED, |
| 62 | + ) |
| 63 | + |
| 64 | + return Response( |
| 65 | + {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST |
| 66 | + ) |
| 67 | + except Exception as e: |
| 68 | + if isinstance(e, FeideError): |
| 69 | + return Response( |
| 70 | + {"detail": e.message}, |
| 71 | + status=e.status_code, |
| 72 | + ) |
| 73 | + |
| 74 | + return Response( |
| 75 | + {"detail": "Det skjedde en feil på serveren"}, |
| 76 | + status=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| 77 | + ) |
| 78 | +``` |
| 79 | + |
| 80 | +Vi benytter oss av dekoratøren **api_view** for å opprette en funksjon for vårt endepunkt. Vi har ingen behov for noen aksesskontroll siden man ikke har en bruker allerede når man skal registrere seg. |
| 81 | + |
| 82 | +```python |
| 83 | +class FeideUserCreateSerializer(serializers.Serializer): |
| 84 | + code = serializers.CharField(max_length=36) |
| 85 | + |
| 86 | + def create(self, validated_data): |
| 87 | + code = validated_data["code"] |
| 88 | + |
| 89 | + access_token, jwt_token = get_feide_tokens(code) |
| 90 | + full_name, username = get_feide_user_info_from_jwt(jwt_token) |
| 91 | + |
| 92 | + existing_user = User.objects.filter(user_id=username).first() |
| 93 | + if existing_user: |
| 94 | + raise FeideUserExistsError() |
| 95 | + |
| 96 | + groups = get_feide_user_groups(access_token) |
| 97 | + group_slugs = parse_feide_groups(groups) |
| 98 | + password = generate_random_password() |
| 99 | + |
| 100 | + user_info = { |
| 101 | + "user_id": username, |
| 102 | + "password": make_password(password), |
| 103 | + "first_name": full_name.split()[0], |
| 104 | + "last_name": " ".join(full_name.split()[1:]), |
| 105 | + "email": f"{username}@stud.ntnu.no", |
| 106 | + } |
| 107 | + |
| 108 | + user = User.objects.create(**user_info) |
| 109 | + |
| 110 | + self.make_TIHLDE_member(user, password) |
| 111 | + |
| 112 | + for slug in group_slugs: |
| 113 | + self.add_user_to_study(user, slug) |
| 114 | + |
| 115 | + return user |
| 116 | +``` |
| 117 | + |
| 118 | +Vi benytter en egen **FeideSerializer** for å validere **code** som vi sender fra frontend. Denne må være nøyaktig 36 bokstaver lang. |
| 119 | + |
| 120 | + |
| 121 | +### Uthenting av Feide token |
| 122 | +Selv om vi har validert lengde og type for **code**, er det ikke sikkert den er riktig. Dette finner vi ut ved å benytte oss av **get_feide_tokens** metoden vår. |
| 123 | + |
| 124 | +```python |
| 125 | +def get_feide_tokens(code: str) -> tuple[str, str]: |
| 126 | + """Get access and JWT tokens for signed in Feide user""" |
| 127 | + |
| 128 | + grant_type = "authorization_code" |
| 129 | + |
| 130 | + auth = HTTPBasicAuth(username=FEIDE_CLIENT_ID, password=FEIDE_CLIENT_SECRET) |
| 131 | + |
| 132 | + payload = { |
| 133 | + "grant_type": grant_type, |
| 134 | + "client_id": FEIDE_CLIENT_ID, |
| 135 | + "redirect_uri": FEIDE_REDIRECT_URL, |
| 136 | + "code": code, |
| 137 | + } |
| 138 | + |
| 139 | + response = requests.post(url=FEIDE_TOKEN_URL, auth=auth, data=payload) |
| 140 | + |
| 141 | + if response.status_code == 400: |
| 142 | + raise FeideUsedUserCode() |
| 143 | + |
| 144 | + if response.status_code != 200: |
| 145 | + raise FeideGetTokenError() |
| 146 | + |
| 147 | + json = response.json() |
| 148 | + |
| 149 | + if "access_token" not in json or "id_token" not in json: |
| 150 | + raise FeideTokenNotFoundError() |
| 151 | + |
| 152 | + return (json["access_token"], json["id_token"]) |
| 153 | +``` |
| 154 | + |
| 155 | +Her benytter vi oss av Feide sitt API for å hente ut **access_token** og **id_token** (JWT) om innlogget bruker. Hvis denne forespørselen går gjennom vet vi at bruker er logget inn og autentisert gjennom Feide. |
| 156 | + |
| 157 | +### Dekoding av JWT |
| 158 | +```python |
| 159 | +full_name, username = get_feide_user_info_from_jwt(jwt_token) |
| 160 | +``` |
| 161 | + |
| 162 | +Neste steg er å dekode **id_token** fra forrige metode. JWT er en kjent metode man benytter for å autentisere en brukere, ved å sende ved informasjon i token. Du kan lese mer om [JWT her](https://jwt.io/introduction). |
| 163 | + |
| 164 | +``` |
| 165 | +{ |
| 166 | + "iss": "https://auth.dataporten.no", |
| 167 | + "jti": "f95ed523-b9b2-42e7-b193-a08143d9f342", |
| 168 | + "aud": "5ac8753f-8296-41bf-b985-59d89769005e", |
| 169 | + "sub": "76a7a061-3c55-430d-8ee0-6f82ec42501f", |
| 170 | + "iat": 1635509702, |
| 171 | + "exp": 1635513302, |
| 172 | + "auth_time": 1635505713, |
| 173 | + "nonce": "PLt3i3bT2~xTw7m", |
| 174 | + |
| 175 | + "name": "Jon Kåre Hellan", |
| 176 | + "picture": "https://api.dataporten.no/userinfo/v1/user/media/p:c0050004-386e-4c58-9073-e37344bc8769", |
| 177 | + "https://n.feide.no/claims/userid_sec": [ |
| 178 | + |
| 179 | + ], |
| 180 | + "https://n.feide.no/claims/eduPersonPrincipalName": "[email protected]", |
| 181 | + "at_hash": "DiafctHGah2reptMDjEqUg" |
| 182 | +} |
| 183 | +``` |
| 184 | + |
| 185 | +Her ser vi et eksempel fra Feide sin [dokumentasjon](https://docs.feide.no/reference/tokens.html) som viser hva man kan hente ut av JWT token som vi har hentet ut. Vi har kun behov for to ting; brukernavn og fullt navn. |
| 186 | + |
| 187 | + |
| 188 | +```python |
| 189 | +import jwt |
| 190 | + |
| 191 | + |
| 192 | +def get_feide_user_info_from_jwt(jwt_token: str) -> tuple[str, str]: |
| 193 | + """Get Feide user info from jwt token""" |
| 194 | + user_info = jwt.decode(jwt_token, options={"verify_signature": False}) |
| 195 | + |
| 196 | + if ( |
| 197 | + "name" not in user_info |
| 198 | + or "https://n.feide.no/claims/userid_sec" not in user_info |
| 199 | + ): |
| 200 | + raise FeideUserInfoNotFoundError() |
| 201 | + |
| 202 | + feide_username = None |
| 203 | + for id in user_info["https://n.feide.no/claims/userid_sec"]: |
| 204 | + if "feide:" in id: |
| 205 | + feide_username = id.split(":")[1].split("@")[0] |
| 206 | + |
| 207 | + if not feide_username: |
| 208 | + raise FeideUsernameNotFoundError() |
| 209 | + |
| 210 | + return (user_info["name"], feide_username) |
| 211 | +``` |
| 212 | +Ved å benytte oss av Python sin jwt dependency kan vi dekode vår token. Her ønsker vi å hente ut **name** for fullt navn, og iterere gjennom bruker sine **bruker id'er** og finne den som tilhører Feide. |
| 213 | + |
| 214 | + |
| 215 | +### Validering av studie |
| 216 | +Nå som vi har informasjon om brukeren, må vi finne ut om brukeren faktisk går på et studie som tilhører TIHLDE. Vi ønsker ikke å gi tilgang til noen andre studier utenfor TIHLDE. |
| 217 | + |
| 218 | +```python |
| 219 | +groups = get_feide_user_groups(access_token) |
| 220 | +group_slugs = parse_feide_groups(groups) |
| 221 | +``` |
| 222 | + |
| 223 | +Først må vi bruke Feide sitt API igjen for å hente ut gruppene til brukeren ved hjelp av brukeren sin **access_token**. |
| 224 | + |
| 225 | +```python |
| 226 | +def get_feide_user_groups(access_token: str) -> list[str]: |
| 227 | + """Get a Feide user's groups""" |
| 228 | + |
| 229 | + response = requests.get( |
| 230 | + url=FEIDE_USER_GROUPS_INFO_URL, |
| 231 | + headers={"Authorization": f"Bearer {access_token}"}, |
| 232 | + ) |
| 233 | + |
| 234 | + if response.status_code != 200: |
| 235 | + raise FeideGetUserGroupsError() |
| 236 | + |
| 237 | + groups = response.json() |
| 238 | + |
| 239 | + if not groups: |
| 240 | + raise FeideUserGroupsNotFoundError() |
| 241 | + |
| 242 | + return [group["id"] for group in groups] # Eks: fc:fs:fs:prg:ntnu.no:ITBAITBEDR |
| 243 | +``` |
| 244 | +Vi sender en GET request for å hente gruppene ved deretter å filtrere gruppene: |
| 245 | + |
| 246 | +```pyhton |
| 247 | +def parse_feide_groups(groups: list[str]) -> list[str]: |
| 248 | + """Parse groups and return list of group slugs""" |
| 249 | + program_codes = [ |
| 250 | + "BIDATA", |
| 251 | + "ITBAITBEDR", |
| 252 | + "BDIGSEC", |
| 253 | + "ITMAIKTSA", |
| 254 | + "ITBAINFODR", |
| 255 | + "ITBAINFO", |
| 256 | + ] |
| 257 | + program_slugs = [ |
| 258 | + "dataingenir", |
| 259 | + "digital-forretningsutvikling", |
| 260 | + "digital-infrastruktur-og-cybersikkerhet", |
| 261 | + "digital-samhandling", |
| 262 | + "drift-studie", |
| 263 | + "informasjonsbehandling", |
| 264 | + ] |
| 265 | +
|
| 266 | + slugs = [] |
| 267 | +
|
| 268 | + for group in groups: |
| 269 | +
|
| 270 | + id_parts = group.split(":") |
| 271 | +
|
| 272 | + group_code = id_parts[5] |
| 273 | +
|
| 274 | + if group_code not in program_codes: |
| 275 | + continue |
| 276 | +
|
| 277 | + index = program_codes.index(group_code) |
| 278 | + slugs.append(program_slugs[index]) |
| 279 | +
|
| 280 | + if not len(slugs): |
| 281 | + raise FeideParseGroupsError() |
| 282 | +
|
| 283 | + return slugs |
| 284 | +``` |
| 285 | +Hvis studenten ikke tilhører ett av våre studier kaster vi en feil og nekter adgang. |
| 286 | + |
| 287 | +### Brukerinformasjon |
| 288 | +```python |
| 289 | +password = generate_random_password() |
| 290 | + |
| 291 | +user_info = { |
| 292 | + "user_id": username, |
| 293 | + "password": make_password(password), |
| 294 | + "first_name": full_name.split()[0], |
| 295 | + "last_name": " ".join(full_name.split()[1:]), |
| 296 | + "email": f"{username}@stud.ntnu.no", |
| 297 | +} |
| 298 | + |
| 299 | +user = User.objects.create(**user_info) |
| 300 | +``` |
| 301 | + |
| 302 | +Neste steg er å lage selve brukeren. Siden vi ikke ønsker at alle nye brukere skal ha likt passord, og som vi som utviklere vet om, så genererer vi et sikkert og tilfeldig passord med en lengde på 12 bokstaver slik: |
| 303 | + |
| 304 | +```python |
| 305 | +def generate_random_password(length=12): |
| 306 | + """Generate random password with ascii letters, digits and punctuation""" |
| 307 | + characters = string.ascii_letters + string.digits + string.punctuation |
| 308 | + |
| 309 | + password = "".join(secrets.choice(characters) for _ in range(length)) |
| 310 | + |
| 311 | + return password |
| 312 | +``` |
| 313 | + |
| 314 | + |
| 315 | +### Gi bruker tilgang |
| 316 | +Nå som brukeren er laget, må vi ha en måte å levere passordet til brukeren. Vi velger å sende passordet ved hjelp av mail: |
| 317 | + |
| 318 | +```python |
| 319 | +def make_TIHLDE_member(self, user, password): |
| 320 | + TIHLDE = Group.objects.get(slug=Groups.TIHLDE) |
| 321 | + Membership.objects.get_or_create(user=user, group=TIHLDE) |
| 322 | + |
| 323 | + Notify( |
| 324 | + [user], "Velkommen til TIHLDE", UserNotificationSettingType.OTHER |
| 325 | + ).add_paragraph(f"Hei, {user.first_name}!").add_paragraph( |
| 326 | + f"Din bruker har nå blitt automatisk generert ved hjelp av Feide. Ditt brukernavn er dermed ditt brukernavn fra Feide: {user.user_id}. Du kan nå logge inn og ta i bruk våre sider." |
| 327 | + ).add_paragraph( |
| 328 | + f"Ditt autogenererte passord: {password}" |
| 329 | + ).add_paragraph( |
| 330 | + "Vi anbefaler at du bytter passord ved å følge lenken under:" |
| 331 | + ).add_link( |
| 332 | + "Bytt passord", "/glemt-passord/" |
| 333 | + ).add_link( |
| 334 | + "Logg inn", "/logg-inn/" |
| 335 | + ).send( |
| 336 | + website=False, slack=False |
| 337 | + ) |
| 338 | +``` |
| 339 | + |
| 340 | +Først lager vi et medlemskap i TIHLDE for brukeren, slik at den har mulighet til å logge inn. Deretter sender vi en mail med brukernavn og passord. Vi ønsker ikke at bruker skal bruke det genererte passordet siden det står i klartekst i en mail, og dermed er en sikkerhetsrisiko. Dermed legger vi ved en link til nettsiden for å resette passordet til et passord brukeren selv velger. |
| 341 | + |
| 342 | +### Studie og årskull |
| 343 | +Til slutt må vi legge den opprettede brukeren til sitt riktige studie og kull. |
| 344 | + |
| 345 | +```python |
| 346 | +def add_user_to_study(self, user, slug): |
| 347 | + study = Group.objects.filter(type=GroupType.STUDY, slug=slug).first() |
| 348 | + study_year = get_study_year(slug) |
| 349 | + class_ = Group.objects.get_or_create( |
| 350 | + name=study_year, type=GroupType.STUDYYEAR, slug=study_year |
| 351 | + ) |
| 352 | + |
| 353 | + if not study or not class_: |
| 354 | + return |
| 355 | + |
| 356 | + Membership.objects.create(user=user, group=study) |
| 357 | + Membership.objects.create(user=user, group=class_[0]) |
| 358 | +``` |
| 359 | + |
| 360 | +Siden Feide API'et kun gir tilgang til navn på studie, men ikke årskull så antar vi at brukeren er ny, og setter dem dermed til følgende år med følgende metode: |
| 361 | + |
| 362 | +```python |
| 363 | +def get_study_year(slug: str) -> str: |
| 364 | + today = datetime.today() |
| 365 | + current_year = today.year |
| 366 | + |
| 367 | + # Check if today's date is before July 20th |
| 368 | + if today < datetime(current_year, 7, 20): |
| 369 | + current_year -= 1 |
| 370 | + |
| 371 | + if slug == "digital-samhandling": |
| 372 | + return str(current_year - 3) |
| 373 | + |
| 374 | + return str(current_year) |
| 375 | +``` |
| 376 | + |
| 377 | +I tillegg så trekker vi fra 3 år hvis studiet er Digital Transformasjon siden de i praksis starter i 4. klasse. |
| 378 | + |
| 379 | + |
| 380 | +## Konklusjon |
| 381 | +Du har nå sett hvordan vi setter opp automatisk registrering av brukere ved hjelp av Feide. Dette er en funksjonalitet som fører til drastisk reduksjon av manuelt arbeid for HS og Index. I tillegg så er det positivt at brukere sin e-post blir satt til skole e-posten. Dette fører til at flere får med seg beskjeder siden de bruker skole e-posten sin aktivt. |
0 commit comments