Skip to content
This repository was archived by the owner on Dec 20, 2023. It is now read-only.

Commit 9e8222b

Browse files
authored
Merge pull request #71 from DSAV-Dodeka/header-tests
Use FastAPI Depends, improve ctxlize, context header and token tests
2 parents c493c03 + ad85297 commit 9e8222b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+1464
-1147
lines changed

actions/local_actions.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,19 @@
55
import pytest
66
import pytest_asyncio
77
from faker import Faker
8+
from apiserver.data.context.ranking import add_new_event
89

910
import apiserver.lib.utilities as util
1011
import auth.core.util
1112
from apiserver import data
12-
from apiserver.app.modules.ranking import add_new_event, NewEvent
1313
from apiserver.app.ops.startup import get_keystate
1414
from apiserver.data import Source, get_conn
1515
from apiserver.data.api.classifications import insert_classification, UserPoints
1616
from apiserver.data.api.ud.userdata import new_userdata
1717
from apiserver.data.special import update_class_points
1818
from apiserver.define import DEFINE
1919
from apiserver.env import load_config
20-
from apiserver.lib.model.entities import SignedUp, UserNames
20+
from apiserver.lib.model.entities import NewEvent, SignedUp, UserNames
2121
from auth.data.authentication import get_apake_setup
2222
from auth.data.keys import get_keys
2323
from auth.data.relational.opaque import get_setup
@@ -28,7 +28,7 @@
2828
from store import Store
2929

3030

31-
@pytest.fixture(scope="module", autouse=True)
31+
@pytest.fixture(scope="session", autouse=True)
3232
def event_loop():
3333
"""Necessary for async tests with module-scoped fixtures"""
3434
loop = asyncio.get_event_loop()
@@ -212,4 +212,4 @@ async def test_add_event(local_dsrc, faker: Faker):
212212
description="desc",
213213
)
214214

215-
await add_new_event(local_dsrc, new_event)
215+
await add_new_event(DontReplaceContext(), local_dsrc, new_event)

poetry.lock

+107-104
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ pytest-mock = "^3.7.0"
4545
pre-commit = "^2.20.0"
4646
httpx = "^0.24.1"
4747
alembic = "^1.12.0"
48-
coverage = "^6.3.2"
48+
coverage = "^7.3.2"
4949
black = "^23.9.1"
5050
mypy = "^1.5.1"
5151
#types-redis = "^4.3.21"

src/apiserver/app/dependencies.py

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from typing import Annotated
2+
from fastapi import Depends, Request
3+
from apiserver.app.error import ErrorResponse
4+
from apiserver.app.ops.header import auth_header, verify_token_header
5+
from apiserver.data.context.app_context import Code, SourceContexts
6+
7+
from apiserver.data.source import Source
8+
from apiserver.lib.model.entities import AccessToken
9+
from apiserver.lib.resource.error import ResourceError, resource_error_code
10+
from auth.data.context import Contexts as AuthContexts
11+
from datacontext.context import ctxlize
12+
13+
14+
def dep_source(request: Request) -> Source:
15+
dsrc: Source = request.state.dsrc
16+
return dsrc
17+
18+
19+
def dep_app_context(request: Request) -> SourceContexts:
20+
cd: Code = request.state.cd
21+
return cd.app_context
22+
23+
24+
def dep_auth_context(request: Request) -> AuthContexts:
25+
cd: Code = request.state.cd
26+
return cd.auth_context
27+
28+
29+
SourceDep = Annotated[Source, Depends(dep_source)]
30+
AppContext = Annotated[SourceContexts, Depends(dep_app_context)]
31+
AuthContext = Annotated[AuthContexts, Depends(dep_auth_context)]
32+
33+
Authorization = Annotated[str, Depends(auth_header)]
34+
35+
36+
async def dep_header_token(
37+
authorization: Authorization, dsrc: SourceDep, app_ctx: AppContext
38+
) -> AccessToken:
39+
try:
40+
return await ctxlize(verify_token_header)(
41+
app_ctx.authrz_ctx, authorization, dsrc
42+
)
43+
except ResourceError as e:
44+
code = resource_error_code(e.err_type)
45+
46+
raise ErrorResponse(
47+
code,
48+
err_type=e.err_type,
49+
err_desc=e.err_desc,
50+
debug_key=e.debug_key,
51+
)
52+
53+
54+
AccessDep = Annotated[AccessToken, Depends(dep_header_token)]
55+
56+
57+
def verify_user(acc: AccessToken, user_id: str) -> bool:
58+
"""Verifies if the user in the access token corresponds to the provided user_id.
59+
60+
Args:
61+
acc: AccessToken object.
62+
user_id: user_id that will be compared against.
63+
64+
Returns:
65+
True if user_id = acc.sub.
66+
67+
Raises:
68+
ErrorResponse: If access token subject does not correspond to user_id.
69+
"""
70+
if acc.sub != user_id:
71+
reason = "Resource not available to this subject."
72+
raise ErrorResponse(
73+
403, err_type="wrong_subject", err_desc=reason, debug_key="bad_sub"
74+
)
75+
76+
return True
77+
78+
79+
def has_scope(scopes: str, required: set[str]) -> bool:
80+
scope_set = set(scopes.split())
81+
return required.issubset(scope_set)
82+
83+
84+
def require_admin(acc: AccessDep) -> AccessToken:
85+
if not has_scope(acc.scope, {"admin"}):
86+
raise ErrorResponse(
87+
403,
88+
err_type="insufficient_scope",
89+
err_desc="Insufficient permissions to access this resource.",
90+
debug_key="low_perms",
91+
)
92+
93+
return acc
94+
95+
96+
def require_member(acc: AccessDep) -> AccessToken:
97+
if not has_scope(acc.scope, {"member"}):
98+
raise ErrorResponse(
99+
403,
100+
err_type="insufficient_scope",
101+
err_desc="Insufficient permissions to access this resource.",
102+
debug_key="low_perms",
103+
)
104+
105+
return acc
106+
107+
108+
RequireMember = Annotated[AccessToken, Depends(require_member)]
109+
RequireAdmin = Annotated[AccessToken, Depends(require_admin)]

src/apiserver/app/error.py

+7
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,22 @@ class ErrorKeys(StrEnum):
1010
RANKING_UPDATE = "invalid_ranking_update"
1111
DATA = "invalid_data_load"
1212
GET_CLASS = "invalid_get_class"
13+
CHECK = "invalid_code_check"
14+
UPDATE = "invalid_update"
1315

1416

1517
class AppError(Exception):
18+
err_type: ErrorKeys
19+
err_desc: str
20+
debug_key: Optional[str]
21+
1622
def __init__(
1723
self,
1824
err_type: ErrorKeys,
1925
err_desc: str,
2026
debug_key: Optional[str] = None,
2127
):
28+
super().__init__(err_desc)
2229
self.err_type = err_type
2330
self.err_desc = err_desc
2431
self.debug_key = debug_key

src/apiserver/app/modules/ranking.py

+9-8
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33

44
from apiserver.app.error import ErrorKeys, AppError
55
from apiserver.data import Source
6-
from apiserver.data.context.app_context import RankingContext
7-
from apiserver.data.context.ranking import (
8-
context_events_in_class,
9-
context_most_recent_class_id_of_type,
10-
context_user_events_in_class,
11-
)
6+
from apiserver.data.api.classifications import events_in_class
7+
from apiserver.data.context.app_context import RankingContext, conn_wrap
8+
from apiserver.data.context.ranking import context_most_recent_class_id_of_type
129
from apiserver.data.source import source_session
10+
from apiserver.data.special import user_events_in_class
1311
from apiserver.lib.logic.ranking import is_rank_type
1412
from apiserver.lib.model.entities import ClassEvent, UserEvent
13+
from datacontext.context import ctxlize_wrap
1514

1615

