from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core.errors import ( ConflictError, DomainValidationError, ForbiddenError, NotFoundError, UnauthorizedError, ) from app.core.security import ( generate_device_key, hash_password, verify_password, ) from app.core.timeutils import utcnow from app.modules.auth.models import DeviceApiKey, User from app.modules.auth.schemas import DeviceKeyCreate, SetupRequest, UserUpdate def setup_required(db: Session) -> bool: return (db.scalar(select(func.count()).select_from(User)) or 0) == 0 def create_first_user(db: Session, data: SetupRequest) -> User: if not setup_required(db): raise ForbiddenError( "Un utilisateur existe déjà : l'assistant de configuration est désactivé." ) user = User( email=data.email.strip().lower(), password_hash=hash_password(data.password), display_name=data.display_name.strip(), ) db.add(user) db.commit() return user def authenticate(db: Session, email: str, password: str) -> User: user = db.scalar(select(User).where(User.email == email.strip().lower())) if ( user is None or not user.is_active or not verify_password(password, user.password_hash) ): raise UnauthorizedError("Adresse e-mail ou mot de passe incorrect.") return user def update_me(db: Session, user: User, data: UserUpdate) -> User: if data.password is not None or ( data.email is not None and data.email.strip().lower() != user.email ): if not data.current_password: raise DomainValidationError( "Le mot de passe actuel est requis pour cette modification." ) if not verify_password(data.current_password, user.password_hash): raise UnauthorizedError("Mot de passe actuel incorrect.") if data.email is not None: email = data.email.strip().lower() if email != user.email: existing = db.scalar(select(User).where(User.email == email)) if existing is not None: raise ConflictError("Cette adresse e-mail est déjà utilisée.") user.email = email if data.display_name is not None: user.display_name = data.display_name.strip() if data.password is not None: user.password_hash = hash_password(data.password) db.commit() return user def list_device_keys(db: Session, user_id: int) -> list[DeviceApiKey]: stmt = ( select(DeviceApiKey) .where(DeviceApiKey.user_id == user_id) .order_by(DeviceApiKey.created_at.desc(), DeviceApiKey.id.desc()) ) return list(db.scalars(stmt).all()) def create_device_key( db: Session, user_id: int, data: DeviceKeyCreate ) -> tuple[DeviceApiKey, str]: """Create a device key; returns (row, plaintext_key). The plaintext key is returned exactly once and never stored.""" full_key, prefix, key_hash = generate_device_key() key = DeviceApiKey( user_id=user_id, name=data.name.strip(), key_prefix=prefix, key_hash=key_hash, scopes=list(data.scopes), ) db.add(key) db.commit() return key, full_key def revoke_device_key(db: Session, user_id: int, key_id: int) -> None: key = db.get(DeviceApiKey, key_id) if key is None or key.user_id != user_id: raise NotFoundError("Clé d'appareil introuvable.") if key.revoked_at is None: key.revoked_at = utcnow() db.commit()