Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/apps/audit/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class AuditEvent(PublicModel):

Applicable to HTTP requests:
- client_ip
- client_source
- http_request_id
- http_request_method,
- http_response_status_code
Expand Down Expand Up @@ -92,6 +93,7 @@ class AuditEvent(PublicModel):

# For HTTP requests
client_ip: Annotated[str | None, Field(alias="client.ip")] = None
client_source: Annotated[str | None, Field(alias="client.source")] = None # Mindlogger-Content-Source header
http_request_id: Annotated[str | None, Field(alias="http.request.id")] = None # from asgi-correlation-id
http_request_method: Annotated[str | None, Field(alias="http.request.method")] = None
http_response_status_code: Annotated[int | None, Field(alias="http.response.status_code")] = None
Expand Down
1 change: 1 addition & 0 deletions src/apps/audit/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def http_audit_fields(request: Request, error: BaseError | StarletteHTTPExceptio
span = tracer.current_span()
fields = {
"client_ip": request.client and request.client.host,
"client_source": request.headers.get("mindlogger-content-source"),
"http_request_id": correlation_id.get(),
"http_request_method": request.method,
"http_response_status_code": isinstance(route, APIRoute) and route.status_code or 200,
Expand Down
37 changes: 31 additions & 6 deletions src/apps/authentication/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,24 @@
from config import settings
from infrastructure.database import atomic
from infrastructure.database.deps import get_session
from infrastructure.http.deps import get_optional_mindlogger_content_source
from infrastructure.http.domain import MindloggerContentSource
from infrastructure.logger import logger


def client_token_claims(content_source: MindloggerContentSource | None) -> dict:
"""Extra claims recording which client the tokens are issued to; empty when unknown."""
return {JWTClaim.client: content_source} if content_source else {}


async def get_token(
request: Request,
user_login_schema: UserLoginRequest = Body(...),
session=Depends(get_session),
os_name: Annotated[str | None, Header()] = None,
os_version: Annotated[str | None, Header()] = None,
app_version: Annotated[str | None, Header()] = None,
content_source: MindloggerContentSource | None = Depends(get_optional_mindlogger_content_source),
) -> Response[UserLogin | MFARequiredResponse]:
"""Generate the JWT access token."""
try:
Expand Down Expand Up @@ -104,12 +112,15 @@ async def get_token(
)

rjti = str(uuid.uuid4())
refresh_token = AuthenticationService.create_refresh_token({JWTClaim.sub: str(user.id), JWTClaim.jti: rjti})
refresh_token = AuthenticationService.create_refresh_token(
{JWTClaim.sub: str(user.id), JWTClaim.jti: rjti, **client_token_claims(content_source)}
)

access_token = AuthenticationService.create_access_token(
{
JWTClaim.sub: str(user.id),
JWTClaim.rjti: rjti,
**client_token_claims(content_source),
}
)

Expand Down Expand Up @@ -139,6 +150,7 @@ async def verify_mfa_totp(
os_name: Annotated[str | None, Header()] = None,
os_version: Annotated[str | None, Header()] = None,
app_version: Annotated[str | None, Header()] = None,
content_source: MindloggerContentSource | None = Depends(get_optional_mindlogger_content_source),
) -> Response[UserLogin]:
"""Verify TOTP code during MFA and return tokens."""
user_id: uuid.UUID | None = None
Expand Down Expand Up @@ -280,7 +292,7 @@ async def verify_mfa_totp(

logger.info(
f"MFA verification successful user_id={user.id} email={user.email_encrypted} "
f"device_id={verify_request.device_id}"
f"device_id={verify_request.device_id} client={content_source}"
)

# Register device if device_id provided
Expand All @@ -295,12 +307,15 @@ async def verify_mfa_totp(

# Issue refresh and access tokens
rjti = str(uuid.uuid4())
refresh_token = AuthenticationService.create_refresh_token({JWTClaim.sub: str(user.id), JWTClaim.jti: rjti})
refresh_token = AuthenticationService.create_refresh_token(
{JWTClaim.sub: str(user.id), JWTClaim.jti: rjti, **client_token_claims(content_source)}
)

access_token = AuthenticationService.create_access_token(
{
JWTClaim.sub: str(user.id),
JWTClaim.rjti: rjti,
**client_token_claims(content_source),
}
)
except BaseError as e:
Expand Down Expand Up @@ -339,6 +354,7 @@ async def verify_mfa_recovery_code(
os_name: Annotated[str | None, Header()] = None,
os_version: Annotated[str | None, Header()] = None,
app_version: Annotated[str | None, Header()] = None,
content_source: MindloggerContentSource | None = Depends(get_optional_mindlogger_content_source),
) -> Response[UserLogin]:
"""Verify recovery code during MFA and return tokens."""
user_id: uuid.UUID | None = None
Expand Down Expand Up @@ -553,7 +569,7 @@ async def verify_mfa_recovery_code(

logger.info(
f"MFA recovery code verification successful user_id={user_id} email={user.email_encrypted} "
f"device_id={verify_request.device_id}"
f"device_id={verify_request.device_id} client={content_source}"
)

# Step 5: Register device if device_id provided
Expand All @@ -568,12 +584,15 @@ async def verify_mfa_recovery_code(

# Step 6: Issue refresh and access tokens
rjti = str(uuid.uuid4())
refresh_token = AuthenticationService.create_refresh_token({JWTClaim.sub: str(user_id), JWTClaim.jti: rjti})
refresh_token = AuthenticationService.create_refresh_token(
{JWTClaim.sub: str(user_id), JWTClaim.jti: rjti, **client_token_claims(content_source)}
)

access_token = AuthenticationService.create_access_token(
{
JWTClaim.sub: str(user_id),
JWTClaim.rjti: rjti,
**client_token_claims(content_source),
}
)
except BaseError as e:
Expand Down Expand Up @@ -668,13 +687,19 @@ async def refresh_access_token(

rjti = str(uuid.uuid4())
refresh_token = AuthenticationService.create_refresh_token(
{JWTClaim.sub: str(user_id), JWTClaim.jti: rjti, JWTClaim.exp: token_data.exp}
{
JWTClaim.sub: str(user_id),
JWTClaim.jti: rjti,
JWTClaim.exp: token_data.exp,
**client_token_claims(token_data.client),
}
)

access_token = AuthenticationService.create_access_token(
{
JWTClaim.sub: str(user_id),
JWTClaim.rjti: rjti,
**client_token_claims(token_data.client),
}
)
except BaseError as e:
Expand Down
6 changes: 6 additions & 0 deletions src/apps/authentication/domain/token/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pydantic import EmailStr

from apps.shared.domain.base import InternalModel
from infrastructure.http.domain import MindloggerContentSource


class TokenPurpose(StrEnum):
Expand All @@ -21,13 +22,18 @@ class JWTClaim(StrEnum):
exp = "exp"
rjti = "rjti"
mfa_session_id = "mfa_session_id"
client = "client"


class TokenPayload(InternalModel):
sub: uuid.UUID
exp: int
jti: str
rjti: str | None = None
# Which client the token was issued to (Mindlogger-Content-Source header).
# None for tokens issued before the claim existed or to clients that do not
# send the header
client: MindloggerContentSource | None = None


class InternalToken(InternalModel):
Expand Down
184 changes: 184 additions & 0 deletions src/apps/authentication/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,78 @@ async def test_refresh_access_token(self, client: TestClient, user: User, mocker
assert event.event_outcome == EventOutcome.SUCCESS
assert response.status_code == http.HTTPStatus.OK

async def test_refresh_access_token__propagates_client_claim(
self, client: TestClient, user: User, mocker: MockerFixture
):
mocker.patch("apps.authentication.api.auth.log")
refresh_token = AuthenticationService.create_refresh_token(
{
"sub": str(user.id),
"jti": str(uuid.uuid4()),
"client": "admin",
}
)
response = await client.post(url=self.refresh_access_token_url, data={"refresh_token": refresh_token})
assert response.status_code == http.HTTPStatus.OK
result = response.json()["result"]
assert result["refreshToken"] == refresh_token
access_payload = jwt.decode(
result["accessToken"],
settings.authentication.access_token.secret_key,
algorithms=[settings.authentication.algorithm],
)
assert access_payload["client"] == "admin"

async def test_refresh_access_token__legacy_token_without_client_claim(
self, client: TestClient, user: User, mocker: MockerFixture
):
mocker.patch("apps.authentication.api.auth.log")
refresh_token = AuthenticationService.create_refresh_token(
{
"sub": str(user.id),
"jti": str(uuid.uuid4()),
}
)
response = await client.post(url=self.refresh_access_token_url, data={"refresh_token": refresh_token})
assert response.status_code == http.HTTPStatus.OK
access_payload = jwt.decode(
response.json()["result"]["accessToken"],
settings.authentication.access_token.secret_key,
algorithms=[settings.authentication.algorithm],
)
assert "client" not in access_payload

async def test_refresh_token_key_transition__preserves_client_claim(
self, client: TestClient, user: User, mocker: MockerFixture
):
token_key = settings.authentication.refresh_token.secret_key
refresh_token = AuthenticationService.create_refresh_token(
{
"sub": str(user.id),
"jti": str(uuid.uuid4()),
"client": "web",
}
)
new_token_key = "new token key"
transition_expire_date = datetime.datetime.now(datetime.timezone.utc).date() + datetime.timedelta(days=1)

with mock.patch("config.settings.authentication.refresh_token") as token_settings_mock:
token_settings_mock.secret_key = new_token_key
token_settings_mock.transition_key = token_key
token_settings_mock.transition_expire_date = transition_expire_date
token_settings_mock.expiration = 540

_status_code, new_refresh_token = await self._request_refresh_token(client, refresh_token)
assert _status_code == http.HTTPStatus.OK
assert new_refresh_token
assert new_refresh_token != refresh_token
refresh_payload = jwt.decode(
new_refresh_token,
new_token_key,
algorithms=[settings.authentication.algorithm],
)
assert refresh_payload["client"] == "web"

async def test_login_and_logout_device(self, client: TestClient, user: User):
device_id = str(uuid.uuid4())

Expand Down Expand Up @@ -321,3 +393,115 @@ async def test_refresh_access_token__refresh_token_is_expired(
assert len(result) == 1
assert result[0]["message"] == InvalidRefreshToken.message
settings.authentication.refresh_token.expiration = 540


class TestLoginClientClaim(BaseTest):
"""The client claim records which client tokens were issued to, without changing lifetimes."""

get_token_url = auth_router.url_path_for("get_token")

@staticmethod
def _decode_tokens(result: dict) -> tuple[dict, dict]:
access_payload = jwt.decode(
result["token"]["accessToken"],
settings.authentication.access_token.secret_key,
algorithms=[settings.authentication.algorithm],
)
refresh_payload = jwt.decode(
result["token"]["refreshToken"],
settings.authentication.refresh_token.secret_key,
algorithms=[settings.authentication.algorithm],
)
return access_payload, refresh_payload

@staticmethod
def _assert_lifetimes_unchanged(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Just a thought, do you feel the name of this function truly represents what it does?

access_payload: dict, refresh_payload: dict, before: datetime.datetime, after: datetime.datetime
):
access_delta = datetime.timedelta(minutes=settings.authentication.access_token.expiration)
refresh_delta = datetime.timedelta(minutes=settings.authentication.refresh_token.expiration)
assert (
int((before + access_delta).timestamp())
<= access_payload["exp"]
<= int((after + access_delta).timestamp()) + 1
)
assert (
int((before + refresh_delta).timestamp())
<= refresh_payload["exp"]
<= int((after + refresh_delta).timestamp()) + 1
)

@pytest.mark.parametrize("content_source", ("web", "admin", "mobile"))
async def test_login_embeds_client_claim(self, client: TestClient, user: User, content_source: str):
before = datetime.datetime.now(datetime.timezone.utc)
resp = await client.post(
self.get_token_url,
data={"email": user.email_encrypted, "password": TEST_PASSWORD},
headers={"Mindlogger-Content-Source": content_source},
)
after = datetime.datetime.now(datetime.timezone.utc)
assert resp.status_code == http.HTTPStatus.OK
access_payload, refresh_payload = self._decode_tokens(resp.json()["result"])
assert access_payload["client"] == content_source
assert refresh_payload["client"] == content_source
self._assert_lifetimes_unchanged(access_payload, refresh_payload, before, after)

async def test_login_audit_event_records_client_source(self, client: TestClient, user: User, mocker: MockerFixture):
audit_log = mocker.patch("apps.authentication.api.auth.log")
resp = await client.post(
self.get_token_url,
data={"email": user.email_encrypted, "password": TEST_PASSWORD},
headers={"Mindlogger-Content-Source": "admin"},
)
assert resp.status_code == http.HTTPStatus.OK
event = audit_log.call_args[0][0]
assert event.client_source == "admin"

async def test_login_audit_event_without_client_source(self, client: TestClient, user: User, mocker: MockerFixture):
audit_log = mocker.patch("apps.authentication.api.auth.log")
resp = await client.post(
self.get_token_url,
data={"email": user.email_encrypted, "password": TEST_PASSWORD},
)
assert resp.status_code == http.HTTPStatus.OK
event = audit_log.call_args[0][0]
assert event.client_source is None

async def test_refresh_audit_event_records_client_source(
self, client: TestClient, user: User, mocker: MockerFixture
):
audit_log = mocker.patch("apps.authentication.api.auth.log")
refresh_token = AuthenticationService.create_refresh_token(
{
"sub": str(user.id),
"jti": str(uuid.uuid4()),
"client": "web",
}
)
resp = await client.post(
auth_router.url_path_for("refresh_access_token"),
data={"refresh_token": refresh_token},
headers={"Mindlogger-Content-Source": "web"},
)
assert resp.status_code == http.HTTPStatus.OK
event = audit_log.call_args[0][0]
assert event.client_source == "web"

@pytest.mark.parametrize(
"headers",
(None, {"Mindlogger-Content-Source": "invalid-content-source"}),
ids=("missing-header", "invalid-header"),
)
async def test_login_without_client_claim(self, client: TestClient, user: User, headers: dict | None):
before = datetime.datetime.now(datetime.timezone.utc)
resp = await client.post(
self.get_token_url,
data={"email": user.email_encrypted, "password": TEST_PASSWORD},
headers=headers,
)
after = datetime.datetime.now(datetime.timezone.utc)
assert resp.status_code == http.HTTPStatus.OK
access_payload, refresh_payload = self._decode_tokens(resp.json()["result"])
assert "client" not in access_payload
assert "client" not in refresh_payload
self._assert_lifetimes_unchanged(access_payload, refresh_payload, before, after)
Loading
Loading