1716
async def class_id_or_recent(
@@ -49,7 +48,7 @@ async def mod_user_events_in_class(
4948
async with source_session(dsrc) as session:
5049
sure_class_id = await class_id_or_recent(session, ctx, class_id, rank_type)
5150

52-
user_events = await context_user_events_in_class(
51+
user_events = await ctxlize_wrap(user_events_in_class, conn_wrap)(
5352
ctx, session, user_id, sure_class_id
5453
)
5554

@@ -65,6 +64,8 @@ async def mod_events_in_class(
6564
async with source_session(dsrc) as session:
6665
sure_class_id = await class_id_or_recent(session, ctx, class_id, rank_type)
6766

68-
events = await context_events_in_class(ctx, session, sure_class_id)
67+
events = await ctxlize_wrap(events_in_class, conn_wrap)(
68+
ctx, session, sure_class_id
69+
)
6970

7071
return events

src/apiserver/app/modules/update.py

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from apiserver.app.error import AppError, ErrorKeys
2+
from apiserver.data.context.app_context import UpdateContext
3+
from apiserver.data.source import Source
4+
from apiserver.data.trs.trs import pop_string
5+
from auth.data.authentication import pop_flow_user
6+
from auth.data.context import LoginContext
7+
from datacontext.context import ctxlize
8+
from store.error import NoDataError
9+
10+
11+
async def verify_delete_account(
12+
auth_code: str,
13+
flow_id: str,
14+
dsrc: Source,
15+
update_ctx: UpdateContext,
16+
login_ctx: LoginContext,
17+
) -> str:
18+
# First we check if the auth code exists. This proves the flow_user logged in with the correct password.
19+
# Note that such a code can be generated by any user that logs in, so this is not proof that this user wants to
20+
# delete their account
21+
try:
22+
flow_user = await pop_flow_user(login_ctx, dsrc.store, auth_code)
23+
except NoDataError:
24+
# logger.debug(e.message)
25+
reason = "Expired or missing auth code"
26+
raise AppError(
27+
err_type=ErrorKeys.CHECK, err_desc=reason, debug_key="empty_flow"
28+
)
29+
# We check if the user who authenticated with their password has the same flow_id as the one requested.
30+
# Note that we have not yet checked if this is a valid flow ID.
31+
if flow_user.flow_id != flow_id:
32+
reason = "Flow for auth code does not match requested delete flow!"
33+
raise AppError(
34+
err_type=ErrorKeys.UPDATE,
35+
err_desc=reason,
36+
debug_key="update_flows_dont_match",
37+
)
38+
39+
# If the flow ID exists, then we know that a delete request was indeed initiated.
40+
stored_user_id = await ctxlize(pop_string)(update_ctx, dsrc, flow_id)
41+
if stored_user_id is None:
42+
reason = "Delete request has expired, please try again!"
43+
# logger.debug(reason + f" {flow_user.user_id}")
44+
raise AppError(err_type=ErrorKeys.UPDATE, err_desc=reason)
45+
46+
# If the user ID's are equal, we know that the person who requested the deletion is the same person who
47+
# logged in. Therefore, we have proved that the delete account initiator has entered their password.
48+
if flow_user.user_id != stored_user_id:
49+
reason = "Flow user does not match requested delete flow's user!"
50+
raise AppError(err_type=ErrorKeys.UPDATE, err_desc=reason)
51+
if stored_user_id is None:
52+
reason = "Delete request has expired, please try again!"
53+
# logger.debug(reason + f" {flow_user.user_id}")
54+
raise AppError(err_type=ErrorKeys.UPDATE, err_desc=reason)
55+
56+
return stored_user_id

src/apiserver/app/ops/errors.py

-27
This file was deleted.

src/apiserver/app/ops/header.py

+35-37
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,55 @@
1-
from typing import Annotated
2-
3-
from fastapi.params import Security
4-
from fastapi.security.api_key import APIKeyHeader
1+
from fastapi import HTTPException, Request
2+
from fastapi.datastructures import Headers
53

64
from apiserver.define import DEFINE, grace_period
75
from apiserver import data
8-
from apiserver.app.ops.errors import BadAuth
9-
from apiserver.lib.hazmat.tokens import (
10-
verify_access_token,
11-
BadVerification,
12-
get_kid,
13-
)
146
from apiserver.data import Source
7+
from apiserver.lib.resource.error import ResourceError
8+
from apiserver.lib.resource.header import (
9+
AccessSettings,
10+
extract_token_and_kid,
11+
resource_verify_token,
12+
)
1513
from store.error import NoDataError
1614
from apiserver.lib.model.entities import AccessToken
1715

18-
scheme = "Bearer"
16+
www_authenticate = f"Bearer realm={DEFINE.realm}"
1917

20-
# TODO modify APIKeyHeader for better status code
21-
auth_header = APIKeyHeader(name="Authorization", scheme_name=scheme, auto_error=True)
2218

23-
Authorization = Annotated[str, Security(auth_header)]
19+
def auth_header(request: Request) -> str:
20+
# This is so we don't have to instantiate a Request object, which can be annoying
21+
return parse_auth_header(request.headers)
2422

2523

26-
async def handle_header(authorization: str, dsrc: Source) -> AccessToken:
27-
if authorization is None:
28-
raise BadAuth(err_type="invalid_request", err_desc="No authorization provided.")
29-
if "Bearer " not in authorization:
30-
raise BadAuth(
31-
err_type="invalid_request",
32-
err_desc="Authorization must follow 'Bearer' scheme",
24+
def parse_auth_header(headers: Headers) -> str:
25+
authorization = headers.get("Authorization")
26+
if not authorization:
27+
# Conforms to RFC6750 https://www.rfc-editor.org/rfc/rfc6750.html
28+
raise HTTPException(
29+
status_code=400, headers={"WWW-Authenticate": www_authenticate}
3330
)
34-
token = authorization.removeprefix("Bearer ")
31+
32+
return authorization
33+
34+
35+
async def verify_token_header(authorization: str, dsrc: Source) -> AccessToken:
36+
# THROWS ResourceError
37+
token, kid = extract_token_and_kid(authorization)
3538

3639
try:
37-
kid = get_kid(token)
3840
public_key = (await data.trs.key.get_pem_key(dsrc, kid)).public
39-
return verify_access_token(
40-
public_key,
41-
token,
42-
grace_period,
43-
DEFINE.issuer,
44-
DEFINE.backend_client_id,
45-
)
4641
except NoDataError as e:
47-
raise BadAuth(
42+
raise ResourceError(
4843
err_type="invalid_token",
4944
err_desc="Key does not exist!",
5045
debug_key=e.key,
5146
)
52-
except BadVerification as e:
53-
raise BadAuth(
54-
err_type="invalid_token",
55-
err_desc="Token verification failed!",
56-
debug_key=e.err_key,
57-
)
47+
48+
access_settings = AccessSettings(
49+
grace_period=grace_period,
50+
issuer=DEFINE.issuer,
51+
aud_client_ids=[DEFINE.backend_client_id],
52+
)
53+
54+
# THROWS ResourceError
55+
return resource_verify_token(token, public_key, access_settings)

0 commit comments

Comments
 (0)