from __future__ import annotations
from datetime import datetime, timedelta
from uuid import uuid4, UUID
from onegov.core.orm import Base
from onegov.core.orm.mixins import ContentMixin, TimestampMixin
from sqlalchemy import Index
from sqlalchemy.ext.hybrid import hybrid_method
from sqlalchemy.orm import mapped_column, Mapped
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.sql import ColumnElement
[docs]
DEFAULT_EXPIRES_AFTER = timedelta(hours=1)
[docs]
class TAN(Base, TimestampMixin, ContentMixin):
"""
A single use TAN for temporarily elevating access or to serve
as a second authentication factor through e.g. a mobile phone
number.
"""
[docs]
__table_args__ = (
# TimestampMixin by default does not generate an index for
# the created column, so we do it here instead
Index('ix_tans_created', 'created'),
)
[docs]
id: Mapped[UUID] = mapped_column(
primary_key=True,
default=uuid4
)
[docs]
hashed_tan: Mapped[str] = mapped_column(index=True)
[docs]
scope: Mapped[str] = mapped_column(index=True)
[docs]
expired: Mapped[datetime | None] = mapped_column(index=True)
@hybrid_method
[docs]
def is_active(
self,
expires_after: timedelta | None = DEFAULT_EXPIRES_AFTER
) -> bool:
now = self.timestamp()
if self.expired and self.expired <= now:
return False
if expires_after is not None:
if now >= (self.created + expires_after):
return False
return True
@is_active.expression # type:ignore[no-redef]
def is_active(
cls,
expires_after: timedelta | None = DEFAULT_EXPIRES_AFTER
) -> ColumnElement[bool]:
now = cls.timestamp()
expr = (cls.expired > now) | cls.expired.is_(None)
if expires_after is not None:
expr &= cls.created >= (now - expires_after)
return expr
[docs]
def expire(self) -> None:
if self.expired:
raise ValueError('TAN already expired')
self.expired = self.timestamp()