Skip to content
This repository has been archived by the owner on Mar 16, 2024. It is now read-only.

Commit

Permalink
added login exceptions and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
Yannic Schröer authored and Yannic Schröer committed Dec 22, 2021
1 parent 3fe5c2f commit 9812b60
Show file tree
Hide file tree
Showing 13 changed files with 351 additions and 169 deletions.
Binary file modified .coverage
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
## Introduction

Welcome to `fastapi-keycloak`. This projects goal is to ease the integration of Keycloak (OpenID Connect) with Python, especially FastAPI. FastAPI is not necessary but is
encouraged due to specific features. Currently, this package supports only the `password` and the `authorization_code` flow. However, the `get_current_user()` method accepts any
encouraged due to specific features. Currently, this package supports only the `password` and the `authorization_code` . However, the `get_current_user()` method accepts any
JWT
that was signed using Keycloak's private key.

Expand Down
12 changes: 0 additions & 12 deletions fastapi-keycloak/exceptions.py

This file was deleted.

5 changes: 5 additions & 0 deletions fastapi_keycloak/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from fastapi_keycloak.api import FastAPIKeycloak
from fastapi_keycloak.model import OIDCUser, UsernamePassword, HTTPMethod, KeycloakError, KeycloakUser, KeycloakToken, KeycloakRole, KeycloakIdentityProvider

__all__ = [FastAPIKeycloak.__name__, OIDCUser.__name__, UsernamePassword.__name__, HTTPMethod.__name__, KeycloakError.__name__, KeycloakUser.__name__, KeycloakToken.__name__,
KeycloakRole.__name__, KeycloakIdentityProvider.__name__]
62 changes: 57 additions & 5 deletions fastapi-keycloak/api.py → fastapi_keycloak/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
from pydantic import BaseModel
from requests import Response

from fastapi_keycloak.exceptions import KeycloakError
from fastapi_keycloak.exceptions import KeycloakError, MandatoryActionException, UpdateUserLocaleException, ConfigureTOTPException, VerifyEmailException, \
UpdateProfileException, UpdatePasswordException
from fastapi_keycloak.model import HTTPMethod, KeycloakUser, OIDCUser, KeycloakToken, KeycloakRole, KeycloakIdentityProvider


