Source code for qcportal.auth.models

import re
from datetime import datetime
from enum import Enum

from pydantic import AwareDatetime, BaseModel, Field, field_validator, ConfigDict

from ..common_types import Max128Str
from ..exceptions import InvalidPasswordError, InvalidUsernameError, InvalidGroupnameError

# Every API token begins with this fixed prefix. It makes a token recognizable to secret scanners
# and lets the server dispatch on the shape of an Authorization header before attempting to parse
# it as a JWT. Shared here so the client and server agree on it.
API_TOKEN_PREFIX = "qcf_"

# Longest name accepted for an API token
MAX_API_TOKEN_NAME_LENGTH = 128

# Longest string we will treat as a possible API token. A token is a fixed, known size; anything
# much larger is malformed, and (on the server) hashing an unbounded header on every request would
# be a cheap amplification vector.
MAX_API_TOKEN_LENGTH = 128

# The random part of a token is secrets.token_urlsafe output
_API_TOKEN_BODY_RE = re.compile(r"^[A-Za-z0-9_-]+$")


[docs] class APITokenScopeEnum(str, Enum): """ Named scopes for API tokens A token's scope is what it is allowed to do, relative to its owner's role - the effective permission can only ever be a restriction of the role, never an extension. A scope is stored and transmitted as a plain string, validated by validate_api_token_scope(); this enum lists the named values that validator accepts. Currently there is exactly one, ``unlimited``, meaning the token carries the owner's full role. Future scopes may be parameterized (for example, limiting writes to particular projects) and so will be handled by the validator's grammar rather than listed here. The "everything" scope is always this explicit named value - a null or empty scope must never be interpreted as unlimited access. """ unlimited = "unlimited"
[docs] def validate_api_token_scope(scope: str | APITokenScopeEnum) -> str: """ Validates an API token scope, returning its canonical string form This is the single place scope values are validated, shared by the client models and the server. Today the grammar is trivial - the only valid scope is "unlimited" - and future parameterized scopes extend the grammar here, without changing any field or column type. Raises ValueError for a scope this version does not recognize. """ if isinstance(scope, APITokenScopeEnum): return scope.value if isinstance(scope, str): try: return APITokenScopeEnum(scope).value except ValueError: pass raise ValueError(f"Unknown API token scope: '{scope}'")
[docs] def looks_like_api_token(raw: str) -> bool: """ Returns whether a string is shaped like an API token This is a cheap syntactic check (prefix, length, character set), shared by the client (to fail fast before a network round trip) and the server (to reject obviously-bad input before hashing). It says nothing about whether the token actually exists or is valid. """ if not isinstance(raw, str) or not raw.startswith(API_TOKEN_PREFIX): return False if len(raw) > MAX_API_TOKEN_LENGTH: return False body = raw[len(API_TOKEN_PREFIX) :] return bool(body) and bool(_API_TOKEN_BODY_RE.match(body))
[docs] class AuthTypeEnum(str, Enum): password = "password"
# Minimum length (in characters) required of a newly-set password MIN_PASSWORD_LENGTH = 12 # bcrypt only considers the first 72 bytes of a password, and bcrypt >= 5.0 raises # an error rather than silently truncating. So new passwords are capped here. MAX_PASSWORD_BYTES = 72
[docs] def is_valid_password(password: str) -> None: """ Checks that a password is acceptable as a *new* password This is the password policy applied when a password is being set (adding a user or changing a password). It is deliberately *not* applied when verifying a password at login time -- see the verification-time checks in the user socket -- since tightening this policy would otherwise lock out existing users with older, weaker passwords. Raises an InvalidPasswordError if the password is not acceptable. """ if not isinstance(password, str): raise InvalidPasswordError("Password must be a string") # Null character not allowed if "\x00" in password: raise InvalidPasswordError("Password contains a NUL character") if len(password) == 0: raise InvalidPasswordError("Password is empty") # Password should be somewhat long if len(password) < MIN_PASSWORD_LENGTH: raise InvalidPasswordError(f"Password must contain at least {MIN_PASSWORD_LENGTH} characters") # ... but not longer than what bcrypt is able to handle if len(password.encode("UTF-8")) > MAX_PASSWORD_BYTES: raise InvalidPasswordError( f"Password must be at most {MAX_PASSWORD_BYTES} bytes when encoded as UTF-8 " "(note that non-ASCII characters take up more than one byte)" )
# Longest permitted username. Usernames are echoed into logs, error messages and JWT claims, and # are accepted from unauthenticated callers at login, so an unbounded one is a way to make the # server do unbounded work. This is generous compared to any real username MAX_USERNAME_LENGTH = 64
[docs] def is_valid_username(username: str) -> None: if not isinstance(username, str): raise InvalidUsernameError("Username must be a string") if len(username) == 0: raise InvalidUsernameError("Username is empty") # Deliberately does not echo the username back - it is attacker-controlled at login if len(username) > MAX_USERNAME_LENGTH: raise InvalidUsernameError(f"Username must be at most {MAX_USERNAME_LENGTH} characters") # Null character not allowed if "\x00" in username: raise InvalidUsernameError("Username contains a NUL character") # Spaces are not allowed if " " in username: raise InvalidUsernameError("Username contains spaces") # Username cannot be all numbers if username.isdecimal(): raise InvalidUsernameError("Username cannot be all numbers")
[docs] def is_valid_groupname(groupname: str) -> None: if len(groupname) == 0: raise InvalidGroupnameError("Groupname is empty") # Null character not allowed if "\x00" in groupname: raise InvalidGroupnameError("Groupname contains a NUL character") # Spaces are not allowed if " " in groupname: raise InvalidGroupnameError("Groupname contains spaces") # Groupname cannot be all numbers if groupname.isdecimal(): raise InvalidGroupnameError("Groupname cannot be all numbers")
[docs] class GroupInfo(BaseModel): """ Information about a group """ id: int | None = None """ID of the group""" groupname: str """The name of the group""" description: str = "" """Text description of the group""" model_config = ConfigDict(extra="forbid", use_attribute_docstrings=True) @field_validator("groupname", mode="before") @classmethod def _valid_groupname(cls, v): """Makes sure the groupname is a valid string""" try: is_valid_groupname(v) return v except Exception as e: raise ValueError(str(e))
[docs] class UserInfo(BaseModel): """ Information about a user """ # id may be None when used for initial creation id: int | None = Field(None, frozen=True) """The id of the user""" auth_type: AuthTypeEnum = Field(AuthTypeEnum.password, frozen=True) """Type of authentication the user uses""" username: str = Field(..., frozen=True) """The username of this user""" role: str """The role this user belongs to""" groups: list[str] = [] """Groups this user belongs to""" enabled: bool """Whether this user is enabled or not""" fullname: Max128Str = "" """The full name or description of the user""" organization: Max128Str = "" """The organization the user belongs to""" email: Max128Str = "" """The email address for the user""" model_config = ConfigDict(extra="forbid", use_attribute_docstrings=True) @field_validator("username", mode="before") @classmethod def _valid_username(cls, v): """Makes sure the username is a valid string""" try: is_valid_username(v) return v except Exception as e: raise ValueError(str(e)) @field_validator("groups", mode="after") @classmethod def _valid_groupnames(cls, v): """Makes sure the groupnames are valid strings""" try: for x in v: is_valid_groupname(x) return v except Exception as e: raise ValueError(str(e))
[docs] class APIToken(BaseModel): """ Metadata about a long-lived API token This never contains the token itself. The plaintext token is shown exactly once, when the token is created (see NewAPIToken); afterwards only this metadata is available. """ id: int """The id of the token (used to revoke it)""" user_id: int """The id of the user the token authenticates as""" token_prefix: str """The first few characters of the token, for identifying it in a listing""" name: str """The name given to the token when it was created (unique among the user's tokens)""" scope: str = APITokenScopeEnum.unlimited.value """What the token is allowed to do (see APITokenScopeEnum). Deliberately a plain string here, like UserInfo.role, so an older client can still parse listings from a newer server that has scopes this client does not know about""" created_at: datetime """When the token was created""" expires_at: datetime | None = None """When the token expires, or null if it never expires""" last_used_at: datetime | None = None """Approximate time the token was last presented on a request, or null if never used""" model_config = ConfigDict(extra="forbid", use_attribute_docstrings=True)
[docs] class NewAPIToken(BaseModel): """ A newly-created API token, including the plaintext token The plaintext token is only available here, in the response to creating the token. It is not stored and cannot be retrieved later. """ token: str """The plaintext token. Paste this into a client's Authorization header. Store it securely - it cannot be retrieved again""" info: APIToken """Metadata about the token""" model_config = ConfigDict(extra="forbid", use_attribute_docstrings=True)
[docs] class APITokenCreateBody(BaseModel): """ Options for creating a new API token """ name: str = Field(..., min_length=1, max_length=MAX_API_TOKEN_NAME_LENGTH) """A name to identify the token. Must be unique among the user's tokens.""" scope: str = APITokenScopeEnum.unlimited.value """What the token should be allowed to do. Currently only "unlimited" (the owner's full role) exists. A plain (but validated) string, so the public schema never changes when new scopes are added; a scope this server does not recognize is rejected""" expires_at: AwareDatetime | None = None """When the token should expire. Must be timezone-aware. Null requests a non-expiring token, subject to the server's api_token_default_lifetime and api_token_max_lifetime policy""" model_config = ConfigDict(extra="forbid", use_attribute_docstrings=True) @field_validator("scope", mode="before") @classmethod def _valid_scope(cls, v): """Makes sure the scope is one this version understands""" try: return validate_api_token_scope(v) except Exception as e: raise ValueError(str(e))
[docs] class APITokenModifyBody(BaseModel): """ Changes to an existing API token Only the name may be changed. Everything else about a token (its secret, owner, scope, and expiration) is fixed at creation; to change those, create a new token and delete the old one. """ name: str = Field(..., min_length=1, max_length=MAX_API_TOKEN_NAME_LENGTH) """The new name for the token. Must be unique among the user's tokens.""" model_config = ConfigDict(extra="forbid", use_attribute_docstrings=True)