v0.150.0
 1from __future__ import annotations
 2
 3from typing import TYPE_CHECKING
 4
 5from app.users.models import User
 6
 7from plain.signing import BadSignature, SignatureExpired
 8from plain.urls import reverse
 9
10from .signing import ExpiringSigner
11
12if TYPE_CHECKING:
13    from plain.http import Request
14
15
16class LoginLinkExpired(Exception):
17    pass
18
19
20class LoginLinkInvalid(Exception):
21    pass
22
23
24class LoginLinkChanged(Exception):
25    pass
26
27
28def generate_link_url(
29    *, request: Request, user: User, email: str, expires_in: int
30) -> str:
31    """
32    Generate a login link using both the user's ID
33    and email address, so links break if the user email changes or is assigned to another user.
34    """
35    token = ExpiringSigner(salt="plain.loginlink").sign_object(
36        {"user_id": user.id, "email": email}, expires_in=expires_in
37    )
38
39    return request.build_absolute_uri(reverse("loginlink:login", token=token))
40
41
42def get_link_token_user(token: str) -> User:
43    """
44    Validate a link token and get the user from it.
45    """
46    try:
47        signed_data = ExpiringSigner(salt="plain.loginlink").unsign_object(token)
48    except SignatureExpired:
49        raise LoginLinkExpired()
50    except BadSignature:
51        raise LoginLinkInvalid()
52
53    user_id = signed_data["user_id"]
54    email = signed_data["email"]
55
56    try:
57        return User.query.get(id=user_id, email__iexact=email)
58    except User.DoesNotExist:
59        raise LoginLinkChanged()