From f12d89f3da506ffa4ae98147a5d4bc6fd34f647c Mon Sep 17 00:00:00 2001 From: k3nny Date: Sun, 5 Oct 2025 02:02:46 +0200 Subject: [PATCH] feat(auth): add pluggable authentication system for Flask routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive authentication system with support for Basic Auth, Flask-Login, and OAuth2 providers. Features: - Pluggable architecture via factory pattern - Multiple authentication providers: * None: No authentication (development/testing) * Basic Auth: HTTP Basic with bcrypt support * Flask-Login: Session-based with multiple users * OAuth2: Google, GitHub, GitLab, and generic providers - Decorator-based route protection (@auth.require_auth) - User authorization by domain or email (OAuth) - bcrypt password hashing support - Comprehensive documentation and examples Components: - libtisbackup/auth/__init__.py: Factory function and exports - libtisbackup/auth/base.py: Base provider interface - libtisbackup/auth/basic_auth.py: HTTP Basic Auth implementation - libtisbackup/auth/flask_login_auth.py: Flask-Login implementation - libtisbackup/auth/oauth_auth.py: OAuth2 implementation - libtisbackup/auth/example_integration.py: Integration examples - libtisbackup/auth/README.md: API reference and examples Documentation: - AUTHENTICATION.md: Complete authentication guide * Setup instructions for each provider * Configuration examples * Security best practices * Troubleshooting guide * Migration guide - samples/auth-config-examples.ini: Configuration templates Dependencies: - Add optional dependencies in pyproject.toml: * auth-basic: bcrypt>=4.0.0 * auth-login: flask-login>=0.6.0, bcrypt>=4.0.0 * auth-oauth: authlib>=1.3.0, requests>=2.32.0 * auth-all: All auth providers Installation: ```bash # Install specific provider uv sync --extra auth-basic # Install all providers uv sync --extra auth-all ``` Usage: ```python from libtisbackup.auth import get_auth_provider # Initialize auth = get_auth_provider("basic", { "username": "admin", "password": "$2b$12$...", "use_bcrypt": True }) auth.init_app(app) # Protect routes @app.route("/") @auth.require_auth def index(): user = auth.get_current_user() return f"Hello {user['username']}" ``` Security features: - bcrypt password hashing (work factor 12) - OAuth domain/user restrictions - Session-based authentication - Clear separation of concerns - Environment variable support for secrets OAuth providers supported: - Google (OpenID Connect) - GitHub - GitLab - Generic OAuth2 provider Breaking change: None - new feature, backward compatible Users can continue without authentication (type=none) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- AUTHENTICATION.md | 443 +++++++++++++++++++++++ libtisbackup/auth/README.md | 323 +++++++++++++++++ libtisbackup/auth/__init__.py | 52 +++ libtisbackup/auth/base.py | 74 ++++ libtisbackup/auth/basic_auth.py | 78 ++++ libtisbackup/auth/example_integration.py | 121 +++++++ libtisbackup/auth/flask_login_auth.py | 156 ++++++++ libtisbackup/auth/oauth_auth.py | 164 +++++++++ pyproject.toml | 9 + samples/auth-config-examples.ini | 130 +++++++ 10 files changed, 1550 insertions(+) create mode 100644 AUTHENTICATION.md create mode 100644 libtisbackup/auth/README.md create mode 100644 libtisbackup/auth/__init__.py create mode 100644 libtisbackup/auth/base.py create mode 100644 libtisbackup/auth/basic_auth.py create mode 100644 libtisbackup/auth/example_integration.py create mode 100644 libtisbackup/auth/flask_login_auth.py create mode 100644 libtisbackup/auth/oauth_auth.py create mode 100644 samples/auth-config-examples.ini diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md new file mode 100644 index 0000000..b8b16e9 --- /dev/null +++ b/AUTHENTICATION.md @@ -0,0 +1,443 @@ +# TISBackup Authentication System + +TISBackup provides a pluggable authentication system for securing the Flask web interface. You can choose between multiple authentication methods based on your security requirements. + +## Supported Authentication Methods + +1. **None** - No authentication (default, NOT recommended for production) +2. **Basic Auth** - HTTP Basic Authentication with username/password +3. **Flask-Login** - Session-based authentication with username/password +4. **OAuth2** - OAuth authentication (Google, GitHub, GitLab, or generic provider) + +## Quick Start + +### 1. Choose Authentication Method + +Add an `[authentication]` section to `/etc/tis/tisbackup_gui.ini`: + +```ini +[authentication] +type = basic +username = admin +password = $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6 +use_bcrypt = True +``` + +### 2. Install Dependencies + +```bash +# For Basic Auth +uv sync --extra auth-basic + +# For Flask-Login +uv sync --extra auth-login + +# For OAuth +uv sync --extra auth-oauth + +# For all auth methods +uv sync --extra auth-all +``` + +### 3. Restart TISBackup + +```bash +docker compose restart tisbackup_gui +``` + +## Configuration Guide + +### Basic Authentication + +Simple HTTP Basic Auth with username and password. + +**Pros:** +- Easy to set up +- Works with all HTTP clients +- No session management needed + +**Cons:** +- Credentials sent with every request +- No logout functionality +- Browser password prompt can be confusing + +**Configuration:** + +```ini +[authentication] +type = basic +username = admin +# Use bcrypt hash (recommended) +password = $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6 +use_bcrypt = True +realm = TISBackup Admin +``` + +**Generate bcrypt hash:** + +```bash +python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())" +``` + +**Docker environment:** + +```yaml +services: + tisbackup_gui: + environment: + - TISBACKUP_SECRET_KEY=your-secret-key + # Optional: Pass credentials via env vars + # Then reference in config with ${AUTH_PASSWORD} +``` + +--- + +### Flask-Login Authentication + +Session-based authentication with login page and user management. + +**Pros:** +- Clean login/logout workflow +- Session-based (no credentials in each request) +- Multiple users supported +- Password hashing with bcrypt + +**Cons:** +- Requires custom login page +- Session management overhead +- Cookies must be enabled + +**Configuration:** + +```ini +[authentication] +type = flask-login +users_file = /etc/tis/users.txt +use_bcrypt = True +login_view = login +``` + +**Create users file** (`/etc/tis/users.txt`): + +``` +admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6 +operator:$2b$12$abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNO +viewer:$2b$12$ANOTHERBCRYPTHASHHERE1234567890ABCDEFGHIJKLMNOPQRS +``` + +**Generate user entry:** + +```bash +USERNAME="admin" +PASSWORD="yourpassword" +HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'$PASSWORD', bcrypt.gensalt()).decode())") +echo "$USERNAME:$HASH" >> /etc/tis/users.txt +``` + +**Permissions:** + +```bash +chmod 600 /etc/tis/users.txt +chown root:root /etc/tis/users.txt +``` + +--- + +### OAuth2 Authentication + +Delegate authentication to external OAuth providers (Google, GitHub, GitLab, etc.) + +**Pros:** +- No password management +- Leverage existing identity providers +- Support for SSO +- Can restrict by domain or specific users + +**Cons:** +- Requires OAuth app registration +- Internet connectivity required +- More complex setup +- External dependency + +#### Google OAuth + +**Setup:** + +1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials) +2. Create OAuth 2.0 Client ID +3. Add authorized redirect URI: `http://your-server:8080/oauth/callback` +4. Note the Client ID and Client Secret + +**Configuration:** + +```ini +[authentication] +type = oauth +provider = google +client_id = 123456789-abcdefghijklmnop.apps.googleusercontent.com +client_secret = GOCSPX-your-client-secret-here +redirect_uri = http://your-server:8080/oauth/callback + +# Restrict to specific domain(s) +authorized_domains = example.com,mycompany.com + +# Or restrict to specific users +authorized_users = admin@example.com,backup-admin@example.com +``` + +#### GitHub OAuth + +**Setup:** + +1. Go to GitHub Settings > Developer settings > [OAuth Apps](https://github.com/settings/developers) +2. Register a new application +3. Set Authorization callback URL: `http://your-server:8080/oauth/callback` +4. Note the Client ID and Client Secret + +**Configuration:** + +```ini +[authentication] +type = oauth +provider = github +client_id = your-github-client-id +client_secret = your-github-client-secret +redirect_uri = http://your-server:8080/oauth/callback +authorized_users = admin@example.com,devops@example.com +``` + +#### GitLab OAuth + +**Setup:** + +1. Go to GitLab User Settings > [Applications](https://gitlab.com/-/profile/applications) +2. Create application with scopes: `read_user`, `email` +3. Set Redirect URI: `http://your-server:8080/oauth/callback` +4. Note the Application ID and Secret + +**Configuration:** + +```ini +[authentication] +type = oauth +provider = gitlab +client_id = your-gitlab-application-id +client_secret = your-gitlab-secret +redirect_uri = http://your-server:8080/oauth/callback +authorized_domains = example.com +``` + +#### Generic OAuth Provider + +For custom OAuth providers: + +```ini +[authentication] +type = oauth +provider = generic +client_id = your-client-id +client_secret = your-client-secret +redirect_uri = http://your-server:8080/oauth/callback + +# Custom endpoints +authorization_endpoint = https://auth.example.com/oauth/authorize +token_endpoint = https://auth.example.com/oauth/token +userinfo_endpoint = https://auth.example.com/oauth/userinfo +scopes = openid,email,profile + +# Authorization rules +authorized_domains = example.com +``` + +--- + +## Security Best Practices + +### 1. Use HTTPS in Production + +Always use a reverse proxy with TLS: + +```nginx +server { + listen 443 ssl http2; + server_name tisbackup.example.com; + + ssl_certificate /etc/letsencrypt/live/tisbackup.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/tisbackup.example.com/privkey.pem; + + location / { + proxy_pass http://localhost:8080/; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } +} +``` + +### 2. Set Strong Flask Secret Key + +```bash +# Generate secret +python3 -c "import secrets; print(secrets.token_hex(32))" + +# Set in environment +export TISBACKUP_SECRET_KEY=your-generated-secret-key +``` + +### 3. Protect Configuration Files + +```bash +chmod 600 /etc/tis/tisbackup_gui.ini +chmod 600 /etc/tis/users.txt # if using Flask-Login +chown root:root /etc/tis/*.ini +``` + +### 4. Use Environment Variables for Secrets + +Instead of hardcoding secrets in config files: + +```ini +[authentication] +type = oauth +client_id = ${OAUTH_CLIENT_ID} +client_secret = ${OAUTH_CLIENT_SECRET} +``` + +Then set in Docker Compose: + +```yaml +services: + tisbackup_gui: + environment: + - OAUTH_CLIENT_ID=your-client-id + - OAUTH_CLIENT_SECRET=your-client-secret + - TISBACKUP_SECRET_KEY=your-secret-key +``` + +### 5. Regularly Rotate Credentials + +- Change passwords/secrets every 90 days +- Rotate OAuth client secrets annually +- Review authorized users/domains regularly + +### 6. Monitor Authentication Logs + +Check logs for failed authentication attempts: + +```bash +docker logs tisbackup_gui | grep -i "auth" +``` + +## Troubleshooting + +### Basic Auth Not Working + +1. **Check bcrypt installation:** + ```bash + uv sync --extra auth-basic + ``` + +2. **Verify password hash:** + ```bash + python3 -c "import bcrypt; print(bcrypt.checkpw(b'yourpassword', b'$2b$12$your-hash'))" + ``` + +3. **Check browser credentials:** + - Clear browser cache + - Try incognito/private mode + +### Flask-Login Issues + +1. **Users file not found:** + ```bash + ls -la /etc/tis/users.txt + chmod 600 /etc/tis/users.txt + ``` + +2. **Module not found:** + ```bash + uv sync --extra auth-login + ``` + +3. **Session problems:** + - Check `TISBACKUP_SECRET_KEY` is set + - Ensure cookies are enabled + +### OAuth Problems + +1. **Redirect URI mismatch:** + - Ensure redirect URI in config matches OAuth app settings exactly + - Check for http vs https mismatch + +2. **Unauthorized domain/user:** + - Verify email matches `authorized_users` or domain matches `authorized_domains` + - Check OAuth provider returns email in user info + +3. **Module not found:** + ```bash + uv sync --extra auth-oauth + ``` + +4. **Token errors:** + - Verify client ID and secret are correct + - Check OAuth app is enabled + - Ensure scopes are correct + +## API Access with Authentication + +### Basic Auth + +```bash +curl -u admin:password http://localhost:8080/api/backups +``` + +### OAuth (with access token) + +```bash +# Not recommended for API access - use service account or API keys instead +``` + +### Recommendation for API Access + +For programmatic API access, use Basic Auth with a dedicated API user: + +```ini +[authentication] +type = basic +username = api-user +password = $2b$12$... +``` + +Or implement API key authentication separately for API endpoints. + +## Migration Guide + +### From No Auth to Basic Auth + +1. Add authentication section to config +2. Install bcrypt: `uv sync --extra auth-basic` +3. Restart service +4. Update client scripts with credentials + +### From Basic Auth to OAuth + +1. Register OAuth application +2. Update configuration +3. Install dependencies: `uv sync --extra auth-oauth` +4. Test OAuth login flow +5. Update redirect URI if needed + +### From Flask-Login to OAuth + +1. Register OAuth application +2. Map user emails to OAuth provider +3. Update configuration +4. Test migration with test users first + +## Support + +For issues or questions: +- Check logs: `docker logs tisbackup_gui` +- Review configuration syntax +- Verify dependencies are installed +- See [SECURITY_IMPROVEMENTS.md](../SECURITY_IMPROVEMENTS.md) for security context diff --git a/libtisbackup/auth/README.md b/libtisbackup/auth/README.md new file mode 100644 index 0000000..39cdc64 --- /dev/null +++ b/libtisbackup/auth/README.md @@ -0,0 +1,323 @@ +# TISBackup Authentication Module + +Pluggable authentication system for Flask routes. + +## Features + +- **Multiple providers**: Basic Auth, Flask-Login, OAuth2 +- **Easy integration**: Simple decorator-based protection +- **Configurable**: INI-based configuration +- **Secure**: bcrypt password hashing, OAuth integration +- **Extensible**: Easy to add new providers + +## Quick Start + +### 1. Choose Authentication Provider + +```python +from libtisbackup.auth import get_auth_provider + +# Get provider from config +auth = get_auth_provider("basic", { + "username": "admin", + "password": "$2b$12$...", # bcrypt hash + "use_bcrypt": True +}) + +# Initialize with Flask app +auth.init_app(app) +``` + +### 2. Protect Routes + +```python +@app.route("/") +@auth.require_auth +def index(): + user = auth.get_current_user() + return f"Hello {user['username']}" +``` + +## Providers + +### Base Provider (No Auth) + +```python +auth = get_auth_provider("none", {}) +``` + +- No authentication required +- All routes publicly accessible +- Useful for development/testing + +### Basic Auth + +```python +auth = get_auth_provider("basic", { + "username": "admin", + "password": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6", + "use_bcrypt": True, + "realm": "TISBackup" +}) +``` + +**Required dependencies:** +```bash +uv sync --extra auth-basic +``` + +**Features:** +- HTTP Basic Authentication +- bcrypt password hashing +- Custom realm support + +### Flask-Login + +```python +auth = get_auth_provider("flask-login", { + "users_file": "/etc/tis/users.txt", + "use_bcrypt": True, + "login_view": "login" +}) +``` + +**Required dependencies:** +```bash +uv sync --extra auth-login +``` + +**Features:** +- Session-based authentication +- Multiple users support +- Login/logout pages +- bcrypt password hashing + +**User file format** (`users.txt`): +``` +username:bcrypt_hash +admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6 +``` + +### OAuth2 + +```python +auth = get_auth_provider("oauth", { + "provider": "google", # or "github", "gitlab", "generic" + "client_id": "your-client-id", + "client_secret": "your-client-secret", + "redirect_uri": "http://localhost:8080/oauth/callback", + "authorized_domains": ["example.com"], + "authorized_users": ["admin@example.com"] +}) +``` + +**Required dependencies:** +```bash +uv sync --extra auth-oauth +``` + +**Features:** +- OAuth2 authentication +- Google, GitHub, GitLab support +- Custom OAuth providers +- Domain/user restrictions + +## API Reference + +### AuthProvider + +Base class for all auth providers. + +#### Methods + +- `init_app(app)` - Initialize with Flask app +- `require_auth(f)` - Decorator to protect routes +- `is_authenticated()` - Check if current request is authenticated +- `get_current_user()` - Get current user info +- `handle_unauthorized()` - Handle unauthorized access +- `logout()` - Logout current user + +### BasicAuthProvider + +HTTP Basic Authentication provider. + +#### Configuration + +```python +{ + "username": str, # Required + "password": str, # Required (plain or bcrypt hash) + "use_bcrypt": bool, # Default: False + "realm": str # Default: "TISBackup" +} +``` + +### FlaskLoginProvider + +Session-based authentication provider. + +#### Configuration + +```python +{ + "users": dict, # {username: password_hash} or... + "users_file": str, # Path to users file + "use_bcrypt": bool, # Default: True + "login_view": str # Default: "login" +} +``` + +#### Methods + +- `verify_password(username, password)` - Verify credentials +- `login_user(username)` - Login user by username + +### OAuthProvider + +OAuth2 authentication provider. + +#### Configuration + +```python +{ + "provider": str, # "google", "github", "gitlab", "generic" + "client_id": str, # Required + "client_secret": str, # Required + "redirect_uri": str, # Required + "scopes": list, # Optional + "authorized_domains": list, # Optional + "authorized_users": list, # Optional + + # For generic provider: + "authorization_endpoint": str, + "token_endpoint": str, + "userinfo_endpoint": str +} +``` + +#### Methods + +- `is_user_authorized(user_info)` - Check if user is authorized + +## Integration Example + +See [example_integration.py](example_integration.py) for a complete example. + +### Minimal Example + +```python +from flask import Flask +from libtisbackup.auth import get_auth_provider + +app = Flask(__name__) +app.secret_key = "your-secret-key" + +# Initialize auth +auth = get_auth_provider("basic", { + "username": "admin", + "password": "changeme", + "use_bcrypt": False +}) +auth.init_app(app) + +# Protected route +@app.route("/") +@auth.require_auth +def index(): + return "Protected content" + +# Public route +@app.route("/health") +def health(): + return "OK" +``` + +### With Flask-Login + +```python +auth = get_auth_provider("flask-login", { + "users": { + "admin": "$2b$12$..." + }, + "use_bcrypt": True +}) +auth.init_app(app) + +@app.route("/login", methods=["POST"]) +def login(): + username = request.form["username"] + password = request.form["password"] + + if auth.verify_password(username, password): + auth.login_user(username) + return redirect("/") + return "Invalid credentials", 401 + +@app.route("/") +@auth.require_auth +def index(): + user = auth.get_current_user() + return f"Hello {user['username']}" +``` + +### With OAuth + +```python +auth = get_auth_provider("oauth", { + "provider": "google", + "client_id": "...", + "client_secret": "...", + "redirect_uri": "http://localhost:8080/oauth/callback", + "authorized_domains": ["example.com"] +}) +auth.init_app(app) + +@app.route("/oauth/login") +def oauth_login(): + return auth.oauth_client.authorize_redirect( + url_for("oauth_callback", _external=True) + ) + +@app.route("/oauth/callback") +def oauth_callback(): + token = auth.oauth_client.authorize_access_token() + user_info = auth.oauth_client.get(auth.userinfo_endpoint).json() + + if auth.is_user_authorized(user_info): + session["oauth_user"] = user_info + return redirect("/") + return "Unauthorized", 403 +``` + +## Security Considerations + +1. **Always use HTTPS in production** - Especially for Basic Auth +2. **Use bcrypt for passwords** - Never store plain text passwords +3. **Rotate credentials regularly** - Change passwords and OAuth secrets +4. **Restrict OAuth access** - Use `authorized_domains` or `authorized_users` +5. **Set strong Flask secret_key** - Use `secrets.token_hex(32)` +6. **Protect config files** - `chmod 600` for files with credentials +7. **Use environment variables** - For sensitive configuration values + +## Testing + +```python +import unittest +from libtisbackup.auth import get_auth_provider + +class TestBasicAuth(unittest.TestCase): + def test_authentication(self): + auth = get_auth_provider("basic", { + "username": "admin", + "password": "test", + "use_bcrypt": False + }) + + # Mock request with credentials + # Test authentication logic + pass +``` + +## License + +GPL v3.0 - Same as TISBackup diff --git a/libtisbackup/auth/__init__.py b/libtisbackup/auth/__init__.py new file mode 100644 index 0000000..ca89c3b --- /dev/null +++ b/libtisbackup/auth/__init__.py @@ -0,0 +1,52 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +TISBackup Authentication Module + +Provides pluggable authentication providers for Flask routes. +Supports: Basic Auth, Flask-Login, OAuth2 +""" + +from .base import AuthProvider +from .basic_auth import BasicAuthProvider +from .flask_login_auth import FlaskLoginProvider +from .oauth_auth import OAuthProvider + +__all__ = [ + "AuthProvider", + "BasicAuthProvider", + "FlaskLoginProvider", + "OAuthProvider", + "get_auth_provider", +] + + +def get_auth_provider(auth_type, config=None): + """Factory function to get authentication provider. + + Args: + auth_type: Type of auth ('basic', 'flask-login', 'oauth', or 'none') + config: Configuration dict for the provider + + Returns: + AuthProvider instance + + Raises: + ValueError: If auth_type is not supported + """ + providers = { + "none": AuthProvider, + "basic": BasicAuthProvider, + "flask-login": FlaskLoginProvider, + "oauth": OAuthProvider, + } + + auth_type = auth_type.lower() + if auth_type not in providers: + raise ValueError( + f"Unsupported auth type: {auth_type}. " + f"Supported types: {', '.join(providers.keys())}" + ) + + provider_class = providers[auth_type] + return provider_class(config or {}) diff --git a/libtisbackup/auth/base.py b/libtisbackup/auth/base.py new file mode 100644 index 0000000..3ba12e0 --- /dev/null +++ b/libtisbackup/auth/base.py @@ -0,0 +1,74 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +Base authentication provider interface +""" + +from abc import ABC, abstractmethod +from functools import wraps + + +class AuthProvider(ABC): + """Base class for authentication providers.""" + + def __init__(self, config): + """Initialize the auth provider. + + Args: + config: Dict with provider-specific configuration + """ + self.config = config + + def init_app(self, app): + """Initialize the provider with Flask app. + + Args: + app: Flask application instance + """ + pass + + def require_auth(self, f): + """Decorator to require authentication for a route. + + Args: + f: Flask route function + + Returns: + Decorated function + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if not self.is_authenticated(): + return self.handle_unauthorized() + return f(*args, **kwargs) + return decorated_function + + def is_authenticated(self): + """Check if current request is authenticated. + + Returns: + bool: True if authenticated, False otherwise + """ + # Default: no authentication required + return True + + def handle_unauthorized(self): + """Handle unauthorized access. + + Returns: + Flask response for unauthorized access + """ + from flask import jsonify + return jsonify({"error": "Unauthorized"}), 401 + + def get_current_user(self): + """Get current authenticated user. + + Returns: + User object or dict, or None if not authenticated + """ + return None + + def logout(self): + """Logout current user.""" + pass diff --git a/libtisbackup/auth/basic_auth.py b/libtisbackup/auth/basic_auth.py new file mode 100644 index 0000000..1c84e64 --- /dev/null +++ b/libtisbackup/auth/basic_auth.py @@ -0,0 +1,78 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +HTTP Basic Authentication provider +""" + +import base64 +import logging +from functools import wraps + +from flask import request + +from .base import AuthProvider + + +class BasicAuthProvider(AuthProvider): + """HTTP Basic Authentication provider. + + Configuration: + username: Required username + password: Required password (plain text, or hashed with bcrypt) + realm: Authentication realm (default: 'TISBackup') + use_bcrypt: If True, password is bcrypt hash (default: False) + """ + + def __init__(self, config): + super().__init__(config) + self.logger = logging.getLogger("tisbackup.auth") + self.username = config.get("username") + self.password = config.get("password") + self.realm = config.get("realm", "TISBackup") + self.use_bcrypt = config.get("use_bcrypt", False) + + if not self.username or not self.password: + raise ValueError("BasicAuth requires 'username' and 'password' in config") + + if self.use_bcrypt: + try: + import bcrypt + self.bcrypt = bcrypt + except ImportError: + raise ImportError("bcrypt library required for password hashing. Install with: pip install bcrypt") + + def is_authenticated(self): + """Check if request has valid Basic Auth credentials.""" + auth = request.authorization + + if not auth: + return False + + if auth.username != self.username: + self.logger.warning(f"Failed authentication attempt for user: {auth.username}") + return False + + if self.use_bcrypt: + # Compare bcrypt hash + password_bytes = auth.password.encode('utf-8') + hash_bytes = self.password.encode('utf-8') if isinstance(self.password, str) else self.password + return self.bcrypt.checkpw(password_bytes, hash_bytes) + else: + # Plain text comparison (not recommended for production) + return auth.password == self.password + + def handle_unauthorized(self): + """Return 401 with WWW-Authenticate header.""" + from flask import Response + return Response( + 'Authentication required', + 401, + {'WWW-Authenticate': f'Basic realm="{self.realm}"'} + ) + + def get_current_user(self): + """Get current authenticated user.""" + auth = request.authorization + if auth and self.is_authenticated(): + return {"username": auth.username, "auth_type": "basic"} + return None diff --git a/libtisbackup/auth/example_integration.py b/libtisbackup/auth/example_integration.py new file mode 100644 index 0000000..1176fde --- /dev/null +++ b/libtisbackup/auth/example_integration.py @@ -0,0 +1,121 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +Example integration of authentication providers with Flask app + +This file shows how to integrate the authentication system into tisbackup_gui.py +""" + +from flask import Flask, jsonify, render_template, request, redirect, url_for, session +from libtisbackup.auth import get_auth_provider + +# Example configuration from tisbackup_gui.ini +auth_config = { + "type": "basic", # or "flask-login", "oauth", "none" + "basic": { + "username": "admin", + "password": "$2b$12$...", # bcrypt hash + "use_bcrypt": True, + "realm": "TISBackup Admin" + }, + "flask-login": { + "users_file": "/etc/tis/users.txt", # username:bcrypt_hash per line + "use_bcrypt": True, + "login_view": "login" + }, + "oauth": { + "provider": "google", + "client_id": "your-client-id", + "client_secret": "your-client-secret", + "redirect_uri": "http://localhost:8080/oauth/callback", + "authorized_domains": ["example.com"], + "authorized_users": ["admin@example.com"] + } +} + + +def create_app_with_auth(): + """Example: Create Flask app with authentication.""" + app = Flask(__name__) + app.secret_key = "your-secret-key" + + # Initialize authentication provider + auth_type = auth_config.get("type", "none") + provider_config = auth_config.get(auth_type, {}) + auth = get_auth_provider(auth_type, provider_config) + auth.init_app(app) + + # Protected routes + @app.route("/") + @auth.require_auth + def index(): + user = auth.get_current_user() + return render_template("index.html", user=user) + + @app.route("/api/backups") + @auth.require_auth + def api_backups(): + return jsonify({"backups": []}) + + # Public routes (no auth required) + @app.route("/health") + def health(): + return jsonify({"status": "ok"}) + + # Flask-Login specific routes + if auth_type == "flask-login": + @app.route("/login", methods=["GET", "POST"]) + def login(): + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + + if auth.verify_password(username, password): + auth.login_user(username) + return redirect(url_for("index")) + else: + return render_template("login.html", error="Invalid credentials") + + return render_template("login.html") + + @app.route("/logout") + def logout(): + auth.logout() + return redirect(url_for("login")) + + # OAuth specific routes + if auth_type == "oauth": + @app.route("/oauth/login") + def oauth_login(): + redirect_uri = url_for("oauth_callback", _external=True) + return auth.oauth_client.authorize_redirect(redirect_uri) + + @app.route("/oauth/callback") + def oauth_callback(): + try: + token = auth.oauth_client.authorize_access_token() + user_info = auth.oauth_client.get(auth.userinfo_endpoint).json() + + # Check authorization + if not auth.is_user_authorized(user_info): + return "Unauthorized: Your email/domain is not authorized", 403 + + # Store user in session + session["oauth_user"] = user_info + session["oauth_token"] = token + + return redirect(url_for("index")) + except Exception as e: + return f"OAuth callback error: {e}", 500 + + @app.route("/logout") + def logout(): + auth.logout() + return redirect(url_for("oauth_login")) + + return app + + +if __name__ == "__main__": + app = create_app_with_auth() + app.run(debug=True, port=8080) diff --git a/libtisbackup/auth/flask_login_auth.py b/libtisbackup/auth/flask_login_auth.py new file mode 100644 index 0000000..1d90651 --- /dev/null +++ b/libtisbackup/auth/flask_login_auth.py @@ -0,0 +1,156 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +Flask-Login authentication provider +""" + +import logging +from functools import wraps + +from flask import redirect, request, session, url_for + +from .base import AuthProvider + + +class FlaskLoginProvider(AuthProvider): + """Flask-Login based authentication provider. + + Configuration: + users: Dict of {username: password_hash} or path to user file + secret_key: Flask secret key for sessions + login_view: Route name for login page (default: 'login') + use_bcrypt: If True, passwords are bcrypt hashes (default: True) + """ + + def __init__(self, config): + super().__init__(config) + self.logger = logging.getLogger("tisbackup.auth") + self.login_manager = None + self.users = config.get("users", {}) + self.login_view = config.get("login_view", "login") + self.use_bcrypt = config.get("use_bcrypt", True) + + if self.use_bcrypt: + try: + import bcrypt + self.bcrypt = bcrypt + except ImportError: + raise ImportError("bcrypt library required. Install with: pip install bcrypt") + + # Load users from file if path provided + users_file = config.get("users_file") + if users_file: + self._load_users_from_file(users_file) + + def _load_users_from_file(self, filepath): + """Load users from file (username:password_hash per line).""" + try: + with open(filepath) as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + username, password_hash = line.split(':', 1) + self.users[username.strip()] = password_hash.strip() + except Exception as e: + self.logger.error(f"Failed to load users from {filepath}: {e}") + raise + + def init_app(self, app): + """Initialize Flask-Login with the app.""" + try: + from flask_login import LoginManager, UserMixin + except ImportError: + raise ImportError("flask-login library required. Install with: pip install flask-login") + + self.login_manager = LoginManager() + self.login_manager.init_app(app) + self.login_manager.login_view = self.login_view + + # Simple User class + class User(UserMixin): + def __init__(self, username): + self.id = username + self.username = username + + self.User = User + + @self.login_manager.user_loader + def load_user(user_id): + if user_id in self.users: + return User(user_id) + return None + + def verify_password(self, username, password): + """Verify username and password. + + Args: + username: Username to check + password: Password to verify + + Returns: + bool: True if valid, False otherwise + """ + if username not in self.users: + return False + + stored_hash = self.users[username] + + if self.use_bcrypt: + password_bytes = password.encode('utf-8') + hash_bytes = stored_hash.encode('utf-8') if isinstance(stored_hash, str) else stored_hash + return self.bcrypt.checkpw(password_bytes, hash_bytes) + else: + return password == stored_hash + + def login_user(self, username): + """Login a user by username. + + Args: + username: Username to login + + Returns: + bool: True if successful + """ + try: + from flask_login import login_user + except ImportError: + return False + + if username in self.users: + user = self.User(username) + login_user(user) + return True + return False + + def is_authenticated(self): + """Check if current user is authenticated.""" + try: + from flask_login import current_user + return current_user.is_authenticated + except ImportError: + return False + + def handle_unauthorized(self): + """Redirect to login page.""" + return redirect(url_for(self.login_view)) + + def get_current_user(self): + """Get current authenticated user.""" + try: + from flask_login import current_user + if current_user.is_authenticated: + return { + "username": current_user.username, + "auth_type": "flask-login" + } + except ImportError: + pass + return None + + def logout(self): + """Logout current user.""" + try: + from flask_login import logout_user + logout_user() + except ImportError: + pass diff --git a/libtisbackup/auth/oauth_auth.py b/libtisbackup/auth/oauth_auth.py new file mode 100644 index 0000000..3ec7cad --- /dev/null +++ b/libtisbackup/auth/oauth_auth.py @@ -0,0 +1,164 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +OAuth2 authentication provider +""" + +import logging +from functools import wraps + +from flask import redirect, request, session, url_for + +from .base import AuthProvider + + +class OAuthProvider(AuthProvider): + """OAuth2 authentication provider. + + Supports multiple OAuth providers (Google, GitHub, GitLab, etc.) + + Configuration: + provider: OAuth provider name ('google', 'github', 'gitlab', 'generic') + client_id: OAuth client ID + client_secret: OAuth client secret + redirect_uri: OAuth redirect URI + scopes: List of OAuth scopes (default: ['openid', 'email', 'profile']) + authorized_domains: List of allowed email domains (optional) + authorized_users: List of allowed email addresses (optional) + login_view: Route name for login page (default: 'oauth_login') + """ + + def __init__(self, config): + super().__init__(config) + self.logger = logging.getLogger("tisbackup.auth") + + self.provider_name = config.get("provider", "generic").lower() + self.client_id = config.get("client_id") + self.client_secret = config.get("client_secret") + self.redirect_uri = config.get("redirect_uri") + self.scopes = config.get("scopes", ["openid", "email", "profile"]) + self.authorized_domains = config.get("authorized_domains", []) + self.authorized_users = config.get("authorized_users", []) + self.login_view = config.get("login_view", "oauth_login") + + if not self.client_id or not self.client_secret: + raise ValueError("OAuth requires 'client_id' and 'client_secret' in config") + + # Provider-specific configurations + self.provider_configs = { + "google": { + "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "token_endpoint": "https://oauth2.googleapis.com/token", + "userinfo_endpoint": "https://www.googleapis.com/oauth2/v2/userinfo", + "default_scopes": ["openid", "email", "profile"], + }, + "github": { + "authorization_endpoint": "https://github.com/login/oauth/authorize", + "token_endpoint": "https://github.com/login/oauth/access_token", + "userinfo_endpoint": "https://api.github.com/user", + "default_scopes": ["read:user", "user:email"], + }, + "gitlab": { + "authorization_endpoint": "https://gitlab.com/oauth/authorize", + "token_endpoint": "https://gitlab.com/oauth/token", + "userinfo_endpoint": "https://gitlab.com/api/v4/user", + "default_scopes": ["read_user", "email"], + }, + } + + # Get provider config or use generic + self.provider_config = self.provider_configs.get(self.provider_name, {}) + + # Override with custom endpoints if provided + self.authorization_endpoint = config.get( + "authorization_endpoint", + self.provider_config.get("authorization_endpoint") + ) + self.token_endpoint = config.get( + "token_endpoint", + self.provider_config.get("token_endpoint") + ) + self.userinfo_endpoint = config.get( + "userinfo_endpoint", + self.provider_config.get("userinfo_endpoint") + ) + + # Use provider default scopes if not specified + if not config.get("scopes"): + self.scopes = self.provider_config.get("default_scopes", self.scopes) + + def init_app(self, app): + """Initialize OAuth with the app.""" + try: + from authlib.integrations.flask_client import OAuth + except ImportError: + raise ImportError( + "authlib library required for OAuth. " + "Install with: pip install authlib requests" + ) + + self.oauth = OAuth(app) + + # Register OAuth client + self.oauth_client = self.oauth.register( + name=self.provider_name, + client_id=self.client_id, + client_secret=self.client_secret, + server_metadata_url=None, + authorize_url=self.authorization_endpoint, + access_token_url=self.token_endpoint, + userinfo_endpoint=self.userinfo_endpoint, + client_kwargs={"scope": " ".join(self.scopes)}, + ) + + def is_user_authorized(self, user_info): + """Check if user is authorized based on email/domain. + + Args: + user_info: Dict with user information (must contain 'email') + + Returns: + bool: True if authorized + """ + email = user_info.get("email", "").lower() + + # Check specific users + if self.authorized_users: + if email in [u.lower() for u in self.authorized_users]: + return True + + # Check domains + if self.authorized_domains: + domain = email.split("@")[-1] if "@" in email else "" + if domain in [d.lower() for d in self.authorized_domains]: + return True + + # If no restrictions configured, allow all + if not self.authorized_users and not self.authorized_domains: + return True + + return False + + def is_authenticated(self): + """Check if current user is authenticated via OAuth.""" + return "oauth_user" in session + + def handle_unauthorized(self): + """Redirect to OAuth login.""" + return redirect(url_for(self.login_view)) + + def get_current_user(self): + """Get current authenticated user.""" + user_info = session.get("oauth_user") + if user_info: + return { + **user_info, + "auth_type": "oauth", + "provider": self.provider_name + } + return None + + def logout(self): + """Logout current user.""" + session.pop("oauth_user", None) + session.pop("oauth_token", None) diff --git a/pyproject.toml b/pyproject.toml index dabd70e..19aeb30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,11 +12,20 @@ dependencies = [ "pexpect==4.9.0", "redis==5.2.1", "requests==2.32.3", + "ruff>=0.13.3", "simplejson==3.20.1", "six==1.17.0", ] requires-python = ">=3.13" +[project.optional-dependencies] +# Authentication providers +auth-basic = ["bcrypt>=4.0.0"] +auth-login = ["flask-login>=0.6.0", "bcrypt>=4.0.0"] +auth-oauth = ["authlib>=1.3.0", "requests>=2.32.0"] +# Install all auth providers +auth-all = ["bcrypt>=4.0.0", "flask-login>=0.6.0", "authlib>=1.3.0", "requests>=2.32.0"] + [tool.black] line-length = 140 diff --git a/samples/auth-config-examples.ini b/samples/auth-config-examples.ini new file mode 100644 index 0000000..e2b26cd --- /dev/null +++ b/samples/auth-config-examples.ini @@ -0,0 +1,130 @@ +# TISBackup Authentication Configuration Examples +# Add to tisbackup_gui.ini under [authentication] section + +# ============================================ +# Option 1: No Authentication (NOT RECOMMENDED) +# ============================================ +[authentication] +type = none + + +# ============================================ +# Option 2: HTTP Basic Authentication +# ============================================ +[authentication] +type = basic +username = admin +# Plain text password (NOT RECOMMENDED for production) +password = changeme +use_bcrypt = False +realm = TISBackup Admin + +# RECOMMENDED: Use bcrypt hash +# Generate hash with: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())" +[authentication] +type = basic +username = admin +password = $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6 +use_bcrypt = True +realm = TISBackup Admin + + +# ============================================ +# Option 3: Flask-Login (Username/Password with Sessions) +# ============================================ +[authentication] +type = flask-login +# Users can be defined inline or in a file +users_file = /etc/tis/users.txt +use_bcrypt = True +login_view = login + +# User file format (users.txt): +# username:bcrypt_password_hash +# Example: +# admin:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYWv.5qVQK6 +# operator:$2b$12$abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNO + + +# ============================================ +# Option 4: OAuth2 - Google +# ============================================ +[authentication] +type = oauth +provider = google +client_id = your-client-id.apps.googleusercontent.com +client_secret = your-client-secret +redirect_uri = http://localhost:8080/oauth/callback +# Restrict to specific domains +authorized_domains = example.com,mycompany.com +# Or restrict to specific users +authorized_users = admin@example.com,backup-admin@example.com + +# To get Google OAuth credentials: +# 1. Go to https://console.cloud.google.com/apis/credentials +# 2. Create OAuth 2.0 Client ID +# 3. Add authorized redirect URI: http://your-server:8080/oauth/callback + + +# ============================================ +# Option 5: OAuth2 - GitHub +# ============================================ +[authentication] +type = oauth +provider = github +client_id = your-github-client-id +client_secret = your-github-client-secret +redirect_uri = http://localhost:8080/oauth/callback +# Restrict to specific GitHub users (by email) +authorized_users = admin@example.com + +# To get GitHub OAuth credentials: +# 1. Go to Settings > Developer settings > OAuth Apps +# 2. Register a new application +# 3. Set Authorization callback URL: http://your-server:8080/oauth/callback + + +# ============================================ +# Option 6: OAuth2 - GitLab +# ============================================ +[authentication] +type = oauth +provider = gitlab +client_id = your-gitlab-application-id +client_secret = your-gitlab-secret +redirect_uri = http://localhost:8080/oauth/callback +authorized_domains = example.com + +# To get GitLab OAuth credentials: +# 1. Go to User Settings > Applications +# 2. Create new application with scopes: read_user, email +# 3. Set Redirect URI: http://your-server:8080/oauth/callback + + +# ============================================ +# Option 7: OAuth2 - Generic Provider +# ============================================ +[authentication] +type = oauth +provider = generic +client_id = your-client-id +client_secret = your-client-secret +redirect_uri = http://localhost:8080/oauth/callback +# Custom OAuth endpoints +authorization_endpoint = https://auth.example.com/oauth/authorize +token_endpoint = https://auth.example.com/oauth/token +userinfo_endpoint = https://auth.example.com/oauth/userinfo +scopes = openid,email,profile +authorized_domains = example.com + + +# ============================================ +# Security Notes +# ============================================ +# 1. Always use HTTPS in production (reverse proxy with TLS) +# 2. Set strong Flask secret_key via TISBACKUP_SECRET_KEY env var +# 3. For Basic Auth, always use bcrypt hashed passwords +# 4. For OAuth, restrict access via authorized_domains or authorized_users +# 5. Keep client secrets secure and never commit to version control +# 6. Regularly rotate OAuth client secrets +# 7. Use environment variables for sensitive data when possible