diff --git a/FRONTEND_INTEGRATION.md b/FRONTEND_INTEGRATION.md new file mode 100644 index 00000000..fecab772 --- /dev/null +++ b/FRONTEND_INTEGRATION.md @@ -0,0 +1,181 @@ +# Frontend Infrastructure Integration Guide + +## Overview + +The frontend infrastructure has been successfully implemented and integrated into the SDLC Core system. This guide explains how to use and integrate the authentication system with the existing components. + +## Quick Start + +### 1. Starting the Frontend +```bash +cd src/frontend +python app.py +``` + +### 2. Default Access +- **URL**: http://localhost:5000 +- **Admin Username**: admin +- **Admin Password**: admin123 + +## Integration with Existing Components + +### Using Authentication in Your Code + +```python +# Import the frontend components +from src.frontend import create_app, User, Role, require_role, login_required + +# Example: Protecting an API endpoint +from src.frontend.decorators import require_role + +@require_role('developer') +def my_protected_api(): + return {"message": "Developer access granted"} + +# Example: Checking user roles +from flask_login import current_user + +if current_user.has_role('admin'): + # Admin functionality + pass +``` + +### Database Integration + +The frontend uses SQLAlchemy models that can be extended: + +```python +from src.frontend.models import db, User + +# Add custom fields to user +class ExtendedUser(User): + __tablename__ = 'users' + + # Add custom fields + department = db.Column(db.String(100)) + preferences = db.Column(db.JSON) +``` + +### API Integration + +The system provides REST API endpoints that can be used by other components: + +```python +import requests + +# Login via API +response = requests.post('http://localhost:5000/auth/api/login', + json={'username': 'admin', 'password': 'admin123'}) + +# Get user profile +response = requests.get('http://localhost:5000/auth/api/user/profile') +``` + +## Role-Based Access Control + +### Available Roles +- **admin**: Full system access +- **user**: Standard user access +- **moderator**: Content moderation +- **analyst**: Data analysis and reporting +- **developer**: Development and API access + +### Using Roles in Components + +```python +# In LLM components +from src.frontend.decorators import require_role + +@require_role('analyst') +def generate_analytics_report(): + # Only analysts can generate reports + pass + +# In agents +@require_role('developer') +def deploy_agent(): + # Only developers can deploy agents + pass +``` + +## Configuration + +### Environment Variables +```bash +export SECRET_KEY="your-production-secret-key" +export DATABASE_URL="postgresql://user:pass@localhost/sdlc_core" +export WTF_CSRF_ENABLED="true" +``` + +### Production Deployment +1. Set secure SECRET_KEY +2. Use PostgreSQL database +3. Enable HTTPS +4. Change default admin password +5. Configure proper CORS settings + +## Security Considerations + +1. **Password Security**: Uses bcrypt hashing +2. **Session Security**: Flask-Login session management +3. **CSRF Protection**: Built-in CSRF token validation +4. **Role Validation**: Server-side role checking +5. **API Security**: JWT tokens can be added for API authentication + +## Extending the System + +### Adding New Roles +```python +# Add in models.py or via admin interface +new_role = Role(name='researcher', description='Research access') +db.session.add(new_role) +db.session.commit() +``` + +### Custom Decorators +```python +from src.frontend.decorators import require_any_role + +@require_any_role('admin', 'developer', 'analyst') +def advanced_feature(): + pass +``` + +### Adding Custom Routes +```python +from src.frontend.auth import main_bp +from src.frontend.decorators import login_required + +@main_bp.route('/custom-feature') +@login_required +def custom_feature(): + return render_template('custom.html') +``` + +## Testing + +Run the authentication tests: +```bash +PYTHONPATH=src python -m pytest test/unit/frontend/ -v +``` + +## Troubleshooting + +### Common Issues +1. **Import Errors**: Ensure PYTHONPATH includes src directory +2. **Database Errors**: Check database permissions and connectivity +3. **CSRF Errors**: Disable CSRF for API testing with WTF_CSRF_ENABLED=false +4. **Role Errors**: Ensure users have appropriate roles assigned + +### Debug Mode +```bash +FLASK_ENV=development python app.py +``` + +## Next Steps + +1. Integrate with existing LLM components +2. Add API authentication tokens +3. Implement user preferences and profiles +4. Add audit logging for security events +5. Integrate with CI/CD pipelines for role-based deployments \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e69de29b..c44c7c0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,16 @@ +# Web Framework and Authentication +Flask==3.0.0 +Flask-Login==0.6.3 +Flask-WTF==1.2.1 +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.5 +Werkzeug==3.0.1 +WTForms==3.1.1 + +# Security +bcrypt==4.1.2 +PyJWT==2.8.0 + +# Testing +pytest==7.4.3 +pytest-flask==1.3.0 diff --git a/src/frontend/README.md b/src/frontend/README.md new file mode 100644 index 00000000..41c0c897 --- /dev/null +++ b/src/frontend/README.md @@ -0,0 +1,240 @@ +# Frontend Infrastructure for Role & Authentication + +This module provides a comprehensive frontend infrastructure with role-based authentication for the SDLC Core system. + +## Features + +### šŸ” Authentication System +- **User Registration & Login**: Secure user account creation and authentication +- **Password Security**: Bcrypt password hashing for security +- **Session Management**: Flask-Login for session handling +- **CSRF Protection**: Built-in CSRF protection for forms + +### šŸ‘„ Role-Based Access Control (RBAC) +- **Flexible Role System**: Support for multiple user roles +- **Role Decorators**: Easy-to-use decorators for protecting routes +- **Default Roles**: Pre-configured roles (admin, user, moderator, analyst, developer) +- **Dynamic Permissions**: Runtime role checking and permission enforcement + +### 🌐 Web Interface +- **Responsive Design**: Bootstrap 5 responsive web interface +- **Modern UI**: Clean, professional interface with icons and animations +- **Dashboard**: User dashboard with profile information +- **Admin Panel**: Comprehensive admin interface for user/role management + +### šŸ”Œ API Endpoints +- **RESTful API**: JSON API endpoints for authentication +- **User Profile API**: Programmatic access to user information +- **Admin API**: Administrative endpoints for user management +- **Error Handling**: Consistent error responses with proper HTTP status codes + +## Quick Start + +### 1. Install Dependencies +```bash +pip install -r requirements.txt +``` + +### 2. Run the Application +```bash +cd src/frontend +python app.py +``` + +### 3. Access the Application +- **Home**: http://localhost:5000/ +- **Login**: http://localhost:5000/auth/login +- **Register**: http://localhost:5000/auth/register +- **Admin Panel**: http://localhost:5000/admin (admin role required) + +### 4. Default Admin Account +- **Username**: admin +- **Password**: admin123 +- āš ļø **Important**: Change the admin password after first login! + +## Architecture + +### Models +- **User**: Core user model with authentication capabilities +- **Role**: Role definition with description +- **UserRole**: Many-to-many relationship between users and roles + +### Views +- **Authentication Routes**: Login, register, logout, unauthorized +- **Main Routes**: Index, dashboard, admin panel +- **API Routes**: JSON endpoints for programmatic access + +### Security Features +- **Password Hashing**: Secure bcrypt password storage +- **Session Security**: Secure session management with Flask-Login +- **CSRF Protection**: Built-in CSRF token validation +- **Role Validation**: Server-side role validation for all protected routes + +## API Reference + +### Authentication Endpoints + +#### POST /auth/api/login +Login a user and return user information. + +**Request:** +```json +{ + "username": "string", + "password": "string" +} +``` + +**Response:** +```json +{ + "message": "Login successful", + "user": { + "id": 1, + "username": "string", + "email": "string", + "roles": ["role1", "role2"] + } +} +``` + +#### GET /auth/api/user/profile +Get current user profile (requires authentication). + +**Response:** +```json +{ + "user": { + "id": 1, + "username": "string", + "email": "string", + "roles": ["role1", "role2"], + "is_active": true, + "created_at": "2024-01-01T00:00:00", + "last_login": "2024-01-01T00:00:00" + } +} +``` + +### Admin Endpoints + +#### GET /api/admin/users +Get all users (admin only). + +#### GET /api/admin/roles +Get all roles (admin only). + +## Role System + +### Default Roles +- **admin**: Full system administrator access +- **user**: Standard user access +- **moderator**: Content moderation access +- **analyst**: Data analysis and reporting access +- **developer**: Development and API access + +### Using Role Decorators + +```python +from frontend.decorators import require_role, admin_required, login_required + +@require_role('admin') +def admin_only_view(): + return "Admin content" + +@admin_required +def another_admin_view(): + return "Also admin content" + +@login_required +def authenticated_view(): + return "Requires login" +``` + +### Checking Roles in Templates +```html +{% if current_user.has_role('admin') %} + Admin Panel +{% endif %} +``` + +## Configuration + +### Environment Variables +- **SECRET_KEY**: Flask secret key for sessions (required in production) +- **DATABASE_URL**: Database connection string (default: SQLite) +- **FLASK_ENV**: Environment setting (development/production) +- **PORT**: Port to run on (default: 5000) + +### Database +The system uses SQLAlchemy with support for multiple database backends: +- **SQLite**: Default, good for development +- **PostgreSQL**: Recommended for production +- **MySQL**: Also supported + +## Testing + +Run the test suite: +```bash +pytest test/unit/frontend/ +``` + +Tests cover: +- User model functionality +- Role system +- Authentication flows +- API endpoints +- Access control + +## Security Considerations + +### Production Deployment +1. **Change Default Credentials**: Update admin password immediately +2. **Set SECRET_KEY**: Use a secure, random secret key +3. **Use HTTPS**: Always use HTTPS in production +4. **Database Security**: Use a production database with proper credentials +5. **Environment Variables**: Store sensitive config in environment variables + +### Best Practices +- Regular password updates +- Monitor failed login attempts +- Audit user roles and permissions +- Keep dependencies updated +- Use secure session settings + +## File Structure + +``` +src/frontend/ +ā”œā”€ā”€ __init__.py # Module initialization +ā”œā”€ā”€ app.py # Application entry point +ā”œā”€ā”€ auth.py # Authentication routes and app factory +ā”œā”€ā”€ models.py # Database models +ā”œā”€ā”€ forms.py # WTForms form definitions +ā”œā”€ā”€ decorators.py # Authentication decorators +ā”œā”€ā”€ static/ +│ ā”œā”€ā”€ css/ +│ │ └── style.css # Custom styles +│ └── js/ +│ └── app.js # Frontend JavaScript +└── templates/ + ā”œā”€ā”€ base.html # Base template + ā”œā”€ā”€ index.html # Home page + ā”œā”€ā”€ dashboard.html # User dashboard + ā”œā”€ā”€ admin.html # Admin panel + └── auth/ + ā”œā”€ā”€ login.html # Login form + ā”œā”€ā”€ register.html # Registration form + └── unauthorized.html # Access denied page +``` + +## Contributing + +1. Follow the existing code style +2. Add tests for new features +3. Update documentation for changes +4. Ensure security best practices + +## License + +This project is part of the SDLC Core system. See the main project license for details. \ No newline at end of file diff --git a/src/frontend/__init__.py b/src/frontend/__init__.py new file mode 100644 index 00000000..f4a70d10 --- /dev/null +++ b/src/frontend/__init__.py @@ -0,0 +1,14 @@ +# Frontend Infrastructure Module +from .auth import create_app, auth_bp, main_bp +from .models import db, User, Role, UserRole, init_default_roles +from .decorators import require_role, login_required, admin_required, require_any_role +from .forms import LoginForm, RegisterForm, ChangePasswordForm, UserEditForm, RoleAssignmentForm + +__all__ = [ + 'create_app', 'auth_bp', 'main_bp', 'db', + 'User', 'Role', 'UserRole', 'init_default_roles', + 'require_role', 'login_required', 'admin_required', 'require_any_role', + 'LoginForm', 'RegisterForm', 'ChangePasswordForm', 'UserEditForm', 'RoleAssignmentForm' +] + +__version__ = '1.0.0' \ No newline at end of file diff --git a/src/frontend/app.py b/src/frontend/app.py new file mode 100644 index 00000000..3fba61df --- /dev/null +++ b/src/frontend/app.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +SDLC Core Frontend Application Entry Point + +This script starts the Flask web application with authentication and role management. +""" +import os +import sys +from pathlib import Path + +# Add the src directory to the Python path +src_path = Path(__file__).parent.parent +sys.path.insert(0, str(src_path)) + +from frontend.auth import create_app +from frontend.models import db, User, Role, UserRole, init_default_roles + + +def create_admin_user(app): + """Create a default admin user if none exists.""" + with app.app_context(): + admin_user = User.query.filter_by(username='admin').first() + if not admin_user: + admin_role = Role.query.filter_by(name='admin').first() + if admin_role: + admin_user = User( + username='admin', + email='admin@sdlccore.com' + ) + admin_user.set_password('admin123') # Change this in production! + db.session.add(admin_user) + db.session.flush() # Get user ID before adding role + + user_role = UserRole(user_id=admin_user.id, role_id=admin_role.id) + db.session.add(user_role) + db.session.commit() + print("āœ“ Default admin user created (username: admin, password: admin123)") + else: + print("āœ— Admin role not found. Cannot create admin user.") + else: + print("āœ“ Admin user already exists") + + +def main(): + """Main entry point for the application.""" + print("šŸš€ Starting SDLC Core Frontend Infrastructure...") + + # Set environment variables if not set + if not os.getenv('SECRET_KEY'): + os.environ['SECRET_KEY'] = 'dev-secret-key-change-in-production' + print("āš ļø Using default SECRET_KEY. Change in production!") + + if not os.getenv('DATABASE_URL'): + db_path = Path(__file__).parent / 'sdlc_core.db' + os.environ['DATABASE_URL'] = f'sqlite:///{db_path.absolute()}' + print(f"šŸ“ Using SQLite database: {db_path.absolute()}") + + # Create the Flask app + app = create_app() + + # Create admin user + create_admin_user(app) + + # Print available routes + print("\nšŸ“‹ Available Routes:") + with app.app_context(): + for rule in app.url_map.iter_rules(): + methods = ','.join(rule.methods - {'HEAD', 'OPTIONS'}) + print(f" {rule.endpoint:30} {methods:10} {rule.rule}") + + print("\n🌐 Frontend Infrastructure Setup Complete!") + print(" • Role-based authentication system āœ“") + print(" • User management interface āœ“") + print(" • Admin panel āœ“") + print(" • REST API endpoints āœ“") + print(" • Responsive web interface āœ“") + + print(f"\nšŸ”— Access the application at: http://localhost:5000") + print(" • Home: http://localhost:5000/") + print(" • Login: http://localhost:5000/auth/login") + print(" • Register: http://localhost:5000/auth/register") + print(" • Admin: http://localhost:5000/admin (admin role required)") + + print("\nšŸ”‘ Default Admin Credentials:") + print(" Username: admin") + print(" Password: admin123") + print(" āš ļø Please change the admin password after first login!") + + # Run the application + debug = os.getenv('FLASK_ENV') == 'development' + port = int(os.getenv('PORT', 5000)) + + print(f"\nšŸƒ Running on port {port} (debug={'on' if debug else 'off'})") + app.run(host='0.0.0.0', port=port, debug=debug) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/src/frontend/auth.py b/src/frontend/auth.py new file mode 100644 index 00000000..69321179 --- /dev/null +++ b/src/frontend/auth.py @@ -0,0 +1,264 @@ +""" +Main Flask application factory and authentication blueprint. +""" +import os +from datetime import datetime +from flask import Flask, Blueprint, render_template, request, flash, redirect, url_for, jsonify, current_app +from flask_login import LoginManager, login_user, logout_user, current_user +from flask_migrate import Migrate +from flask_wtf.csrf import CSRFProtect, validate_csrf +from werkzeug.exceptions import BadRequest + +from .models import db, User, Role, UserRole, init_default_roles +from .forms import LoginForm, RegisterForm +from .decorators import login_required, require_role, admin_required + + +def create_app(config=None): + """Application factory pattern.""" + app = Flask(__name__) + + # Configuration + app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') + app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get( + 'DATABASE_URL', 'sqlite:///sdlc_core.db' + ) + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config['WTF_CSRF_ENABLED'] = os.environ.get('WTF_CSRF_ENABLED', 'True').lower() == 'true' + + if config: + app.config.update(config) + + # Initialize extensions + db.init_app(app) + migrate = Migrate(app, db) + csrf = CSRFProtect(app) + + # Initialize login manager + login_manager = LoginManager() + login_manager.init_app(app) + login_manager.login_view = 'auth.login' + login_manager.login_message = 'Please log in to access this page.' + login_manager.login_message_category = 'info' + + @login_manager.user_loader + def load_user(user_id): + return User.query.get(int(user_id)) + + # Register blueprints + app.register_blueprint(auth_bp, url_prefix='/auth') + app.register_blueprint(main_bp) + + # Create tables and default data + with app.app_context(): + db.create_all() + init_default_roles() + + return app + + +# Authentication Blueprint +auth_bp = Blueprint('auth', __name__, template_folder='templates') + + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + """User login route.""" + if current_user.is_authenticated: + return redirect(url_for('main.dashboard')) + + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data).first() + + if user and user.check_password(form.password.data): + if not user.is_active: + flash('Your account has been deactivated.', 'error') + return render_template('auth/login.html', form=form) + + login_user(user, remember=form.remember_me.data) + user.last_login = datetime.utcnow() + db.session.commit() + + next_page = request.args.get('next') + if not next_page or not next_page.startswith('/'): + next_page = url_for('main.dashboard') + + flash('Logged in successfully!', 'success') + return redirect(next_page) + else: + flash('Invalid username or password.', 'error') + + return render_template('auth/login.html', form=form) + + +@auth_bp.route('/register', methods=['GET', 'POST']) +def register(): + """User registration route.""" + if current_user.is_authenticated: + return redirect(url_for('main.dashboard')) + + form = RegisterForm() + if form.validate_on_submit(): + # Check if user already exists + existing_user = User.query.filter( + (User.username == form.username.data) | + (User.email == form.email.data) + ).first() + + if existing_user: + flash('Username or email already exists.', 'error') + return render_template('auth/register.html', form=form) + + # Create new user + user = User( + username=form.username.data, + email=form.email.data + ) + user.set_password(form.password.data) + + # Assign default role + default_role = Role.query.filter_by(name='user').first() + if default_role: + db.session.add(user) + db.session.flush() # Get user ID before adding role + user_role = UserRole(user_id=user.id, role_id=default_role.id) + db.session.add(user_role) + db.session.commit() + + flash('Registration successful! Please log in.', 'success') + return redirect(url_for('auth.login')) + + return render_template('auth/register.html', form=form) + + +@auth_bp.route('/logout') +@login_required +def logout(): + """User logout route.""" + logout_user() + flash('You have been logged out.', 'info') + return redirect(url_for('main.index')) + + +@auth_bp.route('/unauthorized') +def unauthorized(): + """Unauthorized access page.""" + return render_template('auth/unauthorized.html'), 403 + + +# API Authentication Routes +@auth_bp.route('/api/login', methods=['POST']) +def api_login(): + """API login endpoint.""" + # Skip CSRF validation for API endpoints + try: + data = request.get_json() + except BadRequest: + return jsonify({'error': 'Invalid JSON'}), 400 + + if not data or not data.get('username') or not data.get('password'): + return jsonify({'error': 'Username and password required'}), 400 + + user = User.query.filter_by(username=data['username']).first() + + if user and user.check_password(data['password']): + if not user.is_active: + return jsonify({'error': 'Account deactivated'}), 403 + + user.last_login = datetime.utcnow() + db.session.commit() + + return jsonify({ + 'message': 'Login successful', + 'user': { + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'roles': user.get_roles() + } + }) + else: + return jsonify({'error': 'Invalid credentials'}), 401 + + +@auth_bp.route('/api/user/profile') +@login_required +def api_user_profile(): + """API endpoint to get current user profile.""" + return jsonify({ + 'user': { + 'id': current_user.id, + 'username': current_user.username, + 'email': current_user.email, + 'roles': current_user.get_roles(), + 'is_active': current_user.is_active, + 'created_at': current_user.created_at.isoformat(), + 'last_login': current_user.last_login.isoformat() if current_user.last_login else None + } + }) + + +# Main Blueprint +main_bp = Blueprint('main', __name__, template_folder='templates') + + +@main_bp.route('/') +def index(): + """Main index page.""" + return render_template('index.html') + + +@main_bp.route('/dashboard') +@login_required +def dashboard(): + """User dashboard.""" + return render_template('dashboard.html', user=current_user) + + +@main_bp.route('/admin') +@admin_required +def admin(): + """Admin panel.""" + users = User.query.all() + roles = Role.query.all() + return render_template('admin.html', users=users, roles=roles) + + +@main_bp.route('/api/admin/users') +@admin_required +def api_admin_users(): + """API endpoint to get all users (admin only).""" + users = User.query.all() + return jsonify({ + 'users': [ + { + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'is_active': user.is_active, + 'roles': user.get_roles(), + 'created_at': user.created_at.isoformat(), + 'last_login': user.last_login.isoformat() if user.last_login else None + } + for user in users + ] + }) + + +@main_bp.route('/api/admin/roles') +@admin_required +def api_admin_roles(): + """API endpoint to get all roles (admin only).""" + roles = Role.query.all() + return jsonify({ + 'roles': [ + { + 'id': role.id, + 'name': role.name, + 'description': role.description, + 'created_at': role.created_at.isoformat() + } + for role in roles + ] + }) \ No newline at end of file diff --git a/src/frontend/decorators.py b/src/frontend/decorators.py new file mode 100644 index 00000000..01a65cd8 --- /dev/null +++ b/src/frontend/decorators.py @@ -0,0 +1,87 @@ +""" +Authentication and authorization decorators. +""" +from functools import wraps +from flask import flash, redirect, url_for, request, jsonify +from flask_login import current_user, login_required as flask_login_required + + +def login_required(f): + """Decorator to require user authentication.""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated: + if request.is_json: + return jsonify({'error': 'Authentication required'}), 401 + flash('You need to be logged in to access this page.', 'warning') + return redirect(url_for('auth.login', next=request.url)) + return f(*args, **kwargs) + return decorated_function + + +def require_role(role_name): + """Decorator to require specific role for access.""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated: + if request.is_json: + return jsonify({'error': 'Authentication required'}), 401 + flash('You need to be logged in to access this page.', 'warning') + return redirect(url_for('auth.login', next=request.url)) + + if not current_user.has_role(role_name): + if request.is_json: + return jsonify({'error': f'Role "{role_name}" required'}), 403 + flash(f'You need "{role_name}" role to access this page.', 'error') + return redirect(url_for('auth.unauthorized')) + + return f(*args, **kwargs) + return decorated_function + return decorator + + +def require_any_role(*role_names): + """Decorator to require any of the specified roles for access.""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated: + if request.is_json: + return jsonify({'error': 'Authentication required'}), 401 + flash('You need to be logged in to access this page.', 'warning') + return redirect(url_for('auth.login', next=request.url)) + + has_required_role = any(current_user.has_role(role) for role in role_names) + if not has_required_role: + if request.is_json: + return jsonify({'error': f'One of roles {role_names} required'}), 403 + flash(f'You need one of these roles to access this page: {", ".join(role_names)}', 'error') + return redirect(url_for('auth.unauthorized')) + + return f(*args, **kwargs) + return decorated_function + return decorator + + +def admin_required(f): + """Decorator to require admin role.""" + return require_role('admin')(f) + + +def api_key_required(f): + """Decorator to require API key authentication for API endpoints.""" + @wraps(f) + def decorated_function(*args, **kwargs): + api_key = request.headers.get('X-API-Key') or request.args.get('api_key') + + if not api_key: + return jsonify({'error': 'API key required'}), 401 + + # Here you would validate the API key against your database + # For now, we'll use a simple check (replace with proper validation) + if api_key != 'your-api-key-here': # Replace with proper validation + return jsonify({'error': 'Invalid API key'}), 401 + + return f(*args, **kwargs) + return decorated_function \ No newline at end of file diff --git a/src/frontend/forms.py b/src/frontend/forms.py new file mode 100644 index 00000000..cbb3fcb2 --- /dev/null +++ b/src/frontend/forms.py @@ -0,0 +1,89 @@ +""" +WTForms for authentication and user management. +""" +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField, SelectField +from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError +from .models import User + + +class LoginForm(FlaskForm): + """Login form.""" + username = StringField('Username', validators=[DataRequired(), Length(min=3, max=80)]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Remember Me') + submit = SubmitField('Sign In') + + +class RegisterForm(FlaskForm): + """Registration form.""" + username = StringField('Username', validators=[ + DataRequired(), + Length(min=3, max=80) + ]) + email = StringField('Email', validators=[ + DataRequired(), + Email(), + Length(max=120) + ]) + password = PasswordField('Password', validators=[ + DataRequired(), + Length(min=6, max=128) + ]) + confirm_password = PasswordField('Confirm Password', validators=[ + DataRequired(), + EqualTo('password', message='Passwords must match') + ]) + submit = SubmitField('Register') + + def validate_username(self, username): + """Custom validation for username uniqueness.""" + user = User.query.filter_by(username=username.data).first() + if user: + raise ValidationError('Username already exists. Please choose a different one.') + + def validate_email(self, email): + """Custom validation for email uniqueness.""" + user = User.query.filter_by(email=email.data).first() + if user: + raise ValidationError('Email already registered. Please choose a different one.') + + +class ChangePasswordForm(FlaskForm): + """Change password form.""" + current_password = PasswordField('Current Password', validators=[DataRequired()]) + new_password = PasswordField('New Password', validators=[ + DataRequired(), + Length(min=6, max=128) + ]) + confirm_password = PasswordField('Confirm New Password', validators=[ + DataRequired(), + EqualTo('new_password', message='Passwords must match') + ]) + submit = SubmitField('Change Password') + + +class UserEditForm(FlaskForm): + """Form for editing user details (admin).""" + username = StringField('Username', validators=[ + DataRequired(), + Length(min=3, max=80) + ]) + email = StringField('Email', validators=[ + DataRequired(), + Email(), + Length(max=120) + ]) + is_active = BooleanField('Active') + submit = SubmitField('Update User') + + +class RoleAssignmentForm(FlaskForm): + """Form for assigning roles to users.""" + role = SelectField('Role', coerce=int, validators=[DataRequired()]) + submit = SubmitField('Assign Role') + + def __init__(self, *args, **kwargs): + super(RoleAssignmentForm, self).__init__(*args, **kwargs) + from .models import Role + self.role.choices = [(role.id, role.name) for role in Role.query.all()] \ No newline at end of file diff --git a/src/frontend/models.py b/src/frontend/models.py new file mode 100644 index 00000000..3249791a --- /dev/null +++ b/src/frontend/models.py @@ -0,0 +1,112 @@ +""" +Database models for user authentication and role management. +""" +from datetime import datetime +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash + +db = SQLAlchemy() + + +class User(UserMixin, db.Model): + """User model for authentication.""" + + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(128), nullable=False) + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + last_login = db.Column(db.DateTime) + + # Relationship to roles + user_roles = db.relationship('UserRole', backref='user', lazy='dynamic', cascade='all, delete-orphan') + + def set_password(self, password): + """Set password hash.""" + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + """Check password against hash.""" + return check_password_hash(self.password_hash, password) + + def has_role(self, role_name): + """Check if user has a specific role.""" + return self.user_roles.join(Role).filter(Role.name == role_name).count() > 0 + + def get_roles(self): + """Get all roles for this user.""" + return [ur.role.name for ur in self.user_roles] + + def add_role(self, role): + """Add a role to this user.""" + if not self.has_role(role.name): + user_role = UserRole(user_id=self.id, role_id=role.id) + db.session.add(user_role) + return True + return False + + def remove_role(self, role): + """Remove a role from this user.""" + user_role = self.user_roles.join(Role).filter(Role.name == role.name).first() + if user_role: + db.session.delete(user_role) + + def __repr__(self): + return f'' + + +class Role(db.Model): + """Role model for role-based access control.""" + + __tablename__ = 'roles' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False, index=True) + description = db.Column(db.String(255)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationship to users + user_roles = db.relationship('UserRole', backref='role', lazy='dynamic', cascade='all, delete-orphan') + + def __repr__(self): + return f'' + + +class UserRole(db.Model): + """Association table for many-to-many relationship between users and roles.""" + + __tablename__ = 'user_roles' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + role_id = db.Column(db.Integer, db.ForeignKey('roles.id'), nullable=False) + assigned_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Ensure unique user-role combinations + __table_args__ = (db.UniqueConstraint('user_id', 'role_id'),) + + def __repr__(self): + return f'' + + +def init_default_roles(): + """Initialize default roles in the database.""" + default_roles = [ + {'name': 'admin', 'description': 'Full system administrator access'}, + {'name': 'user', 'description': 'Standard user access'}, + {'name': 'moderator', 'description': 'Content moderation access'}, + {'name': 'analyst', 'description': 'Data analysis and reporting access'}, + {'name': 'developer', 'description': 'Development and API access'}, + ] + + for role_data in default_roles: + existing_role = Role.query.filter_by(name=role_data['name']).first() + if not existing_role: + role = Role(name=role_data['name'], description=role_data['description']) + db.session.add(role) + + db.session.commit() \ No newline at end of file diff --git a/src/frontend/sdlc_core.db b/src/frontend/sdlc_core.db new file mode 100644 index 00000000..ea6a121a Binary files /dev/null and b/src/frontend/sdlc_core.db differ diff --git a/src/frontend/static/css/style.css b/src/frontend/static/css/style.css new file mode 100644 index 00000000..92650cf3 --- /dev/null +++ b/src/frontend/static/css/style.css @@ -0,0 +1,162 @@ +/* Custom styles for SDLC Core Frontend */ + +:root { + --primary-color: #0d6efd; + --secondary-color: #6c757d; + --success-color: #198754; + --danger-color: #dc3545; + --warning-color: #ffc107; + --info-color: #0dcaf0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} + +.navbar-brand { + font-weight: 600; +} + +.card { + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + border: 1px solid rgba(0, 0, 0, 0.125); +} + +.jumbotron { + padding: 4rem 2rem; + margin-bottom: 2rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 0.5rem; +} + +.btn { + border-radius: 0.375rem; + font-weight: 500; + transition: all 0.15s ease-in-out; +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15); +} + +.alert { + border-radius: 0.5rem; + border: none; +} + +.form-control:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +.table th { + border-top: none; + font-weight: 600; + background-color: #f8f9fa; +} + +.badge { + font-size: 0.75em; + font-weight: 500; +} + +/* Dashboard specific styles */ +.card-header { + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; + font-weight: 600; +} + +/* Authentication forms */ +.auth-form { + max-width: 400px; + margin: 0 auto; +} + +/* Loading spinner */ +.spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .jumbotron { + padding: 2rem 1rem; + } + + .btn-lg { + padding: 0.5rem 1rem; + font-size: 1rem; + } +} + +/* Role badges */ +.role-admin { background-color: var(--danger-color) !important; } +.role-moderator { background-color: var(--warning-color) !important; } +.role-analyst { background-color: var(--info-color) !important; } +.role-developer { background-color: var(--success-color) !important; } +.role-user { background-color: var(--secondary-color) !important; } + +/* Status indicators */ +.status-active { + color: var(--success-color); +} + +.status-inactive { + color: var(--danger-color); +} + +/* Footer styles */ +footer { + margin-top: auto; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* Animation classes */ +.fade-in { + animation: fadeIn 0.5s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.slide-in { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} \ No newline at end of file diff --git a/src/frontend/static/js/app.js b/src/frontend/static/js/app.js new file mode 100644 index 00000000..2c71ea22 --- /dev/null +++ b/src/frontend/static/js/app.js @@ -0,0 +1,213 @@ +// SDLC Core Frontend JavaScript + +document.addEventListener('DOMContentLoaded', function() { + // Initialize tooltips + var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); + + // Auto-dismiss alerts after 5 seconds + setTimeout(function() { + var alerts = document.querySelectorAll('.alert-dismissible'); + alerts.forEach(function(alert) { + var bsAlert = new bootstrap.Alert(alert); + bsAlert.close(); + }); + }, 5000); + + // Add fade-in animation to main content + var mainContent = document.querySelector('main'); + if (mainContent) { + mainContent.classList.add('fade-in'); + } +}); + +// API Helper Functions +class APIClient { + constructor() { + this.baseURL = ''; + } + + async get(endpoint) { + try { + const response = await fetch(this.baseURL + endpoint, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin' + }); + return await this.handleResponse(response); + } catch (error) { + console.error('API GET Error:', error); + throw error; + } + } + + async post(endpoint, data) { + try { + const response = await fetch(this.baseURL + endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify(data) + }); + return await this.handleResponse(response); + } catch (error) { + console.error('API POST Error:', error); + throw error; + } + } + + async handleResponse(response) { + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || `HTTP ${response.status}`); + } + return await response.json(); + } +} + +// Initialize API client +const api = new APIClient(); + +// Authentication Functions +async function loginUser(username, password) { + try { + const response = await api.post('/auth/api/login', { + username: username, + password: password + }); + return response; + } catch (error) { + console.error('Login error:', error); + throw error; + } +} + +async function getUserProfile() { + try { + const response = await api.get('/auth/api/user/profile'); + return response; + } catch (error) { + console.error('Get profile error:', error); + throw error; + } +} + +// Admin Functions +async function getUsers() { + try { + const response = await api.get('/api/admin/users'); + return response; + } catch (error) { + console.error('Get users error:', error); + throw error; + } +} + +async function getRoles() { + try { + const response = await api.get('/api/admin/roles'); + return response; + } catch (error) { + console.error('Get roles error:', error); + throw error; + } +} + +// Utility Functions +function showAlert(message, type = 'info') { + const alertContainer = document.querySelector('.container'); + if (alertContainer) { + const alert = document.createElement('div'); + alert.className = `alert alert-${type} alert-dismissible fade show`; + alert.innerHTML = ` + ${message} + + `; + alertContainer.insertBefore(alert, alertContainer.firstChild); + + // Auto-dismiss after 5 seconds + setTimeout(() => { + const bsAlert = new bootstrap.Alert(alert); + bsAlert.close(); + }, 5000); + } +} + +function showLoading(element) { + const originalText = element.innerHTML; + element.innerHTML = ' Loading...'; + element.disabled = true; + + return function hideLoading() { + element.innerHTML = originalText; + element.disabled = false; + }; +} + +// Form validation helpers +function validateEmail(email) { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); +} + +function validatePassword(password) { + return password.length >= 6; +} + +function validateUsername(username) { + return username.length >= 3 && /^[a-zA-Z0-9_]+$/.test(username); +} + +// Real-time form validation +document.addEventListener('input', function(e) { + if (e.target.type === 'email') { + const isValid = validateEmail(e.target.value); + e.target.classList.toggle('is-valid', isValid && e.target.value !== ''); + e.target.classList.toggle('is-invalid', !isValid && e.target.value !== ''); + } + + if (e.target.name === 'password') { + const isValid = validatePassword(e.target.value); + e.target.classList.toggle('is-valid', isValid); + e.target.classList.toggle('is-invalid', !isValid && e.target.value !== ''); + } + + if (e.target.name === 'username') { + const isValid = validateUsername(e.target.value); + e.target.classList.toggle('is-valid', isValid); + e.target.classList.toggle('is-invalid', !isValid && e.target.value !== ''); + } +}); + +// Role badge styling +function styleRoleBadges() { + const roleBadges = document.querySelectorAll('.badge'); + roleBadges.forEach(badge => { + const role = badge.textContent.toLowerCase(); + badge.classList.add(`role-${role}`); + }); +} + +// Initialize role badge styling +document.addEventListener('DOMContentLoaded', styleRoleBadges); + +// Export functions for global use +window.SDLC = { + api, + loginUser, + getUserProfile, + getUsers, + getRoles, + showAlert, + showLoading, + validateEmail, + validatePassword, + validateUsername, + styleRoleBadges +}; \ No newline at end of file diff --git a/src/frontend/templates/admin.html b/src/frontend/templates/admin.html new file mode 100644 index 00000000..ecc551b2 --- /dev/null +++ b/src/frontend/templates/admin.html @@ -0,0 +1,157 @@ +{% extends "base.html" %} + +{% block title %}Admin Panel - SDLC Core{% endblock %} + +{% block content %} +
+
+

