phase2-step3-background-job-queue #1
@@ -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)
|
||||||
|
|||||||
279
tests/test_authentication.py
Normal file
279
tests/test_authentication.py
Normal 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 == '/'
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
39
web/app.py
39
web/app.py
@@ -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
9
web/auth/__init__.py
Normal 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
65
web/auth/decorators.py
Normal 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
107
web/auth/models.py
Normal 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
120
web/auth/routes.py
Normal 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
5
web/routes/__init__.py
Normal 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
68
web/routes/main.py
Normal 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)
|
||||||
84
web/templates/dashboard.html
Normal file
84
web/templates/dashboard.html
Normal 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
95
web/templates/login.html
Normal 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>
|
||||||
16
web/templates/scan_detail.html
Normal file
16
web/templates/scan_detail.html
Normal 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
16
web/templates/scans.html
Normal 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
95
web/templates/setup.html
Normal 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>
|
||||||
Reference in New Issue
Block a user