Skip to content
Snippets Groups Projects
Verified Commit 5775a6e6 authored by Jakob Moser's avatar Jakob Moser
Browse files

Add Key type

parent 99557b65
No related branches found
No related tags found
1 merge request!5Add basic User auth endpoints (non functional)
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,
}
alembic~=1.12.1
argon2-cffi~=23.1.0
Flask-SQLAlchemy~=3.1.1
Flask~=3.0.0
SQLAlchemy~=2.0.23
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment