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:
@@ -239,7 +239,7 @@ def admin_token(client, admin_user):
|
||||
"""Get an auth token for the admin user."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
data={"username": "admin@test.com", "password": "admin123"},
|
||||
)
|
||||
return response.json()["access_token"]
|
||||
|
||||
@@ -249,7 +249,7 @@ def red_tech_token(client, red_tech_user):
|
||||
"""Get an auth token for the red_tech user."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "redtech", "password": "redtech123"},
|
||||
data={"username": "redtech@test.com", "password": "redtech123"},
|
||||
)
|
||||
return response.json()["access_token"]
|
||||
|
||||
@@ -271,7 +271,7 @@ def blue_tech_token(client, blue_tech_user):
|
||||
"""Get an auth token for the blue_tech user."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "bluetech", "password": "bluetech123"},
|
||||
data={"username": "bluetech@test.com", "password": "bluetech123"},
|
||||
)
|
||||
return response.json()["access_token"]
|
||||
|
||||
@@ -287,7 +287,7 @@ def red_lead_token(client, red_lead_user):
|
||||
"""Get an auth token for the red_lead user."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "redlead", "password": "redlead123"},
|
||||
data={"username": "redlead@test.com", "password": "redlead123"},
|
||||
)
|
||||
return response.json()["access_token"]
|
||||
|
||||
@@ -303,7 +303,7 @@ def blue_lead_token(client, blue_lead_user):
|
||||
"""Get an auth token for the blue_lead user."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "bluelead", "password": "bluelead123"},
|
||||
data={"username": "bluelead@test.com", "password": "bluelead123"},
|
||||
)
|
||||
return response.json()["access_token"]
|
||||
|
||||
@@ -319,7 +319,7 @@ def manager_token(client, manager_user):
|
||||
"""Get an auth token for the manager user."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "manager", "password": "manager123"},
|
||||
data={"username": "manager@test.com", "password": "manager123"},
|
||||
)
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@@ -105,24 +105,35 @@ def test_import_config_rejects_invalid_json(client, auth_headers):
|
||||
def test_import_config_creates_new_user_with_forced_reset(client, db, auth_headers):
|
||||
resp = client.post(
|
||||
"/api/v1/admin/import-config",
|
||||
json={"users": [{"username": "imported_user", "role": "red_tech", "is_active": True}]},
|
||||
json={"users": [{"username": "imported_user", "email": "imported_user@test.com", "role": "red_tech", "is_active": True}]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["summary"]["users_created"] == 1
|
||||
|
||||
user = db.query(User).filter(User.username == "imported_user").first()
|
||||
user = db.query(User).filter(User.email == "imported_user@test.com").first()
|
||||
assert user is not None
|
||||
assert user.must_change_password is True
|
||||
assert user.role == "red_tech"
|
||||
|
||||
|
||||
def test_import_config_skips_user_with_no_email(client, db, auth_headers):
|
||||
resp = client.post(
|
||||
"/api/v1/admin/import-config",
|
||||
json={"users": [{"username": "no_email_user", "role": "red_tech", "is_active": True}]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["summary"]["users_skipped_no_email"] == 1
|
||||
assert db.query(User).filter(User.username == "no_email_user").first() is None
|
||||
|
||||
|
||||
def test_import_config_updates_existing_user_role_only(client, db, auth_headers, red_tech_user):
|
||||
original_hash = red_tech_user.hashed_password
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/admin/import-config",
|
||||
json={"users": [{"username": red_tech_user.username, "role": "red_lead", "is_active": True}]},
|
||||
json={"users": [{"username": red_tech_user.username, "email": red_tech_user.email, "role": "red_lead", "is_active": True}]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_login_success(client, admin_user):
|
||||
"""Test successful login returns a token."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
data={"username": "admin@test.com", "password": "admin123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -35,7 +35,7 @@ def test_login_wrong_password(client, admin_user):
|
||||
"""Test login with wrong password returns 400."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "wrongpassword"},
|
||||
data={"username": "admin@test.com", "password": "wrongpassword"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
@@ -56,6 +56,7 @@ def test_login_inactive_user(client, db):
|
||||
|
||||
user = User(
|
||||
username="inactive",
|
||||
email="inactive@test.com",
|
||||
hashed_password=hash_password("password"),
|
||||
role="viewer",
|
||||
is_active=False,
|
||||
@@ -65,7 +66,7 @@ def test_login_inactive_user(client, db):
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "inactive", "password": "password"},
|
||||
data={"username": "inactive@test.com", "password": "password"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -106,7 +107,7 @@ def test_logout_revokes_token(client, admin_user):
|
||||
"""
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
data={"username": "admin@test.com", "password": "admin123"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
token = login.json()["access_token"]
|
||||
|
||||
@@ -6,7 +6,7 @@ from app.models.audit import AuditLog
|
||||
def test_login_failed_creates_audit_entry(client, admin_user, db):
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "wrong"},
|
||||
data={"username": "admin@test.com", "password": "wrong"},
|
||||
headers={"X-Forwarded-For": "198.51.100.10", "User-Agent": "LoginAuditTest/1.0"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -19,7 +19,7 @@ def test_login_failed_creates_audit_entry(client, admin_user, db):
|
||||
)
|
||||
assert log is not None
|
||||
assert log.entity_type == "auth"
|
||||
assert log.details["username"] == "admin"
|
||||
assert log.details["email"] == "admin@test.com"
|
||||
assert log.details["reason"] == "invalid_credentials"
|
||||
assert log.ip_address == "198.51.100.10"
|
||||
assert log.user_agent == "LoginAuditTest/1.0"
|
||||
@@ -30,7 +30,7 @@ def test_login_success_creates_audit_entry(client, admin_user, db):
|
||||
client.cookies.clear()
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
data={"username": "admin@test.com", "password": "admin123"},
|
||||
headers={"X-Forwarded-For": "198.51.100.20"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -137,7 +137,7 @@ def test_viewer_cannot_update_classification(client, db, admin_user, red_lead_us
|
||||
)
|
||||
db.add(viewer)
|
||||
db.commit()
|
||||
login = client.post("/api/v1/auth/login", data={"username": "viewer_classif", "password": "x"})
|
||||
login = client.post("/api/v1/auth/login", data={"username": "viewer_classif@test.com", "password": "x"})
|
||||
viewer_token = login.json()["access_token"]
|
||||
|
||||
technique = _seed_technique(db)
|
||||
|
||||
@@ -101,7 +101,7 @@ def test_review_red_forbidden_for_non_assigned_lead(
|
||||
db.add(other_lead)
|
||||
db.commit()
|
||||
|
||||
login = client.post("/api/v1/auth/login", data={"username": "otherredlead", "password": "x"})
|
||||
login = client.post("/api/v1/auth/login", data={"username": "otherredlead@test.com", "password": "x"})
|
||||
assert login.status_code == 200
|
||||
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
|
||||
@@ -176,7 +176,7 @@ def test_review_blue_forbidden_for_non_assigned_lead(
|
||||
db.add(other_lead)
|
||||
db.commit()
|
||||
|
||||
login = client.post("/api/v1/auth/login", data={"username": "otherbluelead", "password": "x"})
|
||||
login = client.post("/api/v1/auth/login", data={"username": "otherbluelead@test.com", "password": "x"})
|
||||
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", other_headers, json={"decision": "approve"})
|
||||
|
||||
@@ -102,7 +102,7 @@ def test_start_execution_twice_returns_invalid_transition(
|
||||
|
||||
rl = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "redtech", "password": "redtech123"},
|
||||
data={"username": "redtech@test.com", "password": "redtech123"},
|
||||
)
|
||||
assert rl.status_code == 200
|
||||
red_headers = {"Authorization": f"Bearer {rl.json()['access_token']}"}
|
||||
|
||||
@@ -953,7 +953,7 @@ class TestReviewerSelection:
|
||||
|
||||
def _make_lead(self, db, username, role="red_lead"):
|
||||
from app.models.user import User
|
||||
u = User(username=username, role=role, hashed_password="x", is_active=True)
|
||||
u = User(username=username, email=f"{username}@test.com", role=role, hashed_password="x", is_active=True)
|
||||
db.add(u)
|
||||
db.flush()
|
||||
return u
|
||||
|
||||
Reference in New Issue
Block a user