feat(auth): make email the unique login identifier
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

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).
This commit is contained in:
kitos
2026-07-23 14:39:36 +02:00
parent 0a6cb5510c
commit 504dfc52f5
21 changed files with 184 additions and 81 deletions
+4 -4
View File
@@ -19,15 +19,15 @@ _DUMMY_HASH = "$2b$12$LJ3m4ys3Lg3dMO/NpNmOaeVwFpWJMxlB2FLmEAo9fZr.S8H1vC4Wy"
# Define function authenticate_user
def authenticate_user(db: Session, *, username: str, password: str) -> 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.username == username).first()
user = db.query(User).filter(User.username == username).first()
# 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)
@@ -36,7 +36,7 @@ def authenticate_user(db: Session, *, username: str, password: str) -> User:
# Check: user is None or not password_valid
if user is None or not password_valid:
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Incorrect username or password")
raise BusinessRuleViolation("Incorrect email or password")
# Check: not user.is_active
if not user.is_active:
# Raise PermissionViolation
+5 -4
View File
@@ -177,8 +177,9 @@ def process_callback(db: Session, request_data: dict) -> User:
name_id = auth.get_nameid()
# Attribute claim URIs — defaults support both plain names and Azure AD full URIs
# (attr_username is no longer read: email is the sole login identifier
# platform-wide, username always mirrors it — see below.)
email_attr = cfg.attr_email or "email"
username_attr = cfg.attr_username or "email" # Azure AD: use email as username
role_attr = cfg.attr_role or "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
# Resolve email: try configured attr → Azure email claim URI → NameID
@@ -189,9 +190,9 @@ def process_callback(db: Session, request_data: dict) -> User:
or ""
)
# Resolve username: keep full email (e.g. user@company.com) to avoid collisions with local accounts
raw_username = _first_attr(attrs, username_attr) or email or name_id or ""
username = raw_username.strip() or email.split("@")[0] or name_id
# Email is the unique login identifier platform-wide — username always
# mirrors it (see User model), never a separately-provisioned IdP value.
username = email
# Resolve role: try configured attr → Azure role claim URI → default
role = (
+19 -10
View File
@@ -45,27 +45,28 @@ def create_user(
# Entry: db
db: Session,
*,
# Entry: username
username: str,
# Entry: email
email: str | None,
email: str,
# Entry: password
password: str,
# Entry: role
role: str,
) -> User:
"""Create a new user.
"""Create a new user. Email is the unique login identifier.
Raises DuplicateEntityError if username already exists.
``username`` is derived from ``email`` — it's kept internally (JWT,
audit logs) but always mirrors email, never a separate value.
Raises DuplicateEntityError if a user with this email already exists.
Raises BusinessRuleViolation if role is invalid.
Does not commit; the router handles that.
"""
# Assign existing = db.query(User).filter(User.username == username).first()
existing = db.query(User).filter(User.username == username).first()
# Assign existing = db.query(User).filter(User.email == email).first()
existing = db.query(User).filter(User.email == email).first()
# Check: existing
if existing:
# Raise DuplicateEntityError
raise DuplicateEntityError("User", "username", username)
raise DuplicateEntityError("User", "email", email)
# Check: role not in VALID_ROLES
if role not in VALID_ROLES:
@@ -77,7 +78,7 @@ def create_user(
# Assign user = User(
user = User(
# Keyword argument: username
username=username,
username=email,
# Keyword argument: email
email=email,
# Keyword argument: hashed_password
@@ -102,7 +103,7 @@ def create_user_without_password(db: Session, *, full_name: str, email: str, rol
exists. Raises BusinessRuleViolation if role is invalid. Does not
commit; the router handles that.
"""
existing = db.query(User).filter(User.username == email).first()
existing = db.query(User).filter(User.email == email).first()
if existing:
raise DuplicateEntityError("User", "email", email)
@@ -171,6 +172,14 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User:
# Assign update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
# Email is the unique login identifier — enforce uniqueness on change,
# and keep username (the internal id) mirrored to it.
if update_data.get("email") is not None and update_data["email"] != user.email:
existing = db.query(User).filter(User.email == update_data["email"], User.id != user_id).first()
if existing:
raise DuplicateEntityError("User", "email", update_data["email"])
update_data["username"] = update_data["email"]
# Iterate over update_data.items()
for field, value in update_data.items():
# Call setattr()