Files
space-booking/backend/app/api/auth.py
Claude Agent 7ce430cc1d feat(security): harden for production deployment
- auth: first registered user becomes superadmin (active immediately)
- entrypoint: no longer seeds demo data in prod (opt-in via RUN_SEED=1)
- config: refuse to boot in prod with weak/placeholder SECRET_KEY (<32 chars)
- main: restrict CORS to FRONTEND_URL only in prod (localhost dev-only)
- seed_db: block prod seeding, read passwords from env, stop printing them
- login: remove demo account credentials from UI

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:44:20 +00:00

229 lines
6.6 KiB
Python

"""Authentication endpoints."""
from datetime import datetime, timedelta
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import get_db
from app.core.security import create_access_token, get_password_hash, verify_password
from app.models.user import User
from app.schemas.auth import EmailVerificationRequest, LoginRequest, UserRegister
from app.schemas.user import Token
from app.services.email_service import send_email
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=Token)
def login(
login_data: LoginRequest,
db: Annotated[Session, Depends(get_db)],
) -> Token:
"""
Login with email and password.
Returns JWT token for authenticated requests.
"""
user = db.query(User).filter(User.email == login_data.email).first()
if not user or not verify_password(login_data.password, str(user.hashed_password)):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled",
)
access_token = create_access_token(subject=int(user.id))
return Token(access_token=access_token, token_type="bearer")
@router.post("/register", status_code=201)
async def register(
data: UserRegister,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
) -> dict:
"""
Register new user with email verification.
Creates an inactive user account and sends verification email.
"""
# Check if email already exists
existing = db.query(User).filter(User.email == data.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
# The very first user to register becomes the superadmin (the instance
# owner) and is activated immediately, so the platform can be bootstrapped
# without depending on email/SMTP delivery.
is_first_user = db.query(User).count() == 0
user = User(
email=data.email,
hashed_password=get_password_hash(data.password),
full_name=data.full_name,
organization=data.organization,
role="superadmin" if is_first_user else "user",
is_active=is_first_user, # First user active immediately; others verify email
)
db.add(user)
db.commit()
db.refresh(user)
# First user is already active — no verification email needed.
if is_first_user:
return {
"message": "Admin account created. You can log in now.",
"email": user.email,
}
# Generate verification token (JWT, expires in 24h)
verification_token = jwt.encode(
{
"sub": str(user.id),
"type": "email_verification",
"exp": datetime.utcnow() + timedelta(hours=24),
},
settings.secret_key,
algorithm="HS256",
)
# Send verification email (background task)
verification_link = f"{settings.frontend_url}/verify?token={verification_token}"
subject = "Verifică contul tău - Space Booking"
body = f"""Bună ziua {user.full_name},
Bine ai venit pe platforma Space Booking!
Pentru a-ți activa contul, te rugăm să accesezi link-ul de mai jos:
{verification_link}
Link-ul va expira în 24 de ore.
Dacă nu ai creat acest cont, te rugăm să ignori acest email.
Cu stimă,
Echipa Space Booking
"""
background_tasks.add_task(send_email, user.email, subject, body)
return {
"message": "Registration successful. Please check your email to verify your account.",
"email": user.email,
}
@router.post("/verify")
def verify_email(
data: EmailVerificationRequest,
db: Annotated[Session, Depends(get_db)],
) -> dict:
"""
Verify email address with token.
Activates the user account if token is valid.
"""
try:
# Decode token
payload = jwt.decode(
data.token,
settings.secret_key,
algorithms=["HS256"],
)
# Check token type
if payload.get("type") != "email_verification":
raise HTTPException(status_code=400, detail="Invalid verification token")
user_id = int(payload.get("sub"))
# Get user
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check if already verified
if user.is_active:
return {"message": "Email already verified"}
# Activate user
user.is_active = True
db.commit()
return {"message": "Email verified successfully. You can now log in."}
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=400,
detail="Verification link expired. Please request a new one.",
)
except JWTError:
raise HTTPException(status_code=400, detail="Invalid verification token")
@router.post("/resend-verification")
async def resend_verification(
email: str,
background_tasks: BackgroundTasks,
db: Annotated[Session, Depends(get_db)],
) -> dict:
"""
Resend verification email.
For security, always returns success even if email doesn't exist.
"""
user = db.query(User).filter(User.email == email).first()
if not user:
# Don't reveal if email exists
return {"message": "If the email exists, a verification link has been sent."}
if user.is_active:
raise HTTPException(status_code=400, detail="Account already verified")
# Generate new token
verification_token = jwt.encode(
{
"sub": str(user.id),
"type": "email_verification",
"exp": datetime.utcnow() + timedelta(hours=24),
},
settings.secret_key,
algorithm="HS256",
)
# Send email
verification_link = f"{settings.frontend_url}/verify?token={verification_token}"
subject = "Verifică contul tău - Space Booking"
body = f"""Bună ziua {user.full_name},
Ai solicitat un nou link de verificare pentru contul tău pe Space Booking.
Pentru a-ți activa contul, te rugăm să accesezi link-ul de mai jos:
{verification_link}
Link-ul va expira în 24 de ore.
Cu stimă,
Echipa Space Booking
"""
background_tasks.add_task(send_email, user.email, subject, body)
return {"message": "If the email exists, a verification link has been sent."}