-
Notifications
You must be signed in to change notification settings - Fork 12
feat(auth): add multi-user authentication foundation. Related to #151 #365
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| """Add users table | ||
|
|
||
| Revision ID: 0024_users | ||
| Revises: 0023_intelligence_events | ||
| Create Date: 2026-03-09 | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| from alembic import op | ||
| import sqlalchemy as sa | ||
|
|
||
|
|
||
| revision = "0024_users" | ||
| down_revision = "0023_intelligence_events" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| conn = op.get_bind() | ||
| inspector = sa.inspect(conn) | ||
|
|
||
| if "users" not in inspector.get_table_names(): | ||
| op.create_table( | ||
| "users", | ||
| sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), | ||
| sa.Column("email", sa.String(255), nullable=True), | ||
| sa.Column("hashed_password", sa.String(255), nullable=True), | ||
| sa.Column("github_id", sa.String(64), nullable=True), | ||
| sa.Column("github_username", sa.String(128), nullable=True), | ||
| sa.Column("display_name", sa.String(128), nullable=True), | ||
| sa.Column("avatar_url", sa.String(512), nullable=True), | ||
| sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.true()), | ||
| sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), | ||
| sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), | ||
| sa.CheckConstraint( | ||
| "email IS NOT NULL OR github_id IS NOT NULL", | ||
| name="ck_users_identity", | ||
| ), | ||
| ) | ||
|
|
||
| existing_indexes = {i["name"] for i in inspector.get_indexes("users")} | ||
| if "uq_users_email" not in existing_indexes: | ||
| op.create_index("uq_users_email", "users", ["email"], unique=True) | ||
| if "uq_users_github_id" not in existing_indexes: | ||
| op.create_index("uq_users_github_id", "users", ["github_id"], unique=True) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.drop_index("uq_users_github_id", table_name="users") | ||
| op.drop_index("uq_users_email", table_name="users") | ||
| op.drop_table("users") | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| """Add password_reset_tokens table | ||
|
|
||
| Revision ID: 0025_password_reset_tokens | ||
| Revises: 0024_users | ||
| Create Date: 2026-03-10 | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| from alembic import op | ||
| import sqlalchemy as sa | ||
|
|
||
|
|
||
| revision = "0025_password_reset_tokens" | ||
| down_revision = "0024_users" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| conn = op.get_bind() | ||
| inspector = sa.inspect(conn) | ||
|
|
||
| if "password_reset_tokens" not in inspector.get_table_names(): | ||
| op.create_table( | ||
| "password_reset_tokens", | ||
| sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), | ||
| sa.Column("user_id", sa.Integer, nullable=False), | ||
| sa.Column("token", sa.String(64), nullable=False), | ||
| sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), | ||
| sa.Column("used", sa.Boolean, nullable=False, server_default=sa.false()), | ||
| sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), | ||
| ) | ||
| op.create_index("ix_prt_token", "password_reset_tokens", ["token"], unique=True) | ||
| op.create_index("ix_prt_user_id", "password_reset_tokens", ["user_id"]) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.drop_index("ix_prt_user_id", table_name="password_reset_tokens") | ||
| op.drop_index("ix_prt_token", table_name="password_reset_tokens") | ||
| op.drop_table("password_reset_tokens") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import logging | ||
| import os | ||
|
|
||
| from fastapi import Depends, HTTPException, status | ||
| from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials | ||
| from jose import JWTError | ||
|
|
||
| from paperbot.api.auth.jwt import decode_token | ||
| from paperbot.infrastructure.stores.user_store import SqlAlchemyUserStore | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| bearer = HTTPBearer(auto_error=False) | ||
|
|
||
| AUTH_OPTIONAL = os.getenv("AUTH_OPTIONAL", "false").lower() in {"1", "true", "yes"} | ||
|
|
||
| _user_store = SqlAlchemyUserStore() | ||
|
|
||
|
|
||
| def _resolve_user(credentials: HTTPAuthorizationCredentials | None): | ||
| if not credentials or not credentials.credentials: | ||
| logger.warning("[auth] Missing token — no credentials provided") | ||
| if AUTH_OPTIONAL: | ||
| return None | ||
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token") | ||
| token = credentials.credentials | ||
| logger.debug("[auth] Received token (first 20 chars): %s...", token[:20]) | ||
| try: | ||
| user_id = decode_token(token) | ||
| logger.debug("[auth] Token valid, user_id=%s", user_id) | ||
| except JWTError as e: | ||
| logger.warning("[auth] Invalid token: %s | token prefix: %s...", e, token[:20]) | ||
| if AUTH_OPTIONAL: | ||
| return None | ||
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") | ||
|
|
||
| user = _user_store.get_by_id(user_id) | ||
| if not user or not user.is_active: | ||
| if AUTH_OPTIONAL: | ||
| return None | ||
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") | ||
| return user | ||
|
|
||
|
|
||
| def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)): | ||
| """Strict user dependency: always requires a valid user. | ||
|
|
||
| Use this for endpoints that must not fall back to the legacy "default" namespace. | ||
| """ | ||
|
|
||
| user = _resolve_user(credentials) | ||
| if user is None: | ||
| # AUTH_OPTIONAL only affects get_user_id; this dependency always enforces auth. | ||
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid token") | ||
| return user | ||
|
|
||
|
|
||
| def get_user_id(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)) -> str: | ||
| """Return the authenticated user id as a string. | ||
|
|
||
| When AUTH_OPTIONAL=true, missing/invalid tokens fall back to "default" so | ||
| legacy callers keep functioning while we migrate to multi-user auth. | ||
| """ | ||
|
|
||
| user = _resolve_user(credentials) | ||
| if user is None: | ||
| return "default" | ||
| return str(user.id) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| """Email sending for auth flows. | ||
|
|
||
| Currently in log mode — no real email is sent. | ||
| To switch to Resend, set RESEND_API_KEY and flip _SEND_MODE = "resend". | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| import os | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| _SEND_MODE = os.getenv("EMAIL_SEND_MODE", "log") # "log" | "resend" | ||
|
|
||
|
|
||
| def send_password_reset_email(to_email: str, reset_url: str) -> None: | ||
| if _SEND_MODE == "resend": | ||
| _send_via_resend(to_email, reset_url) | ||
| else: | ||
| _log_email(to_email, reset_url) | ||
|
|
||
|
|
||
| def _log_email(to_email: str, reset_url: str) -> None: | ||
| logger.warning( | ||
| "[DEV] Password reset link for %s → %s", | ||
| to_email, | ||
| reset_url, | ||
| ) | ||
|
|
||
|
|
||
| def _send_via_resend(to_email: str, reset_url: str) -> None: # pragma: no cover | ||
| import resend # type: ignore[import] | ||
|
|
||
| api_key = os.getenv("RESEND_API_KEY") | ||
| if not api_key: | ||
| logger.error("Cannot send email via Resend: RESEND_API_KEY is not set.") | ||
| return | ||
| resend.api_key = api_key | ||
| from_addr = os.getenv("EMAIL_FROM", "PaperBot <noreply@paperbot.app>") | ||
|
|
||
| resend.Emails.send({ | ||
| "from": from_addr, | ||
| "to": [to_email], | ||
| "subject": "Reset your PaperBot password", | ||
| "html": f""" | ||
| <p>Hi,</p> | ||
| <p>Click the link below to reset your password. This link expires in <strong>1 hour</strong>.</p> | ||
| <p><a href="{reset_url}">{reset_url}</a></p> | ||
| <p>If you didn't request a password reset, you can ignore this email.</p> | ||
| """, | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import os | ||
| from datetime import datetime, timedelta, timezone | ||
| from jose import jwt | ||
|
|
||
| SECRET_KEY = os.environ.get("PAPERBOT_JWT_SECRET", "change-me-in-production") | ||
| ALGORITHM = "HS256" | ||
| ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days | ||
|
|
||
|
|
||
| def create_access_token(user_id: int) -> str: | ||
| expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | ||
| payload = {"sub": str(user_id), "exp": expire} | ||
| return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) | ||
|
|
||
|
|
||
| def decode_token(token: str) -> int: | ||
| payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) | ||
| return int(payload["sub"]) # raises if missing/invalid | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import bcrypt | ||
|
|
||
|
|
||
| def hash_password(password: str) -> str: | ||
| return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() | ||
|
|
||
|
|
||
| def verify_password(plain: str, hashed: str) -> bool: | ||
| if not hashed: | ||
| return False | ||
| return bcrypt.checkpw(plain.encode(), hashed.encode()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The index creation logic in this migration is not idempotent. The
create_indexcalls are inside theif "password_reset_tokens" not in inspector.get_table_names()block. If the table already exists but the indexes are missing (e.g., due to a partial failure on a previous run), re-running the migration will not create them. To improve robustness, the index creation should be moved outside this block and should check for the existence of each index before creation, similar to the pattern used in the0024_users.pymigration.