Login tokens¶
commons.users.login_tokens
¶
Passwordless login with a link or code sent to a registered user.
Overview¶
- LoginTokenService: The flow.
request_login(email, ...)creates a request with a random link token and a numeric code and hands both to a sender;redeem_link(token)/redeem_code(request_id, code)return the user to log in. Single use, short-lived, rate limited. - LoginTokenStore: Where requests are kept (hashes only, never the
secrets).
InMemoryLoginTokenStorefor a single process,SQLLoginTokenStorefor several processes or workers. - SendLoginTokenPort: How link and code reach the user. The default
LoggingLoginTokenSenderwrites them to the log (development only);SMTPLoginTokenSendersends them by email via the SMTP server ofapp.smtp(see :mod:commons.users.login_tokens.smtp). - LoginTokenSettings: Validity, code length and the rate limit, read from
app.login_tokens.*.
The module is synchronous like :mod:commons.users; async apps call it via
asyncio.to_thread. Users are looked up with
UsersRepository.find_user_by_email.
Example::
from commons.users.login_tokens import InMemoryLoginTokenStore, LoginTokenService
service = LoginTokenService(users, InMemoryLoginTokenStore(), secret_key=app_secret)
request = service.request_login(
"jane@example.com",
link_for=lambda token: f"https://app.example.com/login/token?t={token}",
)
# Show the same message whatever request.status is.
result = service.redeem_code(request.request_id, "123456")
if result.ok:
... # log result.user in
Modules:
| Name | Description |
|---|---|
memory |
In-memory storage of login requests, for a single process. |
model |
Data types of the login-token flow. |
sender |
Delivery of login links and codes to the user. |
service |
The login-token flow: request a link and code, redeem one of them. |
smtp |
Sending login links and codes by email via SMTP. |
sql |
SQL storage of login requests, shared by several processes. |
store |
Abstract storage of login requests. |
Classes:
| Name | Description |
|---|---|
InMemoryLoginTokenStore |
Keeps login requests in a dict, guarded by a lock. |
LoginRequest |
Result of |
LoginTokenRequest |
One login request of a registered user, as kept by a |
RedeemResult |
Result of |
RedeemStatus |
Outcome of redeeming a link or a code. |
RequestStatus |
Outcome of |
LoggingLoginTokenSender |
Writes login links and codes to the log instead of sending them. |
LoginTokenMessage |
What to deliver to the user. |
SendLoginTokenPort |
Delivers a login link and code to a user. |
LoginTokenService |
Lets registered users log in with a link or code sent to their email |
LoginTokenSettings |
Settings of the login-token flow. |
LoginTokenMail |
The login mail to send. |
SMTPLoginTokenSender |
Sends login links and codes by email via SMTP. |
SQLLoginTokenStore |
Keeps login requests in the |
LoginTokenStore |
Abstract base class for storing login requests. |
Attributes:
| Name | Type | Description |
|---|---|---|
ComposeLoginTokenMail |
Builds the login mail for a message, e.g. in the user's language. |
ComposeLoginTokenMail
module-attribute
¶
ComposeLoginTokenMail = Callable[
[LoginTokenMessage], LoginTokenMail
]
Builds the login mail for a message, e.g. in the user's language.
__all__
module-attribute
¶
__all__ = [
"ComposeLoginTokenMail",
"InMemoryLoginTokenStore",
"LoggingLoginTokenSender",
"LoginRequest",
"LoginTokenMail",
"LoginTokenMessage",
"LoginTokenRequest",
"LoginTokenService",
"LoginTokenSettings",
"LoginTokenStore",
"RedeemResult",
"RedeemStatus",
"RequestStatus",
"SMTPLoginTokenSender",
"SQLLoginTokenStore",
"SendLoginTokenPort",
]
InMemoryLoginTokenStore
¶
Bases: LoginTokenStore
Keeps login requests in a dict, guarded by a lock.
Suited for development, tests and apps running a single process. With
several processes or workers, each would have its own requests (a link
sent by one worker wouldn't work on another) and its own rate limits;
use SQLLoginTokenStore there.
Methods:
| Name | Description |
|---|---|
add |
|
get |
|
find_by_link_hash |
|
consume |
|
record_failed_attempt |
|
count_requests |
|
delete_expired |
|
Source code in src/commons/users/login_tokens/memory.py
add
¶
add(request: LoginTokenRequest) -> None
get
¶
get(request_id: str) -> LoginTokenRequest | None
find_by_link_hash
¶
find_by_link_hash(
link_hash: str,
) -> LoginTokenRequest | None
consume
¶
Source code in src/commons/users/login_tokens/memory.py
record_failed_attempt
¶
count_requests
¶
Source code in src/commons/users/login_tokens/memory.py
delete_expired
¶
Source code in src/commons/users/login_tokens/memory.py
LoginRequest
dataclass
¶
LoginRequest(
request_id: str,
status: RequestStatus,
expires_at: datetime,
)
Result of LoginTokenService.request_login.
Attributes:
| Name | Type | Description |
|---|---|---|
request_id |
str
|
Identifier to redeem the code with (keep it, e.g. in the session). Present for every status, so callers can treat all outcomes alike; for an unknown address or a rate-limited request it's random and stored nowhere, so a code for it fails. |
status |
RequestStatus
|
What happened; don't show it to the user (see |
expires_at |
datetime
|
When the link and the code expire. |
LoginTokenRequest
dataclass
¶
LoginTokenRequest(
id: str,
user_id: str,
email_hash: str,
link_hash: str,
code_hash: str,
created_at: datetime,
expires_at: datetime,
redirect_to: str | None = None,
failed_attempts: int = 0,
consumed_at: datetime | None = None,
)
One login request of a registered user, as kept by a LoginTokenStore.
Requests for unknown email addresses are not stored: nothing is kept about people who aren't users.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
Random identifier of the request. Callers keep it (e.g. in the session) to redeem the code in the same browser. |
email_hash |
str
|
HMAC-SHA256 of the lower-cased email address, for the per-address rate limit. |
created_at |
datetime
|
When the request was made (timezone-aware, UTC). |
expires_at |
datetime
|
When the link and the code stop working (UTC). |
user_id |
str
|
The user to log in. |
link_hash |
str
|
HMAC-SHA256 of the link token. |
code_hash |
str
|
HMAC-SHA256 of |
redirect_to |
str | None
|
Where the caller wants to go after logging in. |
failed_attempts |
int
|
Number of wrong codes entered so far. |
consumed_at |
datetime | None
|
When the request was redeemed (or invalidated); |
RedeemResult
dataclass
¶
RedeemResult(
status: RedeemStatus,
user: RegisteredUser | None = None,
redirect_to: str | None = None,
)
Result of LoginTokenService.redeem_link / redeem_code.
Attributes:
| Name | Type | Description |
|---|---|---|
status |
RedeemStatus
|
Whether the redemption succeeded and, if not, why. |
user |
RegisteredUser | None
|
The user to log in; only set when |
redirect_to |
str | None
|
The |
RedeemStatus
¶
Bases: StrEnum
Outcome of redeeming a link or a code.
Attributes:
| Name | Type | Description |
|---|---|---|
OK |
The user is logged in. |
|
INVALID |
Unknown link or request, or a wrong code. |
|
EXPIRED |
The link or code is too old. |
|
USED |
The request was already redeemed (or invalidated). |
|
TOO_MANY_ATTEMPTS |
Too many wrong codes; the request is invalidated. |
RequestStatus
¶
Bases: StrEnum
Outcome of LoginTokenService.request_login.
Callers should show the same message for every status ("If an account exists, we've sent you a link"), so the response doesn't reveal which addresses are registered or rate limited.
Attributes:
| Name | Type | Description |
|---|---|---|
SENT |
A link and a code were sent to the user. |
|
UNKNOWN_EMAIL |
No user has this address; nothing was sent or stored. |
|
RATE_LIMITED |
Too many requests for this address; nothing was sent. |
LoggingLoginTokenSender
¶
Writes login links and codes to the log instead of sending them.
The default sender: lets an app use login tokens in development without a mail server. Never use it in production — anyone with access to the logs could log in as any user.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
logger
|
Logger | None
|
The logger to write to; defaults to this module's logger. |
None
|
level
|
int
|
The log level; defaults to WARNING, so the line shows up with default logging configuration. |
WARNING
|
Methods:
| Name | Description |
|---|---|
send_login_token |
|
Source code in src/commons/users/login_tokens/sender.py
send_login_token
¶
send_login_token(message: LoginTokenMessage) -> None
Source code in src/commons/users/login_tokens/sender.py
LoginTokenMessage
dataclass
¶
LoginTokenMessage(
user: RegisteredUser,
email: str,
link: str,
code: str,
expires_at: datetime,
locale: str | None = None,
)
What to deliver to the user.
Attributes:
| Name | Type | Description |
|---|---|---|
user |
RegisteredUser
|
The user who requested the login. |
email |
str
|
The address the user entered (and the message goes to). |
link |
str
|
Absolute URL that logs the user in when opened. |
code |
str
|
Code the user can type in instead, in the browser that requested the login. |
expires_at |
datetime
|
When the link and the code stop working (UTC). |
locale |
str | None
|
The language to write the message in (e.g. |
SendLoginTokenPort
¶
Bases: Protocol
Delivers a login link and code to a user.
Implementations must not swallow errors: if the message can't be delivered, raise, so the caller can tell the user to try again.
Example::
class TeamsLoginTokenSender:
def send_login_token(self, message: LoginTokenMessage) -> None:
post_to_teams(message.user, f"Your code: {message.code}")
Methods:
| Name | Description |
|---|---|
send_login_token |
Deliver the message to the user. |
send_login_token
¶
send_login_token(message: LoginTokenMessage) -> None
Deliver the message to the user.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
LoginTokenMessage
|
The link, code and expiry to deliver. |
required |
LoginTokenService
¶
LoginTokenService(
users: UsersRepository,
store: LoginTokenStore,
*,
secret_key: str,
sender: SendLoginTokenPort | None = None,
settings: LoginTokenSettings | None = None,
clock: Callable[[], datetime] | None = None
)
Lets registered users log in with a link or code sent to their email address.
request_login creates a request with a random link token and a
numeric code, stores only their hashes, and hands both to the sender.
The user then either opens the link (redeem_link) or types the code
into the browser that requested it (redeem_code). Redeeming one
invalidates the other; both expire after token_ttl.
The rate limit counts the stored requests per address (against flooding
one person's inbox). No client data such as IP addresses is stored.
Wrong codes count per request; after max_code_attempts the request
is invalidated, which makes guessing a 6-digit code hopeless.
All methods are synchronous; call them via asyncio.to_thread from
async code.
Everything stored is an HMAC-SHA256 keyed with secret_key: without
the key, neither an address can be confirmed from email_hash (as a
plain hash of known addresses would allow) nor a 6-digit code be
brute-forced from code_hash.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
users
|
UsersRepository
|
Repository to look users up by email and id. |
required |
store
|
LoginTokenStore
|
Where requests are kept. |
required |
secret_key
|
str
|
Key for hashing what is stored (e.g. the app's session secret); at least 16 characters, and the same for every process sharing the store. |
required |
sender
|
SendLoginTokenPort | None
|
Delivers link and code; defaults to |
None
|
settings
|
LoginTokenSettings | None
|
Defaults to |
None
|
clock
|
Callable[[], datetime] | None
|
Returns the current time (UTC); for tests. |
None
|
Example::
service = LoginTokenService(users, InMemoryLoginTokenStore(), secret_key=app_secret)
request = service.request_login(
"jane@example.com",
link_for=lambda token: f"https://app.example.com/login/token?t={token}",
)
# keep request.request_id, e.g. in the session
result = service.redeem_code(request.request_id, "123456")
if result.ok:
log_in(result.user)
Methods:
| Name | Description |
|---|---|
request_login |
Request a login link and code for an email address. |
request_id_for_link |
Find out which request a login link belongs to, without redeeming it, |
redeem_link |
Redeem the token from a login link. |
redeem_code |
Redeem a code typed in by the user. |
purge_expired |
Delete requests no longer needed, neither to log in nor for the |
Attributes:
| Name | Type | Description |
|---|---|---|
settings |
LoginTokenSettings
|
|
Source code in src/commons/users/login_tokens/service.py
request_login
¶
request_login(
email: str,
*,
link_for: Callable[[str], str],
redirect_to: str | None = None,
locale: str | None = None
) -> LoginRequest
Request a login link and code for an email address.
Returns a request_id for every outcome, and callers should show
the same message for every status, so the response doesn't reveal
whether the address is registered.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
email
|
str
|
The address the user entered. |
required |
link_for
|
Callable[[str], str]
|
Builds the absolute login URL from the link token. |
required |
redirect_to
|
str | None
|
Where to go after logging in; returned by the redeem methods. |
None
|
locale
|
str | None
|
The language of the message ( |
None
|
Returns:
| Type | Description |
|---|---|
LoginRequest
|
The request id, what happened, and when the link expires. |
Raises:
| Type | Description |
|---|---|
Exception
|
Whatever the sender raises if delivery fails. |
Source code in src/commons/users/login_tokens/service.py
request_id_for_link
¶
Find out which request a login link belongs to, without redeeming it, as long as the link can still be redeemed.
Lets a web app log a user in right away when the link is opened in the browser that requested it (its session holds the request id), and ask for a confirmation click otherwise — mail scanners open links in advance, but never with the requesting browser's session.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
link_token
|
str
|
The token from the link. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The request id, or None if no request has this link or it has |
str | None
|
been redeemed or has expired. |
Source code in src/commons/users/login_tokens/service.py
redeem_link
¶
redeem_link(link_token: str) -> RedeemResult
Redeem the token from a login link.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
link_token
|
str
|
The token from the link. |
required |
Returns:
| Type | Description |
|---|---|
RedeemResult
|
|
Source code in src/commons/users/login_tokens/service.py
redeem_code
¶
redeem_code(request_id: str, code: str) -> RedeemResult
Redeem a code typed in by the user.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request_id
|
str
|
The id returned by |
required |
code
|
str
|
The code the user entered. |
required |
Returns:
| Type | Description |
|---|---|
RedeemResult
|
|
Source code in src/commons/users/login_tokens/service.py
purge_expired
¶
Delete requests no longer needed, neither to log in nor for the rate limit.
Returns:
| Type | Description |
|---|---|
int
|
The number of deleted requests. |
Source code in src/commons/users/login_tokens/service.py
LoginTokenSettings
¶
LoginTokenSettings(
token_ttl: int = 15 * 60,
code_length: int = 6,
max_code_attempts: int = 5,
max_requests_per_email: int = 3,
email_window: int = 15 * 60,
mail_subject: str | None = None,
mail_text: str | None = None,
mail_html: str | None = None,
**_kwargs: Any
)
Settings of the login-token flow.
Read from app.login_tokens.* (see :mod:commons.settings); every
field has a default, so no configuration is needed.
TOML::
[app.login_tokens]
token_ttl = 900 # link and code validity, seconds
code_length = 6 # digits of the code
max_code_attempts = 5 # wrong codes before the request is invalidated
max_requests_per_email = 3 # requests per address ...
email_window = 900 # ... within this many seconds
mail_subject = "..." # optional: the login mail of SMTPLoginTokenSender,
mail_text = "..." # see commons.users.login_tokens.smtp
mail_html = "..."
Env var overrides follow the standard pattern, e.g.
APP__LOGIN_TOKENS__TOKEN_TTL.
Methods:
| Name | Description |
|---|---|
read |
Read the settings; defaults if |
Attributes:
| Name | Type | Description |
|---|---|---|
token_ttl |
|
|
code_length |
|
|
max_code_attempts |
|
|
max_requests_per_email |
|
|
email_window |
|
|
mail_subject |
|
|
mail_text |
|
|
mail_html |
|
Source code in src/commons/users/login_tokens/service.py
read
staticmethod
¶
read() -> LoginTokenSettings
Read the settings; defaults if app.login_tokens is not configured.
Source code in src/commons/users/login_tokens/service.py
LoginTokenMail
dataclass
¶
SMTPLoginTokenSender
¶
SMTPLoginTokenSender(
smtp: SMTPSettings | None = None,
settings: LoginTokenSettings | None = None,
compose: ComposeLoginTokenMail | None = None,
language: Language = "en",
)
Sends login links and codes by email via SMTP.
Reads and checks its settings when it is created, so a misconfigured app fails at startup. Every delivery error is raised (connection, timeout, TLS, sign-in, refused sender or recipient); nothing is logged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
smtp
|
SMTPSettings | None
|
The SMTP connection; defaults to |
None
|
settings
|
LoginTokenSettings | None
|
The templates ( |
None
|
compose
|
ComposeLoginTokenMail | None
|
Builds the mail per message; defaults to the templates. |
None
|
language
|
Language
|
Language of the built-in templates for messages without a
built-in |
'en'
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
ValidationError
|
If |
Methods:
| Name | Description |
|---|---|
send_login_token |
|
Source code in src/commons/users/login_tokens/smtp.py
send_login_token
¶
send_login_token(message: LoginTokenMessage) -> None
Source code in src/commons/users/login_tokens/smtp.py
SQLLoginTokenStore
¶
Bases: LoginTokenStore
Keeps login requests in the login_token_requests table.
Use it when the app runs several processes or workers: they all see the same requests (a link sent by one worker works on every other) and share the rate limits. Creates its table on initialization if it doesn't exist.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
SQLAlchemy Engine connected to the database. |
required |
Example::
from sqlalchemy import create_engine
from commons.users.login_tokens import SQLLoginTokenStore
store = SQLLoginTokenStore(create_engine("sqlite:///app.db"))
Methods:
| Name | Description |
|---|---|
add |
|
get |
|
find_by_link_hash |
|
consume |
|
record_failed_attempt |
|
count_requests |
|
delete_expired |
|
Source code in src/commons/users/login_tokens/sql.py
add
¶
add(request: LoginTokenRequest) -> None
get
¶
get(request_id: str) -> LoginTokenRequest | None
find_by_link_hash
¶
find_by_link_hash(
link_hash: str,
) -> LoginTokenRequest | None
Source code in src/commons/users/login_tokens/sql.py
consume
¶
Source code in src/commons/users/login_tokens/sql.py
record_failed_attempt
¶
Source code in src/commons/users/login_tokens/sql.py
count_requests
¶
Source code in src/commons/users/login_tokens/sql.py
delete_expired
¶
Source code in src/commons/users/login_tokens/sql.py
LoginTokenStore
¶
Bases: ABC
Abstract base class for storing login requests.
Implementations must be safe to call from several threads (callers in
async web apps run them via asyncio.to_thread). consume must be
atomic: of two concurrent calls for the same request, only one may
succeed, so a link or code can't be redeemed twice.
The rate limit of LoginTokenService is a count over the stored
requests (count_requests), so a store shared by several processes
(e.g. SQLLoginTokenStore) also shares the limits.
See Also
InMemoryLoginTokenStore: For a single process (default, tests). SQLLoginTokenStore: For several processes or workers.
Methods:
| Name | Description |
|---|---|
add |
Store a new request. |
get |
Find a request by its id. |
find_by_link_hash |
Find a request by the hash of its link token. |
consume |
Mark a request as redeemed, if it's still usable. |
record_failed_attempt |
Count one more wrong code for a request. |
count_requests |
Count the requests for an address since a point in time, for rate |
delete_expired |
Delete requests that expired before a point in time. |
add
abstractmethod
¶
add(request: LoginTokenRequest) -> None
Store a new request.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
LoginTokenRequest
|
The request to store. |
required |
get
abstractmethod
¶
get(request_id: str) -> LoginTokenRequest | None
Find a request by its id.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request_id
|
str
|
The request's id. |
required |
Returns:
| Type | Description |
|---|---|
LoginTokenRequest | None
|
The request, or None if there is none with this id. |
find_by_link_hash
abstractmethod
¶
find_by_link_hash(
link_hash: str,
) -> LoginTokenRequest | None
Find a request by the hash of its link token.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
link_hash
|
str
|
HMAC-SHA256 of the link token (see |
required |
Returns:
| Type | Description |
|---|---|
LoginTokenRequest | None
|
The request, or None if no request has this link. |
Source code in src/commons/users/login_tokens/store.py
consume
abstractmethod
¶
Mark a request as redeemed, if it's still usable.
Atomic: succeeds only if the request exists, is not consumed yet and
has not expired at now.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request_id
|
str
|
The request's id. |
required |
now
|
datetime
|
The current time (UTC). |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if this call consumed the request, False otherwise. |
Source code in src/commons/users/login_tokens/store.py
record_failed_attempt
abstractmethod
¶
Count one more wrong code for a request.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request_id
|
str
|
The request's id. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The number of failed attempts after this one; 0 if there is no |
int
|
request with this id. |
Source code in src/commons/users/login_tokens/store.py
count_requests
abstractmethod
¶
Count the requests for an address since a point in time, for rate limiting.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
email_hash
|
str
|
Count requests for this address hash. |
required |
since
|
datetime
|
Count requests created at or after this time (UTC). |
required |
Returns:
| Type | Description |
|---|---|
int
|
The number of matching requests. |
Source code in src/commons/users/login_tokens/store.py
delete_expired
abstractmethod
¶
Delete requests that expired before a point in time.
Keep requests at least as long as the rate-limit window, or the limit counts too few.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
before
|
datetime
|
Delete requests whose |
required |
Returns:
| Type | Description |
|---|---|
int
|
The number of deleted requests. |
Source code in src/commons/users/login_tokens/store.py
Sending by email¶
Sending login links and codes by email via SMTP.
SMTPLoginTokenSender implements SendLoginTokenPort: it connects to the
server of app.smtp (:class:commons.settings.SMTPSettings), always
encrypted, and sends one mail per login request.
The mail is built per message by a mail function (ComposeLoginTokenMail).
The default one fills string.Template templates with these placeholders:
$name: display name of the user, else the username$link: the login link$code: the login code$valid_minutes: how long link and code are valid$expires_at: when they expire,YYYY-MM-DD HH:MM UTC
Write $$ for a literal $; braces (e.g. CSS) need no escaping. Subject
and text are built in for English ("en"), German ("de"), Italian
("it") and French ("fr"); there is no built-in HTML. Each mail is
written in the language of its message (LoginTokenMessage.locale, e.g. the
user's language as pyhx resolves it; "de-CH" counts as "de") if it is
built in, else in the language of the sender. An app overrides the texts
in app.login_tokens; its templates then apply to every language::
[app.login_tokens]
mail_subject = "Your code for CaseSight: $code"
mail_html = """
<p>Hello $name,</p>
<p>your code is <strong>$code</strong>, or <a href="$link">sign in</a>.</p>
"""
In the HTML, the values are HTML-escaped. For anything else (texts of your own per language, a template engine, a template file) pass your own mail function.
Wiring it into pyhx::
sender = SMTPLoginTokenSender(language="de") # fallback language; reads app.smtp and app.login_tokens
EmailTokenLogin(store=SQLLoginTokenStore(engine), sender=sender)