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

50 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 06:18 +0000

1from datetime import datetime 

2from typing import TYPE_CHECKING, Any 

3 

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

5from sqlalchemy.ext.hybrid import hybrid_property 

6from sqlalchemy.orm import Mapped, mapped_column, relationship 

7from sqlalchemy.sql import expression 

8 

9from couchers.models.base import Base 

10from couchers.utils import now 

11 

12if TYPE_CHECKING: 

13 from couchers.models.users import User 

14 

15 

16class UserSession(Base, kw_only=True): 

17 """ 

18 API keys/session cookies for the app 

19 

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

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

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

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

24 site. 

25 

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

27 

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

29 """ 

30 

31 __tablename__ = "sessions" 

32 

33 token: Mapped[str] = mapped_column(String, primary_key=True) 

34 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

35 

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

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

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

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

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

41 is_api_key: Mapped[bool] = mapped_column(Boolean, server_default=expression.false()) 

42 

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

44 long_lived: Mapped[bool] = mapped_column(Boolean) 

45 

46 # the time at which the session was created 

47 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

48 

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

50 expiry: Mapped[datetime] = mapped_column( 

51 DateTime(timezone=True), server_default=func.now() + text("interval '730 days'"), init=False 

52 ) 

53 

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

55 deleted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) 

56 

57 # the last time this session was used 

58 last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

59 

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

61 api_calls: Mapped[int] = mapped_column(Integer, default=0) 

62 

63 # details of the browser, if available 

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

65 ip_address: Mapped[str | None] = mapped_column(String, default=None) 

66 user_agent: Mapped[str | None] = mapped_column(String, default=None) 

67 

68 user: Mapped[User] = relationship(init=False, backref="sessions") 

69 

70 @hybrid_property 

71 def is_valid(self) -> Any: 

72 """ 

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

74 

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

76 

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

78 """ 

79 return ( 

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

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

82 & (self.deleted == None) 

83 & (self.long_lived | (func.now() - self.last_seen < text("interval '168 hours'"))) # type: ignore[operator] 

84 ) 

85 

86 __table_args__ = ( 

87 Index( 

88 "ix_sessions_by_token", 

89 "token", 

90 postgresql_using="hash", 

91 ), 

92 ) 

93 

94 

95class LoginToken(Base, kw_only=True): 

96 """ 

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

98 """ 

99 

100 __tablename__ = "login_tokens" 

101 

102 token: Mapped[str] = mapped_column(String, primary_key=True) 

103 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

104 

105 # timezones should always be UTC 

106 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

107 expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True)) 

108 

109 user: Mapped[User] = relationship(init=False, backref="login_tokens") 

110 

111 @hybrid_property 

112 def is_valid(self) -> Any: 

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

114 

115 def __repr__(self) -> str: 

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

117 

118 

119class PasswordResetToken(Base, kw_only=True): 

120 __tablename__ = "password_reset_tokens" 

121 

122 token: Mapped[str] = mapped_column(String, primary_key=True) 

123 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

124 

125 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

126 expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True)) 

127 

128 user: Mapped[User] = relationship(init=False, backref="password_reset_tokens") 

129 

130 @hybrid_property 

131 def is_valid(self) -> Any: 

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

133 

134 def __repr__(self) -> str: 

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