Skip to content

Commit 85c7e10

Browse files
Backend authentication [gh-6] (#16)
* feat: user creation, use username as identifier, admin account creation, configure auth in env * chore: linter * chore: linter * chore: .envexample update * chore: update poetry lock
1 parent 90649c6 commit 85c7e10

File tree

30 files changed

+729
-24
lines changed

30 files changed

+729
-24
lines changed

.envexample

+5-1
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ POSTGRES_USER: dev
44
POSTGRES_PASSWORD: local-dev
55
POSTGRES_DB: cpf
66

7+
BASE_URL: /cpf/api
8+
USE_MOCK_USER: False
79

810
FUSIONAUTH_POSTGRES_DB: fusionauth
911
FUSIONAUTH_POSTGRES_USER: fusionauth
1012
FUSIONAUTH_POSTGRES_PASSWORD: password
11-
13+
FUSIONAUTH_API_KEY: 00c24029-e66a-43f2-9000-544e90e8d46e
14+
FUSIONAUTH_URL: http://fusionauth:9011
15+
FUSIONAUTH_APPLICATION_ID: 23e4b229-1219-42e5-aed6-f9b6f1eedef8

backend/src/cpf/adapters/inbound/data_loader/loader.py

+18-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import json
22
import os
33

4-
from cpf.core.ports.provided.services import ManageService
4+
from cpf.core.ports.provided.services import ManageService, UserManagementService
55

66
manage_service: ManageService | None = None
7+
user_management_service: UserManagementService | None = None
78

89

9-
def set_manage_service(service: ManageService):
10+
def set_manage_service(service: ManageService) -> None:
1011
global manage_service
1112
manage_service = service
1213

@@ -17,6 +18,17 @@ def get_manage_service() -> ManageService:
1718
return manage_service
1819

1920

21+
def set_user_management_service(service: UserManagementService) -> None:
22+
global user_management_service
23+
user_management_service = service
24+
25+
26+
def get_user_management_service() -> UserManagementService:
27+
if not user_management_service:
28+
raise RuntimeError("User management service not set")
29+
return user_management_service
30+
31+
2032
def start_data_upload() -> None:
2133
service = get_manage_service()
2234
if service.check_if_data_is_exists():
@@ -38,3 +50,7 @@ def start_data_upload() -> None:
3850
ladder_data = json.loads(file.read())
3951
service.create_ladder(ladder_data=ladder_data)
4052
print(f"Ladder created from file {ladder_data_path}")
53+
# Create admin account
54+
user_service = get_user_management_service()
55+
# TODO Create new service method for admin account creation when role management system will be ready
56+
user_service.create_new_user(email="[email protected]", first_name="Cpf", last_name="Admin")

backend/src/cpf/adapters/inbound/rest_api/library/api.py

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
from typing import Annotated
23

34
from fastapi import APIRouter, Depends
45

@@ -13,16 +14,19 @@
1314
LadderDetailResponse,
1415
LadderResponse,
1516
)
16-
from cpf.adapters.inbound.rest_api.rest_api import get_library_query_service
17+
from cpf.adapters.inbound.rest_api.permissions import check_permissions
18+
from cpf.adapters.inbound.rest_api.rest_api import auth, get_library_query_service
1719
from cpf.core.ports.provided.services import QueryService
18-
from cpf.core.ports.required.dtos import LadderDetailDTO
20+
from cpf.core.ports.required.dtos import LadderDetailDTO, UserDTO
1921
from cpf.core.ports.required.readmodels import BucketReadModel, LadderReadModel
2022

2123
router = APIRouter(prefix=f"{os.getenv('BASE_URL')}/library")
2224

2325

