Verified Commit 1a2a6f26 authored by Jakob Moser's avatar Jakob Moser
Browse files

Add authentication endpoints

parent 5ee23d8e
Loading
Loading
Loading
Loading
+1 −0
Changes for portal/api/__init__.py: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
from .blueprint import bp
from .auth import *
from .rest import *
from .version import *

portal/api/auth.py

0 → 100644
+83 −0
Changes for portal/api/auth.py: 83 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Login code adapted from the flaskr tutorial (see LICENSE-3RD-PARTY.md for details)
# See https://flask.palletsprojects.com/en/2.0.x/tutorial/views/
import functools

from flask import request, g

from portal import db

from .blueprint import bp
from ..model import User, Key
from ..problem_details import not_found, unauthorized


@bp.before_app_request
def set_user_if_valid():
    if not request.authorization or request.authorization.type != "basic":
        g.user = None
        return

    g.user = User.authenticate(
        request.authorization.username, request.authorization.password
    )


@bp.before_app_request
def set_key_if_valid():
    if not request.authorization or request.authorization.type != "bearer":
        g.key = None
        return

    g.key = Key.get_by_secret_unless_expired(request.authorization.token)


def require_valid(credential_type):
    """
    Requires that valid credentials are provided before allowing access to a route.
    If no valid credentials are provided, abort and send a 401.

    This decorator can be used in two ways:
    - `@require_valid(User)` to require a valid username and password
    - `@require_valid(Key)` to require a valid key
    """

    def decorator(route):
        @functools.wraps(route)
        def protected_route(**kwargs):
            # Determine which credentials to use
            credentials = {User: g.user, Key: g.key}.get(credential_type)

            if credentials is not None:
                # If g.user or g.key is set, we can trust they are valid.
                return route(**kwargs)
            else:
                return unauthorized()

        return protected_route

    return decorator


@bp.post("/keys")
@require_valid(User)
def post_key():
    key, secret = Key.generate(g.user)
    return key.to_dict() | {"secret": secret}, 201


@bp.delete("/keys/<string:uuid>")
@require_valid(Key)
def delete_key(uuid: str):
    key_to_delete = Key.get_only(uuid)

    if not key_to_delete:
        return not_found()

    if key_to_delete.user_uuid != g.key.user_uuid:
        # If the key exists, but is from a different user, we pretend it isn't there
        return not_found()

    db.session.delete(key_to_delete)
    db.session.commit()

    return "", 204
+7 −2
Changes for portal/model/User.py: 7 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -9,8 +9,13 @@ from portal.model.Base import Base
class User(Base):
    name: Mapped[str] = mapped_column(unique=True, index=True)

    def __init__(self, name: str) -> None:
        self.name = name
    @classmethod
    def authenticate(cls, name: str, password: str) -> Optional[Self]:
        """Try to authenticate a session with the given username and password.

        :return: A "User" instance, if successful. None, otherwise.
        """
        return None  # TODO

    @classmethod
    def get_by_name(cls, name: str) -> Optional[Self]:
+2 −0
Changes for portal/model/__init__.py: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
from .Base import Base
from .FlaskConfigEntry import FlaskConfigEntry
from .InvalidUuidError import InvalidUuidError
from .Key import Key
from .Retrievable import Retrievable
from .User import User
from .Uuid import Uuid
+38 −0
Changes for portal/problem_details.py: 38 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""
Provide RFC 7807-compliant problem details responses for the API.
"""

from typing import Optional


def unauthorized():
    """
    Return a 401 message and status code
    """
    return _problem_response(title="Unauthorized", status=401)


def not_found():
    """
    Return a 404 message and status code
    """
    return _problem_response(title="Not Found", status=404)


def _problem_response(
    title: str,
    status: int,
    type_uri: Optional[str] = None,
    extensions: Optional[dict] = None,
) -> tuple[dict, int, dict]:
    type_dict = {"type": type_uri} if type_uri is not None else {}
    title_dict = {"title": title}

    # Combine the type_dict, title_dict and extensions (if provided) into one response dictionary
    response_dict = type_dict | title_dict | (extensions or {})

    return (
        response_dict,
        status,
        {"Content-Type": "application/problem+json"},
    )
+1 −1

File changed.

Contains only whitespace changes.