phase2-step3-background-job-queue #1

Merged
ptarrant merged 10 commits from phase2-step3-background-job-queue into master 2025-11-14 18:40:23 +00:00
18 changed files with 1127 additions and 4 deletions
Showing only changes of commit abc682a634 - Show all commits

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import sessionmaker
from web.app import create_app from web.app import create_app
from web.models import Base, Scan from web.models import Base, Scan
from web.utils.settings import PasswordManager, SettingsManager
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
@@ -283,3 +284,101 @@ def sample_scan(db):
db.refresh(scan) db.refresh(scan)
return 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)

View File

@@ -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 == '/'

View File

@@ -6,10 +6,13 @@ Handles endpoints for viewing alert history and managing alert rules.
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from web.auth.decorators import api_auth_required
bp = Blueprint('alerts', __name__) bp = Blueprint('alerts', __name__)
@bp.route('', methods=['GET']) @bp.route('', methods=['GET'])
@api_auth_required
def list_alerts(): def list_alerts():
""" """
List recent alerts. List recent alerts.
@@ -36,6 +39,7 @@ def list_alerts():
@bp.route('/rules', methods=['GET']) @bp.route('/rules', methods=['GET'])
@api_auth_required
def list_alert_rules(): def list_alert_rules():
""" """
List all alert rules. List all alert rules.
@@ -51,6 +55,7 @@ def list_alert_rules():
@bp.route('/rules', methods=['POST']) @bp.route('/rules', methods=['POST'])
@api_auth_required
def create_alert_rule(): def create_alert_rule():
""" """
Create a new alert rule. Create a new alert rule.
@@ -76,6 +81,7 @@ def create_alert_rule():
@bp.route('/rules/<int:rule_id>', methods=['PUT']) @bp.route('/rules/<int:rule_id>', methods=['PUT'])
@api_auth_required
def update_alert_rule(rule_id): def update_alert_rule(rule_id):
""" """
Update an existing alert rule. Update an existing alert rule.
@@ -103,6 +109,7 @@ def update_alert_rule(rule_id):
@bp.route('/rules/<int:rule_id>', methods=['DELETE']) @bp.route('/rules/<int:rule_id>', methods=['DELETE'])
@api_auth_required
def delete_alert_rule(rule_id): def delete_alert_rule(rule_id):
""" """
Delete an alert rule. Delete an alert rule.

View File

@@ -9,6 +9,7 @@ import logging
from flask import Blueprint, current_app, jsonify, request from flask import Blueprint, current_app, jsonify, request
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from web.auth.decorators import api_auth_required
from web.services.scan_service import ScanService from web.services.scan_service import ScanService
from web.utils.validators import validate_config_file, validate_page_params from web.utils.validators import validate_config_file, validate_page_params
@@ -17,6 +18,7 @@ logger = logging.getLogger(__name__)
@bp.route('', methods=['GET']) @bp.route('', methods=['GET'])
@api_auth_required
def list_scans(): def list_scans():
""" """
List all scans with pagination. List all scans with pagination.
@@ -79,6 +81,7 @@ def list_scans():
@bp.route('/<int:scan_id>', methods=['GET']) @bp.route('/<int:scan_id>', methods=['GET'])
@api_auth_required
def get_scan(scan_id): def get_scan(scan_id):
""" """
Get details for a specific scan. Get details for a specific scan.
@@ -119,6 +122,7 @@ def get_scan(scan_id):
@bp.route('', methods=['POST']) @bp.route('', methods=['POST'])
@api_auth_required
def trigger_scan(): def trigger_scan():
""" """
Trigger a new scan. Trigger a new scan.
@@ -180,6 +184,7 @@ def trigger_scan():
@bp.route('/<int:scan_id>', methods=['DELETE']) @bp.route('/<int:scan_id>', methods=['DELETE'])
@api_auth_required
def delete_scan(scan_id): def delete_scan(scan_id):
""" """
Delete a scan and its associated files. Delete a scan and its associated files.
@@ -224,6 +229,7 @@ def delete_scan(scan_id):
@bp.route('/<int:scan_id>/status', methods=['GET']) @bp.route('/<int:scan_id>/status', methods=['GET'])
@api_auth_required
def get_scan_status(scan_id): def get_scan_status(scan_id):
""" """
Get current status of a running scan. Get current status of a running scan.
@@ -264,6 +270,7 @@ def get_scan_status(scan_id):
@bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET']) @bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET'])
@api_auth_required
def compare_scans(scan_id1, scan_id2): def compare_scans(scan_id1, scan_id2):
""" """
Compare two scans and show differences. Compare two scans and show differences.