Admin Panel

+

Manage users and roles in the system.

+
+
+ +
+
+
+
+
Users Management
+
+
+
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% endfor %} + +
IDUsernameEmailStatusRolesCreatedActions
{{ user.id }}{{ user.username }}{{ user.email }} + {% if user.is_active %} + Active + {% else %} + Inactive + {% endif %} + + {% for role in user.get_roles() %} + {{ role }} + {% endfor %} + {{ user.created_at.strftime('%m/%d/%Y') }} + +
+
+
+
+
+ +
+
+
+
Roles
+
+
+ {% for role in roles %} +
+
{{ role.name }}
+ {{ role.description }} +
+ {% endfor %} +
+
+ +
+
+
System Stats
+
+
+
+
+

{{ users|length }}

+ Total Users +
+
+

{{ roles|length }}

+ Total Roles +
+
+
+
+ +
+
+
Admin Tools
+
+
+
+ + + +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/src/frontend/templates/auth/login.html b/src/frontend/templates/auth/login.html new file mode 100644 index 00000000..f257ef65 --- /dev/null +++ b/src/frontend/templates/auth/login.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block title %}Login - SDLC Core{% endblock %} + +{% block content %} +
+
+
+
+

Sign In

+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }} + {% if form.username.errors %} +
+ {% for error in form.username.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }} + {% if form.password.errors %} +
+ {% for error in form.password.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.remember_me(class="form-check-input") }} + {{ form.remember_me.label(class="form-check-label") }} +
+ +
+ {{ form.submit(class="btn btn-primary") }} +
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/src/frontend/templates/auth/register.html b/src/frontend/templates/auth/register.html new file mode 100644 index 00000000..61455701 --- /dev/null +++ b/src/frontend/templates/auth/register.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% block title %}Register - SDLC Core{% endblock %} + +{% block content %} +
+
+
+
+

