Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions alembic/versions/0024_users.py
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")

40 changes: 40 additions & 0 deletions alembic/versions/0025_password_reset_tokens.py
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"])
Comment on lines +23 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The index creation logic in this migration is not idempotent. The create_index calls are inside the if "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 the 0024_users.py migration.

Suggested change
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"])
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),
)
existing_indexes = {i["name"] for i in inspector.get_indexes("password_reset_tokens")}
if "ix_prt_token" not in existing_indexes:
op.create_index("ix_prt_token", "password_reset_tokens", ["token"], unique=True)
if "ix_prt_user_id" not in existing_indexes:
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")
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [
"beautifulsoup4>=4.11.0",
"cryptography>=41.0.0",
"lxml>=4.9.0",
"pydantic>=2.0",
"pydantic[email]>=2.0",
"loguru>=0.7.0",
"openai>=1.0.0",
"anthropic>=0.3.0",
Expand All @@ -56,6 +56,8 @@ dependencies = [
"json-repair>=0.22.0",
"arq>=0.25.0,<0.26.0",
"redis>=5.0.0",
"python-jose[cryptography]>=3.3.0",
"bcrypt>=4.0.0",
]

[project.optional-dependencies]
Expand Down
9 changes: 7 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ python-dotenv>=0.19.0
cryptography>=41.0.0
keyring>=25.0.0

# 配置模型
pydantic>=2.0.0
# 配置模型(需要 EmailStr 支持)
pydantic[email]>=2.0.0

# 日志和调试
colorlog>=6.7.0
Expand Down Expand Up @@ -101,3 +101,8 @@ sqlite-vec>=0.1.6

# Fuzzy string matching for paper deduplication
rapidfuzz>=3.0.0

# Authentication (JWT + password hashing)
python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0
resend>=0.7.0
68 changes: 68 additions & 0 deletions src/paperbot/api/auth/dependencies.py
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)
51 changes: 51 additions & 0 deletions src/paperbot/api/auth/email.py
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>
""",
})
19 changes: 19 additions & 0 deletions src/paperbot/api/auth/jwt.py
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

11 changes: 11 additions & 0 deletions src/paperbot/api/auth/password.py
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())
2 changes: 2 additions & 0 deletions src/paperbot/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
intelligence,
push_commands,
agent_board,
auth,
)
from paperbot.api.error_handling import install_api_error_handling
from paperbot.infrastructure.event_log.logging_event_log import LoggingEventLog
Expand Down Expand Up @@ -90,6 +91,7 @@ async def health_check():
app.include_router(intelligence.router, prefix="/api", tags=["Intelligence"])
app.include_router(push_commands.router, prefix="/api", tags=["Push"])
app.include_router(agent_board.router, tags=["Agent Board"])
app.include_router(auth.router)


@app.on_event("startup")
Expand Down
Loading
Loading