diff --git a/ooniapi/common/src/common/config.py b/ooniapi/common/src/common/config.py index 82d2c538..347fbc45 100644 --- a/ooniapi/common/src/common/config.py +++ b/ooniapi/common/src/common/config.py @@ -15,6 +15,12 @@ class Settings(BaseSettings): statsd_port: int = 8125 statsd_prefix: str = "ooniapi" jwt_encryption_key: str = "CHANGEME" + account_id_hashing_key: str = "CHANGEME" prometheus_metrics_password: str = "CHANGEME" session_expiry_days: int = 10 login_expiry_days: int = 10 + + aws_region: str = "" + aws_access_key_id: str = "" + aws_secret_access_key: str = "" + email_source_address: str = "contact+dev@ooni.io" diff --git a/ooniapi/services/ooniauth/.gitignore b/ooniapi/services/ooniauth/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/ooniapi/services/ooniauth/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/ooniapi/services/ooniauth/pyproject.toml b/ooniapi/services/ooniauth/pyproject.toml index 1799bf5d..dbf880f7 100644 --- a/ooniapi/services/ooniauth/pyproject.toml +++ b/ooniapi/services/ooniauth/pyproject.toml @@ -27,7 +27,6 @@ dependencies = [ "clickhouse-driver ~= 0.2.6", "sqlalchemy ~= 2.0.27", "ujson ~= 5.9.0", - "urllib3 ~= 2.1.0", "python-dateutil ~= 2.8.2", "pydantic-settings ~= 2.1.0", "uvicorn ~= 0.25.0", @@ -37,6 +36,8 @@ dependencies = [ "alembic ~= 1.13.1", "prometheus-fastapi-instrumentator ~= 6.1.0", "prometheus-client", + "email-validator", + "boto3 ~= 1.34.0", ] [project.urls] diff --git a/ooniapi/services/ooniauth/src/ooniauth/dependencies.py b/ooniapi/services/ooniauth/src/ooniauth/dependencies.py index a51949d8..ee4b6ab0 100644 --- a/ooniapi/services/ooniauth/src/ooniauth/dependencies.py +++ b/ooniapi/services/ooniauth/src/ooniauth/dependencies.py @@ -1,5 +1,8 @@ from typing import Annotated + from clickhouse_driver import Client as ClickhouseClient +import boto3 + from fastapi import Depends from .common.dependencies import get_settings @@ -10,3 +13,12 @@ def get_clickhouse_client( settings: Annotated[Settings, Depends(get_settings)] ) -> ClickhouseClient: return ClickhouseClient.from_url(settings.clickhouse_url) + + +def get_ses_client(settings: Annotated[Settings, Depends(get_settings)]): + return boto3.client( + "ses", + region_name=settings.aws_region, + aws_access_key_id=settings.aws_access_key_id, + aws_secret_access_key=settings.aws_secret_access_key, + ) diff --git a/ooniapi/services/ooniauth/src/ooniauth/main.py b/ooniapi/services/ooniauth/src/ooniauth/main.py index fc86b341..1a69d92f 100644 --- a/ooniapi/services/ooniauth/src/ooniauth/main.py +++ b/ooniapi/services/ooniauth/src/ooniauth/main.py @@ -8,7 +8,7 @@ from prometheus_fastapi_instrumentator import Instrumentator -from .routers import ooniauth +from .routers import v1 from .common.dependencies import get_settings from .common.version import get_build_label, get_pkg_version @@ -32,7 +32,7 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) instrumentor = Instrumentator().instrument( - app, metric_namespace="ooniapi", metric_subsystem="oonirun" + app, metric_namespace="ooniapi", metric_subsystem="ooniauth" ) # TODO: temporarily enable all @@ -45,7 +45,7 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) -app.include_router(ooniauth.router, prefix="/api") +app.include_router(v1.router, prefix="/api") @app.get("/version") diff --git a/ooniapi/services/ooniauth/src/ooniauth/routers/ooniauth.py b/ooniapi/services/ooniauth/src/ooniauth/routers/ooniauth.py deleted file mode 100644 index 695eb5ff..00000000 --- a/ooniapi/services/ooniauth/src/ooniauth/routers/ooniauth.py +++ /dev/null @@ -1,286 +0,0 @@ -""" -OONIRun link management - -https://github.com/ooni/spec/blob/master/backends/bk-005-ooni-run-v2.md -""" - -from datetime import datetime, timedelta, timezone, date -from typing import Dict, List, Optional, Tuple -import logging -import jwt - -import sqlalchemy as sa -from sqlalchemy.orm import Session -from fastapi import APIRouter, Depends, Query, HTTPException, Header, Path -from pydantic import computed_field, Field, validator -from pydantic import BaseModel as PydandicBaseModel -from typing_extensions import Annotated - -from ..dependencies import get_clickhouse_client - -from ..utils import create_session_token, get_account_role -from ..common.dependencies import get_settings, role_required -from ..common.config import Settings -from ..common.routers import BaseModel -from ..common.utils import ( - decode_jwt, - get_client_role, - get_client_token, - get_account_id_or_raise, - get_account_id_or_none, -) - - -log = logging.getLogger(__name__) - -router = APIRouter() - - -class SessionTokenCreate(BaseModel): - bearer: str - redirect_to: str - email_address: str - - -@router.get("/api/v1/user_login", response_model=SessionTokenCreate) -def user_login( - token: Annotated[ - str, - Query(alias="k", description="JWT token with aud=register"), - ], - settings: Settings = Depends(get_settings), - db: Settings = Depends(get_clickhouse_client), -): - """Auth Services: login using a registration/login link""" - try: - dec = decode_jwt( - token=token, key=settings.jwt_encryption_key, audience="register" - ) - except jwt.exceptions.MissingRequiredClaimError: - raise HTTPException(401, "Invalid token") - except jwt.exceptions.InvalidSignatureError: - raise HTTPException(401, "Invalid credential signature") - except jwt.exceptions.DecodeError: - raise HTTPException(401, "Invalid credentials") - except jwt.exceptions.ExpiredSignatureError: - raise HTTPException(401, "Expired token") - - log.info("user login successful") - - # Store account role in token to prevent frequent DB lookups - role = get_account_role(db=db, account_id=dec["account_id"]) or "user" - redirect_to = dec.get("redirect_to", "") - email = dec["email_address"] - - token = create_session_token( - key=settings.jwt_encryption_key, - account_id=dec["account_id"], - role=role, - session_expiry_days=settings.session_expiry_days, - login_expiry_days=settings.login_expiry_days, - ) - return SessionTokenCreate( - bearer=token, - redirect_to=redirect_to, - email_address=email, - ) - - -class SessionTokenRefresh(BaseModel): - bearer: str - - -@router.get( - "/api/v1/user_refresh_token", - dependencies=[Depends(role_required(["admin", "user"]))], - response_model=SessionTokenRefresh, -) -def user_refresh_token( - settings: Settings = Depends(get_settings), - authorization: str = Header("authorization"), -): - """Auth services: refresh user token - --- - responses: - 200: - description: JSON with "bearer" key - """ - tok = get_client_token( - authorization=authorization, jwt_encryption_key=settings.jwt_encryption_key - ) - - # @role_required already checked for expunged tokens - if not tok: - raise HTTPException(401, "Invalid credentials") - - newtoken = create_session_token( - key=settings.jwt_encryption_key, - account_id=tok["account_id"], - role=tok["role"], - session_expiry_days=settings.session_expiry_days, - login_expiry_days=settings.login_expiry_days, - login_time=tok["login_time"], - ) - log.debug("user token refresh successful") - return SessionTokenRefresh(bearer=newtoken) - - -class AccountMetadata(BaseModel): - logged_in: bool - role: str - - -# @cross_origin(origins=origins, supports_credentials=True) -@router.get("/api/_/account_metadata") -def get_account_metadata( - settings: Settings = Depends(get_settings), - authorization: str = Header("authorization"), -): - """Get account metadata for logged-in users - --- - responses: - 200: - description: Username and role if logged in. - schema: - type: object - """ - try: - tok = get_client_token( - authorization=authorization, jwt_encryption_key=settings.jwt_encryption_key - ) - if not tok: - raise HTTPException(401, "Invalid credentials") - return AccountMetadata(logged_in=True, role=tok["role"]) - except Exception: - raise HTTPException(401, "Invalid credentials") - - -# def _set_account_role(email_address, role: str) -> int: -# log = current_app.logger -# account_id = hash_email_address(email_address) -# # log.info(f"Giving account {account_id} role {role}") -# # TODO: when role is changed enforce token expunge -# query_params = dict(account_id=account_id, role=role) -# log.info("Creating/Updating account role") -# # 'accounts' is on RocksDB (ACID key-value database) -# query = """INSERT INTO accounts (account_id, role) VALUES""" -# return insert_click(query, [query_params]) -# @router.get("/api/v1/get_account_role/") -# # @role_required("admin") -# def admin_get_account_role(email_address) -> Response: -# """Get account role. Return an error message if the account is not found. -# Only for admins. -# --- -# security: -# cookieAuth: -# type: JWT -# in: cookie -# name: ooni -# parameters: -# - name: email_address -# in: path -# required: true -# type: string -# responses: -# 200: -# description: Role or error message -# schema: -# type: object -# """ -# log = current_app.logger -# email_address = email_address.strip().lower() -# if EMAIL_RE.fullmatch(email_address) is None: -# return jerror("Invalid email address") -# account_id = hash_email_address(email_address) -# role = _get_account_role(account_id) -# if role is None: -# log.info(f"Getting account {account_id} role: not found") -# return jerror("Account not found") - -# log.info(f"Getting account {account_id} role: {role}") -# return nocachejson(role=role) - - -# @auth_blueprint.route("/api/v1/set_session_expunge", methods=["POST"]) -# # @role_required("admin") -# def admin_set_session_expunge() -> Response: -# """Force refreshing all session tokens for a given account. -# Only for admins. -# --- -# security: -# cookieAuth: -# type: JWT -# in: cookie -# name: ooni -# parameters: -# - in: body -# name: email address -# description: data as HTML form or JSON -# required: true -# schema: -# type: object -# properties: -# email_address: -# type: string -# responses: -# 200: -# description: Confirmation -# """ -# log = current_app.logger -# req = request.json if request.is_json else request.form -# assert req -# email_address = req.get("email_address", "").strip().lower() -# if EMAIL_RE.fullmatch(email_address) is None: -# return jerror("Invalid email address") -# account_id = hash_email_address(email_address) -# log.info(f"Setting expunge for account {account_id}") -# # If an entry is already in place update the threshold as the new -# # value is going to be safer -# # 'session_expunge' is on RocksDB (ACID key-value database) -# log.info("Inserting into Clickhouse session_expunge") -# query = "INSERT INTO session_expunge (account_id) VALUES" -# query_params: Any = dict(account_id=account_id) -# # the `threshold` column defaults to the current time -# insert_click(query, [query_params]) -# return nocachejson() - -# @auth_blueprint.route("/api/v1/set_account_role", methods=["POST"]) -# @role_required("admin") -# def set_account_role() -> Response: -# """Set a role to a given account identified by an email address. -# Only for admins. -# --- -# security: -# cookieAuth: -# type: JWT -# in: cookie -# name: ooni -# parameters: -# - in: body -# name: email address and role -# description: data as HTML form or JSON -# required: true -# schema: -# type: object -# properties: -# email_address: -# type: string -# role: -# type: string -# responses: -# 200: -# description: Confirmation -# """ -# log = current_app.logger -# req = request.json if request.is_json else request.form -# assert req -# role = req.get("role", "").strip().lower() -# email_address = req.get("email_address", "").strip().lower() -# if EMAIL_RE.fullmatch(email_address) is None: -# return jerror("Invalid email address") -# if role not in ["user", "admin"]: -# return jerror("Invalid role") - -# r = _set_account_role(email_address, role) -# log.info(f"Role set {r}") -# return nocachejson() diff --git a/ooniapi/services/ooniauth/src/ooniauth/routers/v1.py b/ooniapi/services/ooniauth/src/ooniauth/routers/v1.py new file mode 100644 index 00000000..c61b8f43 --- /dev/null +++ b/ooniapi/services/ooniauth/src/ooniauth/routers/v1.py @@ -0,0 +1,264 @@ +""" +OONIRun link management + +https://github.com/ooni/spec/blob/master/backends/bk-005-ooni-run-v2.md +""" + +from datetime import datetime, timedelta, timezone +from typing import Optional +from urllib.parse import urlparse +import logging + +import jwt + +from fastapi import APIRouter, Depends, Query, HTTPException, Header, Path +from pydantic import Field, validator +from pydantic import EmailStr +from typing_extensions import Annotated + +from ..dependencies import get_clickhouse_client, get_ses_client + +from ..utils import ( + create_session_token, + get_account_role, + hash_email_address, + send_login_email, +) +from ..common.dependencies import get_settings, role_required +from ..common.config import Settings +from ..common.routers import BaseModel +from ..common.utils import ( + create_jwt, + decode_jwt, + get_client_token, +) + + +log = logging.getLogger(__name__) + +router = APIRouter() + +# @router.get("/api/v2/ooniauth/user-session") +# @router.post("/api/v2/ooniauth/user-session", response_model=SessionTokenCreate) +# redirect_to: ## Make this optional + + +class UserRegister(BaseModel): + email_address: EmailStr = Field( + title="email address of the user", + min_length=5, + max_length=255, + ) + password: str = Field(title="password of the user", min_length=8) + redirect_to: Optional[str] = Field(title="redirect to this URL") + + @validator("redirect_to") + def validate_redirect_to(cls, v): + # None is also a valid type + if v is None: + return v + + u = urlparse(v) + if u.scheme != "https": + raise ValueError("Invalid URL") + valid_dnames = ( + "explorer.ooni.org", + "explorer.test.ooni.org", + "run.ooni.io", + "run.test.ooni.org", + "test-lists.ooni.org", + "test-lists.test.ooni.org", + ) + if u.netloc not in valid_dnames: + raise ValueError("Invalid URL", u.netloc) + + return v + + @property + def redirect_to_fqdm(self): + return urlparse(self.redirect_to).netloc + + +class UserRegistrationResponse(BaseModel): + msg: str + + +@router.post("/v1/user_register", response_model=UserRegistrationResponse) +def user_register( + user_register: UserRegister, + settings: Settings = Depends(get_settings), + ses_client=Depends(get_ses_client), +): + """Auth Services: start email-based user registration + --- + parameters: + - in: body + name: register data + description: Registration data as HTML form or JSON + required: true + schema: + type: object + properties: + email_address: + type: string + redirect_to: ## Make this optional + type: string + responses: + 200: + description: Confirmation + """ + email_address = user_register.email_address.lower() + + account_id = hash_email_address( + email_address=email_address, key=settings.account_id_hashing_key + ) + now = datetime.now(timezone.utc) + expiration = now + timedelta(days=1) + # On the backend side the registration is stateless + payload = { + "nbf": now, + "exp": expiration, + "aud": "register", + "account_id": account_id, + "email_address": email_address, + "redirect_to": user_register.redirect_to, + } + registration_token = create_jwt(payload=payload, key=settings.jwt_encryption_key) + + e = urlparse.urlencode(dict(token=registration_token)) + login_url = urlparse.urlunsplit( + ("https", user_register.redirect_to_fqdm, "/login", e, "") + ) + + log.info("sending registration token") + try: + email_id = send_login_email( + source_address=settings.email_source_address, + destination_address=email_address, + login_url=login_url, + ses_client=ses_client, + ) + log.info(f"email sent: {email_id}") + except Exception as e: + log.error(e, exc_info=True) + raise HTTPException(status_code=500, detail="Unable to send the email") + + return UserRegistrationResponse(msg="ok") + + +class SessionTokenCreate(BaseModel): + bearer: str + redirect_to: str + email_address: str + + +@router.get("/v1/user_login", response_model=SessionTokenCreate) +def user_login( + token: Annotated[ + str, + Query(alias="k", description="JWT token with aud=register"), + ], + settings: Settings = Depends(get_settings), + db: Settings = Depends(get_clickhouse_client), +): + """Auth Services: login using a registration/login link""" + try: + dec = decode_jwt( + token=token, key=settings.jwt_encryption_key, audience="register" + ) + except jwt.exceptions.MissingRequiredClaimError: + raise HTTPException(401, "Invalid token") + except jwt.exceptions.InvalidSignatureError: + raise HTTPException(401, "Invalid credential signature") + except jwt.exceptions.DecodeError: + raise HTTPException(401, "Invalid credentials") + except jwt.exceptions.ExpiredSignatureError: + raise HTTPException(401, "Expired token") + + log.info("user login successful") + + # Store account role in token to prevent frequent DB lookups + role = get_account_role(db=db, account_id=dec["account_id"]) or "user" + redirect_to = dec.get("redirect_to", "") + email = dec["email_address"] + + token = create_session_token( + key=settings.jwt_encryption_key, + account_id=dec["account_id"], + role=role, + session_expiry_days=settings.session_expiry_days, + login_expiry_days=settings.login_expiry_days, + ) + return SessionTokenCreate( + bearer=token, + redirect_to=redirect_to, + email_address=email, + ) + + +class SessionTokenRefresh(BaseModel): + bearer: str + + +@router.get( + "/v1/user_refresh_token", + dependencies=[Depends(role_required(["admin", "user"]))], + response_model=SessionTokenRefresh, +) +def user_refresh_token( + settings: Settings = Depends(get_settings), + authorization: str = Header("authorization"), +): + """Auth services: refresh user token + --- + responses: + 200: + description: JSON with "bearer" key + """ + tok = get_client_token( + authorization=authorization, jwt_encryption_key=settings.jwt_encryption_key + ) + + # @role_required already checked for expunged tokens + if not tok: + raise HTTPException(401, "Invalid credentials") + + newtoken = create_session_token( + key=settings.jwt_encryption_key, + account_id=tok["account_id"], + role=tok["role"], + session_expiry_days=settings.session_expiry_days, + login_expiry_days=settings.login_expiry_days, + login_time=tok["login_time"], + ) + log.debug("user token refresh successful") + return SessionTokenRefresh(bearer=newtoken) + + +class AccountMetadata(BaseModel): + logged_in: bool + role: str + + +@router.get("/_/account_metadata") +def get_account_metadata( + settings: Settings = Depends(get_settings), + authorization: str = Header("authorization"), +): + """Get account metadata for logged-in users + --- + responses: + 200: + description: Username and role if logged in. + schema: + type: object + """ + try: + tok = get_client_token( + authorization=authorization, jwt_encryption_key=settings.jwt_encryption_key + ) + if not tok: + raise HTTPException(401, "Invalid credentials") + return AccountMetadata(logged_in=True, role=tok["role"]) + except Exception: + raise HTTPException(401, "Invalid credentials") diff --git a/ooniapi/services/ooniauth/src/ooniauth/utils.py b/ooniapi/services/ooniauth/src/ooniauth/utils.py index e5560047..e3ae4f21 100644 --- a/ooniapi/services/ooniauth/src/ooniauth/utils.py +++ b/ooniapi/services/ooniauth/src/ooniauth/utils.py @@ -1,6 +1,11 @@ +import hashlib import time -import sqlalchemy as sa from typing import Optional +from textwrap import dedent + +import sqlalchemy as sa +import boto3 + from .common.utils import create_jwt, query_click_one_row @@ -36,3 +41,63 @@ def get_account_role(db, account_id: str) -> Optional[str]: query_params = dict(account_id=account_id) r = query_click_one_row(db, sa.text(query), query_params) return r["role"] if r else None + + +def hash_email_address(email_address: str, key: str) -> str: + return hashlib.blake2b( + email_address.encode(), key=key.encode(), digest_size=16 + ).hexdigest() + + +def send_login_email( + destination_address: str, source_address: str, login_url: str, ses_client +) -> str: + """Format and send a registration/login email""" + body_text = dedent( + f""" + Welcome to OONI. + Please login by following {login_url} + The link can be used on multiple devices and will expire in 24 hours. + """ + ) + + body_html = dedent( + f""" + + + +

Welcome to OONI

+

+ Please login here +

+

The link can be used on multiple devices and will expire in 24 hours.

+ + + """ + ) + + response = ses_client.send_email( + Destination={ + "ToAddresses": [ + destination_address, + ], + }, + Message={ + "Body": { + "Html": { + "Charset": "UTF-8", + "Data": body_html, + }, + "Text": { + "Charset": "UTF-8", + "Data": body_text, + }, + }, + "Subject": { + "Charset": "UTF-8", + "Data": "OONI Account activation email", + }, + }, + Source=source_address, + ) + return response["MessageId"]