Changed module name from my_auth to myauth

Changed encryption algorithm to argon2
Added unit tests
This commit is contained in:
2025-10-19 23:17:38 +02:00
parent 7634631b90
commit 0138ac247a
37 changed files with 261 additions and 160 deletions

View File

@@ -0,0 +1,6 @@
from .sqlite import UserRepository, TokenRepository
__all__ = [
"UserRepository",
"TokenRepository",
]

View File

@@ -0,0 +1,226 @@
"""
Abstract base classes for persistence layer.
This module defines the interfaces that all database implementations must
follow. It provides abstract base classes for user and token repositories,
ensuring consistency across different database backends (MongoDB, SQLite,
PostgreSQL, custom engines, etc.).
"""
from abc import ABC, abstractmethod
from ..models.token import TokenData
from ..models.user import UserCreate, UserInDB, UserUpdate
class UserRepository(ABC):
"""
Abstract base class for user persistence operations.
This interface defines all methods required for user management in the
database. Concrete implementations must provide all these methods for
their specific database backend.
All methods should raise appropriate exceptions (UserNotFoundError,
UserAlreadyExistsError, etc.) when operations fail.
"""
@abstractmethod
def create_user(self, user_data: UserCreate, hashed_password: str) -> UserInDB:
"""
Create a new user in the database.
Args:
user_data: User information from registration.
hashed_password: Pre-hashed password (hashing is done by the service layer).
Returns:
The created user with database-assigned ID and timestamps.
Raises:
UserAlreadyExistsError: If email already exists in database.
"""
pass
@abstractmethod
def get_user_by_email(self, email: str) -> UserInDB | None:
"""
Retrieve a user by their email address.
Args:
email: The email address to search for.
Returns:
The user if found, None otherwise.
"""
pass
@abstractmethod
def get_user_by_id(self, user_id: str) -> UserInDB | None:
"""
Retrieve a user by their unique identifier.
Args:
user_id: The unique user identifier.
Returns:
The user if found, None otherwise.
"""
pass
@abstractmethod
def update_user(self, user_id: str, updates: UserUpdate) -> UserInDB:
"""
Update an existing user's information.
Only fields present in the updates object (non-None) should be updated.
The updated_at timestamp should be automatically set to the current time.
Args:
user_id: The unique user identifier.
updates: Pydantic model containing fields to update.
Returns:
The updated user.
Raises:
UserNotFoundError: If user does not exist.
"""
pass
@abstractmethod
def delete_user(self, user_id: str) -> bool:
"""
Delete a user from the database.
Implementation can choose between soft delete (setting is_active=False)
or hard delete (removing from database). The choice depends on the
application's requirements for data retention.
Args:
user_id: The unique user identifier.
Returns:
True if user was deleted, False if user was not found.
"""
pass
@abstractmethod
def email_exists(self, email: str) -> bool:
"""
Check if an email address is already registered.
This is useful for validating registration requests without
retrieving the full user object.
Args:
email: The email address to check.
Returns:
True if email exists, False otherwise.
"""
pass
class TokenRepository(ABC):
"""
Abstract base class for token persistence operations.
This interface defines all methods required for token management in the
database. It handles both refresh tokens and password reset tokens using
a discriminator field (token_type).
All methods should raise appropriate exceptions (InvalidTokenError,
ExpiredTokenError, etc.) when operations fail.
"""
@abstractmethod
def save_token(self, token_data: TokenData) -> None:
"""
Save a token to the database.
This is used for both refresh tokens and password reset tokens.
The token_type field in TokenData distinguishes between them.
Args:
token_data: Complete token information including type, user_id, expiration.
"""
pass
@abstractmethod
def get_token(self, token: str, token_type: str) -> TokenData | None:
"""
Retrieve a token from the database.
Args:
token: The token string to search for.
token_type: Type of token ("refresh" or "password_reset").
Returns:
The token data if found, None otherwise.
"""
pass
@abstractmethod
def revoke_token(self, token: str) -> bool:
"""
Revoke a specific token.
This sets the is_revoked flag to True, preventing the token from
being used again without physically deleting it (useful for audit trails).
Args:
token: The token string to revoke.
Returns:
True if token was revoked, False if token was not found.
"""
pass
@abstractmethod
def revoke_all_user_tokens(self, user_id: str, token_type: str) -> int:
"""
Revoke all tokens of a specific type for a user.
This is useful for logout-all-devices functionality or when a user's
password is changed (invalidating all refresh tokens).
Args:
user_id: The user whose tokens should be revoked.
token_type: Type of tokens to revoke ("refresh" or "password_reset").
Returns:
Number of tokens revoked.
"""
pass
@abstractmethod
def delete_expired_tokens(self) -> int:
"""
Delete all expired tokens from the database.
This maintenance operation should be called periodically to keep
the tokens collection clean. It permanently removes tokens that
have passed their expiration time.
Returns:
Number of tokens deleted.
"""
pass
@abstractmethod
def is_token_valid(self, token: str, token_type: str) -> bool:
"""
Check if a token is valid (exists, not revoked, not expired).
This is a convenience method that combines multiple checks into
a single boolean result.
Args:
token: The token string to validate.
token_type: Type of token ("refresh" or "password_reset").
Returns:
True if token is valid and can be used, False otherwise.
"""
pass

