Files
replypier/backend/app/services/auth_sessions.py
T

124 lines
4.3 KiB
Python
Raw Normal View History

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Server-side sessions behind the dashboard JWTs.
The tokens are stateless, so on their own signing out only deleted cookies: a
copied access token kept working for its 30 minutes and a refresh token kept
minting new ones for 7 days, and a password change ended nothing. Every token
now names an auth_sessions row in its `sid` claim, and a token is only live
while that row is.
Tokens without a `sid` (issued before this existed, or by the enterprise signup
flow) are accepted until they expire, so deploying this signs nobody out.
None of these functions commit: the session row belongs in the caller's
transaction, alongside whatever it is doing (a login, an org setup, a password
change).
"""
from datetime import datetime, timezone
from typing import Optional, Tuple
from uuid import UUID
from sqlalchemy.orm import Session
from app.core.security import create_access_token, create_refresh_token, verify_token
from app.models.auth_session import AuthSession
ACCESS = "access"
REFRESH = "refresh"
def issue_tokens(db: Session, user_id, org_id, sid=None) -> Tuple[str, str]:
"""Return (access_token, refresh_token) for a session.
Without `sid` a new session is opened (a login). With the `sid` of a live
session the tokens are re-minted for it (a refresh), so revoking it still
ends everything that came from the same login.
"""
if sid is None:
session = AuthSession(user_id=_as_uuid(user_id))
db.add(session)
db.flush()
sid = session.id
token_data = {"sub": str(user_id), "org": str(org_id), "sid": str(sid)}
return create_access_token(token_data), create_refresh_token(token_data)
def live_payload(db: Session, token: str, expected_type: str) -> Optional[dict]:
"""The token's payload if it is valid, of `expected_type` and not revoked.
The type check keeps a 7-day refresh token from being used as an access
token. A token without a `sid` predates sessions and is let through.
"""
payload = verify_token(token)
if not payload or payload.get("type") != expected_type:
return None
sid = payload.get("sid")
if sid is None:
return payload
session_id = _as_uuid(sid)
if session_id is None:
return None
session = db.query(AuthSession).filter(AuthSession.id == session_id).first()
if (
session is None
or session.revoked_at is not None
or str(session.user_id) != str(payload.get("sub"))
):
return None
return payload
def session_id_of(token: Optional[str]) -> Optional[str]:
"""The `sid` a correctly signed token carries, or None."""
payload = verify_token(token) if token else None
return payload.get("sid") if payload else None
def revoke_session(db: Session, sid) -> None:
"""End one session; both of its tokens stop working."""
session_id = _as_uuid(sid)
if session_id is None:
return
db.query(AuthSession).filter(
AuthSession.id == session_id,
AuthSession.revoked_at.is_(None),
).update({"revoked_at": datetime.now(timezone.utc)}, synchronize_session=False)
def revoke_user_sessions(db: Session, user_id, keep_sid=None) -> None:
"""End every session the user has, except `keep_sid` when given."""
query = db.query(AuthSession).filter(
AuthSession.user_id == _as_uuid(user_id),
AuthSession.revoked_at.is_(None),
)
keep = _as_uuid(keep_sid) if keep_sid is not None else None
if keep is not None:
query = query.filter(AuthSession.id != keep)
query.update({"revoked_at": datetime.now(timezone.utc)}, synchronize_session=False)
def _as_uuid(value) -> Optional[UUID]:
if isinstance(value, UUID):
return value
try:
return UUID(str(value))
except (TypeError, ValueError):
return None