Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/contrib/loginkey.py: 0%
44 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
1"""
2Token-based ("magic link") primary authentication for ViUR user modules.
4``LoginKey`` authenticates a user by a secret token stored as an indexed
5``CredentialBone`` on the user skeleton. The caller submits the token as a
6POST parameter; the handler looks up the matching user, validates the account
7state, and completes the authentication flow.
9Typical use cases include magic-link email logins, CLI tool authentication,
10and service-to-service auth where a shared secret is acceptable.
12Usage::
14 from viur.core.modules.user import User
15 from viur.core.contrib.loginkey import LoginKey
17 class MyUser(User):
18 authenticationProviders = [LoginKey, ...]
20.. warning::
21 An indexed :class:`~viur.core.bones.CredentialBone` allows any caller
22 with Datastore read access to enumerate users by key. Only deploy this
23 in environments where that access is appropriately restricted, and always
24 use long (≥ 32 char), randomly generated tokens.
25"""
26import logging
28from viur.core import current, errors
29from viur.core.bones import CredentialBone
30from viur.core.decorators import exposed, force_post, force_ssl, skey
31from viur.core.modules.user import Status, UserPrimaryAuthentication
32from viur.core.ratelimit import RateLimit
33from viur.core.skeleton import SkeletonInstance
35logger = logging.getLogger(__name__)
38class IndexedCredentialBone(CredentialBone):
39 """A :class:`~viur.core.bones.CredentialBone` that is always Datastore-indexed.
41 Regular ``CredentialBone`` values are excluded from indexes for security.
42 This subclass forces indexing so that the value can be used as a filter
43 criterion (e.g. ``filter("login_key =", token)``).
45 .. note::
46 Accepting an indexed credential is a deliberate trade-off: it enables
47 server-side token lookup at the cost of exposing the value to anyone
48 with Datastore read access. Only use this when that trade-off is
49 explicitly acceptable.
50 """
52 def serialize(self, skel: "SkeletonInstance", name: str, parentIndexed: bool) -> bool:
53 skel.dbEntity.exclude_from_indexes.discard(name) # force index even though it's a credential
54 if name in skel.accessedValues and skel.accessedValues[name]:
55 skel.dbEntity[name] = skel.accessedValues[name]
56 return True
57 return False
60class LoginKey(UserPrimaryAuthentication):
61 """Primary authentication via a secret login token.
63 The token is stored in a ``login_key`` bone on the user skeleton
64 (added automatically by :meth:`patch_user_skel`). Failed attempts are
65 rate-limited per IP address; successful logins are *not* counted against
66 the quota.
68 :cvar METHOD_NAME: HTTP header name used to identify this auth method.
69 :cvar loginRateLimit: Allows 12 failed attempts per minute per IP.
70 """
72 METHOD_NAME = "X-AUTH-LOGINKEY"
73 NAME = "LoginKey"
75 # 12 failed attempts per minute, IP-based
76 loginRateLimit = RateLimit("user.loginkey", 12, 1, "ip")
78 @classmethod
79 def patch_user_skel(cls, skel_cls):
80 skel_cls.login_key = IndexedCredentialBone(
81 descr="LoginKey",
82 params={"category": "Authentication"},
83 min_length=32,
84 )
86 @exposed
87 @force_ssl
88 @force_post
89 @skey()
90 def login(self, *, key: str, **kwargs):
91 if current.user.get():
92 return self._user_module.render.loginSucceeded()
94 self.loginRateLimit.assertQuotaIsAvailable()
96 user_skel = self._user_module.baseSkel()
97 user_skel = user_skel.all().filter("login_key =", key).getSkel()
99 is_okay = user_skel is not None
100 logger.debug(f"user found: {is_okay=}")
102 is_okay = is_okay and (user_skel["status"] or 0) >= Status.ACTIVE.value
103 logger.debug(f"account active: {is_okay=}")
105 is_okay = is_okay and len(str(user_skel.dbEntity["login_key"])) >= 32
106 logger.debug(f"key length ok: {is_okay=}")
108 is_okay = is_okay and ("root" not in user_skel["access"])
109 logger.debug(f"not root: {is_okay=}")
111 if not is_okay:
112 self.loginRateLimit.decrementQuota() # only failed attempts count
113 raise errors.Unauthorized()
115 return self.next_or_finish(user_skel)