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
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user