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

Commit

Permalink
Merge pull request #8 from code-specialist/fixes
Browse files Browse the repository at this point in the history
Version 0.0.1a
  • Loading branch information
yannicschroeer authored Dec 23, 2021
2 parents 7e7ba9a + 3681cf3 commit ce47379
Show file tree
Hide file tree
Showing 15 changed files with 399 additions and 240 deletions.
Binary file modified .coverage
Binary file not shown.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright [yyyy] [name of copyright owner]
Copyright [2021] [Jonas Scholl, Yannic Schröer]

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
47 changes: 6 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@
[![codecov](https://codecov.io/gh/code-specialist/fastapi-keycloak/branch/master/graph/badge.svg?token=PX6NJBDUJ9)](https://codecov.io/gh/code-specialist/fastapi-keycloak)
---

## 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`. However, the `get_current_user()` method accepts any JWT
that was signed using Keycloak's private key.
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.

**This package is currently under development and is not yet officially released. However, you may still use it and contribute to it.**
## Docs

## TLDR;
Docs are available at [https://fastapi-keycloak.code-specialist.com/](https://fastapi-keycloak.code-specialist.com/).

## TLDR

FastAPI Keycloak enables you to do the following things without writing a single line of additional code:

Expand All @@ -23,39 +24,3 @@ FastAPI Keycloak enables you to do the following things without writing a single
- Assign/remove roles from users
- Implement the `password` or the `authorization_code` flow (login/callback/logout)

## Example

This example assumes you use a frontend technology (such as React, Vue, or whatever suits you) to render your pages and merely depicts a `protected backend`

### app.py

```python
import uvicorn
from fastapi import FastAPI, Depends

from fastapi_keycloak import FastAPIKeycloak, OIDCUser

app = FastAPI()
idp = FastAPIKeycloak(
server_url="https://auth.some-domain.com/auth",
client_id="some-client",
client_secret="some-client-secret",
admin_client_secret="admin-cli-secret",
realm="some-realm-name",
callback_uri="http://localhost:8081/callback"
)
idp.add_swagger_config(app)

@app.get("/admin")
def admin(user: OIDCUser = Depends(idp.get_current_user(required_roles=["admin"]))):
return f'Hi premium user {user}'


@app.get("/user/roles")
def user_roles(user: OIDCUser = Depends(idp.get_current_user)):
return f'{user.roles}'


if __name__ == '__main__':
uvicorn.run('app:app', host="127.0.0.1", port=8081)
```
2 changes: 2 additions & 0 deletions codecov.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ignore:
- "setup.py"
7 changes: 3 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
## 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`. However, the `get_current_user()` method accepts any JWT
that was signed
using
Keycloak's private key.
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.

!!! Caution
This package is currently under development and is not yet officially released. However, you may still use it and contribute to it.
Expand Down
Binary file removed fastapi_keycloak/.coverage
Binary file not shown.
4 changes: 4 additions & 0 deletions fastapi_keycloak/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""Keycloak API Client for integrating authentication and authorization with FastAPI"""

__version__ = "0.0.1"

from fastapi_keycloak.api import FastAPIKeycloak
from fastapi_keycloak.model import OIDCUser, UsernamePassword, HTTPMethod, KeycloakError, KeycloakUser, KeycloakToken, KeycloakRole, KeycloakIdentityProvider

Expand Down
62 changes: 57 additions & 5 deletions 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
41 changes: 40 additions & 1 deletion fastapi_keycloak/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from fastapi import HTTPException


class KeycloakError(Exception):
""" Thrown if any response of keycloak does not match our expectation
Expand All @@ -9,4 +12,40 @@ class KeycloakError(Exception):
def __init__(self, status_code: int, reason: str):
self.status_code = status_code
self.reason = reason
super().__init__(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")
38 changes: 0 additions & 38 deletions fastapi_keycloak/setup.py

This file was deleted.

52 changes: 52 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
[project]
name = "fastapi_keycloak"
authors = [
{ name = "Jonas Scholl", email = "jonas@code-specialist.com" },
{ name = "Yannic Schröer", email = "yannic@code-specialist.com" }
]
maintainers = [
{ name = "Jonas Scholl", email = "jonas@code-specialist.com" },
{ name = "Yannic Schröer", email = "yannic@code-specialist.com" }
]
readme = "README.md"
keywords = ['Keycloak', 'FastAPI', 'Authentication', 'Authorization']
classifiers = [
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'Topic :: Internet :: WWW/HTTP :: Session',
'Topic :: Internet :: WWW/HTTP :: WSGI',
'Topic :: Software Development :: Libraries :: Application Frameworks',
'Topic :: Software Development :: Libraries :: Python Modules',
'Framework :: FastAPI',
'License :: OSI Approved :: Apache Software License',
'Programming Language :: Python :: 3.8',
]
requires-python = ">=3.8"
dynamic = ["version", "description"]
dependencies = [
"anyio>=3.4.0",
"asgiref>=3.4.1",
"certifi>=2021.10.8",
"charset-normalizer>=2.0.9",
"click>=8.0.3",
"ecdsa>=0.17.0",
"fastapi>=0.70.1",
"h11>=0.12.0",
"idna>=3.3",
"pyasn1>=0.4.8",
"pydantic>=1.5a1",
"python-jose>=3.3.0",
"requests>=2.26.0",
"rsa>=4.8",
"six>=1.16.0",
"sniffio>=1.2.0",
"starlette>=0.16.0",
"typing_extensions>=4.0.1",
"urllib3>=1.26.7",
"uvicorn>=0.16.0",
"itsdangerous>=2.0.1",
]

[project.urls]
Documentation = "https://github.com/code-specialist/fastapi-keycloak"
Source = "https://github.com/code-specialist/fastapi-keycloak/archive/refs/tags/0.0.2a.tar.gz"
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 ce47379

Please sign in to comment.