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

1""" 

2Token-based ("magic link") primary authentication for ViUR user modules. 

3 

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. 

8 

9Typical use cases include magic-link email logins, CLI tool authentication, 

10and service-to-service auth where a shared secret is acceptable. 

11 

12Usage:: 

13 

14 from viur.core.modules.user import User 

15 from viur.core.contrib.loginkey import LoginKey 

16 

17 class MyUser(User): 

18 authenticationProviders = [LoginKey, ...] 

19 

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 

27 

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 

34 

35logger = logging.getLogger(__name__) 

36 

37 

38class IndexedCredentialBone(CredentialBone): 

39 """A :class:`~viur.core.bones.CredentialBone` that is always Datastore-indexed. 

40 

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)``). 

44 

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 """ 

51 

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 

58 

59 

60class LoginKey(UserPrimaryAuthentication): 

61 """Primary authentication via a secret login token. 

62 

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. 

67 

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 """ 

71 

72 METHOD_NAME = "X-AUTH-LOGINKEY" 

73 NAME = "LoginKey" 

74 

75 # 12 failed attempts per minute, IP-based 

76 loginRateLimit = RateLimit("user.loginkey", 12, 1, "ip") 

77 

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 ) 

85 

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

93 

94 self.loginRateLimit.assertQuotaIsAvailable() 

95 

96 user_skel = self._user_module.baseSkel() 

97 user_skel = user_skel.all().filter("login_key =", key).getSkel() 

98 

99 is_okay = user_skel is not None 

100 logger.debug(f"user found: {is_okay=}") 

101 

102 is_okay = is_okay and (user_skel["status"] or 0) >= Status.ACTIVE.value 

103 logger.debug(f"account active: {is_okay=}") 

104 

105 is_okay = is_okay and len(str(user_skel.dbEntity["login_key"])) >= 32 

106 logger.debug(f"key length ok: {is_okay=}") 

107 

108 is_okay = is_okay and ("root" not in user_skel["access"]) 

109 logger.debug(f"not root: {is_okay=}") 

110 

111 if not is_okay: 

112 self.loginRateLimit.decrementQuota() # only failed attempts count 

113 raise errors.Unauthorized() 

114 

115 return self.next_or_finish(user_skel)