View File

@@ -7,10 +7,13 @@ and manual triggering.
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from web.auth.decorators import api_auth_required
bp = Blueprint('schedules', __name__) bp = Blueprint('schedules', __name__)
@bp.route('', methods=['GET']) @bp.route('', methods=['GET'])
@api_auth_required
def list_schedules(): def list_schedules():
""" """
List all schedules. List all schedules.
@@ -26,6 +29,7 @@ def list_schedules():
@bp.route('/<int:schedule_id>', methods=['GET']) @bp.route('/<int:schedule_id>', methods=['GET'])
@api_auth_required
def get_schedule(schedule_id): def get_schedule(schedule_id):
""" """
Get details for a specific schedule. Get details for a specific schedule.
@@ -44,6 +48,7 @@ def get_schedule(schedule_id):
@bp.route('', methods=['POST']) @bp.route('', methods=['POST'])
@api_auth_required
def create_schedule(): def create_schedule():
""" """
Create a new schedule. Create a new schedule.
@@ -68,6 +73,7 @@ def create_schedule():
@bp.route('/<int:schedule_id>', methods=['PUT']) @bp.route('/<int:schedule_id>', methods=['PUT'])
@api_auth_required
def update_schedule(schedule_id): def update_schedule(schedule_id):
""" """
Update an existing schedule. Update an existing schedule.
@@ -96,6 +102,7 @@ def update_schedule(schedule_id):
@bp.route('/<int:schedule_id>', methods=['DELETE']) @bp.route('/<int:schedule_id>', methods=['DELETE'])
@api_auth_required
def delete_schedule(schedule_id): def delete_schedule(schedule_id):
""" """
Delete a schedule. Delete a schedule.
@@ -115,6 +122,7 @@ def delete_schedule(schedule_id):
@bp.route('/<int:schedule_id>/trigger', methods=['POST']) @bp.route('/<int:schedule_id>/trigger', methods=['POST'])
@api_auth_required
def trigger_schedule(schedule_id): def trigger_schedule(schedule_id):
""" """
Manually trigger a scheduled scan. Manually trigger a scheduled scan.

View File

