Files
Aegis/backend/app/services/auth_service.py
T
kitos 504dfc52f5
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
feat(auth): make email the unique login identifier
Login is now by email, not username. username still exists internally
(JWT sub claim, audit logs, Jira actor attribution, SSO provisioning all
still key off it) but is now always kept equal to email everywhere a user
is created or their email changes — never a separately-chosen value.

- User.email is now unique + NOT NULL (migration b067 backfills any
  missing/blank email from username first, so existing rows — notably
  the seeded admin, which historically had none — never violate it).
- /auth/login and the (unused but updated for consistency)
  authenticate_user() now query by email.
- create_user (legacy, unreferenced but kept) and
  create_user_without_password both derive username from email.
- update_user keeps username in sync when email changes, and rejects
  duplicate emails.
- seed.py reads ADMIN_EMAIL (new env var, wired through install.sh and
  docker-compose.prod.yml) for the initial admin; falls back to an
  email-shaped ADMIN_USERNAME or a placeholder that's flagged for the
  operator to fix.
- admin_config.py's import bundle now matches/creates users by email,
  skipping (not crashing on) entries with no email.
- sso_service.py always sets username = email for SSO-provisioned users.
- LoginPage/auth.ts updated to email input/copy (wire field name stays
  'username' — that's the OAuth2PasswordRequestForm spec, not the value).
2026-07-23 14:39:36 +02:00

75 lines
2.7 KiB
Python

"""Authentication service — credential validation and password management."""
# Enable future language features for compatibility
from __future__ import annotations
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import hash_password, verify_password from app.auth
from app.auth import hash_password, verify_password
# Import BusinessRuleViolation, PermissionViolation from app.domain.errors
from app.domain.errors import BusinessRuleViolation, PermissionViolation
# Import User from app.models.user
from app.models.user import User
# Assign _DUMMY_HASH = "$2b$12$LJ3m4ys3Lg3dMO/NpNmOaeVwFpWJMxlB2FLmEAo9fZr.S8H1vC4Wy"
_DUMMY_HASH = "$2b$12$LJ3m4ys3Lg3dMO/NpNmOaeVwFpWJMxlB2FLmEAo9fZr.S8H1vC4Wy"
# Define function authenticate_user
def authenticate_user(db: Session, *, email: str, password: str) -> User:
"""Validate credentials and return the User.
Raises BusinessRuleViolation for invalid credentials.
Raises PermissionViolation for disabled account.
Uses constant-time comparison to prevent timing attacks.
"""
# Assign user = db.query(User).filter(User.email == email).first()
user = db.query(User).filter(User.email == email).first()
# Assign hashed = user.hashed_password if user else _DUMMY_HASH
hashed = user.hashed_password if user else _DUMMY_HASH
# Assign password_valid = verify_password(password, hashed)
password_valid = verify_password(password, hashed)
# Check: user is None or not password_valid
if user is None or not password_valid:
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Incorrect email or password")
# Check: not user.is_active
if not user.is_active:
# Raise PermissionViolation
raise PermissionViolation("Account is disabled. Contact an administrator.")
# Return user
return user
# Define function change_password
def change_password(
# Entry: db
db: Session,
# Entry: user
user: User,
*,
# Entry: current_password
current_password: str,
# Entry: new_password
new_password: str,
) -> None:
"""Change a user's password. Does NOT commit.
Raises BusinessRuleViolation if current password is wrong.
"""
# Check: not verify_password(current_password, user.hashed_password)
if not verify_password(current_password, user.hashed_password):
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Current password is incorrect")
if verify_password(new_password, user.hashed_password):
raise BusinessRuleViolation(
"New password must be different from the current password"
)
user.hashed_password = hash_password(new_password)
# Assign user.must_change_password = False
user.must_change_password = False