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
« 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
6from couchers.models.base import Base
7from couchers.utils import now
10class UserSession(Base):
11 """
12 API keys/session cookies for the app
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.
20 Long-lived tokens are valid from `created` until `expiry`.
22 Short-lived tokens expire after 168 hours (7 days) if they are not used.
23 """
25 __tablename__ = "sessions"
26 token = Column(String, primary_key=True)
28 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
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())
37 # whether it's a long-lived or short-lived session
38 long_lived = Column(Boolean, nullable=False)
40 # the time at which the session was created
41 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
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'"))
46 # the time at which the token was invalidated, allows users to delete sessions
47 deleted = Column(DateTime(timezone=True), nullable=True, default=None)
49 # the last time this session was used
50 last_seen = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
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)
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)
60 user = relationship("User", backref="sessions")
62 @hybrid_property
63 def is_valid(self):
64 """
65 It must have been created and not be expired or deleted.
67 Also, if it's a short lived token, it must have been used in the last 168 hours.
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 )
78 __table_args__ = (
79 Index(
80 "ix_sessions_by_token",
81 "token",
82 postgresql_using="hash",
83 ),
84 )
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 """
92 __tablename__ = "login_tokens"
93 token = Column(String, primary_key=True)
95 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
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)
101 user = relationship("User", backref="login_tokens")
103 @hybrid_property
104 def is_valid(self):
105 return (self.created <= now()) & (self.expiry >= now())
107 def __repr__(self):
108 return f"LoginToken(token={self.token}, user={self.user}, created={self.created}, expiry={self.expiry})"
111class PasswordResetToken(Base):
112 __tablename__ = "password_reset_tokens"
113 token = Column(String, primary_key=True)
115 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
117 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
118 expiry = Column(DateTime(timezone=True), nullable=False)
120 user = relationship("User", backref="password_reset_tokens")
122 @hybrid_property
123 def is_valid(self):
124 return (self.created <= now()) & (self.expiry >= now())
126 def __repr__(self):
127 return f"PasswordResetToken(token={self.token}, user={self.user}, created={self.created}, expiry={self.expiry})"