@@ -7,6 +7,7 @@ authentication, and system preferences.
from flask import Blueprint, current_app, jsonify, request from flask import Blueprint, current_app, jsonify, request
from web.auth.decorators import api_auth_required
from web.utils.settings import PasswordManager, SettingsManager from web.utils.settings import PasswordManager, SettingsManager
bp = Blueprint('settings', __name__) bp = Blueprint('settings', __name__)
@@ -18,6 +19,7 @@ def get_settings_manager():
@bp.route('', methods=['GET']) @bp.route('', methods=['GET'])
@api_auth_required
def get_settings(): def get_settings():
""" """
Get all settings (sanitized - encrypted values masked). Get all settings (sanitized - encrypted values masked).
@@ -42,6 +44,7 @@ def get_settings():
@bp.route('', methods=['PUT']) @bp.route('', methods=['PUT'])
@api_auth_required
def update_settings(): def update_settings():
""" """
Update multiple settings at once. Update multiple settings at once.
@@ -52,7 +55,6 @@ def update_settings():
Returns: Returns:
JSON response with update status JSON response with update status
""" """
# TODO: Add authentication in Phase 2
data = request.get_json() or {} data = request.get_json() or {}
settings_dict = data.get('settings', {}) settings_dict = data.get('settings', {})
@@ -82,6 +84,7 @@ def update_settings():
@bp.route('/<string:key>', methods=['GET']) @bp.route('/<string:key>', methods=['GET'])
@api_auth_required
def get_setting(key): def get_setting(key):
""" """
Get a specific setting by key. Get a specific setting by key.
@@ -120,6 +123,7 @@ def get_setting(key):
@bp.route('/<string:key>', methods=['PUT']) @bp.route('/<string:key>', methods=['PUT'])
@api_auth_required
def update_setting(key): def update_setting(key):
""" """
Update a specific setting. Update a specific setting.
@@ -133,7 +137,6 @@ def update_setting(key):
Returns: Returns:
JSON response with update status JSON response with update status
""" """
# TODO: Add authentication in Phase 2
data = request.get_json() or {} data = request.get_json() or {}
value = data.get('value') value = data.get('value')
@@ -160,6 +163,7 @@ def update_setting(key):
@bp.route('/<string:key>', methods=['DELETE']) @bp.route('/<string:key>', methods=['DELETE'])
@api_auth_required
def delete_setting(key): def delete_setting(key):
""" """
Delete a setting. Delete a setting.
@@ -170,7 +174,6 @@ def delete_setting(key):
Returns: Returns:
JSON response with deletion status JSON response with deletion status
""" """
# TODO: Add authentication in Phase 2
try: try:
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
deleted = settings_manager.delete(key) deleted = settings_manager.delete(key)
@@ -194,6 +197,7 @@ def delete_setting(key):
@bp.route('/password', methods=['POST']) @bp.route('/password', methods=['POST'])
@api_auth_required
def set_password(): def set_password():
""" """
Set the application password. Set the application password.
@@ -204,7 +208,6 @@ def set_password():
Returns: Returns:
JSON response with status JSON response with status
""" """
# TODO: Add current password verification in Phase 2
data = request.get_json() or {} data = request.get_json() or {}
password = data.get('password') password = data.get('password')
@@ -237,6 +240,7 @@ def set_password():
@bp.route('/test-email', methods=['POST']) @bp.route('/test-email', methods=['POST'])
@api_auth_required
def test_email(): def test_email():
""" """
Test email configuration by sending a test email. Test email configuration by sending a test email.

View File

@@ -11,6 +11,7 @@ from pathlib import Path
from flask import Flask, jsonify from flask import Flask, jsonify
from flask_cors import CORS from flask_cors import CORS
from flask_login import LoginManager
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm import scoped_session, sessionmaker
@@ -60,6 +61,9 @@ def create_app(config: dict = None) -> Flask:
# Initialize extensions # Initialize extensions
init_extensions(app) init_extensions(app)
# Initialize authentication
init_authentication(app)
# Initialize background scheduler # Initialize background scheduler
init_scheduler(app) init_scheduler(app)
@@ -172,6 +176,33 @@ def init_extensions(app: Flask) -> None:
app.logger.info("Extensions initialized") 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: def init_scheduler(app: Flask) -> None:
""" """
Initialize background job scheduler. 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.schedules import bp as schedules_bp
from web.api.alerts import bp as alerts_bp from web.api.alerts import bp as alerts_bp
from web.api.settings import bp as settings_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 # Register API blueprints
app.register_blueprint(scans_bp, url_prefix='/api/scans') app.register_blueprint(scans_bp, url_prefix='/api/scans')

9
web/auth/__init__.py Normal file
View File

@@ -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']

65
web/auth/decorators.py Normal file
View File

@@ -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

107
web/auth/models.py Normal file
View File

@@ -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)

120
web/auth/routes.py Normal file
View File

@@ -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')

