Skip to content

Commit

Permalink
[DOP-21576] Update Keycloak provider for auth with Frontend (#131)
Browse files Browse the repository at this point in the history
* [DOP-21576] Update Keycloak provider for auth with Frontend

* [DOP-21576] Move /auth/me -> /users/me; Remove state from redirect

* [DOP-21576] Small fixes

* [DOP-21576] remove logout

* [DOP-21576] Fix interaction schema

* [DOP-21576] Update schema
  • Loading branch information
TiGrib authored Dec 20, 2024
1 parent af8126f commit cada1f1
Show file tree
Hide file tree
Showing 10 changed files with 180 additions and 97 deletions.
15 changes: 12 additions & 3 deletions .env.local
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,24 @@ export DATA_RENTGEN__UI__API_BROWSER_URL=http://localhost:8000
export DATA_RENTGEN__SERVER__SESSION__SECRET_KEY=session_secret_key

# Keycloak Auth
export DATA_RENTGEN__AUTH__KEYCLOAK__SERVER_URL=http://keycloak:8080
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__CLIENT_SECRET=generated_by_keycloak
export DATA_RENTGEN__AUTH__KEYCLOAK__REDIRECT_URI=http://localhost:8000/auth/callback
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
export DATA_RENTGEN__AUTH__KEYCLOAK__VERIFY_SSL=False
export DATA_RENTGEN__AUTH__PROVIDER=data_rentgen.server.providers.auth.keycloak_provider.KeycloakAuthProvider

# Dummy Auth
export DATA_RENTGEN__AUTH__PROVIDER=data_rentgen.server.providers.auth.dummy_provider.DummyAuthProvider
export DATA_RENTGEN__AUTH__ACCESS_TOKEN__SECRET_KEY=secret

# Cors
export DATA_RENTGEN__SERVER__CORS__ENABLED=True
export DATA_RENTGEN__SERVER__CORS__ALLOW_ORIGINS=["http://localhost:3000"]
export DATA_RENTGEN__SERVER__CORS__ALLOW_CREDENTIALS=true
export DATA_RENTGEN__SERVER__CORS__ALLOW_METHODS=["*"]
export DATA_RENTGEN__SERVER__CORS__ALLOW_HEADERS=["*"]
export DATA_RENTGEN__SERVER__CORS__EXPOSE_HEADERS=["X-Request-ID", "Location", "Access-Control-Allow-Credentials"]

19 changes: 15 additions & 4 deletions data_rentgen/server/api/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from asgi_correlation_id import correlation_id
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import RedirectResponse
from pydantic import ValidationError

from data_rentgen.exceptions import ApplicationError, AuthorizationError, RedirectError
Expand Down Expand Up @@ -92,8 +91,20 @@ def application_exception_handler(request: Request, exc: ApplicationError) -> Re
)


def redirect_exception_handler(_: Request, exc: RedirectError) -> Response:
return RedirectResponse(url=exc.message)
def not_authorized_redirect_exception_handler(request: Request, exc: RedirectError) -> Response:
logger.info("Redirect user to keycloak")
response = get_response_for_exception(RedirectError)
if not response:
return unknown_exception_handler(request, exc)
content = response.schema( # type: ignore[call-arg]
message=exc.message,
details=exc.details,
)

return exception_json_response(
status=response.status,
content=content,
)


