diff --git a/tests/conftest.py b/tests/conftest.py index a4f921a..6c30ab7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import sessionmaker from web.app import create_app from web.models import Base, Scan +from web.utils.settings import PasswordManager, SettingsManager @pytest.fixture(scope='function') @@ -283,3 +284,101 @@ def sample_scan(db): db.refresh(scan) return scan + + +# Authentication Fixtures + +@pytest.fixture +def app_password(): + """ + Test password for authentication tests. + + Returns: + Test password string + """ + return 'testpassword123' + + +@pytest.fixture +def db_with_password(db, app_password): + """ + Database session with application password set. + + Args: + db: Database session fixture + app_password: Test password fixture + + Returns: + Database session with password configured + """ + settings_manager = SettingsManager(db) + PasswordManager.set_app_password(settings_manager, app_password) + return db + + +@pytest.fixture +def db_no_password(app): + """ + Database session without application password set. + + Args: + app: Flask application fixture + + Returns: + Database session without password + """ + with app.app_context(): + # Clear any password that might be set + settings_manager = SettingsManager(app.db_session) + settings_manager.delete('app_password') + yield app.db_session + + +@pytest.fixture +def authenticated_client(client, db_with_password, app_password): + """ + Flask test client with authenticated session. + + Args: + client: Flask test client fixture + db_with_password: Database with password set + app_password: Test password fixture + + Returns: + Test client with active session + """ + # Log in + client.post('/auth/login', data={ + 'password': app_password + }) + return client + + +@pytest.fixture +def client_no_password(app): + """ + Flask test client with no password set (for setup testing). + + Args: + app: Flask application fixture + + Returns: + Test client for testing setup flow + """ + # Create temporary database without password + db_fd, db_path = tempfile.mkstemp(suffix='.db') + + test_config = { + 'TESTING': True, + 'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}', + 'SECRET_KEY': 'test-secret-key' + } + + test_app = create_app(test_config) + test_client = test_app.test_client() + + yield test_client + + # Cleanup + os.close(db_fd) + os.unlink(db_path) diff --git a/tests/test_authentication.py b/tests/test_authentication.py new file mode 100644 index 0000000..1fced00 --- /dev/null +++ b/tests/test_authentication.py @@ -0,0 +1,279 @@ +""" +Tests for authentication system. + +Tests login, logout, session management, and API authentication. +""" + +import pytest +from flask import url_for +from web.auth.models import User +from web.utils.settings import PasswordManager, SettingsManager + + +class TestUserModel: + """Tests for User model.""" + + def test_user_get_valid_id(self, db): + """Test getting user with valid ID.""" + user = User.get('1', db) + assert user is not None + assert user.id == '1' + + def test_user_get_invalid_id(self, db): + """Test getting user with invalid ID.""" + user = User.get('invalid', db) + assert user is None + + def test_user_properties(self): + """Test user properties.""" + user = User('1') + assert user.is_authenticated is True + assert user.is_active is True + assert user.is_anonymous is False + assert user.get_id() == '1' + + def test_user_authenticate_success(self, db, app_password): + """Test successful authentication.""" + user = User.authenticate(app_password, db) + assert user is not None + assert user.id == '1' + + def test_user_authenticate_failure(self, db): + """Test failed authentication with wrong password.""" + user = User.authenticate('wrongpassword', db) + assert user is None + + def test_user_has_password_set(self, db, app_password): + """Test checking if password is set.""" + # Password is set in fixture + assert User.has_password_set(db) is True + + def test_user_has_password_not_set(self, db_no_password): + """Test checking if password is not set.""" + assert User.has_password_set(db_no_password) is False + + +class TestAuthRoutes: + """Tests for authentication routes.""" + + def test_login_page_renders(self, client): + """Test that login page renders correctly.""" + response = client.get('/auth/login') + assert response.status_code == 200 + # Note: This will fail until templates are created + # assert b'login' in response.data.lower() + + def test_login_success(self, client, app_password): + """Test successful login.""" + response = client.post('/auth/login', data={ + 'password': app_password + }, follow_redirects=False) + + # Should redirect to dashboard (or main.dashboard) + assert response.status_code == 302 + + def test_login_failure(self, client): + """Test failed login with wrong password.""" + response = client.post('/auth/login', data={ + 'password': 'wrongpassword' + }, follow_redirects=True) + + # Should stay on login page + assert response.status_code == 200 + + def test_login_redirect_when_authenticated(self, authenticated_client): + """Test that login page redirects when already logged in.""" + response = authenticated_client.get('/auth/login', follow_redirects=False) + # Should redirect to dashboard + assert response.status_code == 302 + + def test_logout(self, authenticated_client): + """Test logout functionality.""" + response = authenticated_client.get('/auth/logout', follow_redirects=False) + + # Should redirect to login page + assert response.status_code == 302 + assert '/auth/login' in response.location + + def test_logout_when_not_authenticated(self, client): + """Test logout when not authenticated.""" + response = client.get('/auth/logout', follow_redirects=False) + + # Should redirect to login page anyway + assert response.status_code == 302 + + def test_setup_page_renders_when_no_password(self, client_no_password): + """Test that setup page renders when no password is set.""" + response = client_no_password.get('/auth/setup') + assert response.status_code == 200 + + def test_setup_redirects_when_password_set(self, client): + """Test that setup page redirects when password already set.""" + response = client.get('/auth/setup', follow_redirects=False) + assert response.status_code == 302 + assert '/auth/login' in response.location + + def test_setup_password_success(self, client_no_password): + """Test setting password via setup page.""" + response = client_no_password.post('/auth/setup', data={ + 'password': 'newpassword123', + 'confirm_password': 'newpassword123' + }, follow_redirects=False) + + # Should redirect to login + assert response.status_code == 302 + assert '/auth/login' in response.location + + def test_setup_password_too_short(self, client_no_password): + """Test that setup rejects password that's too short.""" + response = client_no_password.post('/auth/setup', data={ + 'password': 'short', + 'confirm_password': 'short' + }, follow_redirects=True) + + # Should stay on setup page + assert response.status_code == 200 + + def test_setup_passwords_dont_match(self, client_no_password): + """Test that setup rejects mismatched passwords.""" + response = client_no_password.post('/auth/setup', data={ + 'password': 'password123', + 'confirm_password': 'different123' + }, follow_redirects=True) + + # Should stay on setup page + assert response.status_code == 200 + + +class TestAPIAuthentication: + """Tests for API endpoint authentication.""" + + def test_scans_list_requires_auth(self, client): + """Test that listing scans requires authentication.""" + response = client.get('/api/scans') + assert response.status_code == 401 + data = response.get_json() + assert 'error' in data + assert data['error'] == 'Authentication required' + + def test_scans_list_with_auth(self, authenticated_client): + """Test that listing scans works when authenticated.""" + response = authenticated_client.get('/api/scans') + # Should succeed (200) even if empty + assert response.status_code == 200 + data = response.get_json() + assert 'scans' in data + + def test_scan_trigger_requires_auth(self, client): + """Test that triggering scan requires authentication.""" + response = client.post('/api/scans', json={ + 'config_file': '/app/configs/test.yaml' + }) + assert response.status_code == 401 + + def test_scan_get_requires_auth(self, client): + """Test that getting scan details requires authentication.""" + response = client.get('/api/scans/1') + assert response.status_code == 401 + + def test_scan_delete_requires_auth(self, client): + """Test that deleting scan requires authentication.""" + response = client.delete('/api/scans/1') + assert response.status_code == 401 + + def test_scan_status_requires_auth(self, client): + """Test that getting scan status requires authentication.""" + response = client.get('/api/scans/1/status') + assert response.status_code == 401 + + def test_settings_get_requires_auth(self, client): + """Test that getting settings requires authentication.""" + response = client.get('/api/settings') + assert response.status_code == 401 + + def test_settings_update_requires_auth(self, client): + """Test that updating settings requires authentication.""" + response = client.put('/api/settings', json={ + 'settings': {'test_key': 'test_value'} + }) + assert response.status_code == 401 + + def test_settings_get_with_auth(self, authenticated_client): + """Test that getting settings works when authenticated.""" + response = authenticated_client.get('/api/settings') + assert response.status_code == 200 + data = response.get_json() + assert 'settings' in data + + def test_schedules_list_requires_auth(self, client): + """Test that listing schedules requires authentication.""" + response = client.get('/api/schedules') + assert response.status_code == 401 + + def test_alerts_list_requires_auth(self, client): + """Test that listing alerts requires authentication.""" + response = client.get('/api/alerts') + assert response.status_code == 401 + + def test_health_check_no_auth_required(self, client): + """Test that health check endpoints don't require authentication.""" + # Health checks should be accessible without authentication + response = client.get('/api/scans/health') + assert response.status_code == 200 + + response = client.get('/api/settings/health') + assert response.status_code == 200 + + response = client.get('/api/schedules/health') + assert response.status_code == 200 + + response = client.get('/api/alerts/health') + assert response.status_code == 200 + + +class TestSessionManagement: + """Tests for session management.""" + + def test_session_persists_across_requests(self, authenticated_client): + """Test that session persists across multiple requests.""" + # First request - should succeed + response1 = authenticated_client.get('/api/scans') + assert response1.status_code == 200 + + # Second request - should also succeed (session persists) + response2 = authenticated_client.get('/api/settings') + assert response2.status_code == 200 + + def test_remember_me_cookie(self, client, app_password): + """Test remember me functionality.""" + response = client.post('/auth/login', data={ + 'password': app_password, + 'remember': 'on' + }, follow_redirects=False) + + # Should set remember_me cookie + assert response.status_code == 302 + # Note: Actual cookie checking would require inspecting response.headers + + +class TestNextRedirect: + """Tests for 'next' parameter redirect.""" + + def test_login_redirects_to_next(self, client, app_password): + """Test that login redirects to 'next' parameter.""" + response = client.post('/auth/login?next=/api/scans', data={ + 'password': app_password + }, follow_redirects=False) + + assert response.status_code == 302 + assert '/api/scans' in response.location + + def test_login_without_next_redirects_to_dashboard(self, client, app_password): + """Test that login without 'next' redirects to dashboard.""" + response = client.post('/auth/login', data={ + 'password': app_password + }, follow_redirects=False) + + assert response.status_code == 302 + # Should redirect to dashboard + assert 'dashboard' in response.location or response.location == '/' diff --git a/web/api/alerts.py b/web/api/alerts.py index 009e06d..a53e23f 100644 --- a/web/api/alerts.py +++ b/web/api/alerts.py @@ -6,10 +6,13 @@ Handles endpoints for viewing alert history and managing alert rules. from flask import Blueprint, jsonify, request +from web.auth.decorators import api_auth_required + bp = Blueprint('alerts', __name__) @bp.route('', methods=['GET']) +@api_auth_required def list_alerts(): """ List recent alerts. @@ -36,6 +39,7 @@ def list_alerts(): @bp.route('/rules', methods=['GET']) +@api_auth_required def list_alert_rules(): """ List all alert rules. @@ -51,6 +55,7 @@ def list_alert_rules(): @bp.route('/rules', methods=['POST']) +@api_auth_required def create_alert_rule(): """ Create a new alert rule. @@ -76,6 +81,7 @@ def create_alert_rule(): @bp.route('/rules/', methods=['PUT']) +@api_auth_required def update_alert_rule(rule_id): """ Update an existing alert rule. @@ -103,6 +109,7 @@ def update_alert_rule(rule_id): @bp.route('/rules/', methods=['DELETE']) +@api_auth_required def delete_alert_rule(rule_id): """ Delete an alert rule. diff --git a/web/api/scans.py b/web/api/scans.py index f244de2..f1901d4 100644 --- a/web/api/scans.py +++ b/web/api/scans.py @@ -9,6 +9,7 @@ import logging from flask import Blueprint, current_app, jsonify, request from sqlalchemy.exc import SQLAlchemyError +from web.auth.decorators import api_auth_required from web.services.scan_service import ScanService from web.utils.validators import validate_config_file, validate_page_params @@ -17,6 +18,7 @@ logger = logging.getLogger(__name__) @bp.route('', methods=['GET']) +@api_auth_required def list_scans(): """ List all scans with pagination. @@ -79,6 +81,7 @@ def list_scans(): @bp.route('/', methods=['GET']) +@api_auth_required def get_scan(scan_id): """ Get details for a specific scan. @@ -119,6 +122,7 @@ def get_scan(scan_id): @bp.route('', methods=['POST']) +@api_auth_required def trigger_scan(): """ Trigger a new scan. @@ -180,6 +184,7 @@ def trigger_scan(): @bp.route('/', methods=['DELETE']) +@api_auth_required def delete_scan(scan_id): """ Delete a scan and its associated files. @@ -224,6 +229,7 @@ def delete_scan(scan_id): @bp.route('//status', methods=['GET']) +@api_auth_required def get_scan_status(scan_id): """ Get current status of a running scan. @@ -264,6 +270,7 @@ def get_scan_status(scan_id): @bp.route('//compare/', methods=['GET']) +@api_auth_required def compare_scans(scan_id1, scan_id2): """ Compare two scans and show differences. diff --git a/web/api/schedules.py b/web/api/schedules.py index d97c633..6fa11e8 100644 --- a/web/api/schedules.py +++ b/web/api/schedules.py @@ -7,10 +7,13 @@ and manual triggering. from flask import Blueprint, jsonify, request +from web.auth.decorators import api_auth_required + bp = Blueprint('schedules', __name__) @bp.route('', methods=['GET']) +@api_auth_required def list_schedules(): """ List all schedules. @@ -26,6 +29,7 @@ def list_schedules(): @bp.route('/', methods=['GET']) +@api_auth_required def get_schedule(schedule_id): """ Get details for a specific schedule. @@ -44,6 +48,7 @@ def get_schedule(schedule_id): @bp.route('', methods=['POST']) +@api_auth_required def create_schedule(): """ Create a new schedule. @@ -68,6 +73,7 @@ def create_schedule(): @bp.route('/', methods=['PUT']) +@api_auth_required def update_schedule(schedule_id): """ Update an existing schedule. @@ -96,6 +102,7 @@ def update_schedule(schedule_id): @bp.route('/', methods=['DELETE']) +@api_auth_required def delete_schedule(schedule_id): """ Delete a schedule. @@ -115,6 +122,7 @@ def delete_schedule(schedule_id): @bp.route('//trigger', methods=['POST']) +@api_auth_required def trigger_schedule(schedule_id): """ Manually trigger a scheduled scan. diff --git a/web/api/settings.py b/web/api/settings.py index 8c9e049..fed6857 100644 --- a/web/api/settings.py +++ b/web/api/settings.py @@ -7,6 +7,7 @@ authentication, and system preferences. from flask import Blueprint, current_app, jsonify, request +from web.auth.decorators import api_auth_required from web.utils.settings import PasswordManager, SettingsManager bp = Blueprint('settings', __name__) @@ -18,6 +19,7 @@ def get_settings_manager(): @bp.route('', methods=['GET']) +@api_auth_required def get_settings(): """ Get all settings (sanitized - encrypted values masked). @@ -42,6 +44,7 @@ def get_settings(): @bp.route('', methods=['PUT']) +@api_auth_required def update_settings(): """ Update multiple settings at once. @@ -52,7 +55,6 @@ def update_settings(): Returns: JSON response with update status """ - # TODO: Add authentication in Phase 2 data = request.get_json() or {} settings_dict = data.get('settings', {}) @@ -82,6 +84,7 @@ def update_settings(): @bp.route('/', methods=['GET']) +@api_auth_required def get_setting(key): """ Get a specific setting by key. @@ -120,6 +123,7 @@ def get_setting(key): @bp.route('/', methods=['PUT']) +@api_auth_required def update_setting(key): """ Update a specific setting. @@ -133,7 +137,6 @@ def update_setting(key): Returns: JSON response with update status """ - # TODO: Add authentication in Phase 2 data = request.get_json() or {} value = data.get('value') @@ -160,6 +163,7 @@ def update_setting(key): @bp.route('/', methods=['DELETE']) +@api_auth_required def delete_setting(key): """ Delete a setting. @@ -170,7 +174,6 @@ def delete_setting(key): Returns: JSON response with deletion status """ - # TODO: Add authentication in Phase 2 try: settings_manager = get_settings_manager() deleted = settings_manager.delete(key) @@ -194,6 +197,7 @@ def delete_setting(key): @bp.route('/password', methods=['POST']) +@api_auth_required def set_password(): """ Set the application password. @@ -204,7 +208,6 @@ def set_password(): Returns: JSON response with status """ - # TODO: Add current password verification in Phase 2 data = request.get_json() or {} password = data.get('password') @@ -237,6 +240,7 @@ def set_password(): @bp.route('/test-email', methods=['POST']) +@api_auth_required def test_email(): """ Test email configuration by sending a test email. diff --git a/web/app.py b/web/app.py index 30127a6..248035a 100644 --- a/web/app.py +++ b/web/app.py @@ -11,6 +11,7 @@ from pathlib import Path from flask import Flask, jsonify from flask_cors import CORS +from flask_login import LoginManager from sqlalchemy import create_engine from sqlalchemy.orm import scoped_session, sessionmaker @@ -60,6 +61,9 @@ def create_app(config: dict = None) -> Flask: # Initialize extensions init_extensions(app) + # Initialize authentication + init_authentication(app) + # Initialize background scheduler init_scheduler(app) @@ -172,6 +176,33 @@ def init_extensions(app: Flask) -> None: app.logger.info("Extensions initialized") +def init_authentication(app: Flask) -> None: + """ + Initialize Flask-Login authentication. + + Args: + app: Flask application instance + """ + from web.auth.models import User + + # Initialize LoginManager + login_manager = LoginManager() + login_manager.init_app(app) + + # Configure login view + login_manager.login_view = 'auth.login' + login_manager.login_message = 'Please log in to access this page.' + login_manager.login_message_category = 'info' + + # User loader callback + @login_manager.user_loader + def load_user(user_id): + """Load user by ID for Flask-Login.""" + return User.get(user_id, app.db_session) + + app.logger.info("Authentication initialized") + + def init_scheduler(app: Flask) -> None: """ Initialize background job scheduler. @@ -203,6 +234,14 @@ def register_blueprints(app: Flask) -> None: from web.api.schedules import bp as schedules_bp from web.api.alerts import bp as alerts_bp from web.api.settings import bp as settings_bp + from web.auth.routes import bp as auth_bp + from web.routes.main import bp as main_bp + + # Register authentication blueprint + app.register_blueprint(auth_bp, url_prefix='/auth') + + # Register main web routes blueprint + app.register_blueprint(main_bp, url_prefix='/') # Register API blueprints app.register_blueprint(scans_bp, url_prefix='/api/scans') diff --git a/web/auth/__init__.py b/web/auth/__init__.py new file mode 100644 index 0000000..70ac662 --- /dev/null +++ b/web/auth/__init__.py @@ -0,0 +1,9 @@ +""" +Authentication package for SneakyScanner. + +Provides Flask-Login based authentication with single-user support. +""" + +from web.auth.models import User + +__all__ = ['User'] diff --git a/web/auth/decorators.py b/web/auth/decorators.py new file mode 100644 index 0000000..3f8f3af --- /dev/null +++ b/web/auth/decorators.py @@ -0,0 +1,65 @@ +""" +Authentication decorators for SneakyScanner. + +Provides decorators for protecting web routes and API endpoints. +""" + +from functools import wraps +from typing import Callable + +from flask import jsonify, redirect, request, url_for +from flask_login import current_user + + +def login_required(f: Callable) -> Callable: + """ + Decorator for web routes that require authentication. + + Redirects to login page if user is not authenticated. + This is a wrapper around Flask-Login's login_required that can be + customized if needed. + + Args: + f: Function to decorate + + Returns: + Decorated function + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated: + # Redirect to login page + return redirect(url_for('auth.login', next=request.url)) + return f(*args, **kwargs) + return decorated_function + + +def api_auth_required(f: Callable) -> Callable: + """ + Decorator for API endpoints that require authentication. + + Returns 401 JSON response if user is not authenticated. + Uses Flask-Login sessions (same as web UI). + + Args: + f: Function to decorate + + Returns: + Decorated function + + Example: + @bp.route('/api/scans', methods=['POST']) + @api_auth_required + def trigger_scan(): + # Protected endpoint + pass + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated: + return jsonify({ + 'error': 'Authentication required', + 'message': 'Please authenticate to access this endpoint' + }), 401 + return f(*args, **kwargs) + return decorated_function diff --git a/web/auth/models.py b/web/auth/models.py new file mode 100644 index 0000000..849811a --- /dev/null +++ b/web/auth/models.py @@ -0,0 +1,107 @@ +""" +User model for Flask-Login authentication. + +Simple single-user model that loads credentials from the settings table. +""" + +from typing import Optional + +from flask_login import UserMixin +from sqlalchemy.orm import Session + +from web.utils.settings import PasswordManager, SettingsManager + + +class User(UserMixin): + """ + User class for Flask-Login. + + Represents the single application user. Credentials are stored in the + settings table (app_password key). + """ + + # Single user ID (always 1 for single-user app) + USER_ID = '1' + + def __init__(self, user_id: str = USER_ID): + """ + Initialize user. + + Args: + user_id: User ID (always '1' for single-user app) + """ + self.id = user_id + + def get_id(self) -> str: + """ + Get user ID for Flask-Login. + + Returns: + User ID string + """ + return self.id + + @property + def is_authenticated(self) -> bool: + """User is always authenticated if instance exists.""" + return True + + @property + def is_active(self) -> bool: + """User is always active.""" + return True + + @property + def is_anonymous(self) -> bool: + """User is never anonymous.""" + return False + + @staticmethod + def get(user_id: str, db_session: Session = None) -> Optional['User']: + """ + Get user by ID (Flask-Login user_loader). + + Args: + user_id: User ID to load + db_session: Database session (unused - kept for compatibility) + + Returns: + User instance if ID is valid, None otherwise + """ + if user_id == User.USER_ID: + return User(user_id) + return None + + @staticmethod + def authenticate(password: str, db_session: Session) -> Optional['User']: + """ + Authenticate user with password. + + Args: + password: Password to verify + db_session: Database session for accessing settings + + Returns: + User instance if password is correct, None otherwise + """ + settings_manager = SettingsManager(db_session) + + if PasswordManager.verify_app_password(settings_manager, password): + return User(User.USER_ID) + + return None + + @staticmethod + def has_password_set(db_session: Session) -> bool: + """ + Check if application password is set. + + Args: + db_session: Database session for accessing settings + + Returns: + True if password is set, False otherwise + """ + settings_manager = SettingsManager(db_session) + stored_hash = settings_manager.get('app_password', decrypt=False) + return bool(stored_hash) diff --git a/web/auth/routes.py b/web/auth/routes.py new file mode 100644 index 0000000..a12114c --- /dev/null +++ b/web/auth/routes.py @@ -0,0 +1,120 @@ +""" +Authentication routes for SneakyScanner. + +Provides login and logout endpoints for user authentication. +""" + +import logging + +from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for +from flask_login import login_user, logout_user, current_user + +from web.auth.models import User + +logger = logging.getLogger(__name__) + +bp = Blueprint('auth', __name__) + + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + """ + Login page and authentication endpoint. + + GET: Render login form + POST: Authenticate user and create session + + Returns: + GET: Rendered login template + POST: Redirect to dashboard on success, login page with error on failure + """ + # If already logged in, redirect to dashboard + if current_user.is_authenticated: + return redirect(url_for('main.dashboard')) + + # Check if password is set + if not User.has_password_set(current_app.db_session): + flash('Application password not set. Please contact administrator.', 'error') + logger.warning("Login attempted but no password is set") + return render_template('login.html', password_not_set=True) + + if request.method == 'POST': + password = request.form.get('password', '') + + # Authenticate user + user = User.authenticate(password, current_app.db_session) + + if user: + # Login successful + login_user(user, remember=request.form.get('remember', False)) + logger.info(f"User logged in successfully from {request.remote_addr}") + + # Redirect to next page or dashboard + next_page = request.args.get('next') + if next_page: + return redirect(next_page) + return redirect(url_for('main.dashboard')) + else: + # Login failed + flash('Invalid password', 'error') + logger.warning(f"Failed login attempt from {request.remote_addr}") + + return render_template('login.html') + + +@bp.route('/logout') +def logout(): + """ + Logout endpoint. + + Destroys the user session and redirects to login page. + + Returns: + Redirect to login page + """ + if current_user.is_authenticated: + logger.info(f"User logged out from {request.remote_addr}") + logout_user() + flash('You have been logged out successfully', 'info') + + return redirect(url_for('auth.login')) + + +@bp.route('/setup', methods=['GET', 'POST']) +def setup(): + """ + Initial password setup page. + + Only accessible when no password is set. Allows setting the application password. + + Returns: + GET: Rendered setup template + POST: Redirect to login page on success + """ + # If password already set, redirect to login + if User.has_password_set(current_app.db_session): + flash('Password already set. Please login.', 'info') + return redirect(url_for('auth.login')) + + if request.method == 'POST': + password = request.form.get('password', '') + confirm_password = request.form.get('confirm_password', '') + + # Validate passwords + if not password: + flash('Password is required', 'error') + elif len(password) < 8: + flash('Password must be at least 8 characters', 'error') + elif password != confirm_password: + flash('Passwords do not match', 'error') + else: + # Set password + from web.utils.settings import PasswordManager, SettingsManager + settings_manager = SettingsManager(current_app.db_session) + PasswordManager.set_app_password(settings_manager, password) + + logger.info(f"Application password set from {request.remote_addr}") + flash('Password set successfully! You can now login.', 'success') + return redirect(url_for('auth.login')) + + return render_template('setup.html') diff --git a/web/routes/__init__.py b/web/routes/__init__.py new file mode 100644 index 0000000..067f1f7 --- /dev/null +++ b/web/routes/__init__.py @@ -0,0 +1,5 @@ +""" +Main web routes package for SneakyScanner. + +Provides web UI routes (dashboard, scan views, etc.). +""" diff --git a/web/routes/main.py b/web/routes/main.py new file mode 100644 index 0000000..34c7796 --- /dev/null +++ b/web/routes/main.py @@ -0,0 +1,68 @@ +""" +Main web routes for SneakyScanner. + +Provides dashboard and scan viewing pages. +""" + +import logging + +from flask import Blueprint, current_app, redirect, render_template, url_for + +from web.auth.decorators import login_required + +logger = logging.getLogger(__name__) + +bp = Blueprint('main', __name__) + + +@bp.route('/') +def index(): + """ + Root route - redirect to dashboard. + + Returns: + Redirect to dashboard + """ + return redirect(url_for('main.dashboard')) + + +@bp.route('/dashboard') +@login_required +def dashboard(): + """ + Dashboard page - shows recent scans and statistics. + + Returns: + Rendered dashboard template + """ + # TODO: Phase 5 - Add dashboard stats and recent scans + return render_template('dashboard.html') + + +@bp.route('/scans') +@login_required +def scans(): + """ + Scans list page - shows all scans with pagination. + + Returns: + Rendered scans list template + """ + # TODO: Phase 5 - Implement scans list page + return render_template('scans.html') + + +@bp.route('/scans/') +@login_required +def scan_detail(scan_id): + """ + Scan detail page - shows full scan results. + + Args: + scan_id: Scan ID to display + + Returns: + Rendered scan detail template + """ + # TODO: Phase 5 - Implement scan detail page + return render_template('scan_detail.html', scan_id=scan_id) diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..fbd289d --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,84 @@ + + + + + + Dashboard - SneakyScanner + + + + + + +
+
+
+