5
web/routes/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""
Main web routes package for SneakyScanner.
Provides web UI routes (dashboard, scan views, etc.).
"""

68
web/routes/main.py Normal file
View File

@@ -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/<int:scan_id>')
@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)

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background: #1a1a2e;
}
.navbar-brand {
color: #00d9ff !important;
font-weight: 600;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">SneakyScanner</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link active" href="{{ url_for('main.dashboard') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.scans') }}">Scans</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid mt-4">
<div class="row">
<div class="col-12">
<h1 class="mb-4">Dashboard</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="alert alert-info">
<h4 class="alert-heading">Phase 2 Complete!</h4>
<p>Authentication system is now active. Full dashboard UI will be implemented in Phase 5.</p>
<hr>
<p class="mb-0">Use the API endpoints to trigger scans and view results.</p>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Quick Actions</h5>
<p class="card-text">Use the API to manage scans:</p>
<ul>
<li><code>POST /api/scans</code> - Trigger a new scan</li>
<li><code>GET /api/scans</code> - List all scans</li>
<li><code>GET /api/scans/{id}</code> - View scan details</li>
<li><code>DELETE /api/scans/{id}</code> - Delete a scan</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

95
web/templates/login.html Normal file
View File

@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.login-container {
width: 100%;
max-width: 400px;
padding: 2rem;
}
.card {
border: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.brand-title {
color: #00d9ff;
font-weight: 600;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<div class="login-container">
<div class="card">
<div class="card-body p-5">
<div class="text-center mb-4">
<h1 class="brand-title">SneakyScanner</h1>
<p class="text-muted">Network Security Scanner</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if password_not_set %}
<div class="alert alert-warning">
<strong>Setup Required:</strong> Please set an application password first.
<a href="{{ url_for('auth.setup') }}" class="alert-link">Go to Setup</a>
</div>
{% else %}
<form method="post" action="{{ url_for('auth.login') }}">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password"
class="form-control form-control-lg"
id="password"
name="password"
required
autofocus
placeholder="Enter your password">
</div>
<div class="mb-3 form-check">
<input type="checkbox"
class="form-check-input"
id="remember"
name="remember">
<label class="form-check-label" for="remember">
Remember me
</label>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
Login
</button>
</form>
{% endif %}
</div>
</div>
<div class="text-center mt-3">
<small class="text-muted">SneakyScanner v1.0 - Phase 2</small>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scan Detail - SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
<h1>Scan Detail #{{ scan_id }}</h1>
<p>This page will be implemented in Phase 5.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Back to Dashboard</a>
</div>
</body>
</html>

16
web/templates/scans.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scans - SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
<h1>Scans List</h1>
<p>This page will be implemented in Phase 5.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Back to Dashboard</a>
</div>
</body>
</html>

95
web/templates/setup.html Normal file
View File

@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup - SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.setup-container {
width: 100%;
max-width: 500px;
padding: 2rem;
}
.card {
border: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.brand-title {
color: #00d9ff;
font-weight: 600;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<div class="setup-container">
<div class="card">
<div class="card-body p-5">
<div class="text-center mb-4">
<h1 class="brand-title">SneakyScanner</h1>
<p class="text-muted">Initial Setup</p>
</div>
<div class="alert alert-info mb-4">
<strong>Welcome!</strong> Please set an application password to secure your scanner.
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('auth.setup') }}">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password"
class="form-control"
id="password"
name="password"
required
minlength="8"
autofocus
placeholder="Enter password (min 8 characters)">
<div class="form-text">Password must be at least 8 characters long.</div>
</div>
<div class="mb-4">
<label for="confirm_password" class="form-label">Confirm Password</label>
<input type="password"
class="form-control"
id="confirm_password"
name="confirm_password"
required
minlength="8"
placeholder="Confirm your password">
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
Set Password
</button>
</form>
</div>
</div>
<div class="text-center mt-3">
<small class="text-muted">SneakyScanner v1.0 - Phase 2</small>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>