Coverage for src/couchers/models/auth.py: 96%

48 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-08 00:20 +0000

1from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, func, text 

2from sqlalchemy.ext.hybrid import hybrid_property 

3from sqlalchemy.orm import relationship 

4from sqlalchemy.sql import expression 

5 

6from couchers.models.base import Base 

7from couchers.utils import now 

8 

9 

10class UserSession(Base): 

11 """ 

12 API keys/session cookies for the app 

13 

14 There are two types of sessions: long-lived, and short-lived. Long-lived are 

15 like when you choose "remember this browser": they will be valid for a long 

16 time without the user interacting with the site. Short-lived sessions, on the 

17 other hand, get invalidated quickly if the user does not interact with the 

18 site. 

19 

20 Long-lived tokens are valid from `created` until `expiry`. 

21 

22 Short-lived tokens expire after 168 hours (7 days) if they are not used. 

23 """ 

24 

25 __tablename__ = "sessions" 

26 token = Column(String, primary_key=True) 

27 

28 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

29 

30 # sessions are either "api keys" or "session cookies", otherwise identical 

31 # an api key is put in the authorization header (e.g. "authorization: Bearer <token>") 

32 # a session cookie is set in the "couchers-sesh" cookie (e.g. "cookie: couchers-sesh=<token>") 

33 # when a session is created, it's fixed as one or the other for security reasons 

34 # for api keys to be useful, they should be long-lived and have a long expiry 

35 is_api_key = Column(Boolean, nullable=False, server_default=expression.false()) 

36 

37 # whether it's a long-lived or short-lived session 

38 long_lived = Column(Boolean, nullable=False) 

39 

40 # the time at which the session was created 

41 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

42 

43 # the expiry of the session: the session *cannot* be refreshed past this 

44 expiry = Column(DateTime(timezone=True), nullable=False, server_default=func.now() + text("interval '730 days'")) 

45 

46 # the time at which the token was invalidated, allows users to delete sessions 

47 deleted = Column(DateTime(timezone=True), nullable=True, default=None) 

48 

49 # the last time this session was used 

50 last_seen = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

51 

52 # count of api calls made with this token/session (if we're updating last_seen, might as well update this too) 

53 api_calls = Column(Integer, nullable=False, default=0) 

54 

55 # details of the browser, if available 

56 # these are from the request creating the session, not used for anything else 

57 ip_address = Column(String, nullable=True) 

58 user_agent = Column(String, nullable=True) 

59 

60 user = relationship("User", backref="sessions") 

61 

62 @hybrid_property 

63 def is_valid(self): 

64 """ 

65 It must have been created and not be expired or deleted. 

66 

67 Also, if it's a short lived token, it must have been used in the last 168 hours. 

68 

69 TODO: this probably won't run in python (instance level), only in sql (class level) 

70 """ 

71 return ( 

72 (self.created <= func.now()) 

73 & (self.expiry >= func.now()) 

74 & (self.deleted == None) 

75 & (self.long_lived | (func.now() - self.last_seen < text("interval '168 hours'"))) 

76 ) 

77 

78 __table_args__ = ( 

79 Index( 

80 "ix_sessions_by_token", 

81 "token", 

82 postgresql_using="hash", 

83 ), 

84 ) 

85 

86 

87class LoginToken(Base): 

88 """ 

89 A login token sent in an email to a user, allows them to sign in between the times defined by created and expiry 

90 """ 

91 

92 __tablename__ = "login_tokens" 

93 token = Column(String, primary_key=True) 

94 

95 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

96 

97 # timezones should always be UTC 

98 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

99 expiry = Column(DateTime(timezone=True), nullable=False) 

100 

101 user = relationship("User", backref="login_tokens") 

102 

103 @hybrid_property 

104 def is_valid(self): 

105 return (self.created <= now()) & (self.expiry >= now()) 

106 

107 def __repr__(self): 

108 return f"LoginToken(token={self.token}, user={self.user}, created={self.created}, expiry={self.expiry})" 

109 

110 

111class PasswordResetToken(Base): 

112 __tablename__ = "password_reset_tokens" 

113 token = Column(String, primary_key=True) 

114 

115 user_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

116 

117 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

118 expiry = Column(DateTime(timezone=True), nullable=False) 

119 

120 user = relationship("User", backref="password_reset_tokens") 

121 

122 @hybrid_property 

123 def is_valid(self): 

124 return (self.created <= now()) & (self.expiry >= now()) 

125 

126 def __repr__(self): 

127 return f"PasswordResetToken(token={self.token}, user={self.user}, created={self.created}, expiry={self.expiry})"