Verified Commit 5775a6e6 authored by Jakob Moser's avatar Jakob Moser
Browse files

Add Key type

parent 99557b65
Loading
Loading
Loading
Loading

portal/model/Key.py

0 → 100644
+140 −0
Changes for portal/model/Key.py: 140 added lines, 0 removed lines.
Original line number Diff line number Diff line
import secrets

from typing import Dict, Optional, Self
from datetime import datetime, timedelta, timezone
from uuid import UUID

import argon2

from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column

from portal import db
from portal.model.Base import Base
from portal.model.User import User
from portal.model.UtcDateTime import UtcDateTime
from portal.model.Uuid import Uuid

KEY_DURATION = timedelta(years=10)

# Type alias (a secret is nothing but a string, but we want to make type hints a bit clearer
Secret = str


def _hash_secret(secret: Secret) -> str:
    """
    Hash a key's secret and return it.

    This uses Argon2id with mostly default parameters, except it always uses the same salt (!).
    This is usually a security risk, however, I'd argue it is not in this particular case:

    Salting ensures that, globally, no two password hashes are ever the same, even if the password is.
    This is ensured by choosing a unique, random salt per password. Without salting, a cracker could preemptively
    calculate hashes of commonly chosen passwords (a list of such pre-calculated hashes is called a rainbow table).
    In the event of a database breach, the cracker could just compare the hashes in the database with the rainbow table
    and crack all passwords at once.

    However, the secret of a key is not chosen by the user, but instead long and randomly generated. This means that
    no cracker will ever include just the right secret in their rainbow tables, which means in the event of a database
    breach, each secret hash would still have to be cracked individually (making hashing generated secrets without
    salting comparably - if not identically - secure as hashing user-chosen passwords with salting).
    """

    return str(
        argon2.low_level.hash_secret(
            secret=bytes(secret, "utf-8"),
            salt=b"always-the-same-salt",
            time_cost=argon2.DEFAULT_TIME_COST,
            memory_cost=argon2.DEFAULT_MEMORY_COST,
            parallelism=argon2.DEFAULT_PARALLELISM,
            hash_len=argon2.DEFAULT_HASH_LENGTH,
            type=argon2.low_level.Type.ID,
        ),
        "utf-8",
    )


class Key(Base):
    secret_hash: Mapped[str] = mapped_column(String(256), unique=True, index=True)
    created_at: Mapped[UtcDateTime]
    expires_at: Mapped[UtcDateTime]
    last_used_at: Mapped[Optional[UtcDateTime]]
    user_uuid: Mapped[Uuid] = mapped_column(ForeignKey("user.uuid"))

    @classmethod
    def delete_expired(cls) -> list[UUID]:
        """Delete all keys that are expired.

        :return: A list of the uuids of the (now deleted) keys.
        """
        now = datetime.now(timezone.utc)

        # First find the uuids of the expired keys, then delete them.
        # Of course, we could also delete the keys directly, but we'd like to keep
        # the uuids of the deleted keys for logging purposes, so we do it in this
        # two-step process.
        expired_key_uuids = [
            key.uuid
            for key in db.session.execute(
                db.select(cls).where(now >= cls.expires_at)
            ).scalars()
        ]
        db.session.execute(db.delete(cls).where(cls.uuid.in_(expired_key_uuids)))
        db.session.commit()

        return expired_key_uuids

    @classmethod
    def get_by_secret_unless_expired(cls, secret: str) -> Optional[Self]:
        """Return the key with the given secret, it if exists and is not expired."""
        provided_secret_hash = _hash_secret(secret)
        key = db.session.execute(
            db.select(cls).where(cls.secret_hash == provided_secret_hash)
        ).scalar_one_or_none()

        if not key:
            return None
        if key.expired:
            return None

        key.last_used_at = datetime.now(timezone.utc)
        db.session.commit()  # write change to db

        return key

    @classmethod
    def generate(cls, user: User) -> tuple[Self, Secret]:
        """
        Generate a new key for the given user, return it and the plain text secret.
        The latter one should be returned to the user and can then be discarded,
        we only need to store the hash.
        """
        now = datetime.now(timezone.utc)
        secret = "portal." + secrets.token_urlsafe(32)
        key = cls(
            secret_hash=_hash_secret(secret),
            created_at=now,
            expires_at=now + KEY_DURATION,
            user_uuid=user.uuid,
        )

        db.session.add(key)
        db.session.commit()

        return (
            key,
            secret,
        )

    @property
    def expired(self) -> bool:
        """Return if this key is expired"""
        return datetime.now(timezone.utc) >= self.expires_at

    def to_dict(self) -> Dict:
        return {
            "uuid": self.uuid,
            "createdAt": self.created_at.isoformat(),
            "expiresAt": self.expires_at.isoformat(),
            "userUuid": self.user_uuid,
        }
+1 −0
Changes for requirements.txt: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
alembic~=1.12.1
argon2-cffi~=23.1.0
Flask-SQLAlchemy~=3.1.1
Flask~=3.0.0
SQLAlchemy~=2.0.23