118 lines
2.6 KiB
Python
118 lines
2.6 KiB
Python
"""
|
|
ⒸAngelaMos | 2025
|
|
RefreshToken.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING
|
|
from uuid import UUID
|
|
|
|
import uuid6
|
|
from sqlalchemy import (
|
|
DateTime,
|
|
ForeignKey,
|
|
String,
|
|
)
|
|
from sqlalchemy.orm import (
|
|
Mapped,
|
|
mapped_column,
|
|
relationship,
|
|
)
|
|
|
|
from config import (
|
|
DEVICE_ID_MAX_LENGTH,
|
|
DEVICE_NAME_MAX_LENGTH,
|
|
IP_ADDRESS_MAX_LENGTH,
|
|
TOKEN_HASH_LENGTH,
|
|
)
|
|
from core.Base import (
|
|
Base,
|
|
TimestampMixin,
|
|
UUIDMixin,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from user.User import User
|
|
|
|
|
|
class RefreshToken(Base, UUIDMixin, TimestampMixin):
|
|
"""
|
|
Refresh token for JWT authentication
|
|
|
|
Tokens are stored as SHA 256 hashes, never raw
|
|
Family ID enables detection of token reuse attacks
|
|
"""
|
|
__tablename__ = "refresh_tokens"
|
|
|
|
token_hash: Mapped[str] = mapped_column(
|
|
String(TOKEN_HASH_LENGTH),
|
|
unique = True,
|
|
index = True,
|
|
)
|
|
|
|
user_id: Mapped[UUID] = mapped_column(
|
|
ForeignKey("users.id",
|
|
ondelete = "CASCADE"),
|
|
index = True,
|
|
)
|
|
|
|
family_id: Mapped[UUID] = mapped_column(
|
|
default = uuid6.uuid7,
|
|
index = True,
|
|
)
|
|
|
|
device_id: Mapped[str | None] = mapped_column(
|
|
String(DEVICE_ID_MAX_LENGTH),
|
|
default = None,
|
|
)
|
|
device_name: Mapped[str | None] = mapped_column(
|
|
String(DEVICE_NAME_MAX_LENGTH),
|
|
default = None,
|
|
)
|
|
ip_address: Mapped[str | None] = mapped_column(
|
|
String(IP_ADDRESS_MAX_LENGTH),
|
|
default = None,
|
|
)
|
|
|
|
expires_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone = True),
|
|
index = True,
|
|
)
|
|
|
|
is_revoked: Mapped[bool] = mapped_column(default = False)
|
|
revoked_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone = True),
|
|
default = None,
|
|
)
|
|
|
|
user: Mapped[User] = relationship(back_populates = "refresh_tokens")
|
|
|
|
def revoke(self) -> None:
|
|
"""
|
|
Revoke this token
|
|
"""
|
|
self.is_revoked = True
|
|
self.revoked_at = datetime.now(UTC)
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
"""
|
|
Check if token has expired
|
|
|
|
Handles both timezone aware and naive datetimes for SQLite compatibility
|
|
"""
|
|
now = datetime.now(UTC)
|
|
expires = self.expires_at
|
|
if expires.tzinfo is None:
|
|
expires = expires.replace(tzinfo = UTC)
|
|
return now > expires
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
"""
|
|
Check if token is usable
|
|
"""
|
|
return not self.is_revoked and not self.is_expired
|