diff --git a/docs/developers.md b/docs/developers.md new file mode 100644 index 00000000..8ce82d48 --- /dev/null +++ b/docs/developers.md @@ -0,0 +1,11 @@ +# Developer Notes + +## Authentication + +Authentication is generally handled via [JSON Web Tokens](https://jwt.io/) (JWT) which should be passed in the `Authorization` header with a value `Bearer {token}`. Most the of the py-ISPyB resources require a token to be present. + +In certain situations it is not possible to use a JWT. For example when downloading a file it is not possible to pass an Authorization header. For these situations py-ISPyB provides a one time token system. A one time token can be generated for a particular url and this token can then be used as a query parameter to access the specified url a single time. Unused tokens are expired on a short time scale. A signed url can be generated using the `/user/sign` resource, and used as so: + +``` +GET /datacollections/attachments/2?onetime={token} +``` diff --git a/mkdocs.yml b/mkdocs.yml index 53b2ceab..03bee1e4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ nav: - Simulator: simulator.md - User Portal Sync: upsync.md - Developers: + - Notes: developers.md - ⧉ Test coverage: https://app.codecov.io/gh/ispyb/py-ispyb/ theme: diff --git a/pyispyb/app/extensions/auth/bearer.py b/pyispyb/app/extensions/auth/bearer.py index c8f2cb23..b9076696 100644 --- a/pyispyb/app/extensions/auth/bearer.py +++ b/pyispyb/app/extensions/auth/bearer.py @@ -1,11 +1,14 @@ -from fastapi import HTTPException, Depends +from fastapi import HTTPException, Depends, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials import jwt from ...globals import g from .token import decode_token, set_token_data +from .onetime import onetime, validate_onetime_token -security = HTTPBearer() +# auto_error=False to correct 403 -> 401 +# https://github.com/tiangolo/fastapi/issues/2026 +security = HTTPBearer(auto_error=False) def verify_jwt(token: str): @@ -20,8 +23,11 @@ def verify_jwt(token: str): async def JWTBearer( + request: Request, + onetime: str = Depends(onetime), credentials: HTTPAuthorizationCredentials = Depends(security), ): + # JWT authentication if credentials: if not credentials.scheme == "Bearer": raise HTTPException( @@ -36,6 +42,11 @@ async def JWTBearer( set_token_data(decoded) return credentials.credentials + + # One time token authentication + elif onetime: + person_dict = validate_onetime_token(onetime, request.url.components.path) + set_token_data(person_dict) else: raise HTTPException(status_code=401, detail="No token provided.") diff --git a/pyispyb/app/extensions/auth/onetime.py b/pyispyb/app/extensions/auth/onetime.py new file mode 100644 index 00000000..fdb122a3 --- /dev/null +++ b/pyispyb/app/extensions/auth/onetime.py @@ -0,0 +1,135 @@ +import asyncio +import logging +import secrets +from typing import Optional +from urllib.parse import urlparse + +from fastapi import Query, HTTPException +from sqlalchemy import text +from starlette.concurrency import run_in_threadpool +from ispyb import models + +from ....config import settings +from ...extensions.database.definitions import get_current_person +from ...extensions.database.session import get_session +from ...extensions.database.middleware import db + +logger = logging.getLogger(__name__) + + +def onetime( + onetime: Optional[str] = Query( + None, + description="One time token", + include_in_schema=False, + regex=r"^([\w\-_])+$", + ) +) -> str: + return onetime + + +def generate_onetime_token(validity: str, personId: int) -> str: + """Generate a one time token + + Kwargs: + validity (str): The path this token is valid for + login (str): The associated person login + + Returns: + token(str): The generated token + """ + parsed = urlparse(validity) + path = parsed.path + path = path.replace(settings.api_root, "") + + token = secrets.token_urlsafe(96) + once_token = models.SWOnceToken( + personId=personId, + validity=path, + token=token, + ) + db.session.add(once_token) + return token + + +def validate_onetime_token(token: str, validity: str) -> models.Person: + """Validate a one time token + + Kwargs: + token (str): The token to validate + validity (str): The current path + + Returns: + person (models.Person): The validated person + """ + if not hasattr(models, "SWOnceToken"): + raise RuntimeError("Missing table `SWOnceToken`") + + once_token: models.SWOnceToken = ( + db.session.query(models.SWOnceToken) + .filter(models.SWOnceToken.token == token) + .first() + ) + + if not once_token: + logger.warning("Unknown one time token") + raise HTTPException(status_code=401, detail="Invalid one time token.") + + if validity != settings.api_root + once_token.validity: + logger.warning( + f"One time token validity `{settings.api_root+once_token.validity}` and path `{validity}` do not match" + ) + raise HTTPException(status_code=401, detail="Invalid one time token.") + + login = ( + db.session.query(models.Person.login) + .filter(models.Person.personId == once_token.personId) + .first() + ) + person = get_current_person(login[0]) + + db.session.delete(once_token) + db.session.commit() + + return { + "login": person.login, + "personId": person.personId, + "permissions": person._metadata["permissions"], + } + + +def expire_onetime_tokens(expiry: int = 10) -> None: + """Expire one time tokens + + Delete all tokens generated more than 10 seconds ago that are unused + + Kwargs: + expiry (int): Seconds tokens are valid for + """ + if not isinstance(expiry, int): + raise RuntimeError(f"Expiry {expiry} is a none integer value") + + with get_session() as session: + session.query(models.SWOnceToken).filter( + models.SWOnceToken.recordTimeStamp + < text(f"NOW() - INTERVAL {expiry} SECOND") + ).delete(synchronize_session="fetch") + + +async def expire_ontime_tokens_periodically(interval: int = 5) -> None: + """Periodically remove onetime tokens that have expired + + Mostly stolen from https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/tasks.py + """ + + async def loop(): + while True: + try: + logger.debug("Expiring onetime tokens") + await run_in_threadpool(expire_onetime_tokens) + except Exception: + logger.exception("Could not expire onetime tokens") + + await asyncio.sleep(interval) + + asyncio.ensure_future(loop()) diff --git a/pyispyb/app/main.py b/pyispyb/app/main.py index 2d40066f..c82dc4aa 100644 --- a/pyispyb/app/main.py +++ b/pyispyb/app/main.py @@ -1,19 +1,21 @@ -from typing import Any import logging from logging.config import dictConfig +from typing import Any + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi -from pyispyb.app.extensions.database.utils import enable_debug_logging -from pyispyb.app.extensions.database.middleware import get_session -from pyispyb.app.extensions.options.base import setup_options -from pyispyb.app.globals import GlobalsMiddleware +from ..app.extensions.auth.onetime import expire_ontime_tokens_periodically +from ..app.extensions.database.utils import enable_debug_logging +from ..app.extensions.database.middleware import get_session +from ..app.extensions.options.base import setup_options +from ..app.globals import GlobalsMiddleware from ..config import settings, LogConfig -from pyispyb.app import routes as base_routes -from pyispyb.core import routes as core_routes -from pyispyb.app.extensions.auth import auth_provider +from ..app import routes as base_routes +from ..core import routes as core_routes +from ..app.extensions.auth import auth_provider dictConfig(LogConfig().dict()) logger = logging.getLogger("ispyb") @@ -32,6 +34,11 @@ async def get_session_as_middleware(request, call_next): setup_options(app) +@app.on_event("startup") +async def expire_onetime_tokens() -> None: + await expire_ontime_tokens_periodically() + + def enable_cors() -> None: app.add_middleware( CORSMiddleware, diff --git a/pyispyb/core/routes/user.py b/pyispyb/core/routes/user.py index 7db9d07b..c4d1e01d 100644 --- a/pyispyb/core/routes/user.py +++ b/pyispyb/core/routes/user.py @@ -1,9 +1,9 @@ from typing import Optional -from pydantic import BaseModel - -from pyispyb.app.extensions.options.schema import BeamLineGroup +from pydantic import BaseModel, Field from ...app.extensions.database.definitions import get_current_person, get_options +from ...app.extensions.auth.onetime import generate_onetime_token +from ...app.extensions.options.schema import BeamLineGroup from ...app.base import AuthenticatedAPIRouter from ...app.globals import g @@ -46,3 +46,18 @@ def current_user() -> CurrentUser: "beamLineGroups": groups, "beamLines": list(set(beamLines)), } + + +class OneTimeToken(BaseModel): + validity: str = Field(description="The url to sign") + token: Optional[str] + + +@router.post( + "/sign", + response_model=OneTimeToken, +) +def sign_url(token_request: OneTimeToken) -> OneTimeToken: + """Sign a url with a one time token""" + token = generate_onetime_token(token_request.validity, g.personId) + return OneTimeToken(token=token, validity=token_request.validity) diff --git a/tests/core/api/test_authentication.py b/tests/core/api/test_authentication.py index 0af55a1c..137e7bba 100644 --- a/tests/core/api/test_authentication.py +++ b/tests/core/api/test_authentication.py @@ -30,8 +30,37 @@ def test_token_expired(client: TestClient, short_session: float): assert "expired" in res2.json()["detail"].lower() +def test_no_token(client: TestClient): + res = client.get(f"{settings.api_root}/events") + assert res.status_code == 401 + assert "no token" in res.json()["detail"].lower() + + def test_token_invalid(client: TestClient): headers = {"Authorization": "Bearer asda.asda.asda"} res = client.get(f"{settings.api_root}/events", headers=headers) assert res.status_code == 401 assert "invalid" in res.json()["detail"].lower() + + +def test_onetime_invalid(client: TestClient): + res = client.get(f"{settings.api_root}/events?onetime=one") + assert res.status_code == 401 + assert "invalid" in res.json()["detail"].lower() + + +def test_onetime(client: TestClient): + res = client.post( + f"{settings.api_root}/auth/login", + json={"login": "abcd", "password": "abcd", "plugin": "dummy"}, + ) + assert res.status_code == 201 + + headers = {"Authorization": f"Bearer {res.json()['token']}"} + res2 = client.post( + f"{settings.api_root}/user/sign", headers=headers, json={"validity": "/events"} + ) + assert res2.status_code == 200 + + res = client.get(f"{settings.api_root}/events?onetime={res2.json()['token']}") + assert res.status_code == 200