mz_chatbot / app /api /auth.py
Shurem's picture
auth system added
bf0bce6
Raw History Blame Contribute Delete
5.89 kB
"""
api/auth.py -- sign-in endpoints (auth-plan.md).
GET /auth/config public: what the login page needs (client id, domain, dev flag)
POST /auth/google Google ID token -> our session token (+ the user)
POST /auth/signup name + approved email + password -> creates the account and signs in
POST /auth/login approved email + password -> session token
POST /auth/dev-login LOCAL TESTING ONLY (AUTH_DEV_LOGIN=true): email -> session token
GET /auth/me who am I (401 when not signed in)
GET /users active team members (for @mentions and the team tables)
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db, require_access
from app.config import settings
from app.crud import users as crud_users
from app.models import User
from app.schemas.auth import (
AuthConfig,
DevLoginInput,
GoogleLoginInput,
LoginOut,
PasswordLoginInput,
SignupInput,
UserOut,
)
from app.services import auth as auth_service
router = APIRouter(tags=["auth"])
@router.get("/auth/config", response_model=AuthConfig)
def auth_config():
return AuthConfig(
google_client_id=(settings.google_client_id or "").strip(),
allowed_domains=settings.allowed_domain_list,
auth_required=settings.auth_required,
dev_login=settings.auth_dev_login,
signup_code_required=bool((settings.signup_code or "").strip()),
min_password_length=auth_service.MIN_PASSWORD_LENGTH,
)
def _issue(db: Session, email: str, name: str, picture: str) -> LoginOut:
user = crud_users.sign_in(db, email, name, picture)
if user is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="This account has been deactivated.")
try:
token = auth_service.create_token(user.id, user.email, user.name)
except auth_service.AuthError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
return LoginOut(token=token, user=UserOut.from_model(user))
@router.post("/auth/google", response_model=LoginOut)
def google_login(payload: GoogleLoginInput, db: Session = Depends(get_db)):
try:
who = auth_service.verify_google_credential(payload.credential)
except auth_service.AuthError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
return _issue(db, who["email"], who["name"], who["picture"])
_NOT_APPROVED = "This email is not on the approved list. Ask the admin to add it."
@router.post("/auth/signup", response_model=LoginOut)
def signup(payload: SignupInput, db: Session = Depends(get_db)):
"""Create an account with a name, an approved email and a password, and sign in."""
email = payload.email.strip().lower()
if not auth_service.email_allowed(email):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_NOT_APPROVED)
if not auth_service.signup_code_ok(payload.code):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="The invite code is wrong.")
if crud_users.get_user_by_email(db, email) is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="An account with this email already exists. Sign in instead.",
)
user = crud_users.sign_in(db, email, payload.name, "")
crud_users.set_password(db, user, auth_service.hash_password(payload.password))
return _issue(db, email, "", "")
@router.post("/auth/login", response_model=LoginOut)
def password_login(payload: PasswordLoginInput, db: Session = Depends(get_db)):
email = payload.email.strip().lower()
if auth_service.login_throttled(email):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many wrong attempts. Wait 10 minutes and try again.",
)
user = crud_users.get_user_by_email(db, email)
# Same work and same message whether the email exists or not, so the form cannot be used to
# find out who has an account.
ok = auth_service.verify_password(payload.password, (user.password_hash if user else None) or auth_service.dummy_hash())
if not ok:
auth_service.note_login_failure(email)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong email or password.")
# Taking an email off the approved list locks that person out even though the account exists.
if not auth_service.email_allowed(email):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_NOT_APPROVED)
auth_service.clear_login_failures(email)
return _issue(db, email, "", "")
@router.post("/auth/dev-login", response_model=LoginOut)
def dev_login(payload: DevLoginInput, db: Session = Depends(get_db)):
# Hidden (404) unless explicitly enabled, so a production service never advertises it.
if not settings.auth_dev_login:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
email = payload.email.strip().lower()
if not auth_service.email_allowed(email):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_NOT_APPROVED)
return _issue(db, email, email.split("@")[0].replace(".", " ").title(), "")
@router.get("/auth/me", response_model=UserOut)
def me(user: User | None = Depends(get_current_user)):
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not signed in.")
return UserOut.from_model(user)
@router.get("/users", response_model=list[UserOut], dependencies=[Depends(require_access)])
def list_users(db: Session = Depends(get_db)):
return [UserOut.from_model(u) for u in crud_users.list_users(db, active_only=True)]