mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-14 01:06:55 +00:00
feat: Add Single User authentication to Selfhosted (#870)
* Single user/password for selfhosted * fix revision id latest migration
This commit is contained in:
committed by
GitHub
parent
2ba0d965e8
commit
c8db37362b
201
server/tests/test_auth_password.py
Normal file
201
server/tests/test_auth_password.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Tests for the password auth backend."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from jose import jwt
|
||||
|
||||
from reflector.auth.password_utils import hash_password
|
||||
from reflector.settings import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def password_app():
|
||||
"""Create a minimal FastAPI app with the password auth router."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from reflector.auth import auth_password
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(auth_password.router, prefix="/v1")
|
||||
# Reset rate limiter between tests
|
||||
auth_password._login_attempts.clear()
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def password_client(password_app):
|
||||
"""Create a test client for the password auth app."""
|
||||
async with AsyncClient(app=password_app, base_url="http://test/v1") as client:
|
||||
yield client
|
||||
|
||||
|
||||
async def _create_user_with_password(email: str, password: str):
|
||||
"""Helper to create a user with a password hash in the DB."""
|
||||
from reflector.db.users import user_controller
|
||||
from reflector.utils import generate_uuid4
|
||||
|
||||
pw_hash = hash_password(password)
|
||||
return await user_controller.create_or_update(
|
||||
id=generate_uuid4(),
|
||||
authentik_uid=f"local:{email}",
|
||||
email=email,
|
||||
password_hash=pw_hash,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(password_client, setup_database):
|
||||
await _create_user_with_password("admin@test.com", "testpass123")
|
||||
|
||||
response = await password_client.post(
|
||||
"/auth/login",
|
||||
json={"email": "admin@test.com", "password": "testpass123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert data["expires_in"] > 0
|
||||
|
||||
# Verify the JWT is valid
|
||||
payload = jwt.decode(
|
||||
data["access_token"],
|
||||
settings.SECRET_KEY,
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
assert payload["email"] == "admin@test.com"
|
||||
assert "sub" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(password_client, setup_database):
|
||||
await _create_user_with_password("user@test.com", "correctpassword")
|
||||
|
||||
response = await password_client.post(
|
||||
"/auth/login",
|
||||
json={"email": "user@test.com", "password": "wrongpassword"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_nonexistent_user(password_client, setup_database):
|
||||
response = await password_client.post(
|
||||
"/auth/login",
|
||||
json={"email": "nobody@test.com", "password": "anything"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_user_without_password_hash(password_client, setup_database):
|
||||
"""User exists but has no password_hash (e.g. Authentik user)."""
|
||||
from reflector.db.users import user_controller
|
||||
from reflector.utils import generate_uuid4
|
||||
|
||||
await user_controller.create_or_update(
|
||||
id=generate_uuid4(),
|
||||
authentik_uid="authentik:abc123",
|
||||
email="oidc@test.com",
|
||||
)
|
||||
|
||||
response = await password_client.post(
|
||||
"/auth/login",
|
||||
json={"email": "oidc@test.com", "password": "anything"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_rate_limiting(password_client, setup_database):
|
||||
from reflector.auth import auth_password
|
||||
|
||||
# Reset rate limiter
|
||||
auth_password._login_attempts.clear()
|
||||
|
||||
for _ in range(10):
|
||||
await password_client.post(
|
||||
"/auth/login",
|
||||
json={"email": "fake@test.com", "password": "wrong"},
|
||||
)
|
||||
|
||||
# 11th attempt should be rate-limited
|
||||
response = await password_client.post(
|
||||
"/auth/login",
|
||||
json={"email": "fake@test.com", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_create_and_verify():
|
||||
from reflector.auth.auth_password import _create_access_token, _verify_token
|
||||
|
||||
token, expires_in = _create_access_token("user-123", "test@example.com")
|
||||
assert expires_in > 0
|
||||
|
||||
payload = _verify_token(token)
|
||||
assert payload["sub"] == "user-123"
|
||||
assert payload["email"] == "test@example.com"
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_with_jwt():
|
||||
from reflector.auth.auth_password import (
|
||||
_authenticate_user,
|
||||
_create_access_token,
|
||||
)
|
||||
|
||||
token, _ = _create_access_token("user-abc", "abc@test.com")
|
||||
user = await _authenticate_user(token, None)
|
||||
|
||||
assert user is not None
|
||||
assert user.sub == "user-abc"
|
||||
assert user.email == "abc@test.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_invalid_jwt():
|
||||
from fastapi import HTTPException
|
||||
|
||||
from reflector.auth.auth_password import _authenticate_user
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _authenticate_user("invalid.jwt.token", None)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_no_credentials():
|
||||
from reflector.auth.auth_password import _authenticate_user
|
||||
|
||||
user = await _authenticate_user(None, None)
|
||||
assert user is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_user_raises_without_token():
|
||||
"""Verify that current_user dependency raises 401 without token."""
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from reflector.auth import auth_password
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/test")
|
||||
async def test_endpoint(user=Depends(auth_password.current_user)):
|
||||
return {"user": user.sub}
|
||||
|
||||
# Use sync TestClient for simplicity
|
||||
client = TestClient(app)
|
||||
response = client.get("/test")
|
||||
# OAuth2PasswordBearer with auto_error=False returns None, then current_user raises 401
|
||||
assert response.status_code == 401
|
||||
97
server/tests/test_create_admin.py
Normal file
97
server/tests/test_create_admin.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Tests for admin user creation logic (used by create_admin CLI tool)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from reflector.auth.password_utils import hash_password, verify_password
|
||||
from reflector.db.users import user_controller
|
||||
from reflector.utils import generate_uuid4
|
||||
|
||||
|
||||
async def _provision_admin(email: str, password: str):
|
||||
"""Mirrors the logic in create_admin.create_admin() without managing DB connections."""
|
||||
password_hash = hash_password(password)
|
||||
|
||||
existing = await user_controller.get_by_email(email)
|
||||
if existing:
|
||||
await user_controller.set_password_hash(existing.id, password_hash)
|
||||
else:
|
||||
await user_controller.create_or_update(
|
||||
id=generate_uuid4(),
|
||||
authentik_uid=f"local:{email}",
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_new_user(setup_database):
|
||||
await _provision_admin("newadmin@test.com", "password123")
|
||||
|
||||
user = await user_controller.get_by_email("newadmin@test.com")
|
||||
assert user is not None
|
||||
assert user.email == "newadmin@test.com"
|
||||
assert user.authentik_uid == "local:newadmin@test.com"
|
||||
assert user.password_hash is not None
|
||||
assert verify_password("password123", user.password_hash)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_updates_existing(setup_database):
|
||||
# Create first
|
||||
await _provision_admin("admin@test.com", "oldpassword")
|
||||
user1 = await user_controller.get_by_email("admin@test.com")
|
||||
|
||||
# Update password
|
||||
await _provision_admin("admin@test.com", "newpassword")
|
||||
user2 = await user_controller.get_by_email("admin@test.com")
|
||||
|
||||
assert user1.id == user2.id # same user, not duplicated
|
||||
assert verify_password("newpassword", user2.password_hash)
|
||||
assert not verify_password("oldpassword", user2.password_hash)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_idempotent(setup_database):
|
||||
await _provision_admin("admin@test.com", "samepassword")
|
||||
await _provision_admin("admin@test.com", "samepassword")
|
||||
|
||||
# Should only have one user
|
||||
users = await user_controller.list_all()
|
||||
admin_users = [u for u in users if u.email == "admin@test.com"]
|
||||
assert len(admin_users) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_or_update_with_password_hash(setup_database):
|
||||
"""Test the extended create_or_update method with password_hash parameter."""
|
||||
pw_hash = hash_password("test123")
|
||||
user = await user_controller.create_or_update(
|
||||
id=generate_uuid4(),
|
||||
authentik_uid="local:test@example.com",
|
||||
email="test@example.com",
|
||||
password_hash=pw_hash,
|
||||
)
|
||||
|
||||
assert user.password_hash == pw_hash
|
||||
|
||||
fetched = await user_controller.get_by_email("test@example.com")
|
||||
assert fetched is not None
|
||||
assert verify_password("test123", fetched.password_hash)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_password_hash(setup_database):
|
||||
"""Test the set_password_hash method."""
|
||||
user = await user_controller.create_or_update(
|
||||
id=generate_uuid4(),
|
||||
authentik_uid="local:pw@test.com",
|
||||
email="pw@test.com",
|
||||
)
|
||||
assert user.password_hash is None
|
||||
|
||||
pw_hash = hash_password("newpass")
|
||||
await user_controller.set_password_hash(user.id, pw_hash)
|
||||
|
||||
updated = await user_controller.get_by_email("pw@test.com")
|
||||
assert updated is not None
|
||||
assert verify_password("newpass", updated.password_hash)
|
||||
58
server/tests/test_password_utils.py
Normal file
58
server/tests/test_password_utils.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Tests for password hashing utilities."""
|
||||
|
||||
from reflector.auth.password_utils import hash_password, verify_password
|
||||
|
||||
|
||||
def test_hash_and_verify():
|
||||
pw = "my-secret-password"
|
||||
h = hash_password(pw)
|
||||
assert verify_password(pw, h) is True
|
||||
|
||||
|
||||
def test_wrong_password():
|
||||
h = hash_password("correct")
|
||||
assert verify_password("wrong", h) is False
|
||||
|
||||
|
||||
def test_hash_format():
|
||||
h = hash_password("test")
|
||||
parts = h.split("$")
|
||||
assert len(parts) == 3
|
||||
assert parts[0] == "pbkdf2:sha256:100000"
|
||||
assert len(parts[1]) == 32 # 16 bytes hex = 32 chars
|
||||
assert len(parts[2]) == 64 # sha256 hex = 64 chars
|
||||
|
||||
|
||||
def test_different_salts():
|
||||
h1 = hash_password("same")
|
||||
h2 = hash_password("same")
|
||||
assert h1 != h2 # different salts produce different hashes
|
||||
assert verify_password("same", h1) is True
|
||||
assert verify_password("same", h2) is True
|
||||
|
||||
|
||||
def test_malformed_hash():
|
||||
assert verify_password("test", "garbage") is False
|
||||
assert verify_password("test", "") is False
|
||||
assert verify_password("test", "pbkdf2:sha256:100000$short") is False
|
||||
|
||||
|
||||
def test_empty_password():
|
||||
h = hash_password("")
|
||||
assert verify_password("", h) is True
|
||||
assert verify_password("notempty", h) is False
|
||||
|
||||
|
||||
def test_unicode_password():
|
||||
pw = "p\u00e4ssw\u00f6rd\U0001f512"
|
||||
h = hash_password(pw)
|
||||
assert verify_password(pw, h) is True
|
||||
assert verify_password("password", h) is False
|
||||
|
||||
|
||||
def test_constant_time_comparison():
|
||||
"""Verify that hmac.compare_digest is used (structural test)."""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(verify_password)
|
||||
assert "hmac.compare_digest" in source
|
||||
Reference in New Issue
Block a user