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
« 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
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
9from couchers.models.base import Base
10from couchers.utils import now
12if TYPE_CHECKING:
13 from couchers.models.users import User
16class UserSession(Base, kw_only=True):
17 """
18 API keys/session cookies for the app
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.
26 Long-lived tokens are valid from `created` until `expiry`.
28 Short-lived tokens expire after 168 hours (7 days) if they are not used.
29 """
31 __tablename__ = "sessions"
33 token: Mapped[str] = mapped_column(String, primary_key=True)
34 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
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())
43 # whether it's a long-lived or short-lived session
44 long_lived: Mapped[bool] = mapped_column(Boolean)
46 # the time at which the session was created
47 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
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 )
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)
57 # the last time this session was used
58 last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
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)
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)
68 user: Mapped[User] = relationship(init=False, backref="sessions")
70 @hybrid_property
71 def is_valid(self) -> Any:
72 """
73 It must have been created and not be expired or deleted.
75 Also, if it's a short lived token, it must have been used in the last 168 hours.
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 )
86 __table_args__ = (
87 Index(
88 "ix_sessions_by_token",
89 "token",
90 postgresql_using="hash",
91 ),
92 )
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 """
100 __tablename__ = "login_tokens"
102 token: Mapped[str] = mapped_column(String, primary_key=True)
103 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
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))
109 user: Mapped[User] = relationship(init=False, backref="login_tokens")
111 @hybrid_property
112 def is_valid(self) -> Any:
113 return (self.created <= now()) & (self.expiry >= now())
115 def __repr__(self) -> str:
116 return f"LoginToken(token={self.token}, user={self.user}, created={self.created}, expiry={self.expiry})"
119class PasswordResetToken(Base, kw_only=True):
120 __tablename__ = "password_reset_tokens"
122 token: Mapped[str] = mapped_column(String, primary_key=True)
123 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
125 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
126 expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True))
128 user: Mapped[User] = relationship(init=False, backref="password_reset_tokens")
130 @hybrid_property
131 def is_valid(self) -> Any:
132 return (self.created <= now()) & (self.expiry >= now())
134 def __repr__(self) -> str:
135 return f"PasswordResetToken(token={self.token}, user={self.user}, created={self.created}, expiry={self.expiry})"