Added user admin auto creation for Sqlite

This commit is contained in:
2025-10-29 22:07:27 +01:00
parent 5d869e3793
commit ea7cf786cb
9 changed files with 870 additions and 410 deletions

View File

@@ -351,3 +351,8 @@ pytest tests/
## License ## License
MIT MIT
## Release History
* 0.1.0 - Initial Release
* 0.2.0 - Added admin user auto creation

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "myauth" name = "myauth"
version = "0.1.0" version = "0.2.0"
description = "A reusable, modular authentication system for FastAPI applications with pluggable database backends." description = "A reusable, modular authentication system for FastAPI applications with pluggable database backends."
readme = "README.md" readme = "README.md"
authors = [ authors = [

View File

@@ -4,7 +4,7 @@ FastAPI routes for authentication module.
This module provides ready-to-use FastAPI routes for all authentication This module provides ready-to-use FastAPI routes for all authentication
operations. Routes are organized in an APIRouter with /auth prefix. operations. Routes are organized in an APIRouter with /auth prefix.
""" """
import os
from typing import Annotated from typing import Annotated
from fastapi import Depends, HTTPException, status, FastAPI from fastapi import Depends, HTTPException, status, FastAPI
@@ -376,4 +376,5 @@ def create_auth_app(auth_service: AuthService) -> FastAPI:
""" """
return current_user return current_user
return auth_app return auth_app

View File

@@ -5,15 +5,12 @@ This module provides the main authentication service that orchestrates
all authentication operations including registration, login, token management, all authentication operations including registration, login, token management,
password reset, and email verification. password reset, and email verification.
""" """
import os
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from .password import PasswordManager from .password import PasswordManager
from .token import TokenManager from .token import TokenManager
from ..persistence.base import UserRepository, TokenRepository
from ..models.user import UserCreate, UserInDB, UserUpdate
from ..models.token import AccessTokenResponse, TokenData
from ..emailing.base import EmailService from ..emailing.base import EmailService
from ..exceptions import ( from ..exceptions import (
InvalidCredentialsError, InvalidCredentialsError,
@@ -23,6 +20,9 @@ from ..exceptions import (
InvalidTokenError, InvalidTokenError,
RevokedTokenError RevokedTokenError
) )
from ..models.token import AccessTokenResponse, TokenData
from ..models.user import UserCreate, UserInDB, UserUpdate, UserCreateNoValidation
from ..persistence.base import UserRepository, TokenRepository
class AuthService: class AuthService:
@@ -65,7 +65,44 @@ class AuthService:
self.token_manager = token_manager self.token_manager = token_manager
self.email_service = email_service self.email_service = email_service
def register(self, user_data: UserCreate) -> UserInDB: def create_admin_if_needed(self, admin_email: str = None, admin_username: str = None, admin_password: str = None):
"""
Create the admin user if it does not exist. This function checks the current number
of users in the system. If there are no users present, it creates a user with
administrative privileges using the provided credentials or environment variables
as defaults.
:param admin_email: The email of the admin user. Defaults to "AUTH_ADMIN_EMAIL"
environment variable or "admin@myauth.com" if not provided.
:type admin_email: str, optional
:param admin_username: The username of the admin user. Defaults to
"AUTH_ADMIN_USERNAME" environment variable or "admin" if not provided.
:type admin_username: str, optional
:param admin_password: The password of the admin user. Defaults to
"AUTH_ADMIN_PASSWORD" environment variable or "admin" if not provided.
:type admin_password: str, optional
:return: True if an admin user is created, otherwise False.
:rtype: bool
"""
# create the admin user if it doesn't exist
nb_users = self.count_users()
if nb_users == 0:
admin_email = admin_email or os.getenv("AUTH_ADMIN_EMAIL", "admin@myauth.com")
admin_username = admin_username or os.getenv("AUTH_ADMIN_USERNAME", "admin")
admin_password = admin_password or os.getenv("AUTH_ADMIN_PASSWORD", "admin")
admin_user = UserCreateNoValidation(
email=admin_email,
username=admin_username,
password=admin_password,
roles=["admin"]
)
self.register(admin_user)
return True
return False
def register(self, user_data: UserCreate | UserCreateNoValidation) -> UserInDB:
""" """
Register a new user. Register a new user.
@@ -444,3 +481,34 @@ class AuthService:
raise AccountDisabledError() raise AccountDisabledError()
return user return user
def count_users(self) -> int:
"""
Counts the total number of users.
This method retrieves and returns the total count of users
from the user repository. It provides functionality for
fetching user count stored in the underlying repository of
users.
:return: The total count of users.
:rtype: int
"""
return self.user_repository.count_users()
def list_users(self, skip: int = 0, limit: int = 100):
"""
Lists users from the user repository with optional pagination.
This method retrieves a list of users, allowing optional pagination
by specifying the number of records to skip and the maximum number
of users to retrieve.
:param skip: The number of users to skip in the result set. Defaults to 0.
:type skip: int
:param limit: The maximum number of users to retrieve. Defaults to 100.
:type limit: int
:return: A list of users retrieved from the repository.
:rtype: list
"""
return self.user_repository.list_users(skip, limit)

View File

@@ -33,6 +33,16 @@ class UserBase(BaseModel):
user_settings: dict = Field(default_factory=dict) user_settings: dict = Field(default_factory=dict)
class UserCreateNoValidation(UserBase):
"""
Model for user creation (registration).
This model extends UserBase with a password field
"""
password: str
class UserCreate(UserBase): class UserCreate(UserBase):
""" """
Model for user creation (registration). Model for user creation (registration).

View File

@@ -121,6 +121,31 @@ class UserRepository(ABC):
""" """
pass pass
@abstractmethod
def list_users(self, skip: int = 0, limit: int = 100) -> list[UserInDB]:
"""
List users with pagination.
Args:
skip (int): Number of users to skip (default: 0)
limit (int): Maximum number of users to return (default: 100)
Returns:
List[UserInDB]: List of users
"""
pass
@abstractmethod
def count_users(self) -> int:
"""
Count total number of users.
Returns:
int: Total number of users in system
"""
pass
class TokenRepository(ABC): class TokenRepository(ABC):
""" """

View File

@@ -365,6 +365,50 @@ class SQLiteUserRepository(UserRepository):
cursor.execute("SELECT 1 FROM users WHERE email = ? LIMIT 1", (email,)) cursor.execute("SELECT 1 FROM users WHERE email = ? LIMIT 1", (email,))
return cursor.fetchone() is not None return cursor.fetchone() is not None
def list_users(self, skip: int = 0, limit: int = 100) -> list[UserInDB]:
"""
List users with pagination.
Args:
skip: Number of users to skip (default: 0).
limit: Maximum number of users to return (default: 100).
Returns:
List of users.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id,
email,
username,
hashed_password,
roles,
user_settings,
is_verified,
is_active,
created_at,
updated_at
FROM users
ORDER BY created_at DESC LIMIT ?
OFFSET ?
""", (limit, skip))
rows = cursor.fetchall()
return [self._row_to_user(row) for row in rows]
def count_users(self) -> int:
"""
Count total number of users.
Returns:
Total number of users in system.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM users")
result = cursor.fetchone()
return result[0] if result else 0
class SQLiteTokenRepository(TokenRepository): class SQLiteTokenRepository(TokenRepository):
""" """

View File

@@ -78,6 +78,178 @@ class TestAuthServiceRegisterLogin(object):
with pytest.raises(InvalidCredentialsError): with pytest.raises(InvalidCredentialsError):
auth_service.login("non.existent@example.com", "AnyPassword") auth_service.login("non.existent@example.com", "AnyPassword")
def test_create_admin_if_needed_success_with_custom_credentials(
self,
auth_service: AuthService
):
"""Success: Admin is created with custom credentials when no users exist."""
# Arrange
custom_email = "custom.admin@example.com"
custom_username = "custom_admin"
custom_password = "CustomAdminPass123!"
# Act
result = auth_service.create_admin_if_needed(
admin_email=custom_email,
admin_username=custom_username,
admin_password=custom_password
)
# Assert
assert result is True
# Verify admin user was created
admin_user = auth_service.user_repository.get_user_by_email(custom_email)
assert admin_user is not None
assert admin_user.email == custom_email
assert admin_user.username == custom_username
assert "admin" in admin_user.roles
# Verify password was hashed
auth_service.password_manager.hash_password.assert_called()
def test_create_admin_if_needed_success_with_default_credentials(
self,
auth_service: AuthService,
monkeypatch
):
"""Success: Admin is created with default credentials from environment variables."""
# Arrange
monkeypatch.setenv("AUTH_ADMIN_EMAIL", "env.admin@example.com")
monkeypatch.setenv("AUTH_ADMIN_USERNAME", "env_admin")
monkeypatch.setenv("AUTH_ADMIN_PASSWORD", "EnvAdminPass123!")
# Act
result = auth_service.create_admin_if_needed()
# Assert
assert result is True
# Verify admin user was created with env variables
admin_user = auth_service.user_repository.get_user_by_email("env.admin@example.com")
assert admin_user is not None
assert admin_user.email == "env.admin@example.com"
assert admin_user.username == "env_admin"
assert "admin" in admin_user.roles
def test_create_admin_if_needed_success_with_hardcoded_defaults(
self,
auth_service: AuthService,
monkeypatch
):
"""Success: Admin is created with hardcoded defaults when no env vars or params provided."""
# Arrange - Clear any existing env variables
monkeypatch.delenv("AUTH_ADMIN_EMAIL", raising=False)
monkeypatch.delenv("AUTH_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("AUTH_ADMIN_PASSWORD", raising=False)
# Act
result = auth_service.create_admin_if_needed()
# Assert
assert result is True
# Verify admin user was created with hardcoded defaults
admin_user = auth_service.user_repository.get_user_by_email("admin@myauth.com")
assert admin_user is not None
assert admin_user.email == "admin@myauth.com"
assert admin_user.username == "admin"
assert "admin" in admin_user.roles
def test_create_admin_if_needed_no_creation_when_users_exist(
self,
auth_service: AuthService,
test_user_data_create: UserCreate
):
"""Failure: Admin is not created when users already exist in the system."""
# Arrange - Create a regular user first
auth_service.register(test_user_data_create)
# Act
result = auth_service.create_admin_if_needed(
admin_email="should.not.be.created@example.com",
admin_username="should_not_exist",
admin_password="ShouldNotExist123!"
)
# Assert
assert result is False
# Verify admin user was NOT created
admin_user = auth_service.user_repository.get_user_by_email(
"should.not.be.created@example.com"
)
assert admin_user is None
# Verify only the original user exists
assert auth_service.count_users() == 1
def test_create_admin_if_needed_parameters_override_env_variables(
self,
auth_service: AuthService,
monkeypatch
):
"""Success: Parameters take precedence over environment variables."""
# Arrange
monkeypatch.setenv("AUTH_ADMIN_EMAIL", "env.admin@example.com")
monkeypatch.setenv("AUTH_ADMIN_USERNAME", "env_admin")
monkeypatch.setenv("AUTH_ADMIN_PASSWORD", "EnvAdminPass123!")
param_email = "param.admin@example.com"
param_username = "param_admin"
param_password = "ParamAdminPass123!"
# Act
result = auth_service.create_admin_if_needed(
admin_email=param_email,
admin_username=param_username,
admin_password=param_password
)
# Assert
assert result is True
# Verify parameters were used, not env variables
admin_user = auth_service.user_repository.get_user_by_email(param_email)
assert admin_user is not None
assert admin_user.email == param_email
assert admin_user.username == param_username
# Verify env admin was NOT created
env_admin = auth_service.user_repository.get_user_by_email("env.admin@example.com")
assert env_admin is None
def test_create_admin_if_needed_mixed_parameters_and_env(
self,
auth_service: AuthService,
monkeypatch
):
"""Success: Partial parameters combine with environment variables."""
# Arrange
monkeypatch.setenv("AUTH_ADMIN_EMAIL", "env.admin@example.com")
monkeypatch.setenv("AUTH_ADMIN_USERNAME", "env_admin")
monkeypatch.setenv("AUTH_ADMIN_PASSWORD", "EnvAdminPass123!")
# Act - Only provide email as parameter
result = auth_service.create_admin_if_needed(
admin_email="partial.admin@example.com"
)
# Assert
assert result is True
# Verify email from parameter, username and password from env
admin_user = auth_service.user_repository.get_user_by_email("partial.admin@example.com")
assert admin_user is not None
assert admin_user.email == "partial.admin@example.com"
assert admin_user.username == "env_admin"
class TestAuthServiceTokenManagement(object): class TestAuthServiceTokenManagement(object):
"""Tests for token-related flows (Refresh, Logout, GetCurrentUser).""" """Tests for token-related flows (Refresh, Logout, GetCurrentUser)."""

View File

@@ -1,12 +1,12 @@
# tests/persistence/test_sqlite_user.py # tests/persistence/test_sqlite_user.py
import pytest
import json
from datetime import datetime from datetime import datetime
from myauth.persistence.sqlite import SQLiteUserRepository import pytest
from myauth.models.user import UserCreate, UserUpdate
from myauth.exceptions import UserAlreadyExistsError, UserNotFoundError from myauth.exceptions import UserAlreadyExistsError, UserNotFoundError
from myauth.models.user import UserCreate, UserUpdate
from myauth.persistence.sqlite import SQLiteUserRepository
def test_i_can_create_and_retrieve_user_by_email(user_repository: SQLiteUserRepository, def test_i_can_create_and_retrieve_user_by_email(user_repository: SQLiteUserRepository,
@@ -153,3 +153,138 @@ def test_i_cannot_retrieve_non_existent_user_by_email(user_repository: SQLiteUse
retrieved_user = user_repository.get_user_by_email("ghost@example.com") retrieved_user = user_repository.get_user_by_email("ghost@example.com")
assert retrieved_user is None assert retrieved_user is None
def test_i_can_list_users_with_pagination(user_repository: SQLiteUserRepository,
test_user_hashed_password: str):
"""Verifies that list_users returns paginated results correctly."""
# Create multiple users
for i in range(5):
user_data = UserCreate(
email=f"user{i}@example.com",
username=f"User{i}",
password="#Password123",
roles=["user"],
user_settings={}
)
user_repository.create_user(user_data, test_user_hashed_password)
# Test: Get first 3 users
users_page1 = user_repository.list_users(skip=0, limit=3)
assert len(users_page1) == 3
# Test: Get next 2 users
users_page2 = user_repository.list_users(skip=3, limit=3)
assert len(users_page2) == 2
# Test: Verify no duplicates between pages
page1_ids = {user.id for user in users_page1}
page2_ids = {user.id for user in users_page2}
assert len(page1_ids.intersection(page2_ids)) == 0
def test_i_can_list_users_with_default_pagination(user_repository: SQLiteUserRepository,
test_user_data_create: UserCreate,
test_user_hashed_password: str):
"""Verifies that list_users works with default parameters."""
# Create 2 users
user_repository.create_user(test_user_data_create, test_user_hashed_password)
user_data2 = UserCreate(
email="user2@example.com",
username="User2",
password="#Password123",
roles=["user"],
user_settings={}
)
user_repository.create_user(user_data2, test_user_hashed_password)
# Test: Default parameters (skip=0, limit=100)
users = user_repository.list_users()
assert len(users) == 2
assert all(isinstance(user.created_at, datetime) for user in users)
def test_i_get_empty_list_when_no_users_exist(user_repository: SQLiteUserRepository):
"""Verifies that list_users returns an empty list when no users exist."""
users = user_repository.list_users()
assert users == []
assert isinstance(users, list)
def test_i_can_skip_beyond_available_users(user_repository: SQLiteUserRepository,
test_user_data_create: UserCreate,
test_user_hashed_password: str):
"""Verifies that skipping beyond available users returns an empty list."""
user_repository.create_user(test_user_data_create, test_user_hashed_password)
# Skip beyond the only user
users = user_repository.list_users(skip=10, limit=10)
assert users == []
def test_i_can_count_users(user_repository: SQLiteUserRepository,
test_user_hashed_password: str):
"""Verifies that count_users returns the correct number of users."""
# Initial count should be 0
assert user_repository.count_users() == 0
# Create first user
user_data1 = UserCreate(
email="user1@example.com",
username="User1",
password="#Password123",
roles=["user"],
user_settings={}
)
user_repository.create_user(user_data1, test_user_hashed_password)
assert user_repository.count_users() == 1
# Create second user
user_data2 = UserCreate(
email="user2@example.com",
username="User2",
password="#Password123",
roles=["user"],
user_settings={}
)
user_repository.create_user(user_data2, test_user_hashed_password)
assert user_repository.count_users() == 2
def test_i_get_zero_count_when_no_users_exist(user_repository: SQLiteUserRepository):
"""Verifies that count_users returns 0 when the database is empty."""
count = user_repository.count_users()
assert count == 0
assert isinstance(count, int)
def test_list_users_returns_correct_user_structure(user_repository: SQLiteUserRepository,
test_user_data_create: UserCreate,
test_user_hashed_password: str):
"""Verifies that list_users returns UserInDB objects with all fields."""
user_repository.create_user(test_user_data_create, test_user_hashed_password)
users = user_repository.list_users()
assert len(users) == 1
user = users[0]
# Verify all fields are present and correct type
assert user.id is not None
assert user.email == test_user_data_create.email
assert user.username == test_user_data_create.username
assert user.hashed_password == test_user_hashed_password
assert isinstance(user.roles, list)
assert isinstance(user.user_settings, dict)
assert isinstance(user.is_verified, bool)
assert isinstance(user.is_active, bool)
assert isinstance(user.created_at, datetime)
assert isinstance(user.updated_at, datetime)