Dashboard

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+

Phase 2 Complete!

+

Authentication system is now active. Full dashboard UI will be implemented in Phase 5.

+
+

Use the API endpoints to trigger scans and view results.

+
+ +
+
+
Quick Actions
+

Use the API to manage scans:

+
    +
  • POST /api/scans - Trigger a new scan
  • +
  • GET /api/scans - List all scans
  • +
  • GET /api/scans/{id} - View scan details
  • +
  • DELETE /api/scans/{id} - Delete a scan
  • +
+
+
+
+
+
+ + + + diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..1ba36bc --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,95 @@ + + + + + + Login - SneakyScanner + + + + + + + + + diff --git a/web/templates/scan_detail.html b/web/templates/scan_detail.html new file mode 100644 index 0000000..4c97298 --- /dev/null +++ b/web/templates/scan_detail.html @@ -0,0 +1,16 @@ + + + + + + Scan Detail - SneakyScanner + + + +
+

Scan Detail #{{ scan_id }}

+

This page will be implemented in Phase 5.

+ Back to Dashboard +
+ + diff --git a/web/templates/scans.html b/web/templates/scans.html new file mode 100644 index 0000000..0ea6dfb --- /dev/null +++ b/web/templates/scans.html @@ -0,0 +1,16 @@ + + + + + + Scans - SneakyScanner + + + +
+

Scans List

+

This page will be implemented in Phase 5.

+ Back to Dashboard +
+ + diff --git a/web/templates/setup.html b/web/templates/setup.html new file mode 100644 index 0000000..0230e22 --- /dev/null +++ b/web/templates/setup.html @@ -0,0 +1,95 @@ + + + + + + Setup - SneakyScanner + + + + +
+
+
+
+

SneakyScanner

+

Initial Setup

+
+ +
+ Welcome! Please set an application password to secure your scanner. +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+ + +
Password must be at least 8 characters long.
+
+ +
+ + +
+ + +
+
+
+ +
+ SneakyScanner v1.0 - Phase 2 +
+
+ + + +