2426
@router.get(path="/ladders", response_model_exclude_none=True)
27+
@check_permissions(permission_classes=[])
2528
def get_ladders(
29+
user: Annotated[UserDTO | None, Depends(auth)],
2630
service: QueryService = Depends(get_library_query_service),
2731
) -> list[LadderResponse]:
2832
ladder_read_models: list[LadderReadModel] = service.get_all_ladders()
@@ -37,8 +41,11 @@ def get_ladders(
3741

3842

3943
@router.get(path="/ladders/{ladder_slug}", response_model_exclude_none=True)
44+
@check_permissions(permission_classes=[])
4045
def get_ladder_details(
41-
ladder_slug: str, service: QueryService = Depends(get_library_query_service)
46+
ladder_slug: str,
47+
user: Annotated[UserDTO | None, Depends(auth)],
48+
service: QueryService = Depends(get_library_query_service),
4249
) -> LadderDetailResponse:
4350
ladder_detail: LadderDetailDTO = service.get_ladder(ladder_slug=ladder_slug)
4451

@@ -64,8 +71,11 @@ def get_ladder_details(
6471

6572

6673
@router.get(path="/buckets/{bucket_slug}", response_model_exclude_none=True)
74+
@check_permissions(permission_classes=[])
6775
def get_bucket_details(
68-
bucket_slug: str, service: QueryService = Depends(get_library_query_service)
76+
bucket_slug: str,
77+
user: Annotated[UserDTO | None, Depends(auth)],
78+
service: QueryService = Depends(get_library_query_service),
6979
) -> BucketDetailResponse:
7080
bucket_details: BucketReadModel = service.get_bucket(bucket_slug=bucket_slug)
7181
advancement_levels: list[AdvancementLevelResponse] = []
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
from cpf.adapters.inbound.rest_api.ion import IonBaseModel, IonLink
22

33

4-
class RootResponse(IonBaseModel):
4+
class UserResponse(IonBaseModel):
5+
first_name: str
6+
last_name: str
7+
8+
9+
class UnauthenticatedRootResponse(IonBaseModel):
10+
login: IonLink
11+
12+
13+
class AuthenticatedRootResponse(IonBaseModel):
14+
user: UserResponse
515
get_ladders: IonLink
16+
17+
18+
class LadderResponse(IonBaseModel):
19+
ladder_name: str
20+
ladder_slug: str
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from abc import ABC, abstractmethod
2+
from functools import wraps
3+
4+
from fastapi import HTTPException
5+
from starlette import status
6+
7+
from cpf.core.ports.required.dtos import UserDTO
8+
9+
10+
class Permission(ABC):
11+
12+
@classmethod
13+
@abstractmethod
14+
def validate_permission(cls, user: UserDTO) -> bool:
15+
pass
16+
17+
18+
def check_permissions(permission_classes: list[type[Permission]]):
19+
def inner(func):
20+
@wraps(func)
21+
def wrapper(*args, **kwargs):
22+
user: UserDTO = kwargs.get("user")
23+
if not user:
24+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not authenticated")
25+
if not all(permission.validate_permission(user=user) for permission in permission_classes):
26+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User has no permission")
27+
return func(*args, **kwargs)
28+
29+
return wrapper
30+
31+
return inner

backend/src/cpf/adapters/inbound/rest_api/rest_api.py

+61-6
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
1-
from fastapi import APIRouter, FastAPI
1+
import os
2+
from typing import Annotated
3+
4+
from fastapi import APIRouter, Depends, FastAPI
5+
from starlette.requests import Request
26

37
from cpf.adapters.inbound.rest_api.ion import IonLink
4-
from cpf.adapters.inbound.rest_api.models.responses.core import RootResponse
5-
from cpf.core.ports.provided.services import ManageService, QueryService
8+
from cpf.adapters.inbound.rest_api.models.responses.core import (
9+
AuthenticatedRootResponse,
10+
UnauthenticatedRootResponse,
11+
UserResponse,
12+
)
13+
from cpf.adapters.inbound.rest_api.utils import env_to_bool, fake_user_factory
14+
from cpf.core.ports.provided.services import (
15+
ManageService,
16+
QueryService,
17+
UserManagementService,
18+
)
19+
from cpf.core.ports.required.dtos import UserDTO
620

721
router = APIRouter(prefix="/cpf/api")
822
app = FastAPI()
923

1024
library_manage_service: ManageService | None = None
1125
library_query_service: QueryService | None = None
26+
user_management_service: UserManagementService | None = None
1227

1328

1429
def set_library_manage_service(service: ManageService):
@@ -33,9 +48,47 @@ def get_library_query_service() -> QueryService:
3348
return library_query_service
3449

3550

36-
@router.get(path="")
37-
def get_api_root() -> RootResponse:
38-
return RootResponse(get_ladders=IonLink(href="/cpf/api/library/ladders/"))
51+
def set_user_management_service(service: UserManagementService) -> None:
52+
global user_management_service
53+
user_management_service = service
54+
55+
56+
def get_user_management_service() -> UserManagementService:
57+
if not user_management_service:
58+
raise RuntimeError("User management service not set")
59+
return user_management_service
60+
61+
62+
class FastAPIAuth:
63+
64+
def __call__(self, request: Request) -> UserDTO:
65+
# TODO Remove after auth will be implemented on frontend
66+
if env_to_bool(os.getenv("USE_MOCK_USER")):
67+
return fake_user_factory()
68+
service_instance: UserManagementService = get_user_management_service()
69+
return service_instance.get_user(access_token=request.cookies.get("access_token"))
70+
71+
72+
auth = FastAPIAuth()
73+
74+
75+
@router.get(path="", response_model_exclude_none=True)
76+
def get_api_root(
77+
user: Annotated[UserDTO | None, Depends(auth)]
78+
) -> AuthenticatedRootResponse | UnauthenticatedRootResponse:
79+
if not user:
80+
return UnauthenticatedRootResponse(
81+
# TODO Create social auth login redirect
82+
login=IonLink(href="/api/login")
83+
)
84+
85+
return AuthenticatedRootResponse(
86+
user=UserResponse(
87+
first_name=user.first_name,
88+
last_name=user.last_name,
89+
),
90+
get_ladders=IonLink(href="/cpf/api/library/ladders"),
91+
)
3992

4093

4194
@router.get(path="/health")
@@ -44,6 +97,8 @@ def health_check():
4497

4598

4699
from cpf.adapters.inbound.rest_api.library.api import router as library_router # noqa
100+
from cpf.adapters.inbound.rest_api.users.api import router as users_router # noqa
47101

48102
app.include_router(router=router)
49103
app.include_router(router=library_router)
104+
app.include_router(router=users_router)

backend/src/cpf/adapters/inbound/rest_api/users/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import os
2+
from typing import Annotated
3+
4+
from fastapi import APIRouter, Depends
5+
6+
from cpf.adapters.inbound.rest_api.permissions import check_permissions
7+
from cpf.core.ports.provided.services import UserManagementService
8+
from cpf.core.ports.required.dtos import UserDTO
9+
10+
from ..rest_api import auth, get_user_management_service
11+
from .models.requests import PutUser
12+
from .models.responses import UserResponse
13+
14+
router = APIRouter(prefix=f"{os.getenv('BASE_URL')}/users")
15+
16+
17+
@router.post(path="", response_model_exclude_none=True)
18+
@check_permissions(permission_classes=[])
19+
def create_new_user(
20+
request: PutUser,
21+
user: Annotated[UserDTO, Depends(auth)],
22+
service: UserManagementService = Depends(get_user_management_service),
23+
) -> UserResponse:
24+
new_user: UserDTO = service.create_new_user(
25+
first_name=request.first_name, last_name=request.last_name, email=request.email
26+
)
27+
return UserResponse(
28+
username=new_user.username,
29+
email=new_user.email,
30+
first_name=new_user.first_name,
31+
last_name=new_user.last_name,
32+
)

backend/src/cpf/adapters/inbound/rest_api/users/models/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from pydantic import BaseModel, EmailStr, Field
2+
3+
4+
class PutUser(BaseModel):
5+
first_name: str = Field(..., description="New user first name")
6+
last_name: str = Field(..., description="New user last name")
7+
email: EmailStr = Field(..., description="New user email")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from cpf.adapters.inbound.rest_api.ion import IonBaseModel
2+
3+
4+
class UserResponse(IonBaseModel):
5+
username: str
6+
email: str
7+
first_name: str
8+
last_name: str

backend/src/cpf/adapters/inbound/rest_api/utils.py

+10
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
from fastapi import FastAPI
44
from starlette.routing import Route
55

6+
from cpf.core.ports.required.dtos import UserDTO
7+
8+
9+
def env_to_bool(value: str) -> bool:
10+
return value.lower() in ("true", "1")
11+
12+
13+
def fake_user_factory() -> UserDTO:
14+
return UserDTO(identifier="[email protected]", first_name="Mock", last_name="John")
15+
616

717
class EndpointInfo(TypedDict):
818
method: str | None

backend/src/cpf/adapters/outbound/fusionauth/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import os
2+
3+
from fastapi import HTTPException
4+
from fusionauth.fusionauth_client import FusionAuthClient
5+
from starlette import status
6+
7+
from cpf.core.ports.required.clients import AuthenticationClient
8+
from cpf.core.ports.required.readmodels import UserReadModel
9+
10+
11+
class FusionAuthAuthenticationClient(AuthenticationClient):
12+
13+
def __init__(self, client: FusionAuthClient):
14+
self._client = client
15+
16+
@staticmethod
17+
def _get_user_read_model_from_response(response_data: dict) -> UserReadModel:
18+
user_data = response_data.get("user")
19+
return UserReadModel(
20+
username=user_data.get("username"),
21+
email=user_data.get("email"),
22+
first_name=user_data.get("data").get("first_name"),
23+
last_name=user_data.get("data").get("last_name"),
24+
)
25+
26+
def get_user_data(self, access_token: str) -> UserReadModel | None:
27+
fusion_auth_response = self._client.retrieve_user_using_jwt(encoded_jwt=access_token)
28+
if fusion_auth_response.status != status.HTTP_200_OK:
29+
return None
30+
31+
return self._get_user_read_model_from_response(fusion_auth_response.success_response)
32+
33+
def create_user(self, email: str, username: str, first_name: str, last_name: str) -> UserReadModel:
34+
user_request = {
35+
"user": {
36+
"email": email,
37+
"username": username,
38+
"password": "dev-use-only",
39+
"data": {
40+
"first_name": first_name,
41+
"last_name": last_name,
42+
},
43+
}
44+
}
45+
client_response = self._client.create_user(request=user_request, user_id=None)
46+
if not client_response.was_successful():
47+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
48+
49+
return self._get_user_read_model_from_response(client_response.success_response)
50+
51+
52+
fusion_auth_client = FusionAuthClient(api_key=os.getenv("FUSIONAUTH_API_KEY"), base_url=os.getenv("FUSIONAUTH_URL"))
53+
54+
55+
def authentication_client_factory() -> AuthenticationClient:
56+
return FusionAuthAuthenticationClient(client=fusion_auth_client)

backend/src/cpf/adapters/outbound/postgres/daos.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def save(self, uuid: str, serialize_data: dict):
5050
serialize_data=serialize_data,
5151
)
5252
else:
53-
cursor = self._execute_update(cursor=cursor, ladder_slug=uuid, serialize_data=serialize_data)
53+
cursor = self._execute_update(cursor=cursor, bucket_slug=uuid, serialize_data=serialize_data)
5454
conn.commit()
5555

5656
def check_if_bucket_exists(self, bucket_slug: str) -> bool:

0 commit comments

Comments
 (0)