Register

+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }} + {% if form.username.errors %} +
+ {% for error in form.username.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }} + {% if form.email.errors %} +
+ {% for error in form.email.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }} + {% if form.password.errors %} +
+ {% for error in form.password.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.confirm_password.label(class="form-label") }} + {{ form.confirm_password(class="form-control" + (" is-invalid" if form.confirm_password.errors else "")) }} + {% if form.confirm_password.errors %} +
+ {% for error in form.confirm_password.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.submit(class="btn btn-primary") }} +
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/src/frontend/templates/auth/unauthorized.html b/src/frontend/templates/auth/unauthorized.html new file mode 100644 index 00000000..973ffbe1 --- /dev/null +++ b/src/frontend/templates/auth/unauthorized.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block title %}Unauthorized - SDLC Core{% endblock %} + +{% block content %} +
+
+
+ +

Access Denied

+

You don't have permission to access this page.

+

Please contact your administrator if you believe you should have access.

+
+ +
+ + Go Home + + {% if current_user.is_authenticated %} + + Dashboard + + {% else %} + + Login + + {% endif %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/src/frontend/templates/base.html b/src/frontend/templates/base.html new file mode 100644 index 00000000..f49b5181 --- /dev/null +++ b/src/frontend/templates/base.html @@ -0,0 +1,105 @@ + + + + + + {% block title %}SDLC Core{% endblock %} + + + + + + + + + {% block head %}{% endblock %} + + + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + +
+ {% block content %}{% endblock %} +
+ + +
+
+

© 2024 SDLC Core. All rights reserved.

+
+
+ + + + + + + + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/src/frontend/templates/dashboard.html b/src/frontend/templates/dashboard.html new file mode 100644 index 00000000..86ddd084 --- /dev/null +++ b/src/frontend/templates/dashboard.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - SDLC Core{% endblock %} + +{% block content %} +
+
+

Dashboard

+

Welcome to your personal dashboard, {{ user.username }}!

+
+
+ +
+
+
+
+
Profile Information
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Username:{{ user.username }}
Email:{{ user.email }}
Account Status: + {% if user.is_active %} + Active + {% else %} + Inactive + {% endif %} +
Member Since:{{ user.created_at.strftime('%B %d, %Y') }}
Last Login: + {% if user.last_login %} + {{ user.last_login.strftime('%B %d, %Y at %I:%M %p') }} + {% else %} + Never + {% endif %} +
Roles: + {% for role in user.get_roles() %} + {{ role }} + {% endfor %} + {% if not user.get_roles() %} + No roles assigned + {% endif %} +
+
+
+
+ +
+
+
+
Quick Actions
+
+
+
+ {% if user.has_role('admin') %} + + Admin Panel + + {% endif %} + + + + + Logout + +
+
+
+ +
+
+
System Info
+
+
+ + SDLC Core Frontend Infrastructure
+ Role-based Authentication System +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/src/frontend/templates/index.html b/src/frontend/templates/index.html new file mode 100644 index 00000000..cf41d6e3 --- /dev/null +++ b/src/frontend/templates/index.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}Welcome - SDLC Core{% endblock %} + +{% block content %} +
+
+
+

