Merge branch 'feature_user_authentication' of https://github.com/cadtoolbox/bambuddy into test-merge

# Conflicts:
#	frontend/src/components/ConfigureAmsSlotModal.tsx
#	frontend/src/i18n/locales/ja.ts
#	static/index.html
This commit is contained in:
maziggy
2026-02-10 16:40:10 +01:00
31 changed files with 15443 additions and 1162 deletions
+388 -2
View File
@@ -5,19 +5,43 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.api.routes.settings import get_external_login_url
from backend.app.core.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
authenticate_user,
authenticate_user_by_email,
create_access_token,
get_current_active_user,
get_password_hash,
get_user_by_email,
get_user_by_username,
)
from backend.app.core.database import get_db
from backend.app.models.group import Group
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.schemas.auth import GroupBrief, LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
from backend.app.schemas.auth import (
ForgotPasswordRequest,
ForgotPasswordResponse,
GroupBrief,
LoginRequest,
LoginResponse,
ResetPasswordRequest,
ResetPasswordResponse,
SetupRequest,
SetupResponse,
SMTPSettings,
TestSMTPRequest,
TestSMTPResponse,
UserResponse,
)
from backend.app.services.email_service import (
create_password_reset_email_from_template,
generate_secure_password,
get_smtp_settings,
save_smtp_settings,
send_email,
)
def _user_to_response(user: User) -> UserResponse:
@@ -25,6 +49,7 @@ def _user_to_response(user: User) -> UserResponse:
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
is_admin=user.is_admin,
@@ -46,6 +71,27 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
return setting.value.lower() == "true"
async def is_advanced_auth_enabled(db: AsyncSession) -> bool:
"""Check if advanced authentication is enabled."""
result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
setting = result.scalar_one_or_none()
if setting is None:
return False
return setting.value.lower() == "true"
async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None:
"""Set advanced authentication enabled status."""
from sqlalchemy import func
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(Settings).values(key="advanced_auth_enabled", value="true" if enabled else "false")
stmt = stmt.on_conflict_do_update(
index_elements=["key"], set_={"value": "true" if enabled else "false", "updated_at": func.now()}
)
await db.execute(stmt)
async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
"""Set authentication enabled status."""
from sqlalchemy import func
@@ -216,7 +262,10 @@ async def disable_auth(
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Login and get access token."""
"""Login and get access token.
Supports username or email-based login. Username lookup is case-insensitive.
"""
# Check if auth is enabled
auth_enabled = await is_auth_enabled(db)
if not auth_enabled:
@@ -225,7 +274,15 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
detail="Authentication is not enabled",
)
# Try username-based authentication first
user = await authenticate_user(db, request.username, request.password)
# If username auth failed and advanced auth is enabled, try email-based authentication
if not user:
advanced_auth = await is_advanced_auth_enabled(db)
if advanced_auth:
user = await authenticate_user_by_email(db, request.username, request.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -263,3 +320,332 @@ async def get_current_user_info(
async def logout():
"""Logout (client should discard token)."""
return {"message": "Logged out successfully"}
# Advanced Authentication Endpoints
@router.post("/smtp/test", response_model=TestSMTPResponse)
async def test_smtp_connection(
test_request: TestSMTPRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Test SMTP connection with provided settings (admin only)."""
import logging
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can test SMTP settings",
)
try:
smtp_settings = SMTPSettings(
smtp_host=test_request.smtp_host,
smtp_port=test_request.smtp_port,
smtp_username=test_request.smtp_username,
smtp_password=test_request.smtp_password,
smtp_security=test_request.smtp_security,
smtp_auth_enabled=test_request.smtp_auth_enabled,
smtp_from_email=test_request.smtp_from_email,
)
# Send test email
send_email(
smtp_settings=smtp_settings,
to_email=test_request.test_recipient,
subject="BamBuddy SMTP Test",
body_text="This is a test email from BamBuddy. If you received this, your SMTP settings are working correctly!",
body_html="<p>This is a test email from <strong>BamBuddy</strong>.</p><p>If you received this, your SMTP settings are working correctly!</p>",
)
logger.info(f"Test email sent successfully to {test_request.test_recipient}")
return TestSMTPResponse(success=True, message="Test email sent successfully")
except Exception as e:
logger.error(f"Failed to send test email: {e}")
return TestSMTPResponse(success=False, message=f"Failed to send test email: {str(e)}")
@router.get("/smtp", response_model=SMTPSettings | None)
async def get_smtp_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Get SMTP settings (admin only). Password is not returned."""
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can view SMTP settings",
)
smtp_settings = await get_smtp_settings(db)
if smtp_settings:
# Don't return password in response
smtp_settings.smtp_password = None
return smtp_settings
@router.post("/smtp", response_model=dict)
async def save_smtp_config(
smtp_settings: SMTPSettings,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Save SMTP settings (admin only)."""
import logging
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can update SMTP settings",
)
try:
await save_smtp_settings(db, smtp_settings)
await db.commit()
logger.info(f"SMTP settings updated by admin user: {user.username}")
return {"message": "SMTP settings saved successfully"}
except Exception as e:
await db.rollback()
logger.error(f"Failed to save SMTP settings: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save SMTP settings: {str(e)}",
)
@router.post("/advanced-auth/enable", response_model=dict)
async def enable_advanced_auth(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Enable advanced authentication (admin only).
Requires SMTP settings to be configured and tested first.
"""
import logging
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can enable advanced authentication",
)
# Verify SMTP settings are configured
smtp_settings = await get_smtp_settings(db)
if not smtp_settings:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="SMTP settings must be configured before enabling advanced authentication",
)
try:
await set_advanced_auth_enabled(db, True)
await db.commit()
logger.info(f"Advanced authentication enabled by admin user: {user.username}")
return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
except Exception as e:
await db.rollback()
logger.error(f"Failed to enable advanced authentication: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to enable advanced authentication: {str(e)}",
)
@router.post("/advanced-auth/disable", response_model=dict)
async def disable_advanced_auth(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Disable advanced authentication (admin only)."""
import logging
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can disable advanced authentication",
)
try:
await set_advanced_auth_enabled(db, False)
await db.commit()
logger.info(f"Advanced authentication disabled by admin user: {user.username}")
return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
except Exception as e:
await db.rollback()
logger.error(f"Failed to disable advanced authentication: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to disable advanced authentication: {str(e)}",
)
@router.get("/advanced-auth/status")
async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
"""Get advanced authentication status."""
advanced_auth_enabled = await is_advanced_auth_enabled(db)
smtp_configured = await get_smtp_settings(db) is not None
return {
"advanced_auth_enabled": advanced_auth_enabled,
"smtp_configured": smtp_configured,
}
@router.post("/forgot-password", response_model=ForgotPasswordResponse)
async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
"""Request password reset via email (advanced auth only)."""
import logging
logger = logging.getLogger(__name__)
# Check if advanced auth is enabled
advanced_auth = await is_advanced_auth_enabled(db)
if not advanced_auth:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Advanced authentication is not enabled",
)
# Get SMTP settings
smtp_settings = await get_smtp_settings(db)
if not smtp_settings:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Email service is not configured",
)
# Find user by email
user = await get_user_by_email(db, request.email)
# Always return success message to prevent email enumeration
# but only send email if user exists
if user and user.is_active:
try:
# Generate new password
new_password = generate_secure_password()
user.password_hash = get_password_hash(new_password)
await db.commit()
login_url = await get_external_login_url(db)
# Send password reset email
subject, text_body, html_body = await create_password_reset_email_from_template(
db, user.username, new_password, login_url
)
send_email(smtp_settings, user.email, subject, text_body, html_body)
logger.info(f"Password reset email sent to {user.email}")
except Exception as e:
logger.error(f"Failed to send password reset email: {e}")
# Don't reveal error to user for security
return ForgotPasswordResponse(
message="If the email address is associated with an account, a password reset email has been sent."
)
@router.post("/reset-password", response_model=ResetPasswordResponse)
async def reset_user_password(
request: ResetPasswordRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Reset a user's password and send them an email (admin only, advanced auth only)."""
import logging
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
admin_user = result.scalar_one()
if not admin_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can reset user passwords",
)
# Check if advanced auth is enabled
advanced_auth = await is_advanced_auth_enabled(db)
if not advanced_auth:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Advanced authentication is not enabled",
)
# Get SMTP settings
smtp_settings = await get_smtp_settings(db)
if not smtp_settings:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Email service is not configured",
)
# Find user to reset
result = await db.execute(select(User).where(User.id == request.user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not user.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User does not have an email address configured",
)
try:
# Generate new password
new_password = generate_secure_password()
user.password_hash = get_password_hash(new_password)
await db.commit()
login_url = await get_external_login_url(db)
# Send password reset email
subject, text_body, html_body = await create_password_reset_email_from_template(
db, user.username, new_password, login_url
)
send_email(smtp_settings, user.email, subject, text_body, html_body)
logger.info(f"Password reset by admin {admin_user.username} for user {user.username}")
return ResetPasswordResponse(message=f"Password reset email sent to {user.email}")
except Exception as e:
await db.rollback()
logger.error(f"Failed to reset password for user {user.username}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset password: {str(e)}",
)
@@ -43,6 +43,9 @@ EVENT_NAMES = {
"queue_job_skipped": "Queue Job Skipped",
"queue_job_failed": "Queue Job Failed",
"queue_completed": "Queue Completed",
# User management
"user_created": "Welcome Email",
"password_reset": "Password Reset",
}
+21
View File
@@ -32,6 +32,27 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
return setting.value if setting else None
async def get_external_login_url(db: AsyncSession) -> str:
"""Get the external URL for the login page.
Uses external_url from settings if available, otherwise falls back to APP_URL env var.
Args:
db: Database session
Returns:
Full URL to the login page
"""
import os
external_url = await get_setting(db, "external_url")
if external_url:
external_url = external_url.rstrip("/")
else:
external_url = os.environ.get("APP_URL", "http://localhost:5173")
return external_url + "/login"
async def set_setting(db: AsyncSession, key: str, value: str) -> None:
"""Set a single setting value."""
from sqlalchemy import func
+88 -6
View File
@@ -3,6 +3,7 @@ from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.api.routes.settings import get_external_login_url
from backend.app.core.auth import (
RequirePermissionIfAuthEnabled,
get_current_user_optional,
@@ -15,8 +16,15 @@ from backend.app.models.archive import PrintArchive
from backend.app.models.group import Group
from backend.app.models.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
from backend.app.services.email_service import (
create_welcome_email_from_template,
generate_secure_password,
get_smtp_settings,
send_email,
)
router = APIRouter(prefix="/users", tags=["users"])
@@ -26,6 +34,7 @@ def _user_to_response(user: User) -> UserResponse:
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
is_admin=user.is_admin,
@@ -54,9 +63,24 @@ async def create_user(
_: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
db: AsyncSession = Depends(get_db),
):
"""Create a new user."""
# Check if username already exists
existing_user = await db.execute(select(User).where(User.username == user_data.username))
"""Create a new user.
When advanced authentication is enabled:
- Email is required
- Password is auto-generated and emailed to user
- Admin cannot set or see the password
"""
import logging
logger = logging.getLogger(__name__)
# Check if advanced auth is enabled
result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
advanced_auth_setting = result.scalar_one_or_none()
advanced_auth_enabled = advanced_auth_setting and advanced_auth_setting.value.lower() == "true"
# Check if username already exists (case-insensitive)
existing_user = await db.execute(select(User).where(func.lower(User.username) == func.lower(user_data.username)))
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -70,9 +94,36 @@ async def create_user(
detail="Role must be 'admin' or 'user'",
)
# Advanced auth validation
if advanced_auth_enabled:
if not user_data.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required when advanced authentication is enabled",
)
# Check if email already exists (case-insensitive)
existing_email = await db.execute(select(User).where(func.lower(User.email) == func.lower(user_data.email)))
if existing_email.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already exists",
)
# Generate password if advanced auth enabled, otherwise require password
if advanced_auth_enabled:
password = generate_secure_password()
else:
if not user_data.password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password is required when advanced authentication is disabled",
)
password = user_data.password
new_user = User(
username=user_data.username,
password_hash=get_password_hash(user_data.password),
email=user_data.email,
password_hash=get_password_hash(password),
role=user_data.role,
is_active=True,
)
@@ -92,6 +143,23 @@ async def create_user(
await db.commit()
await db.refresh(new_user)
# Send welcome email if advanced auth enabled
if advanced_auth_enabled and new_user.email:
try:
smtp_settings = await get_smtp_settings(db)
if smtp_settings:
login_url = await get_external_login_url(db)
subject, text_body, html_body = await create_welcome_email_from_template(
db, new_user.username, password, login_url
)
send_email(smtp_settings, new_user.email, subject, text_body, html_body)
logger.info(f"Welcome email sent to {new_user.email}")
else:
logger.warning(f"SMTP not configured, could not send welcome email to {new_user.email}")
except Exception as e:
logger.error(f"Failed to send welcome email: {e}")
# Don't fail user creation if email fails
return _user_to_response(new_user)
@@ -161,8 +229,10 @@ async def update_user(
)
if user_data.username is not None:
# Check if new username already exists
existing_user = await db.execute(select(User).where(User.username == user_data.username, User.id != user_id))
# Check if new username already exists (case-insensitive)
existing_user = await db.execute(
select(User).where(func.lower(User.username) == func.lower(user_data.username), User.id != user_id)
)
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -170,6 +240,18 @@ async def update_user(
)
user.username = user_data.username
if user_data.email is not None:
# Check if new email already exists (case-insensitive)
existing_email = await db.execute(
select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)
)
if existing_email.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already exists",
)
user.email = user_data.email
if user_data.password is not None:
user.password_hash = get_password_hash(user_data.password)