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