feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)

feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
This commit is contained in:
Sn0rrii
2026-04-13 13:24:28 +02:00
committed by GitHub
parent 8af0966e68
commit ba1c97c808
44 changed files with 10473 additions and 290 deletions
+88 -34
View File
@@ -61,7 +61,7 @@ class TestAuthSetupAPI:
json={
"auth_enabled": True,
"admin_username": "testadmin",
"admin_password": "testpassword123",
"admin_password": "TestPass1!",
},
)
@@ -96,14 +96,14 @@ class TestAuthLoginAPI:
json={
"auth_enabled": True,
"admin_username": "logintest",
"admin_password": "loginpassword123",
"admin_password": "LoginPass1!",
},
)
# Now login
response = await async_client.post(
"/api/v1/auth/login",
json={"username": "logintest", "password": "loginpassword123"},
json={"username": "logintest", "password": "LoginPass1!"},
)
assert response.status_code == 200
@@ -123,7 +123,7 @@ class TestAuthLoginAPI:
json={
"auth_enabled": True,
"admin_username": "invalidtest",
"admin_password": "correctpassword",
"admin_password": "CorrectPass1!",
},
)
@@ -158,13 +158,13 @@ class TestAuthMeAPI:
json={
"auth_enabled": True,
"admin_username": "metest",
"admin_password": "mepassword123",
"admin_password": "MePass1!",
},
)
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "metest", "password": "mepassword123"},
json={"username": "metest", "password": "MePass1!"},
)
token = login_response.json()["access_token"]
@@ -254,13 +254,13 @@ class TestUsersAPI:
json={
"auth_enabled": True,
"admin_username": "usersadmin",
"admin_password": "adminpassword123",
"admin_password": "AdminPass1!",
},
)
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "usersadmin", "password": "adminpassword123"},
json={"username": "usersadmin", "password": "AdminPass1!"},
)
return login_response.json()["access_token"]
@@ -274,7 +274,7 @@ class TestUsersAPI:
json={
"auth_enabled": True,
"admin_username": "authreqadmin",
"admin_password": "adminpassword123",
"admin_password": "AdminPass1!",
},
)
@@ -306,7 +306,7 @@ class TestUsersAPI:
headers={"Authorization": f"Bearer {auth_token}"},
json={
"username": "newuser",
"password": "newuserpassword",
"password": "Newuserpass1!",
"role": "user",
},
)
@@ -327,7 +327,7 @@ class TestUsersAPI:
headers={"Authorization": f"Bearer {auth_token}"},
json={
"username": "duplicateuser",
"password": "password123",
"password": "Password123!",
"role": "user",
},
)
@@ -338,7 +338,7 @@ class TestUsersAPI:
headers={"Authorization": f"Bearer {auth_token}"},
json={
"username": "duplicateuser",
"password": "password456",
"password": "Password456!",
"role": "user",
},
)
@@ -356,7 +356,7 @@ class TestUsersAPI:
headers={"Authorization": f"Bearer {auth_token}"},
json={
"username": "updateuser",
"password": "password123",
"password": "Password123!",
"role": "user",
},
)
@@ -382,7 +382,7 @@ class TestUsersAPI:
headers={"Authorization": f"Bearer {auth_token}"},
json={
"username": "deleteuser",
"password": "password123",
"password": "Password123!",
"role": "user",
},
)
@@ -410,14 +410,14 @@ class TestAuthDisableAPI:
json={
"auth_enabled": True,
"admin_username": "disableadmin",
"admin_password": "adminpassword123",
"admin_password": "AdminPass1!",
},
)
# Login to get token
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "disableadmin", "password": "adminpassword123"},
json={"username": "disableadmin", "password": "AdminPass1!"},
)
token = login_response.json()["access_token"]
@@ -446,13 +446,13 @@ class TestGroupsAPI:
json={
"auth_enabled": True,
"admin_username": "groupsadmin",
"admin_password": "adminpassword123",
"admin_password": "AdminPass1!",
},
)
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "groupsadmin", "password": "adminpassword123"},
json={"username": "groupsadmin", "password": "AdminPass1!"},
)
return login_response.json()["access_token"]
@@ -592,13 +592,13 @@ class TestUserGroupsAPI:
json={
"auth_enabled": True,
"admin_username": "usergroupadmin",
"admin_password": "adminpassword123",
"admin_password": "AdminPass1!",
},
)
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "usergroupadmin", "password": "adminpassword123"},
json={"username": "usergroupadmin", "password": "AdminPass1!"},
)
return login_response.json()["access_token"]
@@ -619,7 +619,7 @@ class TestUserGroupsAPI:
headers={"Authorization": f"Bearer {auth_token}"},
json={
"username": "groupuser",
"password": "password123",
"password": "Password123!",
"group_ids": [operators_group["id"]],
},
)
@@ -636,7 +636,7 @@ class TestUserGroupsAPI:
user_response = await async_client.post(
"/api/v1/users/",
headers={"Authorization": f"Bearer {auth_token}"},
json={"username": "addtogroup", "password": "password123"},
json={"username": "addtogroup", "password": "Password123!"},
)
user_id = user_response.json()["id"]
@@ -675,13 +675,13 @@ class TestChangePasswordAPI:
json={
"auth_enabled": True,
"admin_username": "pwchangeadmin",
"admin_password": "adminpassword123",
"admin_password": "AdminPass1!",
},
)
admin_login = await async_client.post(
"/api/v1/auth/login",
json={"username": "pwchangeadmin", "password": "adminpassword123"},
json={"username": "pwchangeadmin", "password": "AdminPass1!"},
)
admin_token = admin_login.json()["access_token"]
@@ -689,13 +689,13 @@ class TestChangePasswordAPI:
await async_client.post(
"/api/v1/users/",
headers={"Authorization": f"Bearer {admin_token}"},
json={"username": "pwchangeuser", "password": "oldpassword123"},
json={"username": "pwchangeuser", "password": "Oldpassword123!"},
)
# Login as regular user
user_login = await async_client.post(
"/api/v1/auth/login",
json={"username": "pwchangeuser", "password": "oldpassword123"},
json={"username": "pwchangeuser", "password": "Oldpassword123!"},
)
return user_login.json()["access_token"]
@@ -707,8 +707,8 @@ class TestChangePasswordAPI:
"/api/v1/users/me/change-password",
headers={"Authorization": f"Bearer {user_token}"},
json={
"current_password": "oldpassword123",
"new_password": "newpassword456",
"current_password": "Oldpassword123!",
"new_password": "Newpassword456!",
},
)
@@ -718,7 +718,7 @@ class TestChangePasswordAPI:
# Verify can login with new password
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "pwchangeuser", "password": "newpassword456"},
json={"username": "pwchangeuser", "password": "Newpassword456!"},
)
assert login_response.status_code == 200
@@ -731,7 +731,7 @@ class TestChangePasswordAPI:
headers={"Authorization": f"Bearer {user_token}"},
json={
"current_password": "wrongpassword",
"new_password": "newpassword456",
"new_password": "Newpassword456!",
},
)
@@ -746,7 +746,7 @@ class TestChangePasswordAPI:
"/api/v1/users/me/change-password",
json={
"current_password": "oldpassword",
"new_password": "newpassword",
"new_password": "Strongpass456!",
},
)
@@ -768,7 +768,7 @@ class TestAuthMiddlewarePublicRoutes:
json={
"auth_enabled": True,
"admin_username": "middlewareadmin",
"admin_password": "adminpassword123",
"admin_password": "AdminPass1!",
},
)
@@ -786,7 +786,7 @@ class TestAuthMiddlewarePublicRoutes:
"""Verify /api/v1/auth/login is accessible without auth."""
response = await async_client.post(
"/api/v1/auth/login",
json={"username": "middlewareadmin", "password": "adminpassword123"},
json={"username": "middlewareadmin", "password": "AdminPass1!"},
)
# Should not return 401 (unauthorized) - it should either succeed or return
# a different error (like 400 for wrong credentials)
@@ -826,7 +826,7 @@ class TestAuthMiddlewarePublicRoutes:
# Login to get token
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "middlewareadmin", "password": "adminpassword123"},
json={"username": "middlewareadmin", "password": "AdminPass1!"},
)
token = login_response.json()["access_token"]
@@ -863,3 +863,57 @@ class TestAuthMiddlewarePublicRoutes:
# Will likely be 400 (advanced auth not enabled) but that's okay -
# the important thing is it's not blocked by auth middleware
assert response.status_code in [200, 400]
# ===========================================================================
# H-1: Input length validation
# ===========================================================================
class TestInputLengthValidation:
"""LoginRequest and SetupRequest must reject oversized inputs (H-1)."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_login_password_too_long_rejected(self, async_client: AsyncClient):
"""Password exceeding 256 characters must be rejected with 422."""
response = await async_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "x" * 257},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_login_username_too_long_rejected(self, async_client: AsyncClient):
"""Username exceeding 150 characters must be rejected with 422."""
response = await async_client.post(
"/api/v1/auth/login",
json={"username": "u" * 151, "password": "password"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_setup_password_too_long_rejected(self, async_client: AsyncClient):
"""SetupRequest admin_password exceeding 256 characters must be rejected with 422."""
response = await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "admin",
"admin_password": "x" * 257,
},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_login_password_at_limit_accepted(self, async_client: AsyncClient):
"""Password of exactly 256 characters must pass schema validation (may fail auth)."""
response = await async_client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "x" * 256},
)
# Schema accepts it; auth may reject with 401 (auth disabled) or 400
assert response.status_code != 422