View File

@@ -0,0 +1,611 @@
"""
SQLite implementation of persistence layer.
This module provides SQLite database implementations for user and token
repositories. It uses the standard library sqlite3 module and handles
JSON serialization for complex fields.
"""
import json
import sqlite3
from datetime import datetime
from typing import Optional
from uuid import uuid4
from .base import UserRepository, TokenRepository
from ..models.user import UserCreate, UserInDB, UserUpdate
from ..models.token import TokenData
from ..exceptions import UserAlreadyExistsError, UserNotFoundError
class SQLiteUserRepository(UserRepository):
"""
SQLite implementation of UserRepository.
This implementation uses sqlite3 to manage user data. JSON fields
(roles, user_settings) are serialized/deserialized automatically.
The database schema is created automatically on initialization.
Attributes:
db_path: Path to the SQLite database file.
"""
def __init__(self, db_path: str):
"""
Initialize SQLite user repository.
Creates the users table and indexes if they don't exist.
Args:
db_path: Path to the SQLite database file.
"""
self.db_path = db_path
self._create_tables()
def _create_tables(self) -> None:
"""
Create users table and indexes if they don't exist.
The table schema includes:
- id: Primary key (UUID string)
- email: Unique, indexed for fast lookups
- username: Non-unique display name
- hashed_password: Bcrypt hash
- roles: JSON array of role strings
- user_settings: JSON object for custom settings
- is_verified: Boolean (stored as INTEGER)
- is_active: Boolean (stored as INTEGER)
- created_at: ISO format timestamp
- updated_at: ISO format timestamp
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Create users table
cursor.execute("""
CREATE TABLE IF NOT EXISTS users
(
id
TEXT
PRIMARY
KEY,
email
TEXT
UNIQUE
NOT
NULL,
username
TEXT
NOT
NULL,
hashed_password
TEXT
NOT
NULL,
roles
TEXT
NOT
NULL,
user_settings
TEXT
NOT
NULL,
is_verified
INTEGER
NOT
NULL
DEFAULT
0,
is_active
INTEGER
NOT
NULL
DEFAULT
1,
created_at
TEXT
NOT
NULL,
updated_at
TEXT
NOT
NULL
)
""")
# Create index on email for fast lookups
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_users_email
ON users(email)
""")
conn.commit()
def _row_to_user(self, row: tuple) -> UserInDB:
"""
Convert a database row to a UserInDB model.
Args:
row: Database row tuple.
Returns:
UserInDB model instance.
"""
return UserInDB(
id=row[0],
email=row[1],
username=row[2],
hashed_password=row[3],
roles=json.loads(row[4]),
user_settings=json.loads(row[5]),
is_verified=bool(row[6]),
is_active=bool(row[7]),
created_at=datetime.fromisoformat(row[8]),
updated_at=datetime.fromisoformat(row[9])
)
def create_user(self, user_data: UserCreate, hashed_password: str) -> UserInDB:
"""
Create a new user in the database.
Args:
user_data: User information from registration.
hashed_password: Pre-hashed password.
Returns:
The created user with generated ID and timestamps.
Raises:
UserAlreadyExistsError: If email already exists.
"""
if self.email_exists(user_data.email):
raise UserAlreadyExistsError(f"User with email {user_data.email} already exists")
user_id = str(uuid4())
now = datetime.now()
user = UserInDB(
id=user_id,
email=user_data.email,
username=user_data.username,
hashed_password=hashed_password,
roles=user_data.roles,
user_settings=user_data.user_settings,
is_verified=False,
is_active=True,
created_at=now,
updated_at=now
)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO users (id, email, username, hashed_password, roles,
user_settings, is_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user.id,
user.email,
user.username,
user.hashed_password,
json.dumps(user.roles),
json.dumps(user.user_settings),
int(user.is_verified),
int(user.is_active),
user.created_at.isoformat(),
user.updated_at.isoformat()
))
conn.commit()
return user
def get_user_by_email(self, email: str) -> Optional[UserInDB]:
"""
Retrieve a user by their email address.
Args:
email: The email address to search for.
Returns:
The user if found, None otherwise.
"""
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
WHERE email = ?
""", (email,))
row = cursor.fetchone()
if row:
return self._row_to_user(row)
return None
def get_user_by_id(self, user_id: str) -> Optional[UserInDB]:
"""
Retrieve a user by their unique identifier.
Args:
user_id: The unique user identifier.
Returns:
The user if found, None otherwise.
"""
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
WHERE id = ?
""", (user_id,))
row = cursor.fetchone()
if row:
return self._row_to_user(row)
return None
def update_user(self, user_id: str, updates: UserUpdate) -> UserInDB:
"""
Update an existing user's information.
Only non-None fields in the updates object are applied.
The updated_at timestamp is automatically set to current time.
Args:
user_id: The unique user identifier.
updates: Pydantic model containing fields to update.
Returns:
The updated user.
Raises:
UserNotFoundError: If user does not exist.
"""
user = self.get_user_by_id(user_id)
if not user:
raise UserNotFoundError(f"User with id {user_id} not found")
# Build update query dynamically based on non-None fields
update_fields = []
update_values = []
update_data = updates.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None:
if field == "roles":
update_fields.append("roles = ?")
update_values.append(json.dumps(value))
elif field == "user_settings":
update_fields.append("user_settings = ?")
update_values.append(json.dumps(value))
elif field == "password":
# Password should be hashed before calling this method
# This field should contain hashed_password value
update_fields.append("hashed_password = ?")
update_values.append(value)
elif field in ["is_verified", "is_active"]:
update_fields.append(f"{field} = ?")
update_values.append(int(value))
else:
update_fields.append(f"{field} = ?")
update_values.append(value)
if not update_fields:
return user
# Always update the updated_at timestamp
update_fields.append("updated_at = ?")
update_values.append(datetime.now().isoformat())
# Add user_id for WHERE clause
update_values.append(user_id)
query = f"UPDATE users SET {', '.join(update_fields)} WHERE id = ?"
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(query, update_values)
conn.commit()
# Return updated user
updated_user = self.get_user_by_id(user_id)
if not updated_user:
raise UserNotFoundError(f"User with id {user_id} not found after update")
return updated_user
def delete_user(self, user_id: str) -> bool:
"""
Delete a user from the database (hard delete).
Args:
user_id: The unique user identifier.
Returns:
True if user was deleted, False if user was not found.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
return cursor.rowcount > 0
def email_exists(self, email: str) -> bool:
"""
Check if an email address is already registered.
Args:
email: The email address to check.
Returns:
True if email exists, False otherwise.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM users WHERE email = ? LIMIT 1", (email,))
return cursor.fetchone() is not None
class SQLiteTokenRepository(TokenRepository):
"""
SQLite implementation of TokenRepository.
This implementation manages both refresh tokens and password reset tokens
in a single table with a discriminator field (token_type).
Attributes:
db_path: Path to the SQLite database file.
"""
def __init__(self, db_path: str):
"""
Initialize SQLite token repository.
Creates the tokens table and indexes if they don't exist.
Args:
db_path: Path to the SQLite database file.
"""
self.db_path = db_path
self._create_tables()
def _create_tables(self) -> None:
"""
Create tokens table and indexes if they don't exist.
The table schema includes:
- token: Primary key (random secure string)
- token_type: Discriminator ("refresh" or "password_reset")
- user_id: Foreign key to users
- expires_at: ISO format timestamp
- created_at: ISO format timestamp
- is_revoked: Boolean (stored as INTEGER)
Indexes are created for efficient queries.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Create tokens table
cursor.execute("""
CREATE TABLE IF NOT EXISTS tokens
(
token
TEXT
PRIMARY
KEY,
token_type
TEXT
NOT
NULL,
user_id
TEXT
NOT
NULL,
expires_at
TEXT
NOT
NULL,
created_at
TEXT
NOT
NULL,
is_revoked
INTEGER
NOT
NULL
DEFAULT
0
)
""")
# Create index on user_id for revoking all user tokens
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_tokens_user_id
ON tokens(user_id)
""")
# Create composite index on token_type and user_id
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_tokens_type_user
ON tokens(token_type, user_id)
""")
# Create index on expires_at for cleanup operations
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_tokens_expires_at
ON tokens(expires_at)
""")
conn.commit()
def _row_to_token(self, row: tuple) -> TokenData:
"""
Convert a database row to a TokenData model.
Args:
row: Database row tuple.
Returns:
TokenData model instance.
"""
return TokenData(
token=row[0],
token_type=row[1],
user_id=row[2],
expires_at=datetime.fromisoformat(row[3]),
created_at=datetime.fromisoformat(row[4]),
is_revoked=bool(row[5])
)
def save_token(self, token_data: TokenData) -> None:
"""
Save a token to the database.
Args:
token_data: Complete token information.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO tokens (token, token_type, user_id, expires_at, created_at, is_revoked)
VALUES (?, ?, ?, ?, ?, ?)
""", (
token_data.token,
token_data.token_type,
token_data.user_id,
token_data.expires_at.isoformat(),
token_data.created_at.isoformat(),
int(token_data.is_revoked)
))
conn.commit()
def get_token(self, token: str, token_type: str) -> Optional[TokenData]:
"""
Retrieve a token from the database.
Args:
token: The token string to search for.
token_type: Type of token ("refresh" or "password_reset").
Returns:
The token data if found, None otherwise.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT token, token_type, user_id, expires_at, created_at, is_revoked
FROM tokens
WHERE token = ?
AND token_type = ?
""", (token, token_type))
row = cursor.fetchone()
if row:
return self._row_to_token(row)
return None
def revoke_token(self, token: str) -> bool:
"""
Revoke a specific token.
Args:
token: The token string to revoke.
Returns:
True if token was revoked, False if token was not found.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE tokens
SET is_revoked = 1
WHERE token = ?
""", (token,))
conn.commit()
return cursor.rowcount > 0
def revoke_all_user_tokens(self, user_id: str, token_type: str) -> int:
"""
Revoke all tokens of a specific type for a user.
Args:
user_id: The user whose tokens should be revoked.
token_type: Type of tokens to revoke.
Returns:
Number of tokens revoked.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE tokens
SET is_revoked = 1
WHERE user_id = ?
AND token_type = ?
""", (user_id, token_type))
conn.commit()
return cursor.rowcount
def delete_expired_tokens(self) -> int:
"""
Delete all expired tokens from the database.
Returns:
Number of tokens deleted.
"""
now = datetime.now().isoformat()
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
DELETE
FROM tokens
WHERE expires_at < ?
""", (now,))
conn.commit()
return cursor.rowcount
def is_token_valid(self, token: str, token_type: str) -> bool:
"""
Check if a token is valid (exists, not revoked, not expired).
Args:
token: The token string to validate.
token_type: Type of token.
Returns:
True if token is valid, False otherwise.
"""
token_data = self.get_token(token, token_type)
if not token_data:
return False
if token_data.is_revoked:
return False
if token_data.expires_at < datetime.now():
return False
return True