Skip to content

Commit

Permalink
[DOP-23122] Use async methods of Keycloak client
Browse files Browse the repository at this point in the history
  • Loading branch information
dolfinus committed Dec 25, 2024
1 parent bf103a0 commit fbb23d0
Show file tree
Hide file tree
Showing 13 changed files with 118 additions and 133 deletions.
4 changes: 2 additions & 2 deletions .env.docker
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ DATA_RENTGEN__SERVER__SESSION__SECRET_KEY=session_secret_key

# Keycloak Auth
DATA_RENTGEN__AUTH__KEYCLOAK__SERVER_URL=http://keycloak:8080
DATA_RENTGEN__AUTH__KEYCLOAK__REALM_NAME=manually_created
DATA_RENTGEN__AUTH__KEYCLOAK__CLIENT_ID=manually_created
DATA_RENTGEN__AUTH__KEYCLOAK__REALM_NAME=create_realm_manually
DATA_RENTGEN__AUTH__KEYCLOAK__CLIENT_ID=create_client_manually
DATA_RENTGEN__AUTH__KEYCLOAK__CLIENT_SECRET=generated_by_keycloak
DATA_RENTGEN__AUTH__KEYCLOAK__REDIRECT_URI=http://localhost:8000/auth/callback
DATA_RENTGEN__AUTH__KEYCLOAK__SCOPE=email
Expand Down
4 changes: 2 additions & 2 deletions .env.local
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export DATA_RENTGEN__SERVER__SESSION__SECRET_KEY=session_secret_key

# Keycloak Auth
export DATA_RENTGEN__AUTH__KEYCLOAK__SERVER_URL=http://localhost:8080
export DATA_RENTGEN__AUTH__KEYCLOAK__REALM_NAME=manually_created
export DATA_RENTGEN__AUTH__KEYCLOAK__CLIENT_ID=manually_created
export DATA_RENTGEN__AUTH__KEYCLOAK__REALM_NAME=create_realm_manually
export DATA_RENTGEN__AUTH__KEYCLOAK__CLIENT_ID=create_client_manually
export DATA_RENTGEN__AUTH__KEYCLOAK__CLIENT_SECRET=change_me
export DATA_RENTGEN__AUTH__KEYCLOAK__REDIRECT_URI=http://localhost:3000/auth/callback
export DATA_RENTGEN__AUTH__KEYCLOAK__SCOPE=email
Expand Down
5 changes: 2 additions & 3 deletions data_rentgen/server/api/v1/router/auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# SPDX-FileCopyrightText: 2024 MTS PJSC
# SPDX-License-Identifier: Apache-2.0
from http import HTTPStatus
from typing import Annotated

from fastapi import APIRouter, Depends, Request
Expand Down Expand Up @@ -34,6 +33,7 @@ async def token(
) -> AuthTokenSchema:
user_token = await auth_provider.get_token_password_grant(
login=form_data.username,
password=form_data.password,
)
return AuthTokenSchema.model_validate(user_token)

Expand All @@ -46,9 +46,8 @@ async def auth_callback(
):
code_grant = await auth_provider.get_token_authorization_code_grant(
code=code,
redirect_uri=auth_provider.settings.keycloak.redirect_uri,
)
request.session["access_token"] = code_grant["access_token"]
request.session["refresh_token"] = code_grant["refresh_token"]

return HTTPStatus.OK
return {}
2 changes: 1 addition & 1 deletion data_rentgen/server/middlewares/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ def apply_session_middleware(app: FastAPI, settings: SessionSettings) -> FastAPI

app.add_middleware(
SessionMiddleware,
**settings.dict(),
**settings.model_dump(),
)
return app
16 changes: 4 additions & 12 deletions data_rentgen/server/providers/auth/base_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from abc import ABC, abstractmethod
from typing import Any

from fastapi import FastAPI
from fastapi import FastAPI, Request

from data_rentgen.db.models import User