Expand Down Expand Up @@ -483,10 +484,11 @@ def create_user(
return response

@result_or_error()
def change_password(self, user_id: str, new_password: str) -> dict:
def change_password(self, user_id: str, new_password: str, temporary: bool = False) -> dict:
""" Exchanges a users password.
Args:
temporary (bool): If True, the password must be changed on the first login
user_id (str): The user ID of interest
new_password (str): The new password
Expand All @@ -499,7 +501,7 @@ def change_password(self, user_id: str, new_password: str) -> dict:
Raises:
KeycloakError: If the resulting response is not a successful HTTP-Code (>299)
"""
credentials = {"temporary": False, "type": "password", "value": new_password}
credentials = {"temporary": temporary, "type": "password", "value": new_password}
return self._admin_request(url=f'{self.users_uri}/{user_id}/reset-password', data=credentials, method=HTTPMethod.PUT)

@result_or_error()
Expand Down Expand Up @@ -538,6 +540,28 @@ def get_user(self, user_id: str = None, query: str = "") -> KeycloakUser:
response = self._admin_request(url=f'{self.users_uri}/{user_id}', method=HTTPMethod.GET)
return KeycloakUser(**response.json())

@result_or_error(response_model=KeycloakUser)
def update_user(self, user: KeycloakUser):
""" Updates a user. Requires the whole object.
Args:
user (KeycloakUser): The (new) user object
Returns:
KeycloakUser: The updated user
Raises:
KeycloakError: If the resulting response is not a successful HTTP-Code (>299)
Notes:
- You may alter any aspect of the user object, also the requiredActions for instance. There is not explicit function for updating those as it is a user update in
essence
"""
response = self._admin_request(url=f'{self.users_uri}/{user.id}', data=user.__dict__, method=HTTPMethod.PUT)
if response.status_code == 204: # Update successful
return self.get_user(user_id=user.id)
return response

@result_or_error()
def delete_user(self, user_id: str) -> dict:
""" Deletes an user
Expand Down Expand Up @@ -580,7 +604,7 @@ def get_identity_providers(self) -> List[KeycloakIdentityProvider]:

@result_or_error(response_model=KeycloakToken)
def user_login(self, username: str, password: str) -> KeycloakToken:
""" Models the password OAuth2 flow. Exchanges username and password for an access token.
""" Models the password OAuth2 flow. Exchanges username and password for an access token. Will raise detailed errors if login fails due to requiredActions
Args:
username (str): Username used for login
Expand All @@ -590,7 +614,17 @@ def user_login(self, username: str, password: str) -> KeycloakToken:
KeycloakToken: If the exchange succeeds
Raises:
KeycloakError: If the resulting response is not a successful HTTP-Code (>299)
HTTPException: If the credentials did not match any user
MandatoryActionException: If the login is not possible due to mandatory actions
KeycloakError: If the resulting response is not a successful HTTP-Code (>299, != 400, != 401)
UpdateUserLocaleException: If the credentials we're correct but the has requiredActions of which the first one is to update his locale
ConfigureTOTPException: If the credentials we're correct but the has requiredActions of which the first one is to configure TOTP
VerifyEmailException: If the credentials we're correct but the has requiredActions of which the first one is to verify his email
UpdatePasswordException: If the credentials we're correct but the has requiredActions of which the first one is to update his password
UpdateProfileException: If the credentials we're correct but the has requiredActions of which the first one is to update his profile
Notes:
- To avoid calling this multiple times, you may want to check all requiredActions of the user if it fails due to a (sub)instance of an MandatoryActionException
"""
headers = {
"Content-Type": "application/x-www-form-urlencoded"
Expand All @@ -603,6 +637,24 @@ def user_login(self, username: str, password: str) -> KeycloakToken:
"grant_type": "password"
}
response = requests.post(url=self.token_uri, headers=headers, data=data)
if response.status_code == 401:
raise HTTPException(status_code=401, detail="Invalid user credentials")
if response.status_code == 400:
user: KeycloakUser = self.get_user(query=f'username={username}')
if len(user.requiredActions) > 0:
reason = user.requiredActions[0]
exception = {
"update_user_locale": UpdateUserLocaleException(),
"CONFIGURE_TOTP": ConfigureTOTPException(),
"VERIFY_EMAIL": VerifyEmailException(),
"UPDATE_PASSWORD": UpdatePasswordException(),
"UPDATE_PROFILE": UpdateProfileException(),
}.get(
reason, # Try to return the matching exception
# On custom or unknown actions return a MandatoryActionException by default
MandatoryActionException(detail=f"This user can't login until the following action has been resolved: {reason}")
)
raise exception
return response

@result_or_error(response_model=KeycloakToken)
Expand Down
51 changes: 51 additions & 0 deletions fastapi_keycloak/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from fastapi import HTTPException


class KeycloakError(Exception):
""" Thrown if any response of keycloak does not match our expectation
Attributes:
status_code (int): The status code of the response received
reason (str): The reason why the requests did fail
"""

def __init__(self, status_code: int, reason: str):
self.status_code = status_code
self.reason = reason
super().__init__(f'HTTP {status_code}: {reason}')


class MandatoryActionException(HTTPException):
""" Throw if the exchange of username and password for an access token fails """
def __init__(self, detail: str) -> None:
super().__init__(status_code=400, detail=detail)


class UpdateUserLocaleException(MandatoryActionException):
""" Throw if the exchange of username and password for an access token fails due to the update_user_locale requiredAction"""
def __init__(self) -> None:
super().__init__(detail=f"This user can't login until he updated his locale")


class ConfigureTOTPException(MandatoryActionException):
""" Throw if the exchange of username and password for an access token fails due to the CONFIGURE_TOTP requiredAction"""
def __init__(self) -> None:
super().__init__(detail=f"This user can't login until he configured TOTP")


class VerifyEmailException(MandatoryActionException):
""" Throw if the exchange of username and password for an access token fails due to the VERIFY_EMAIL requiredAction"""
def __init__(self) -> None:
super().__init__(detail=f"This user can't login until he verified his email")


class UpdatePasswordException(MandatoryActionException):
""" Throw if the exchange of username and password for an access token fails due to the UPDATE_PASSWORD requiredAction"""
def __init__(self) -> None:
super().__init__(detail=f"This user can't login until he updated his password")


class UpdateProfileException(MandatoryActionException):
""" Throw if the exchange of username and password for an access token fails due to the UPDATE_PROFILE requiredAction"""
def __init__(self) -> None:
super().__init__(detail=f"This user can't login until he updated his profile")
File renamed without changes.
File renamed without changes.
4 changes: 3 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ def read_description() -> str:
return file.read()


# FLIT

setup(
name='fastapi-keycloak',
packages=['fastapi-keycloak'],
packages=['fastapi_keycloak'],
version='0.0.1a',
license='apache-2.0',
description='Keycloak API Client for integrating authentication and authorization with FastAPI',
Expand Down
7 changes: 6 additions & 1 deletion tests/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from fastapi import FastAPI, Depends, Query, Body
from pydantic import SecretStr

from fastapi_keycloak import FastAPIKeycloak, OIDCUser, UsernamePassword, HTTPMethod
from fastapi_keycloak import FastAPIKeycloak, OIDCUser, UsernamePassword, HTTPMethod, KeycloakUser

app = FastAPI()
idp = FastAPIKeycloak(
Expand Down Expand Up @@ -62,6 +62,11 @@ def get_user(user_id: str = None):
return idp.get_user(user_id=user_id)


@app.put("/user", tags=["user-management"])
def update_user(user: KeycloakUser):
return idp.update_user(user=user)


@app.delete("/user/{user_id}", tags=["user-management"])
def delete_user(user_id: str):
return idp.delete_user(user_id=user_id)
Expand Down
Loading

0 comments on commit 9812b60

Please sign in to comment.