Add configurable admin password via environment variable
This commit is contained in:
@@ -360,21 +360,36 @@ def create_default_admin(db: Session) -> Optional[User]:
|
||||
"""Create the default admin user if no users exist.
|
||||
|
||||
Returns the created user, or None if users already exist.
|
||||
|
||||
The admin password can be set via ORCHARD_ADMIN_PASSWORD environment variable.
|
||||
If not set, defaults to 'changeme123' and requires password change on first login.
|
||||
"""
|
||||
# Check if any users exist
|
||||
user_count = db.query(User).count()
|
||||
if user_count > 0:
|
||||
return None
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Use configured password or default
|
||||
password = settings.admin_password if settings.admin_password else "changeme123"
|
||||
# Only require password change if using the default password
|
||||
must_change = not settings.admin_password
|
||||
|
||||
# Create default admin
|
||||
auth_service = AuthService(db)
|
||||
admin = auth_service.create_user(
|
||||
username="admin",
|
||||
password="changeme123",
|
||||
password=password,
|
||||
is_admin=True,
|
||||
must_change_password=True,
|
||||
must_change_password=must_change,
|
||||
)
|
||||
|
||||
if settings.admin_password:
|
||||
logger.info("Created default admin user with configured password")
|
||||
else:
|
||||
logger.info("Created default admin user with default password (changeme123)")
|
||||
|
||||
return admin
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
log_format: str = "auto" # "json", "standard", or "auto" (json in production)
|
||||
|
||||
# Initial admin user settings
|
||||
admin_password: str = "" # Initial admin password (if empty, uses 'changeme123')
|
||||
|
||||
# JWT Authentication settings (optional, for external identity providers)
|
||||
jwt_enabled: bool = False # Enable JWT token validation
|
||||
jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS
|
||||
|
||||
@@ -56,6 +56,26 @@ os.environ.setdefault("ORCHARD_S3_BUCKET", "test-bucket")
|
||||
os.environ.setdefault("ORCHARD_S3_ACCESS_KEY_ID", "test")
|
||||
os.environ.setdefault("ORCHARD_S3_SECRET_ACCESS_KEY", "test")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Admin Credentials Helper
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_admin_password() -> str:
|
||||
"""Get the admin password for test authentication.
|
||||
|
||||
Returns the password from ORCHARD_TEST_PASSWORD environment variable,
|
||||
or 'changeme123' as the default for local development.
|
||||
"""
|
||||
return os.environ.get("ORCHARD_TEST_PASSWORD", "changeme123")
|
||||
|
||||
|
||||
def get_admin_username() -> str:
|
||||
"""Get the admin username for test authentication."""
|
||||
return os.environ.get("ORCHARD_TEST_USERNAME", "admin")
|
||||
|
||||
|
||||
# Re-export factory functions for backward compatibility
|
||||
from tests.factories import (
|
||||
create_test_file,
|
||||
|
||||
@@ -8,6 +8,8 @@ allow these tests to run. Production uses strict rate limits (5/minute).
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from tests.conftest import get_admin_password, get_admin_username
|
||||
|
||||
|
||||
# Mark all tests in this module as auth_intensive (informational, not excluded from CI)
|
||||
pytestmark = pytest.mark.auth_intensive
|
||||
@@ -21,11 +23,11 @@ class TestAuthLogin:
|
||||
"""Test successful login with default admin credentials."""
|
||||
response = auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == "admin"
|
||||
assert data["username"] == get_admin_username()
|
||||
assert data["is_admin"] is True
|
||||
assert "orchard_session" in response.cookies
|
||||
|
||||
@@ -34,7 +36,7 @@ class TestAuthLogin:
|
||||
"""Test login with wrong password."""
|
||||
response = auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "wrongpassword"},
|
||||
json={"username": get_admin_username(), "password": "wrongpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert "Invalid username or password" in response.json()["detail"]
|
||||
@@ -58,7 +60,7 @@ class TestAuthLogout:
|
||||
# First login
|
||||
login_response = auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
|
||||
@@ -84,13 +86,13 @@ class TestAuthMe:
|
||||
# Login first
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
|
||||
response = auth_client.get("/api/v1/auth/me")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == "admin"
|
||||
assert data["username"] == get_admin_username()
|
||||
assert data["is_admin"] is True
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
@@ -119,7 +121,7 @@ class TestAuthChangePassword:
|
||||
# Login as admin to create a test user
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
test_username = f"pwchange_{uuid4().hex[:8]}"
|
||||
auth_client.post(
|
||||
@@ -162,7 +164,7 @@ class TestAuthChangePassword:
|
||||
# Login as admin to create a test user
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
test_username = f"pwwrong_{uuid4().hex[:8]}"
|
||||
auth_client.post(
|
||||
@@ -194,7 +196,7 @@ class TestAPIKeys:
|
||||
# Login first
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
|
||||
# Create API key
|
||||
@@ -226,7 +228,7 @@ class TestAPIKeys:
|
||||
# Login and create API key
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/auth/keys",
|
||||
@@ -242,12 +244,12 @@ class TestAPIKeys:
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["username"] == "admin"
|
||||
assert response.json()["username"] == get_admin_username()
|
||||
|
||||
# Clean up
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
auth_client.delete(f"/api/v1/auth/keys/{key_id}")
|
||||
|
||||
@@ -257,7 +259,7 @@ class TestAPIKeys:
|
||||
# Login and create API key
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/auth/keys",
|
||||
@@ -288,14 +290,14 @@ class TestAdminUserManagement:
|
||||
# Login as admin
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
|
||||
response = auth_client.get("/api/v1/admin/users")
|
||||
assert response.status_code == 200
|
||||
users = response.json()
|
||||
assert len(users) >= 1
|
||||
assert any(u["username"] == "admin" for u in users)
|
||||
assert any(u["username"] == get_admin_username() for u in users)
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_create_user(self, auth_client):
|
||||
@@ -303,7 +305,7 @@ class TestAdminUserManagement:
|
||||
# Login as admin
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
|
||||
# Create new user
|
||||
@@ -336,7 +338,7 @@ class TestAdminUserManagement:
|
||||
# Login as admin
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
|
||||
# Create a test user
|
||||
@@ -362,7 +364,7 @@ class TestAdminUserManagement:
|
||||
# Login as admin
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
|
||||
# Create a test user
|
||||
@@ -393,7 +395,7 @@ class TestAdminUserManagement:
|
||||
# Login as admin and create non-admin user
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
test_username = f"nonadmin_{uuid4().hex[:8]}"
|
||||
auth_client.post(
|
||||
@@ -423,7 +425,7 @@ class TestSecurityEdgeCases:
|
||||
# Login as admin and create a user
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
test_username = f"inactive_{uuid4().hex[:8]}"
|
||||
auth_client.post(
|
||||
@@ -451,7 +453,7 @@ class TestSecurityEdgeCases:
|
||||
"""Test that short passwords are rejected when creating users."""
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
|
||||
response = auth_client.post(
|
||||
@@ -467,7 +469,7 @@ class TestSecurityEdgeCases:
|
||||
# Create test user
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
test_username = f"shortchange_{uuid4().hex[:8]}"
|
||||
auth_client.post(
|
||||
@@ -494,7 +496,7 @@ class TestSecurityEdgeCases:
|
||||
"""Test that short passwords are rejected when resetting password."""
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
|
||||
# Create a test user first
|
||||
@@ -516,7 +518,7 @@ class TestSecurityEdgeCases:
|
||||
"""Test that duplicate usernames are rejected."""
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
|
||||
test_username = f"duplicate_{uuid4().hex[:8]}"
|
||||
@@ -541,7 +543,7 @@ class TestSecurityEdgeCases:
|
||||
# Login as admin and create an API key
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
create_response = auth_client.post(
|
||||
"/api/v1/auth/keys",
|
||||
@@ -572,7 +574,7 @@ class TestSecurityEdgeCases:
|
||||
auth_client.cookies.clear()
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
auth_client.delete(f"/api/v1/auth/keys/{admin_key_id}")
|
||||
|
||||
@@ -582,7 +584,7 @@ class TestSecurityEdgeCases:
|
||||
# Create a test user
|
||||
auth_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "changeme123"},
|
||||
json={"username": get_admin_username(), "password": get_admin_password()},
|
||||
)
|
||||
test_username = f"sessiontest_{uuid4().hex[:8]}"
|
||||
auth_client.post(
|
||||
|
||||
95
backend/tests/unit/test_auth.py
Normal file
95
backend/tests/unit/test_auth.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Unit tests for authentication module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestCreateDefaultAdmin:
|
||||
"""Tests for the create_default_admin function."""
|
||||
|
||||
def test_create_default_admin_with_env_password(self):
|
||||
"""Test that ORCHARD_ADMIN_PASSWORD env var sets admin password."""
|
||||
from app.auth import create_default_admin, verify_password
|
||||
|
||||
# Create mock settings with custom password
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.admin_password = "my-custom-password-123"
|
||||
|
||||
# Mock database session
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.count.return_value = 0 # No existing users
|
||||
|
||||
# Track the user that gets created
|
||||
created_user = None
|
||||
|
||||
def capture_user(user):
|
||||
nonlocal created_user
|
||||
created_user = user
|
||||
|
||||
mock_db.add.side_effect = capture_user
|
||||
|
||||
with patch("app.auth.get_settings", return_value=mock_settings):
|
||||
admin = create_default_admin(mock_db)
|
||||
|
||||
# Verify the user was created
|
||||
assert mock_db.add.called
|
||||
assert created_user is not None
|
||||
assert created_user.username == "admin"
|
||||
assert created_user.is_admin is True
|
||||
# Password should NOT require change when set via env var
|
||||
assert created_user.must_change_password is False
|
||||
# Verify password was hashed correctly
|
||||
assert verify_password("my-custom-password-123", created_user.password_hash)
|
||||
|
||||
def test_create_default_admin_with_default_password(self):
|
||||
"""Test that default password 'changeme123' is used when env var not set."""
|
||||
from app.auth import create_default_admin, verify_password
|
||||
|
||||
# Create mock settings with empty password (default)
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.admin_password = ""
|
||||
|
||||
# Mock database session
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.count.return_value = 0 # No existing users
|
||||
|
||||
# Track the user that gets created
|
||||
created_user = None
|
||||
|
||||
def capture_user(user):
|
||||
nonlocal created_user
|
||||
created_user = user
|
||||
|
||||
mock_db.add.side_effect = capture_user
|
||||
|
||||
with patch("app.auth.get_settings", return_value=mock_settings):
|
||||
admin = create_default_admin(mock_db)
|
||||
|
||||
# Verify the user was created
|
||||
assert mock_db.add.called
|
||||
assert created_user is not None
|
||||
assert created_user.username == "admin"
|
||||
assert created_user.is_admin is True
|
||||
# Password SHOULD require change when using default
|
||||
assert created_user.must_change_password is True
|
||||
# Verify default password was used
|
||||
assert verify_password("changeme123", created_user.password_hash)
|
||||
|
||||
def test_create_default_admin_skips_when_users_exist(self):
|
||||
"""Test that no admin is created when users already exist."""
|
||||
from app.auth import create_default_admin
|
||||
|
||||
# Create mock settings
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.admin_password = "some-password"
|
||||
|
||||
# Mock database session with existing users
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.count.return_value = 1 # Users exist
|
||||
|
||||
with patch("app.auth.get_settings", return_value=mock_settings):
|
||||
result = create_default_admin(mock_db)
|
||||
|
||||
# Should return None and not create any user
|
||||
assert result is None
|
||||
assert not mock_db.add.called
|
||||
Reference in New Issue
Block a user