Welcome to SDLC Core

+

A comprehensive software development lifecycle management platform with role-based authentication.

+ + {% if not current_user.is_authenticated %} + + {% else %} + + {% endif %} +
+
+
+ +
+
+
+
+ +
Role-Based Access
+

Secure authentication system with granular role-based permissions.

+
+
+
+ +
+
+
+ +
LLM Integration
+

Advanced AI capabilities with multiple LLM provider support.

+
+
+
+ +
+
+
+ +
Analytics & Monitoring
+

Comprehensive analytics and monitoring for development workflows.

+
+
+
+
+ +{% if current_user.is_authenticated %} +
+
+
+ + Welcome back, {{ current_user.username }}! + You are logged in with roles: + {% for role in current_user.get_roles() %} + {{ role }} + {% endfor %} +
+
+
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/test/unit/frontend/__init__.py b/test/unit/frontend/__init__.py new file mode 100644 index 00000000..71dc1a74 --- /dev/null +++ b/test/unit/frontend/__init__.py @@ -0,0 +1 @@ +# Frontend tests \ No newline at end of file diff --git a/test/unit/frontend/test_auth.py b/test/unit/frontend/test_auth.py new file mode 100644 index 00000000..bf9ea30b --- /dev/null +++ b/test/unit/frontend/test_auth.py @@ -0,0 +1,272 @@ +""" +Unit tests for frontend authentication system. +""" +import pytest +import sys +from pathlib import Path + +# Add src to path +src_path = Path(__file__).parent.parent.parent.parent / 'src' +sys.path.insert(0, str(src_path)) + +from frontend.auth import create_app +from frontend.models import db, User, Role, UserRole, init_default_roles + + +@pytest.fixture +def app(): + """Create test application.""" + app = create_app({ + 'TESTING': True, + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', + 'WTF_CSRF_ENABLED': False, + 'SECRET_KEY': 'test-secret-key' + }) + + with app.app_context(): + db.create_all() + init_default_roles() + + # Create test users + admin_role = Role.query.filter_by(name='admin').first() + user_role = Role.query.filter_by(name='user').first() + + admin_user = User(username='testadmin', email='admin@test.com') + admin_user.set_password('testpass123') + admin_user.add_role(admin_role) + + regular_user = User(username='testuser', email='user@test.com') + regular_user.set_password('testpass123') + regular_user.add_role(user_role) + + db.session.add(admin_user) + db.session.add(regular_user) + db.session.commit() + + yield app + + +@pytest.fixture +def client(app): + """Create test client.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """Create test CLI runner.""" + return app.test_cli_runner() + + +class TestUserModel: + """Test User model functionality.""" + + def test_user_creation(self, app): + """Test user creation.""" + with app.app_context(): + user = User(username='newuser', email='new@test.com') + user.set_password('password123') + + assert user.username == 'newuser' + assert user.email == 'new@test.com' + assert user.check_password('password123') + assert not user.check_password('wrongpassword') + + def test_user_roles(self, app): + """Test user role functionality.""" + with app.app_context(): + user = User.query.filter_by(username='testadmin').first() + + assert user.has_role('admin') + assert not user.has_role('nonexistent') + assert 'admin' in user.get_roles() + + def test_password_hashing(self, app): + """Test password hashing security.""" + with app.app_context(): + user = User(username='testpass', email='test@test.com') + user.set_password('plaintext') + + # Password should be hashed + assert user.password_hash != 'plaintext' + assert user.check_password('plaintext') + assert not user.check_password('wrongtext') + + +class TestRoleModel: + """Test Role model functionality.""" + + def test_role_creation(self, app): + """Test role creation.""" + with app.app_context(): + role = Role(name='testrole', description='Test role') + db.session.add(role) + db.session.commit() + + assert role.name == 'testrole' + assert role.description == 'Test role' + + def test_role_relationships(self, app): + """Test role-user relationships.""" + with app.app_context(): + admin_role = Role.query.filter_by(name='admin').first() + users_with_admin = admin_role.user_roles.count() + + assert users_with_admin > 0 + + +class TestAuthentication: + """Test authentication routes and functionality.""" + + def test_login_page(self, client): + """Test login page renders.""" + response = client.get('/auth/login') + assert response.status_code == 200 + assert b'Sign In' in response.data + + def test_register_page(self, client): + """Test register page renders.""" + response = client.get('/auth/register') + assert response.status_code == 200 + assert b'Register' in response.data + + def test_valid_login(self, client): + """Test valid user login.""" + response = client.post('/auth/login', data={ + 'username': 'testuser', + 'password': 'testpass123' + }, follow_redirects=True) + + assert response.status_code == 200 + assert b'Dashboard' in response.data or b'Logged in successfully' in response.data + + def test_invalid_login(self, client): + """Test invalid login credentials.""" + response = client.post('/auth/login', data={ + 'username': 'testuser', + 'password': 'wrongpassword' + }) + + assert response.status_code == 200 + assert b'Invalid username or password' in response.data + + def test_user_registration(self, client): + """Test user registration.""" + response = client.post('/auth/register', data={ + 'username': 'newuser', + 'email': 'newuser@test.com', + 'password': 'newpassword123', + 'confirm_password': 'newpassword123' + }, follow_redirects=True) + + assert response.status_code == 200 + assert b'Registration successful' in response.data or b'Sign In' in response.data + + def test_duplicate_registration(self, client): + """Test registration with existing username.""" + response = client.post('/auth/register', data={ + 'username': 'testuser', # Already exists + 'email': 'different@test.com', + 'password': 'newpassword123', + 'confirm_password': 'newpassword123' + }) + + assert response.status_code == 200 + assert b'Username already exists' in response.data or b'already registered' in response.data + + +class TestAPIEndpoints: + """Test API authentication endpoints.""" + + def test_api_login_valid(self, client): + """Test API login with valid credentials.""" + response = client.post('/auth/api/login', + json={ + 'username': 'testuser', + 'password': 'testpass123' + }, + content_type='application/json' + ) + + assert response.status_code == 200 + data = response.get_json() + assert 'user' in data + assert data['user']['username'] == 'testuser' + + def test_api_login_invalid(self, client): + """Test API login with invalid credentials.""" + response = client.post('/auth/api/login', + json={ + 'username': 'testuser', + 'password': 'wrongpassword' + }, + content_type='application/json' + ) + + assert response.status_code == 401 + data = response.get_json() + assert 'error' in data + + +class TestRoleBasedAccess: + """Test role-based access control.""" + + def login_user(self, client, username, password): + """Helper method to login a user.""" + return client.post('/auth/login', data={ + 'username': username, + 'password': password + }, follow_redirects=True) + + def test_admin_access(self, client): + """Test admin can access admin panel.""" + self.login_user(client, 'testadmin', 'testpass123') + response = client.get('/admin') + assert response.status_code == 200 + assert b'Admin Panel' in response.data or b'Users Management' in response.data + + def test_user_no_admin_access(self, client): + """Test regular user cannot access admin panel.""" + self.login_user(client, 'testuser', 'testpass123') + response = client.get('/admin') + + # Should redirect to unauthorized or return 403 + assert response.status_code in [302, 403] + + def test_unauthenticated_access(self, client): + """Test unauthenticated user redirected to login.""" + response = client.get('/dashboard') + assert response.status_code == 302 # Redirect to login + + +class TestDecorators: + """Test authentication decorators.""" + + def test_login_required_decorator(self, app): + """Test login_required decorator functionality.""" + from frontend.decorators import login_required + + @login_required + def protected_view(): + return "Protected content" + + with app.test_request_context(): + # Should redirect when not authenticated + from flask_login import current_user + assert not current_user.is_authenticated + + def test_role_required_decorator(self, app): + """Test require_role decorator functionality.""" + from frontend.decorators import require_role + + @require_role('admin') + def admin_view(): + return "Admin content" + + with app.test_request_context(): + # Function should be properly decorated + assert hasattr(admin_view, '__wrapped__') + + +if __name__ == '__main__': + pytest.main([__file__]) \ No newline at end of file