diff options
Diffstat (limited to 'jb')
| -rw-r--r-- | jb/api/auth.py | 13 | ||||
| -rw-r--r-- | jb/config.py | 2 | ||||
| -rw-r--r-- | jb/decorators.py | 13 | ||||
| -rw-r--r-- | jb/flow/assignment_tasks.py | 6 | ||||
| -rw-r--r-- | jb/flow/monitoring.py | 2 | ||||
| -rw-r--r-- | jb/flow/tasks.py | 2 | ||||
| -rw-r--r-- | jb/managers/__init__.py | 2 | ||||
| -rw-r--r-- | jb/managers/amt.py | 2 | ||||
| -rw-r--r-- | jb/managers/gr_api.py | 112 | ||||
| -rw-r--r-- | jb/managers/thl.py | 12 | ||||
| -rw-r--r-- | jb/models/auth.py | 47 | ||||
| -rw-r--r-- | jb/models/bonus.py | 2 | ||||
| -rw-r--r-- | jb/models/hit.py | 2 | ||||
| -rw-r--r-- | jb/settings.py | 7 | ||||
| -rw-r--r-- | jb/views/auth.py | 36 |
15 files changed, 216 insertions, 44 deletions
diff --git a/jb/api/auth.py b/jb/api/auth.py index 7d6c352..3481b30 100644 --- a/jb/api/auth.py +++ b/jb/api/auth.py @@ -8,13 +8,12 @@ from fastapi import Depends, HTTPException, Request, Response, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jb.config import settings -from jb.models.auth import AuthenticatedUser +from jb.decorators import gr_api_manager +from jb.models.auth import User bearer = HTTPBearer(auto_error=False) logger = logging.getLogger(__name__) -AUTH_CACHE_TTL_SECONDS = 60 - SESSION_COOKIE_NAME = "jb_session" JWT_ISSUER = "jamesbillings67" JWT_AUDIENCE = "jamesbillings67" @@ -23,7 +22,7 @@ JWT_AUDIENCE = "jamesbillings67" def get_authenticated_user( request: Request, credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], -) -> AuthenticatedUser: +) -> User: """FastAPI dependency for endpoints requiring a valid session.""" if settings.session_jwt_secret is None: raise HTTPException( @@ -66,11 +65,9 @@ def get_authenticated_user( ) product_user_id = claims.get("sub") - # todo: in here, hit THL by the user's bpuid (email hash), - # in order to 1) be sure user exists & 2) pull the display_name - user_email = ... # lookup in thl by product_user_id + user = gr_api_manager.get_user(product_user_id=product_user_id) - return AuthenticatedUser(email=user_email) + return user def create_session(product_user_id: str) -> str: diff --git a/jb/config.py b/jb/config.py index c7d07e5..a91494d 100644 --- a/jb/config.py +++ b/jb/config.py @@ -1,6 +1,6 @@ import logging -from generalresearchutils.config import is_debug +from generalresearch.config import is_debug from jb.settings import get_settings, get_test_settings diff --git a/jb/decorators.py b/jb/decorators.py index 5c1b1f5..cafd7a9 100644 --- a/jb/decorators.py +++ b/jb/decorators.py @@ -1,7 +1,7 @@ import boto3 from botocore.config import Config -from generalresearchutils.pg_helper import PostgresConfig -from generalresearchutils.redis_helper import RedisConfig +from generalresearch.pg_helper import PostgresConfig +from generalresearch.redis_helper import RedisConfig from influxdb import InfluxDBClient from mypy_boto3_mturk import MTurkClient from mypy_boto3_sns import SNSClient @@ -11,7 +11,8 @@ from jb.managers import Permission from jb.managers.amt import AMTManager from jb.managers.assignment import AssignmentManager from jb.managers.bonus import BonusManager -from jb.managers.hit import HitTypeManager, HitManager, HitQuestionManager +from jb.managers.gr_api import GRApiManager +from jb.managers.hit import HitManager, HitQuestionManager, HitTypeManager redis_config = RedisConfig( dsn=settings.redis, @@ -32,6 +33,12 @@ CLIENT_CONFIG = Config( read_timeout=2.5, ) +gr_api_manager = GRApiManager( + base_url=str(settings.gr_api_host), + token=settings.gr_api_token.get_secret_value(), + product_id=settings.product_id, +) + # We shouldn't use this directly. Use our AMTManager wrapper AMT_CLIENT: MTurkClient = boto3.client( service_name="mturk", diff --git a/jb/flow/assignment_tasks.py b/jb/flow/assignment_tasks.py index 18a844e..2db20f8 100644 --- a/jb/flow/assignment_tasks.py +++ b/jb/flow/assignment_tasks.py @@ -3,9 +3,9 @@ import math from datetime import timedelta from typing import Optional -from generalresearchutils.models.thl.definitions import PayoutStatus, StatusCode1 -from generalresearchutils.models.thl.wallet.cashout_method import CashoutRequestInfo -from generalresearchutils.currency import USDCent +from generalresearch.models.thl.definitions import PayoutStatus, StatusCode1 +from generalresearch.models.thl.wallet.cashout_method import CashoutRequestInfo +from generalresearch.currency import USDCent from jb.flow.monitoring import emit_error_event, emit_assignment_event, emit_bonus_event from jb.managers.amt import ( diff --git a/jb/flow/monitoring.py b/jb/flow/monitoring.py index 28f7271..334eab3 100644 --- a/jb/flow/monitoring.py +++ b/jb/flow/monitoring.py @@ -5,7 +5,7 @@ from mypy_boto3_mturk.literals import EventTypeType from jb.config import settings from jb.decorators import influx_client -from generalresearchutils.currency import USDCent +from generalresearch.currency import USDCent from jb.models.definitions import HitStatus, AssignmentStatus diff --git a/jb/flow/tasks.py b/jb/flow/tasks.py index 77825d3..6c5d6bb 100644 --- a/jb/flow/tasks.py +++ b/jb/flow/tasks.py @@ -2,7 +2,7 @@ import logging import time from typing import TypedDict, cast -from generalresearchutils.config import is_debug +from generalresearch.config import is_debug from jb.decorators import AMTM, HTM, HM, HQM, pg_config from jb.flow.maintenance import check_hit_status diff --git a/jb/managers/__init__.py b/jb/managers/__init__.py index e99569a..ec64f9f 100644 --- a/jb/managers/__init__.py +++ b/jb/managers/__init__.py @@ -1,7 +1,7 @@ from enum import IntEnum from typing import Collection -from generalresearchutils.pg_helper import PostgresConfig +from generalresearch.pg_helper import PostgresConfig class Permission(IntEnum): diff --git a/jb/managers/amt.py b/jb/managers/amt.py index 88ae07d..2cb0cbd 100644 --- a/jb/managers/amt.py +++ b/jb/managers/amt.py @@ -3,7 +3,7 @@ from datetime import timezone, datetime from typing import Tuple, Optional, List, Dict, Any import botocore.exceptions -from generalresearchutils.currency import USDCent +from generalresearch.currency import USDCent from mypy_boto3_mturk import MTurkClient from mypy_boto3_mturk.type_defs import ( AssignmentTypeDef, diff --git a/jb/managers/gr_api.py b/jb/managers/gr_api.py new file mode 100644 index 0000000..8ffeaf0 --- /dev/null +++ b/jb/managers/gr_api.py @@ -0,0 +1,112 @@ +"""Client for General Research's product-user API.""" + +from typing import Any + +import requests + +from jb.models.auth import User + + +class GRApiError(RuntimeError): + """The General Research API could not satisfy a request.""" + + +class GRApiManager: + def __init__( + self, + base_url: str, + token: str, + product_id: str, + timeout: float = 5.0, + session: requests.Session | None = None, + ) -> None: + if not token: + raise ValueError("General Research API token must not be empty") + + self.base_url = base_url.rstrip("/") + self.product_id = product_id + self.timeout = timeout + self.session = session or requests.Session() + self.session.headers.update( + { + "Authorization": token, + "Accept": "application/json", + "Content-Type": "application/json", + } + ) + + def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response: + try: + response = self.session.request(method, url, timeout=self.timeout, **kwargs) + response.raise_for_status() + return response + except requests.RequestException as exc: + raise GRApiError( + f"General Research API request failed: {method} {url}" + ) from exc + + def _parse_user_response(self, res: dict) -> User: + return User.model_validate( + { + "product_user_id": res["product_user_id"], + "email": res["metadata"].get("email_address"), + "display_name": res["metadata"].get("display_name"), + } + ) + + def ensure_user_exists(self, user: User) -> User: + """Idempotently create the product user if it does not already exist.""" + url = f"{self.base_url}/{self.product_id}/user/{user.product_user_id}/" + res = self._request("PUT", url).json() + if res["metadata"].get("email_address") is None: + self.set_user_email(user) + return self.get_user(user.product_user_id) + user_thl = self._parse_user_response(res) + if user_thl.email != user.email: + raise ValueError( + f"user {user.product_user_id} already exists with email {user_thl.email}" + ) + return user_thl + + def get_user(self, product_user_id: str) -> User: + """Retrieve the user's email address and display name.""" + url = f"{self.base_url}/{self.product_id}/user/{product_user_id}/" + res = self._request("GET", url).json() + return self._parse_user_response(res) + + def get_user_by_email(self, email: str) -> User: + user = User.model_validate({"email": email}) + return self.get_user(user.product_user_id) + + def set_user_email(self, user: User) -> None: + """This should only be called once per user upon account creation. + A user cannot change their email address.""" + url = f"{self.base_url}/{self.product_id}/user/{user.product_user_id}/metadata/" + res = self._request( + "PATCH", + url, + json={"email_address": str(user.email)}, + ).json() + + def set_user_display_name(self, user: User) -> User: + """Can be called as many times as needed. Multiple + users can have the same display name.""" + if user.display_name is None: + return user + url = f"{self.base_url}/{self.product_id}/user/{user.product_user_id}/metadata/" + res = self._request( + "PATCH", + url, + json={"display_name": user.display_name}, + ).json() + return self.get_user(user.product_user_id) + + def transition_product_user_id(self, user: User, amt_worker_id: str) -> User: + """This should only be called once upon transition from an + AMT account to a General Research account.""" + url = f"{self.base_url}/{self.product_id}/user/{amt_worker_id}/" + res = self._request( + "PATCH", url, json={"product_user_id": user.product_user_id} + ) + self.set_user_email(user) + return self.get_user(user.product_user_id) diff --git a/jb/managers/thl.py b/jb/managers/thl.py index 6e8effc..f0534db 100644 --- a/jb/managers/thl.py +++ b/jb/managers/thl.py @@ -1,16 +1,16 @@ -from generalresearchutils.models.thl.payout import UserPayoutEvent -from generalresearchutils.models.thl.task_status import TaskStatusResponse -from generalresearchutils.models.thl.wallet.cashout_method import ( +from generalresearch.models.thl.payout import UserPayoutEvent +from generalresearch.models.thl.task_status import TaskStatusResponse +from generalresearch.models.thl.wallet.cashout_method import ( CashoutRequestResponse, CashoutRequestInfo, ) -from generalresearchutils.models.thl.user_profile import UserProfile -from generalresearchutils.currency import USDCent +from generalresearch.models.thl.user_profile import UserProfile +from generalresearch.currency import USDCent from jb.config import settings -from generalresearchutils.models.thl.definitions import PayoutStatus +from generalresearch.models.thl.definitions import PayoutStatus from typing import Optional diff --git a/jb/models/auth.py b/jb/models/auth.py index 5f31bd3..8b48088 100644 --- a/jb/models/auth.py +++ b/jb/models/auth.py @@ -1,5 +1,6 @@ import hashlib import hmac +from typing import Any from pydantic import ( BaseModel, @@ -8,6 +9,8 @@ from pydantic import ( Field, TypeAdapter, computed_field, + field_validator, + model_validator, ) from jb.config import settings @@ -30,29 +33,59 @@ def email_to_product_user_id(email: str) -> str: return hmac.new( key=salt_bytes, msg=normalized_email.encode("utf-8"), - digestmod=hashlib.sha256, + digestmod=hashlib.sha1, ).hexdigest() -class AuthenticatedUser(BaseModel): +class User(BaseModel): """A user that has been authenticated and exists in THL""" email: EmailStr = Field() + display_name: str | None = Field(default=None, max_length=255) + + @field_validator("email", mode="before") + @classmethod + def normalize_email(cls, value: Any) -> Any: + if not isinstance(value, str): + return value + if not value.isascii(): + raise ValueError("email must contain ASCII characters only") + return value.lower() + + @model_validator(mode="before") + @classmethod + def validate_product_user_id(cls, data: Any) -> Any: + if not isinstance(data, dict) or "product_user_id" not in data: + return data + + provided_id = data["product_user_id"] + email = data.get("email") + if email is None: + return data + + expected_id = email_to_product_user_id(str(email)) + if not isinstance(provided_id, str) or not hmac.compare_digest( + provided_id, expected_id + ): + raise ValueError(f"product_user_id {provided_id} does not match email {email}") + + # The computed field is authoritative; do not retain the input value. + validated_data = dict(data) + validated_data.pop("product_user_id") + return validated_data + @computed_field + @property def product_user_id(self) -> str: return email_to_product_user_id(self.email) -class AccountCreate(BaseModel): - email: EmailStr = Field() - - class AccountLogin(BaseModel): + # There is no practical difference between an Account "login" and "create" email: EmailStr = Field() - class MagicLinkExchangeRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/jb/models/bonus.py b/jb/models/bonus.py index a536dd1..5f81add 100644 --- a/jb/models/bonus.py +++ b/jb/models/bonus.py @@ -3,7 +3,7 @@ from typing import Optional, Dict, Any from pydantic import BaseModel, Field, ConfigDict, PositiveInt from typing_extensions import Self -from generalresearchutils.currency import USDCent +from generalresearch.currency import USDCent from jb.models.custom_types import AMTBoto3ID, AwareDatetimeISO, UUIDStr diff --git a/jb/models/hit.py b/jb/models/hit.py index fba2ecf..45478fc 100644 --- a/jb/models/hit.py +++ b/jb/models/hit.py @@ -13,7 +13,7 @@ from pydantic import ( ) from typing_extensions import Self -from generalresearchutils.currency import USDCent +from generalresearch.currency import USDCent from jb.models.custom_types import AMTBoto3ID, HttpsUrlStr, AwareDatetimeISO from jb.models.definitions import HitStatus, HitReviewStatus diff --git a/jb/settings.py b/jb/settings.py index cc1e870..78b4b02 100644 --- a/jb/settings.py +++ b/jb/settings.py @@ -3,7 +3,7 @@ from functools import lru_cache from pathlib import Path from typing import Optional -from generalresearchutils.models.custom_types import InfluxDsn +from generalresearch.models.custom_types import InfluxDsn from pydantic import Field, PostgresDsn, HttpUrl, RedisDsn, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict @@ -60,6 +60,11 @@ class Settings(AmtJbBaseSettings): magic_token_salt: SecretStr = Field(min_length=32) + gr_api_host: HttpUrl = Field( + default=HttpUrl("https://generalresearch.com/api/v2/") + ) + gr_api_token: SecretStr = Field(min_length=1) + class TestSettings(Settings): model_config = SettingsConfigDict( diff --git a/jb/views/auth.py b/jb/views/auth.py index 0591294..06dbdde 100644 --- a/jb/views/auth.py +++ b/jb/views/auth.py @@ -6,8 +6,9 @@ has resolved an email address to its stable user identifier, call """ from typing import Annotated +from urllib.parse import urlencode -from fastapi import APIRouter, Depends, Response, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi.responses import HTMLResponse from jb.api.auth import ( @@ -15,11 +16,13 @@ from jb.api.auth import ( create_session, get_authenticated_user, ) -from jb.api.magic_token import consume_magic_token +from jb.api.magic_token import consume_magic_token, create_magic_token from jb.config import settings +from jb.decorators import gr_api_manager from jb.models.auth import ( MagicLinkExchangeRequest, - AuthenticatedUser, + AccountLogin, + User, email_to_product_user_id, ) from jb.settings import BASE_HTML @@ -27,6 +30,20 @@ from jb.settings import BASE_HTML auth_router = APIRouter(prefix="/auth", tags=["Auth"]) +@auth_router.post("/magic-link/request") +def request_mock_magic_link(body: AccountLogin) -> dict[str, str]: + """Create a magic link without sending email in development.""" + if not settings.debug: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + # todo: send email here + + user = User(email=body.email) + token = create_magic_token(str(user.email)) + query = urlencode({"token": token}) + return {"magic_link": f"/auth/magic-link/?{query}"} + + @auth_router.get("/magic-link/", response_class=HTMLResponse, include_in_schema=False) def magic_link_landing_page() -> HTMLResponse: """Serve the SPA without redeeming the token; email prefetches are harmless.""" @@ -44,11 +61,12 @@ def magic_link_landing_page() -> HTMLResponse: def exchange_magic_link(body: MagicLinkExchangeRequest, response: Response) -> None: """Exchange a magic link only after its landing page makes an explicit POST.""" user_email = consume_magic_token(body.token) - product_user_id = email_to_product_user_id(user_email) - # todo: hit thl to make sure this user exists + user = User.model_validate({'email': user_email}) + # hit thl to make sure this user exists + user = gr_api_manager.ensure_user_exists(user) - session_token = create_session(product_user_id) + session_token = create_session(user.product_user_id) response.set_cookie( key=SESSION_COOKIE_NAME, value=session_token, @@ -60,10 +78,10 @@ def exchange_magic_link(body: MagicLinkExchangeRequest, response: Response) -> N ) -@auth_router.get("/session", response_model=AuthenticatedUser) +@auth_router.get("/session", response_model=User) def get_session( - user: Annotated[AuthenticatedUser, Depends(get_authenticated_user)], -) -> AuthenticatedUser: + user: Annotated[User, Depends(get_authenticated_user)], +) -> User: return user |
