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()