555e282cc4
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled
213 lines
6.1 KiB
Python
213 lines
6.1 KiB
Python
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, EmailStr
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from auth import (
|
|
consume_refresh_jti,
|
|
create_access_token,
|
|
create_refresh_token,
|
|
decode_token,
|
|
dummy_verify_password,
|
|
hash_password,
|
|
require_auth,
|
|
verify_password,
|
|
)
|
|
from db import get_db
|
|
from models import User
|
|
from rate_limit import limiter
|
|
from schemas import MessageResponse
|
|
from telemetry import capture_admin_registered, capture_onboarding_completed
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
MIN_PASSWORD_LENGTH = 8
|
|
|
|
|
|
def _require_password_length(password: str) -> None:
|
|
if len(password) < MIN_PASSWORD_LENGTH:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Password must be at least {MIN_PASSWORD_LENGTH} characters.",
|
|
)
|
|
|
|
|
|
class RegisterRequest(BaseModel):
|
|
name: str
|
|
email: EmailStr
|
|
password: str
|
|
|
|
|
|
class OnboardingCompleteRequest(BaseModel):
|
|
use_case: str
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
email: EmailStr
|
|
password: str
|
|
|
|
|
|
class RefreshRequest(BaseModel):
|
|
refresh_token: str
|
|
|
|
|
|
class UpdateProfileRequest(BaseModel):
|
|
name: str | None = None
|
|
email: EmailStr | None = None
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str = "bearer"
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
id: uuid.UUID
|
|
name: str
|
|
email: str
|
|
role: str
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class SetupStatusResponse(BaseModel):
|
|
needsSetup: bool
|
|
|
|
|
|
@router.get("/setup-status", response_model=SetupStatusResponse)
|
|
def setup_status(db: Session = Depends(get_db)):
|
|
count = db.scalar(select(func.count(User.id)))
|
|
return SetupStatusResponse(needsSetup=count == 0)
|
|
|
|
|
|
@router.post("/register", response_model=TokenResponse)
|
|
@limiter.limit("5/minute")
|
|
def register(request: Request, body: RegisterRequest, db: Session = Depends(get_db)):
|
|
"""Create the first admin account. Blocked once any user exists."""
|
|
_require_password_length(body.password)
|
|
|
|
if db.scalar(select(func.count(User.id))) > 0:
|
|
raise HTTPException(status_code=403, detail="Registration is closed. An admin account already exists.")
|
|
|
|
user = User(
|
|
name=body.name,
|
|
email=body.email,
|
|
password_hash=hash_password(body.password),
|
|
role="admin",
|
|
)
|
|
db.add(user)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=403, detail="Registration is closed. An admin account already exists.")
|
|
db.refresh(user)
|
|
|
|
capture_admin_registered(email=body.email)
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(str(user.id), user.role),
|
|
refresh_token=create_refresh_token(str(user.id), db),
|
|
)
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
@limiter.limit("10/minute")
|
|
def login(request: Request, body: LoginRequest, db: Session = Depends(get_db)):
|
|
user = db.scalar(select(User).where(User.email == body.email))
|
|
if user is None:
|
|
dummy_verify_password()
|
|
raise HTTPException(status_code=401, detail="Invalid email or password.")
|
|
if not verify_password(body.password, user.password_hash):
|
|
raise HTTPException(status_code=401, detail="Invalid email or password.")
|
|
|
|
user.last_login_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(str(user.id), user.role),
|
|
refresh_token=create_refresh_token(str(user.id), db),
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
@limiter.limit("20/minute")
|
|
def refresh(request: Request, body: RefreshRequest, db: Session = Depends(get_db)):
|
|
payload = decode_token(body.refresh_token)
|
|
if payload.get("type") != "refresh":
|
|
raise HTTPException(status_code=401, detail="Invalid token type.")
|
|
|
|
jti = payload.get("jti")
|
|
if not jti:
|
|
raise HTTPException(status_code=401, detail="Refresh token is no longer valid.")
|
|
|
|
user = db.get(User, payload["sub"])
|
|
if user is None:
|
|
raise HTTPException(status_code=401, detail="User not found.")
|
|
|
|
consume_refresh_jti(jti, db)
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(str(user.id), user.role),
|
|
refresh_token=create_refresh_token(str(user.id), db),
|
|
)
|
|
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
def me(user: User = Depends(require_auth)):
|
|
return user
|
|
|
|
|
|
@router.patch("/me", response_model=UserResponse)
|
|
def update_me(
|
|
body: UpdateProfileRequest,
|
|
user: User = Depends(require_auth),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
if body.name is not None and body.name.strip():
|
|
user.name = body.name.strip()
|
|
|
|
if body.email is not None and body.email != user.email:
|
|
collision = db.scalar(select(User).where(User.email == body.email, User.id != user.id))
|
|
if collision is not None:
|
|
raise HTTPException(status_code=409, detail="Email is already in use.")
|
|
user.email = body.email
|
|
|
|
db.commit()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
@router.post("/change-password", response_model=MessageResponse)
|
|
def change_password(
|
|
body: ChangePasswordRequest,
|
|
user: User = Depends(require_auth),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
if not verify_password(body.current_password, user.password_hash):
|
|
raise HTTPException(status_code=401, detail="Current password is incorrect.")
|
|
|
|
_require_password_length(body.new_password)
|
|
|
|
user.password_hash = hash_password(body.new_password)
|
|
db.commit()
|
|
return MessageResponse(message="Password updated.")
|
|
|
|
|
|
@router.post("/onboarding-complete", response_model=MessageResponse)
|
|
def onboarding_complete(body: OnboardingCompleteRequest, user: User = Depends(require_auth)):
|
|
"""Fire the one-shot telemetry event after the setup wizard reaches its success state."""
|
|
capture_onboarding_completed(email=user.email, use_case=body.use_case)
|
|
return MessageResponse(message="Onboarding completed.")
|