import datetime as dt
import secrets
from typing import TYPE_CHECKING, Final
from django.conf import settings
from django.utils.crypto import salted_hmac
from typing_extensions import Sentinel
from dmr.types import EMPTY
if TYPE_CHECKING:
from django.contrib.auth.base_user import AbstractBaseUser
from dmr.security.token.models import Token
_RAW_TOKEN_SIZE: Final = 32
# TODO(blocking): all these functions break abstraction on the auth class.
# they work with the specific `Token` model, not `Auth.token_model()` type.
# We need to refactor this before shipping a new release.
def token_hash(raw_token: str) -> str:
"""Return HMAC-SHA256 hex digest of raw_token."""
return salted_hmac(
'dmr.security.token',
raw_token,
secret=settings.SECRET_KEY,
algorithm='sha256',
).hexdigest()
[docs]
def token_create(
*,
user: 'AbstractBaseUser',
name: str,
expires_at: dt.datetime | Sentinel | None = EMPTY,
) -> 'tuple[Token, str]':
"""Create a new token, returning ``(Token instance, raw token string)``."""
from dmr.security.token.models import Token # noqa: PLC0415
from dmr.settings import Settings, resolve_setting # noqa: PLC0415
resolved_expires_at: dt.datetime | None
if expires_at is EMPTY:
default_expiry: dt.timedelta | None = resolve_setting(
Settings.auth_token_default_expiry,
)
if default_expiry is None:
resolved_expires_at = None
else:
resolved_expires_at = dt.datetime.now(dt.UTC) + default_expiry
else:
resolved_expires_at = expires_at # type: ignore[assignment]
raw_token = secrets.token_urlsafe(_RAW_TOKEN_SIZE)
token = Token.objects.create(
user=user,
name=name,
token_hash=token_hash(raw_token),
expires_at=resolved_expires_at,
)
return token, raw_token
[docs]
async def token_acreate(
*,
user: 'AbstractBaseUser',
name: str,
expires_at: dt.datetime | Sentinel | None = EMPTY,
) -> 'tuple[Token, str]':
"""Async version of :func:`token_create`."""
from dmr.security.token.models import Token # noqa: PLC0415
from dmr.settings import Settings, resolve_setting # noqa: PLC0415
resolved_expires_at: dt.datetime | None
if expires_at is EMPTY:
default_expiry: dt.timedelta | None = resolve_setting(
Settings.auth_token_default_expiry,
)
if default_expiry is None:
resolved_expires_at = None
else:
resolved_expires_at = dt.datetime.now(dt.UTC) + default_expiry
else:
resolved_expires_at = expires_at # type: ignore[assignment]
raw_token = secrets.token_urlsafe(_RAW_TOKEN_SIZE)
token = await Token.objects.acreate(
user=user,
name=name,
token_hash=token_hash(raw_token),
expires_at=resolved_expires_at,
)
return token, raw_token
def token_is_expired(token: 'Token') -> bool:
"""Return True if the token has passed its expiry date."""
if token.expires_at is None:
return False
return dt.datetime.now(dt.UTC) >= token.expires_at
def token_is_active(token: 'Token') -> bool:
"""Return True if the token is neither expired nor revoked."""
return not token_is_expired(token) and token.revoked_at is None
[docs]
def token_revoke(
token: 'Token',
*,
at: dt.datetime | None = None,
) -> 'Token':
"""Mark this token as revoked."""
token.revoked_at = at or dt.datetime.now(dt.UTC)
token.save(update_fields=['revoked_at', 'updated_at'])
return token
[docs]
async def token_arevoke(
token: 'Token',
*,
at: dt.datetime | None = None,
) -> 'Token':
"""Async version of :func:`token_revoke`."""
token.revoked_at = at or dt.datetime.now(dt.UTC)
await token.asave(update_fields=['revoked_at', 'updated_at'])
return token