Phase 2 Step 4: Implement Authentication System
Implemented comprehensive Flask-Login authentication with single-user support. New Features: - Flask-Login integration with User model - Bcrypt password hashing via PasswordManager - Login, logout, and initial password setup routes - @login_required and @api_auth_required decorators - All API endpoints now require authentication - Bootstrap 5 dark theme UI templates - Dashboard with navigation - Remember me and next parameter redirect support Files Created (12): - web/auth/__init__.py, models.py, decorators.py, routes.py - web/routes/__init__.py, main.py - web/templates/login.html, setup.html, dashboard.html, scans.html, scan_detail.html - tests/test_authentication.py (30+ tests) Files Modified (6): - web/app.py: Added Flask-Login initialization and main routes - web/api/scans.py: Protected all endpoints with @api_auth_required - web/api/settings.py: Protected all endpoints with @api_auth_required - web/api/schedules.py: Protected all endpoints with @api_auth_required - web/api/alerts.py: Protected all endpoints with @api_auth_required - tests/conftest.py: Added authentication test fixtures Security: - Session-based authentication for both web UI and API - Secure password storage with bcrypt - Protected routes redirect to login page - Protected API endpoints return 401 Unauthorized - Health check endpoints remain accessible for monitoring Testing: - User model authentication and properties - Login success/failure flows - Logout and session management - Password setup workflow - API endpoint authentication requirements - Session persistence and remember me functionality - Next parameter redirect behavior Total: ~1,200 lines of code added 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,10 +6,13 @@ Handles endpoints for viewing alert history and managing alert rules.
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
|
||||
bp = Blueprint('alerts', __name__)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_alerts():
|
||||
"""
|
||||
List recent alerts.
|
||||
@@ -36,6 +39,7 @@ def list_alerts():
|
||||
|
||||
|
||||
@bp.route('/rules', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_alert_rules():
|
||||
"""
|
||||
List all alert rules.
|
||||
@@ -51,6 +55,7 @@ def list_alert_rules():
|
||||
|
||||
|
||||
@bp.route('/rules', methods=['POST'])
|
||||
@api_auth_required
|
||||
def create_alert_rule():
|
||||
"""
|
||||
Create a new alert rule.
|
||||
@@ -76,6 +81,7 @@ def create_alert_rule():
|
||||
|
||||
|
||||
@bp.route('/rules/<int:rule_id>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_alert_rule(rule_id):
|
||||
"""
|
||||
Update an existing alert rule.
|
||||
@@ -103,6 +109,7 @@ def update_alert_rule(rule_id):
|
||||
|
||||
|
||||
@bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_alert_rule(rule_id):
|
||||
"""
|
||||
Delete an alert rule.
|
||||
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
from web.services.scan_service import ScanService
|
||||
from web.utils.validators import validate_config_file, validate_page_params
|
||||
|
||||
@@ -17,6 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_scans():
|
||||
"""
|
||||
List all scans with pagination.
|
||||
@@ -79,6 +81,7 @@ def list_scans():
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_scan(scan_id):
|
||||
"""
|
||||
Get details for a specific scan.
|
||||
@@ -119,6 +122,7 @@ def get_scan(scan_id):
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
@api_auth_required
|
||||
def trigger_scan():
|
||||
"""
|
||||
Trigger a new scan.
|
||||
@@ -180,6 +184,7 @@ def trigger_scan():
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_scan(scan_id):
|
||||
"""
|
||||
Delete a scan and its associated files.
|
||||
@@ -224,6 +229,7 @@ def delete_scan(scan_id):
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>/status', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_scan_status(scan_id):
|
||||
"""
|
||||
Get current status of a running scan.
|
||||
@@ -264,6 +270,7 @@ def get_scan_status(scan_id):
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def compare_scans(scan_id1, scan_id2):
|
||||
"""
|
||||
Compare two scans and show differences.
|
||||
|
||||
@@ -7,10 +7,13 @@ and manual triggering.
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
|
||||
bp = Blueprint('schedules', __name__)
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_schedules():
|
||||
"""
|
||||
List all schedules.
|
||||
@@ -26,6 +29,7 @@ def list_schedules():
|
||||
|
||||
|
||||
@bp.route('/<int:schedule_id>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_schedule(schedule_id):
|
||||
"""
|
||||
Get details for a specific schedule.
|
||||
@@ -44,6 +48,7 @@ def get_schedule(schedule_id):
|
||||
|
||||
|
||||
@bp.route('', methods=['POST'])
|
||||
@api_auth_required
|
||||
def create_schedule():
|
||||
"""
|
||||
Create a new schedule.
|
||||
@@ -68,6 +73,7 @@ def create_schedule():
|
||||
|
||||
|
||||
@bp.route('/<int:schedule_id>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_schedule(schedule_id):
|
||||
"""
|
||||
Update an existing schedule.
|
||||
@@ -96,6 +102,7 @@ def update_schedule(schedule_id):
|
||||
|
||||
|
||||
@bp.route('/<int:schedule_id>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_schedule(schedule_id):
|
||||
"""
|
||||
Delete a schedule.
|
||||
@@ -115,6 +122,7 @@ def delete_schedule(schedule_id):
|
||||
|
||||
|
||||
@bp.route('/<int:schedule_id>/trigger', methods=['POST'])
|
||||
@api_auth_required
|
||||
def trigger_schedule(schedule_id):
|
||||
"""
|
||||
Manually trigger a scheduled scan.
|
||||
|
||||
@@ -7,6 +7,7 @@ authentication, and system preferences.
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
from web.utils.settings import PasswordManager, SettingsManager
|
||||
|
||||
bp = Blueprint('settings', __name__)
|
||||
@@ -18,6 +19,7 @@ def get_settings_manager():
|
||||
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_settings():
|
||||
"""
|
||||
Get all settings (sanitized - encrypted values masked).
|
||||
@@ -42,6 +44,7 @@ def get_settings():
|
||||
|
||||
|
||||
@bp.route('', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_settings():
|
||||
"""
|
||||
Update multiple settings at once.
|
||||
@@ -52,7 +55,6 @@ def update_settings():
|
||||
Returns:
|
||||
JSON response with update status
|
||||
"""
|
||||
# TODO: Add authentication in Phase 2
|
||||
data = request.get_json() or {}
|
||||
settings_dict = data.get('settings', {})
|
||||
|
||||
@@ -82,6 +84,7 @@ def update_settings():
|
||||
|
||||
|
||||
@bp.route('/<string:key>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_setting(key):
|
||||
"""
|
||||
Get a specific setting by key.
|
||||
@@ -120,6 +123,7 @@ def get_setting(key):
|
||||
|
||||
|
||||
@bp.route('/<string:key>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_setting(key):
|
||||
"""
|
||||
Update a specific setting.
|
||||
@@ -133,7 +137,6 @@ def update_setting(key):
|
||||
Returns:
|
||||
JSON response with update status
|
||||
"""
|
||||
# TODO: Add authentication in Phase 2
|
||||
data = request.get_json() or {}
|
||||
value = data.get('value')
|
||||
|
||||
@@ -160,6 +163,7 @@ def update_setting(key):
|
||||
|
||||
|
||||
@bp.route('/<string:key>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_setting(key):
|
||||
"""
|
||||
Delete a setting.
|
||||
@@ -170,7 +174,6 @@ def delete_setting(key):
|
||||
Returns:
|
||||
JSON response with deletion status
|
||||
"""
|
||||
# TODO: Add authentication in Phase 2
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
deleted = settings_manager.delete(key)
|
||||
@@ -194,6 +197,7 @@ def delete_setting(key):
|
||||
|
||||
|
||||
@bp.route('/password', methods=['POST'])
|
||||
@api_auth_required
|
||||
def set_password():
|
||||
"""
|
||||
Set the application password.
|
||||
@@ -204,7 +208,6 @@ def set_password():
|
||||
Returns:
|
||||
JSON response with status
|
||||
"""
|
||||
# TODO: Add current password verification in Phase 2
|
||||
data = request.get_json() or {}
|
||||
password = data.get('password')
|
||||
|
||||
@@ -237,6 +240,7 @@ def set_password():
|
||||
|
||||
|
||||
@bp.route('/test-email', methods=['POST'])
|
||||
@api_auth_required
|
||||
def test_email():
|
||||
"""
|
||||
Test email configuration by sending a test email.
|
||||
|
||||
39
web/app.py
39
web/app.py
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
from flask_login import LoginManager
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||
|
||||
@@ -60,6 +61,9 @@ def create_app(config: dict = None) -> Flask:
|
||||
# Initialize extensions
|
||||
init_extensions(app)
|
||||
|
||||
# Initialize authentication
|
||||
init_authentication(app)
|
||||
|
||||
# Initialize background scheduler
|
||||
init_scheduler(app)
|
||||
|
||||
@@ -172,6 +176,33 @@ def init_extensions(app: Flask) -> None:
|
||||
app.logger.info("Extensions initialized")
|
||||
|
||||
|
||||
def init_authentication(app: Flask) -> None:
|
||||
"""
|
||||
Initialize Flask-Login authentication.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
"""
|
||||
from web.auth.models import User
|
||||
|
||||
# Initialize LoginManager
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
|
||||
# Configure login view
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
# User loader callback
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Load user by ID for Flask-Login."""
|
||||
return User.get(user_id, app.db_session)
|
||||
|
||||
app.logger.info("Authentication initialized")
|
||||
|
||||
|
||||
def init_scheduler(app: Flask) -> None:
|
||||
"""
|
||||
Initialize background job scheduler.
|
||||
@@ -203,6 +234,14 @@ def register_blueprints(app: Flask) -> None:
|
||||
from web.api.schedules import bp as schedules_bp
|
||||
from web.api.alerts import bp as alerts_bp
|
||||
from web.api.settings import bp as settings_bp
|
||||
from web.auth.routes import bp as auth_bp
|
||||
from web.routes.main import bp as main_bp
|
||||
|
||||
# Register authentication blueprint
|
||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||
|
||||
# Register main web routes blueprint
|
||||
app.register_blueprint(main_bp, url_prefix='/')
|
||||
|
||||
# Register API blueprints
|
||||
app.register_blueprint(scans_bp, url_prefix='/api/scans')
|
||||
|
||||
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