Expand Down Expand Up @@ -51,7 +51,7 @@ def __init__(
...

@abstractmethod
async def get_current_user(self, access_token: Any, *args, **kwargs) -> User | None:
async def get_current_user(self, access_token: str | None, request: Request) -> User:
"""
This method should return currently logged in user.
Expand All @@ -70,12 +70,8 @@ async def get_current_user(self, access_token: Any, *args, **kwargs) -> User | N
@abstractmethod
async def get_token_password_grant(
self,
grant_type: str | None = None,
login: str | None = None,
password: str | None = None,
scopes: list[str] | None = None,
client_id: str | None = None,
client_secret: str | None = None,
login: str,
password: str,
) -> dict[str, Any]:
"""
This method should perform authentication and return JWT token.
Expand Down Expand Up @@ -103,10 +99,6 @@ async def get_token_password_grant(
async def get_token_authorization_code_grant(
self,
code: str,
redirect_uri: str,
scopes: list[str] | None = None,
client_id: str | None = None,
client_secret: str | None = None,
) -> dict[str, Any]:
"""
Obtain a token using the Authorization Code grant.
Expand Down
16 changes: 4 additions & 12 deletions data_rentgen/server/providers/auth/dummy_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from time import time
from typing import Annotated, Any

from fastapi import Depends, FastAPI
from fastapi import Depends, FastAPI, Request

from data_rentgen.db.models import User
from data_rentgen.dependencies import Stub
Expand Down Expand Up @@ -37,7 +37,7 @@ def setup(cls, app: FastAPI) -> FastAPI:
app.dependency_overrides[DummyAuthProviderSettings] = lambda: settings
return app

async def get_current_user(self, access_token: str, *args, **kwargs) -> User:
async def get_current_user(self, access_token: str | None, request: Request) -> User:
if not access_token:
raise AuthorizationError("Missing auth credentials")

Expand All @@ -49,12 +49,8 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> User:

async def get_token_password_grant(
self,
grant_type: str | None = None,
login: str | None = None,
password: str | None = None,
scopes: list[str] | None = None,
client_id: str | None = None,
client_secret: str | None = None,
login: str,
password: str,
) -> dict[str, Any]:
if not login:
raise AuthorizationError("Missing auth credentials")
Expand All @@ -75,10 +71,6 @@ async def get_token_password_grant(
async def get_token_authorization_code_grant(
self,
code: str,
redirect_uri: str,
scopes: list[str] | None = None,
client_id: str | None = None,
client_secret: str | None = None,
) -> dict[str, Any]:
raise NotImplementedError("Authorization code grant is not supported by DummyAuthProvider.")

Expand Down
73 changes: 29 additions & 44 deletions data_rentgen/server/providers/auth/keycloak_provider.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# SPDX-FileCopyrightText: 2024 MTS PJSC
# SPDX-License-Identifier: Apache-2.0
import logging
from typing import Annotated, Any
from typing import Annotated, Any, NoReturn

from fastapi import Depends, FastAPI, Request
from keycloak import KeycloakOpenID
from jwcrypto.common import JWException
from keycloak import KeycloakOpenID, KeycloakOperationError

from data_rentgen.db.models import User
from data_rentgen.dependencies import Stub
Expand Down Expand Up @@ -44,87 +45,71 @@ def setup(cls, app: FastAPI) -> FastAPI:

async def get_token_password_grant(
self,
grant_type: str | None = None,
login: str | None = None,
password: str | None = None,
scopes: list[str] | None = None,
client_id: str | None = None,
client_secret: str | None = None,
login: str,
password: str,
) -> dict[str, Any]:
raise NotImplementedError("Password grant is not supported by KeycloakAuthProvider.")

async def get_token_authorization_code_grant(
self,
code: str,
redirect_uri: str,
scopes: list[str] | None = None,
client_id: str | None = None,
client_secret: str | None = None,
) -> dict[str, Any]:
try:
redirect_uri = redirect_uri or self.settings.keycloak.redirect_uri
return self.keycloak_openid.token(
return await self.keycloak_openid.a_token(
grant_type="authorization_code",
code=code,
redirect_uri=redirect_uri,
)
except Exception as e:
logger.error("Error when trying to get token: %s", e)
except KeycloakOperationError as e:
raise AuthorizationError("Failed to get token") from e

async def get_current_user(self, access_token: str, *args, **kwargs) -> User:
request: Request = kwargs["request"]
refresh_token = request.session.get("refresh_token")

async def get_current_user(self, access_token: str | None, request: Request) -> User:
if not access_token:
logger.debug("No access token found in session.")
self.redirect_to_auth()
await self.redirect_to_auth()

# if user is disabled or blocked in Keycloak after the token is issued, he will
# remain authorized until the token expires (not more than 15 minutes in MTS SSO)
token_info = self.decode_token(access_token)
token_info = await self.decode_token(access_token)
refresh_token = request.session.get("refresh_token")

if token_info is None and refresh_token:
logger.debug("Access token invalid. Attempting to refresh.")
access_token, refresh_token = self.refresh_access_token(refresh_token)
access_token, refresh_token = await self.refresh_access_token(refresh_token)
token_info = await self.decode_token(access_token)
request.session["access_token"] = access_token
request.session["refresh_token"] = refresh_token

token_info = self.decode_token(access_token)

if token_info is None:
# If there is no token_info after refresh user get redirect
self.redirect_to_auth()
if token_info is None:
await self.redirect_to_auth()

# these names are hardcoded in keycloak:
# this name is hardcoded in keycloak:
# https://github.com/keycloak/keycloak/blob/3ca3a4ad349b4d457f6829eaf2ae05f1e01408be/core/src/main/java/org/keycloak/representations/IDToken.java
user_id = token_info.get("sub") # type: ignore[union-attr]
login = token_info.get("preferred_username") # type: ignore[union-attr]
if not user_id:
if not login:
raise AuthorizationError("Invalid token payload")
return await self._uow.user.get_or_create(UserDTO(name=login)) # type: ignore[arg-type]

def decode_token(self, access_token: str) -> dict[str, Any] | None:
async def decode_token(self, access_token: str) -> dict[str, Any] | None:
try:
return self.keycloak_openid.decode_token(token=access_token)
except Exception as err:
return await self.keycloak_openid.a_decode_token(token=access_token)
except (KeycloakOperationError, JWException) as err:
logger.info("Access token is invalid or expired: %s", err)
return None

def refresh_access_token(self, refresh_token: str) -> tuple[str, str]: # type: ignore[return]
async def refresh_access_token(self, refresh_token: str) -> tuple[str, str]: # type: ignore[return]
try:
new_tokens = self.keycloak_openid.refresh_token(refresh_token)
new_tokens = await self.keycloak_openid.a_refresh_token(refresh_token)
logger.debug("Access token refreshed")
return new_tokens.get("access_token"), new_tokens.get("refresh_token")
except Exception as err:
return new_tokens["access_token"], new_tokens["refresh_token"]
except (KeycloakOperationError, JWException) as err:
logger.debug("Failed to refresh access token: %s", err)
self.redirect_to_auth()

def redirect_to_auth(self):
await self.redirect_to_auth()

auth_url = self.keycloak_openid.auth_url(
async def redirect_to_auth(self) -> NoReturn:
auth_url = await self.keycloak_openid.a_auth_url(
redirect_uri=self.settings.keycloak.redirect_uri,
scope=self.settings.keycloak.scope,
)

logger.info("Raising redirect error with url: %s", auth_url)
logger.info("Redirecting user to auth url: %s", auth_url)
raise RedirectError(message="Please authorize using provided URL", details=auth_url)
2 changes: 1 addition & 1 deletion data_rentgen/server/services/get_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ async def wrapper(
) -> User:
# keycloak provider patches session and store access_token in cookie,
# dummy auth stores access_token in "Authorization" header
access_token = request.session.get("access_token", "") or access_token
access_token = request.session.get("access_token") or access_token
return await auth_provider.get_current_user( # type: ignore[return-value]
access_token=access_token,
request=request,
Expand Down
19 changes: 7 additions & 12 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ coverage = "^7.6.9"
psycopg2-binary = "^2.9.9"
gevent = "^24.11.1"
deepdiff = "^8.1.1"
responses = "*"
respx = "^0.22.0"

[tool.poetry.group.dev.dependencies]
pre-commit = "^4.0.1"
Expand Down
Loading

0 comments on commit fbb23d0

Please sign in to comment.