Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add onetime tokens #214

Merged
merged 7 commits into from
Nov 29, 2022
Merged
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
11 changes: 11 additions & 0 deletions docs/developers.md
Original file line number Diff line number Diff line change
@@ -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}
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 13 additions & 2 deletions pyispyb/app/extensions/auth/bearer.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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(
Expand All @@ -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.")

Expand Down
135 changes: 135 additions & 0 deletions pyispyb/app/extensions/auth/onetime.py
Original file line number Diff line number Diff line change
@@ -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())
23 changes: 15 additions & 8 deletions pyispyb/app/main.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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,
Expand Down
21 changes: 18 additions & 3 deletions pyispyb/core/routes/user.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
29 changes: 29 additions & 0 deletions tests/core/api/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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