def exception_json_response(
Expand All @@ -112,7 +123,7 @@ def exception_json_response(


def apply_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(RedirectError, redirect_exception_handler) # type: ignore[arg-type]
app.add_exception_handler(RedirectError, not_authorized_redirect_exception_handler) # type: ignore[arg-type]
app.add_exception_handler(ApplicationError, application_exception_handler) # type: ignore[arg-type]
app.add_exception_handler(AuthorizationError, application_exception_handler) # type: ignore[arg-type]
app.add_exception_handler(
Expand Down
2 changes: 2 additions & 0 deletions data_rentgen/server/api/v1/router/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from data_rentgen.server.api.v1.router.location import router as location_router
from data_rentgen.server.api.v1.router.operation import router as operation_router
from data_rentgen.server.api.v1.router.run import router as run_router
from data_rentgen.server.api.v1.router.user import router as user_router

router = APIRouter(prefix="/v1")
router.include_router(auth_router)
Expand All @@ -16,3 +17,4 @@
router.include_router(location_router)
router.include_router(operation_router)
router.include_router(run_router)
router.include_router(user_router)
32 changes: 28 additions & 4 deletions data_rentgen/server/api/v1/router/auth.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
# SPDX-FileCopyrightText: 2024 MTS PJSC
# SPDX-License-Identifier: Apache-2.0
from http import HTTPStatus
from typing import Annotated

from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm

from data_rentgen.dependencies import Stub
from data_rentgen.server.errors.registration import get_error_responses
from data_rentgen.server.errors.schemas.invalid_request import InvalidRequestSchema
from data_rentgen.server.errors.schemas.not_authorized import NotAuthorizedSchema
from data_rentgen.server.providers.auth import AuthProvider, DummyAuthProvider
from data_rentgen.server.errors.schemas.not_authorized import (
NotAuthorizedRedirectSchema,
NotAuthorizedSchema,
)
from data_rentgen.server.providers.auth import (
AuthProvider,
DummyAuthProvider,
KeycloakAuthProvider,
)
from data_rentgen.server.schemas.v1.auth import AuthTokenSchema

router = APIRouter(
prefix="/auth",
tags=["Auth"],
responses=get_error_responses(include={NotAuthorizedSchema, InvalidRequestSchema}),
responses=get_error_responses(include={NotAuthorizedSchema, InvalidRequestSchema, NotAuthorizedRedirectSchema}),
)


Expand All @@ -28,3 +36,19 @@ async def token(
login=form_data.username,
)
return AuthTokenSchema.model_validate(user_token)


@router.get("/callback")
async def auth_callback(
request: Request,
code: str,
auth_provider: Annotated[KeycloakAuthProvider, Depends(Stub(AuthProvider))],
):
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
27 changes: 27 additions & 0 deletions data_rentgen/server/api/v1/router/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: 2024 MTS PJSC
# SPDX-License-Identifier: Apache-2.0
from logging import getLogger

from fastapi import APIRouter, Depends

from data_rentgen.db.models.user import User
from data_rentgen.server.errors.registration import get_error_responses
from data_rentgen.server.schemas.v1.user import UserResponseV1
from data_rentgen.server.services import get_user

logger = getLogger(__name__)


router = APIRouter(
prefix="/users",
tags=["User"],
responses=get_error_responses(),
)


@router.get("/me")
async def check_auth(
current_user: User = Depends(get_user()),
) -> UserResponseV1:
logger.info("User check: %s", current_user.name)
return UserResponseV1.model_validate(current_user)
6 changes: 5 additions & 1 deletion data_rentgen/server/errors/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
# SPDX-FileCopyrightText: 2024 MTS PJSC
# SPDX-License-Identifier: Apache-2.0
from data_rentgen.server.errors.schemas.invalid_request import InvalidRequestSchema
from data_rentgen.server.errors.schemas.not_authorized import NotAuthorizedSchema
from data_rentgen.server.errors.schemas.not_authorized import (
NotAuthorizedRedirectSchema,
NotAuthorizedSchema,
)
from data_rentgen.server.errors.schemas.not_found import NotFoundSchema

__all__ = [
"InvalidRequestSchema",
"NotFoundSchema",
"NotAuthorizedSchema",
"NotAuthorizedRedirectSchema",
]
15 changes: 15 additions & 0 deletions data_rentgen/server/errors/schemas/not_authorized.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any, Literal

from data_rentgen.exceptions.auth import AuthorizationError
from data_rentgen.exceptions.redirect import RedirectError
from data_rentgen.server.errors.base import BaseErrorSchema
from data_rentgen.server.errors.registration import register_error_response

Expand All @@ -15,3 +16,17 @@
class NotAuthorizedSchema(BaseErrorSchema):
code: Literal["unauthorized"] = "unauthorized"
details: Any = None


@register_error_response(
exception=RedirectError,
status=http.HTTPStatus.UNAUTHORIZED,
)
class NotAuthorizedRedirectSchema(BaseErrorSchema):
"""
The reason of using UNAUTHORIZED instead of strict redirect is:
Fronted is using `fetch()` function which can't handle redirect responses
https://github.com/whatwg/fetch/issues/601
"""

code: Literal["auth_redirect"] = "auth_redirect"
9 changes: 6 additions & 3 deletions data_rentgen/server/providers/auth/keycloak_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ async def get_token_authorization_code_grant(
redirect_uri=redirect_uri,
)
except Exception as e:
logger.error("Error when trying to get token: %s", e)
raise AuthorizationError("Failed to get token") from e

async def get_current_user(self, access_token: str, *args, **kwargs) -> User:
Expand All @@ -82,7 +83,6 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> User:
# 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)

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)
Expand Down Expand Up @@ -119,9 +119,12 @@ def refresh_access_token(self, refresh_token: str) -> tuple[str, str]: # type:
logger.debug("Failed to refresh access token: %s", err)
self.redirect_to_auth()

def redirect_to_auth(self) -> None:
def redirect_to_auth(self):

auth_url = self.keycloak_openid.auth_url(
redirect_uri=self.settings.keycloak.redirect_uri,
scope=self.settings.keycloak.scope,
)
raise RedirectError(message=auth_url, details="Authorize on provided url")

logger.info("Raising redirect error with url: %s", auth_url)
raise RedirectError(message="Please authorize using provided URL", details=auth_url)
58 changes: 44 additions & 14 deletions docs/reference/server/auth/keycloak.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,51 +22,81 @@ Interaction schema

@startuml
title DummyAuthProvider
participant "Client"
participant "Frontend"
participant "Backend"
participant "Keycloak"

== Frontend Authentication at Keycloak ==

Frontend -> Backend : Request endpoint with authentication (/v1/locations)

Backend x-[#red]> Frontend: 401 with redirect url in 'details' response field

Frontend -> Keycloak : Redirect user to Keycloak login page

alt Successful login
Frontend --> Keycloak : Log in with login and password
else Login failed
Keycloak x-[#red]> Frontend -- : Display error (401 Unauthorized)
end

Keycloak -> Frontend : Callback to Frontend /callback which is proxy between Keycloak and Backend

Frontend -> Backend : Send request to Backend '/v1/auth/callback'

Backend -> Keycloak : Check original 'state' and exchange code for token's
Keycloak --> Backend : Return token's
Backend --> Frontend : Set token's in user's browser in cookies

Frontend --> Backend : Request to /v1/locations with session cookies
Backend -> Backend : Get user info from token and check user in internal backend database
Backend -> Backend : Create user in internal backend database if not exist
Backend -[#green]> Frontend -- : Return requested data


== GET v1/datasets ==


alt Successful case
"Client" -> "Backend" ++ : access_token
"Frontend" -> "Backend" ++ : access_token
"Backend" --> "Backend" : Validate token
"Backend" --> "Backend" : Check user in internal backend database
"Backend" -> "Backend" : Get data
"Backend" -[#green]> "Client" -- : Return data
"Backend" -[#green]> "Frontend" -- : Return data

else Token is expired (Successful case)
"Client" -> "Backend" ++ : access_token, refresh_token
"Frontend" -> "Backend" ++ : access_token, refresh_token
"Backend" --> "Backend" : Validate token
"Backend" -[#yellow]> "Backend" : Token is expired
"Backend" --> "Backend" : Try to refresh token
"Backend" --> "Keycloak" : Try to refresh token
"Backend" --> "Backend" : Validate new token
"Backend" --> "Backend" : Check user in internal backend database
"Backend" -> "Backend" : Get data
"Backend" -[#green]> "Client" -- : Return data
"Backend" -[#green]> "Frontend" -- : Return data

else Create new User
"Client" -> "Backend" ++ : access_token
"Frontend" -> "Backend" ++ : access_token
"Backend" --> "Backend" : Validate token
"Backend" --> "Backend" : Check user in internal backend database
"Backend" --> "Backend" : Create new user
"Backend" -> "Backend" : Get data
"Backend" -[#green]> "Client" -- : Return data
"Backend" -[#green]> "Frontend" -- : Return data

else Token is expired and bad refresh token
"Client" -> "Backend" ++ : access_token, refresh_token
"Frontend" -> "Backend" ++ : access_token, refresh_token
"Backend" --> "Backend" : Validate token
"Backend" -[#yellow]> "Backend" : Token is expired
"Backend" --> "Backend" : Try to refresh token
"Backend" x-[#red]> "Client" -- : RedirectResponse can't refresh
"Backend" --> "Keycloak" : Try to refresh token
"Backend" x-[#red]> "Frontend" -- : RedirectResponse can't refresh

else Bad Token payload
"Client" -> "Backend" ++ : access_token, refresh_token
"Frontend" -> "Backend" ++ : access_token, refresh_token
"Backend" --> "Backend" : Validate token
"Backend" x-[#red]> "Client" -- : 307 Authorization error
"Backend" x-[#red]> "Frontend" -- : 307 Authorization error

end

deactivate "Client"
deactivate "Frontend"
@enduml


Expand Down
Loading

0 comments on commit cada1f1

Please sign in to comment.