Implement comprehensive JWT token management system

Features implemented:
- APIToken model with secure SHA256 hashing and expiration tracking
- JWT utilities for token generation, validation, and API authentication
- Database migration (Migration_003_CreateAPITokensTable) for APIToken table
- Token management routes for CRUD operations (create, list, delete, revoke, extend)
- Professional web interface for token management with real-time functionality
- API authentication middleware with @require_api_token decorator
- Protected REST API endpoints (/api/fixtures, /api/matches, /api/match/<id>)
- Multiple authentication methods (Bearer token, X-API-Token header, query parameter)
- Token lifecycle management with usage tracking and IP monitoring
- Updated navigation integration across all templates
- Comprehensive README documentation with API examples and security best practices

Security features:
- Cryptographically secure token generation using secrets.token_urlsafe(32)
- SHA256 token hashing for secure database storage
- One-time token display with copy-to-clipboard functionality
- Token expiration management with configurable periods
- Usage tracking with last_used_at and last_used_ip fields
- Proper database indexing and foreign key constraints

Version bump: 1.0.0 → 1.1.0
parent ae3b2bf3
......@@ -15,6 +15,7 @@ A sophisticated Python daemon system for Linux servers with internet exposure, i
### Security Features
- **Multi-layer Authentication**: Session-based and JWT token authentication
- **API Token Management**: User-generated tokens for external application access
- **Rate Limiting**: Protection against brute force attacks
- **File Validation**: Comprehensive security checks and malicious content detection
- **SQL Injection Protection**: Parameterized queries and ORM usage
......@@ -26,6 +27,7 @@ A sophisticated Python daemon system for Linux servers with internet exposure, i
- **Normalized Design**: Optimized relational database structure
- **Primary Matches Table**: Core fixture data with system fields
- **Secondary Outcomes Table**: Dynamic result columns with foreign key relationships
- **API Token Management**: Secure token storage with usage tracking
- **File Upload Tracking**: Complete upload lifecycle management
- **System Logging**: Comprehensive audit trail
- **Session Management**: Secure user session handling
......@@ -119,6 +121,7 @@ The system automatically creates the following tables:
- `users` - User authentication and management
- `matches` - Core fixture data with system fields
- `match_outcomes` - Dynamic outcome results
- `api_tokens` - User-generated API tokens for external access
- `file_uploads` - Upload tracking and progress
- `system_logs` - Comprehensive logging
- `user_sessions` - Session management
......@@ -175,7 +178,31 @@ Access the web dashboard at `http://your-server-ip/`
### API Usage
#### Authentication
#### Authentication Methods
**1. Session-Based Authentication (Web Interface)**
```bash
# Login via web interface
curl -X POST http://your-server/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "admin123"}'
```
**2. API Token Authentication (Recommended for External Apps)**
```bash
# Use API token in Authorization header (recommended)
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
http://your-server/api/fixtures
# Alternative: Use X-API-Token header
curl -H "X-API-Token: YOUR_API_TOKEN" \
http://your-server/api/matches
# Alternative: Use query parameter (less secure)
curl "http://your-server/api/match/123?token=YOUR_API_TOKEN"
```
**3. JWT Token Authentication (Legacy)**
```bash
# Login and get JWT token
curl -X POST http://your-server/auth/api/login \
......@@ -203,7 +230,20 @@ curl -X POST http://your-server/upload/api/zip/123 \
```bash
# Get all matches with pagination
curl -X GET "http://your-server/api/matches?page=1&per_page=20" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
-H "Authorization: Bearer YOUR_API_TOKEN"
#### Get Fixtures
```bash
# Get all fixtures
curl -X GET "http://your-server/api/fixtures" \
-H "Authorization: Bearer YOUR_API_TOKEN"
```
#### Get Match Details
```bash
# Get specific match with outcomes
curl -X GET "http://your-server/api/match/123" \
-H "Authorization: Bearer YOUR_API_TOKEN"
```
## File Format Requirements
......@@ -339,6 +379,66 @@ export DEBUG=true
python daemon.py start --foreground --config development
```
## API Token Management
### Creating API Tokens
**Via Web Interface:**
1. Login to the web dashboard
2. Navigate to "API Tokens" from the main navigation
3. Click "Create New Token"
4. Provide a descriptive name (e.g., "Mobile App", "Dashboard Integration")
5. Copy the generated token immediately (it's only shown once)
6. Use the token in your external applications
**Token Features:**
- **Secure Generation**: Cryptographically secure random tokens
- **Named Tokens**: Descriptive names for easy identification
- **Expiration Management**: Default 1-year expiration, extendable
- **Usage Tracking**: Last used timestamp and IP address
- **Lifecycle Management**: Revoke, extend, or delete tokens
- **Security**: SHA256 hashed storage, one-time display
### Token Management Operations
**Create Token:**
```bash
# Via API (requires session authentication)
curl -X POST http://your-server/profile/tokens/create \
-H "Content-Type: application/json" \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-d '{"name": "My API Integration"}'
```
**List User Tokens:**
```bash
# Via web interface at /profile/tokens
# Shows all tokens with status, creation date, expiration, and usage info
```
**Revoke Token:**
```bash
# Via API (requires session authentication)
curl -X POST http://your-server/profile/tokens/123/revoke \
-H "Cookie: session=YOUR_SESSION_COOKIE"
```
**Extend Token Expiration:**
```bash
# Via API (requires session authentication)
curl -X POST http://your-server/profile/tokens/123/extend \
-H "Content-Type: application/json" \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-d '{"days": 365}'
```
**Delete Token:**
```bash
# Via API (requires session authentication)
curl -X DELETE http://your-server/profile/tokens/123/delete \
-H "Cookie: session=YOUR_SESSION_COOKIE"
```
## API Documentation
### Authentication Endpoints
......@@ -347,18 +447,24 @@ python daemon.py start --foreground --config development
- `POST /auth/api/refresh` - Refresh JWT token
- `GET /auth/api/profile` - Get user profile
### Token Management Endpoints
- `GET /profile/tokens` - Token management page (web interface)
- `POST /profile/tokens/create` - Create new API token
- `POST /profile/tokens/{id}/revoke` - Revoke API token
- `POST /profile/tokens/{id}/extend` - Extend token expiration
- `DELETE /profile/tokens/{id}/delete` - Delete API token
### Protected API Endpoints (Require API Token)
- `GET /api/fixtures` - List all fixtures with match counts
- `GET /api/matches` - List matches with pagination and filtering
- `GET /api/match/{id}` - Get match details with outcomes
### Upload Endpoints
- `POST /upload/api/fixture` - Upload fixture file
- `POST /upload/api/zip/{match_id}` - Upload ZIP file
- `GET /upload/api/progress/{upload_id}` - Get upload progress
- `GET /upload/api/uploads` - List user uploads
### Match Management
- `GET /api/matches` - List matches with pagination
- `GET /api/matches/{id}` - Get match details
- `PUT /api/matches/{id}` - Update match
- `DELETE /api/matches/{id}` - Delete match (admin)
### Administration
- `GET /api/admin/users` - List users (admin)
- `PUT /api/admin/users/{id}` - Update user (admin)
......@@ -453,8 +559,101 @@ For support and questions:
- See BUILD.md for executable build issues
- Contact system administrator
## API Token Security Best Practices
### For Developers
1. **Store Tokens Securely**: Never commit tokens to version control
2. **Use Environment Variables**: Store tokens in environment variables or secure config files
3. **Rotate Tokens Regularly**: Generate new tokens periodically and revoke old ones
4. **Monitor Usage**: Check token usage logs for suspicious activity
5. **Use Descriptive Names**: Name tokens clearly to identify their purpose
6. **Minimum Permissions**: Only use tokens for their intended purpose
### For System Administrators
1. **Monitor Token Activity**: Review token usage logs regularly
2. **Set Expiration Policies**: Enforce reasonable token expiration periods
3. **Audit Token Access**: Regular audits of active tokens and their usage
4. **Revoke Unused Tokens**: Remove tokens that haven't been used recently
5. **Secure Database**: Ensure API token table is properly secured
6. **Backup Considerations**: Include token management in backup/recovery procedures
### Example Integration
**Python Example:**
```python
import requests
# Store token securely (environment variable recommended)
API_TOKEN = "your-api-token-here"
BASE_URL = "http://your-server"
headers = {
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json"
}
# Get all fixtures
response = requests.get(f"{BASE_URL}/api/fixtures", headers=headers)
fixtures = response.json()
# Get specific match details
match_id = 123
response = requests.get(f"{BASE_URL}/api/match/{match_id}", headers=headers)
match_details = response.json()
```
**JavaScript Example:**
```javascript
const API_TOKEN = process.env.API_TOKEN; // Store in environment variable
const BASE_URL = 'http://your-server';
const headers = {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json'
};
// Get all matches
fetch(`${BASE_URL}/api/matches?page=1&per_page=20`, { headers })
.then(response => response.json())
.then(data => console.log(data));
// Get fixtures
fetch(`${BASE_URL}/api/fixtures`, { headers })
.then(response => response.json())
.then(data => console.log(data));
```
**cURL Examples:**
```bash
# Set token as environment variable
export API_TOKEN="your-api-token-here"
# Get fixtures
curl -H "Authorization: Bearer $API_TOKEN" \
http://your-server/api/fixtures
# Get matches with filtering
curl -H "Authorization: Bearer $API_TOKEN" \
"http://your-server/api/matches?fixture_id=abc123&active_only=true"
# Get specific match
curl -H "Authorization: Bearer $API_TOKEN" \
http://your-server/api/match/123
```
---
**Version**: 1.0.0
**Version**: 1.1.0
**Last Updated**: 2025-08-18
**Minimum Requirements**: Python 3.8+, MySQL 5.7+, Linux Kernel 3.10+
\ No newline at end of file
**Minimum Requirements**: Python 3.8+, MySQL 5.7+, Linux Kernel 3.10+
### Recent Updates (v1.1.0)
-**API Token Management**: Complete user-generated token system
-**Enhanced Security**: SHA256 token hashing with usage tracking
-**Web Interface**: Professional token management UI
-**Multiple Auth Methods**: Bearer tokens, headers, and query parameters
-**Token Lifecycle**: Create, revoke, extend, and delete operations
-**Usage Monitoring**: Last used timestamps and IP tracking
-**Database Migration**: Automatic schema updates with versioning
-**REST API Endpoints**: Protected fixture and match data access
-**Documentation**: Comprehensive API and security guidelines
\ No newline at end of file
"""
JWT utilities for API token authentication
Handles token generation, validation, and API authentication
"""
import jwt
import logging
from datetime import datetime, timedelta
from functools import wraps
from flask import request, jsonify, current_app, g
from app.models import APIToken, User
logger = logging.getLogger(__name__)
class JWTError(Exception):
"""Custom JWT error"""
pass
class TokenExpiredError(JWTError):
"""Token has expired"""
pass
class TokenInvalidError(JWTError):
"""Token is invalid"""
pass
def generate_jwt_token(user_id: int, token_name: str, expires_in: timedelta = None) -> str:
"""
Generate a JWT token for API access
Args:
user_id: User ID
token_name: Name/description of the token
expires_in: Token expiration time (default: 1 year)
Returns:
JWT token string
"""
if expires_in is None:
expires_in = timedelta(days=365)
payload = {
'user_id': user_id,
'token_name': token_name,
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + expires_in,
'type': 'api_access'
}
try:
token = jwt.encode(
payload,
current_app.config['JWT_SECRET_KEY'],
algorithm='HS256'
)
return token
except Exception as e:
logger.error(f"Failed to generate JWT token: {str(e)}")
raise JWTError(f"Token generation failed: {str(e)}")
def decode_jwt_token(token: str) -> dict:
"""
Decode and validate a JWT token
Args:
token: JWT token string
Returns:
Decoded token payload
Raises:
TokenExpiredError: If token has expired
TokenInvalidError: If token is invalid
"""
try:
payload = jwt.decode(
token,
current_app.config['JWT_SECRET_KEY'],
algorithms=['HS256']
)
# Validate token type
if payload.get('type') != 'api_access':
raise TokenInvalidError("Invalid token type")
return payload
except jwt.ExpiredSignatureError:
raise TokenExpiredError("Token has expired")
except jwt.InvalidTokenError as e:
raise TokenInvalidError(f"Invalid token: {str(e)}")
except Exception as e:
logger.error(f"Token decode error: {str(e)}")
raise TokenInvalidError(f"Token validation failed: {str(e)}")
def validate_api_token(token: str) -> tuple[User, APIToken]:
"""
Validate an API token and return the associated user and token record
Args:
token: API token string
Returns:
Tuple of (User, APIToken) objects
Raises:
TokenInvalidError: If token is invalid or user not found
TokenExpiredError: If token has expired
"""
try:
# First check if it's a database token (non-JWT)
api_token = APIToken.find_by_token(token)
if api_token:
if not api_token.is_valid():
if api_token.is_expired():
raise TokenExpiredError("API token has expired")
else:
raise TokenInvalidError("API token is inactive")
user = User.query.get(api_token.user_id)
if not user or not user.is_active:
raise TokenInvalidError("User not found or inactive")
# Update last used timestamp
api_token.update_last_used(request.remote_addr)
return user, api_token
# If not a database token, try JWT
payload = decode_jwt_token(token)
user_id = payload.get('user_id')
if not user_id:
raise TokenInvalidError("Token missing user ID")
user = User.query.get(user_id)
if not user or not user.is_active:
raise TokenInvalidError("User not found or inactive")
# For JWT tokens, we don't have an APIToken record
return user, None
except (TokenExpiredError, TokenInvalidError):
raise
except Exception as e:
logger.error(f"Token validation error: {str(e)}")
raise TokenInvalidError(f"Token validation failed: {str(e)}")
def extract_token_from_request() -> str:
"""
Extract API token from request headers
Returns:
Token string or None if not found
"""
# Check Authorization header (Bearer token)
auth_header = request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '):
return auth_header[7:] # Remove 'Bearer ' prefix
# Check X-API-Token header
api_token = request.headers.get('X-API-Token')
if api_token:
return api_token
# Check query parameter (less secure, but sometimes needed)
token_param = request.args.get('token')
if token_param:
return token_param
return None
def require_api_token(f):
"""
Decorator to require valid API token for route access
Usage:
@app.route('/api/data')
@require_api_token
def get_data():
# Access current user via g.current_user
# Access current token via g.current_token (may be None for JWT)
return jsonify({'data': 'protected'})
"""
@wraps(f)
def decorated_function(*args, **kwargs):
try:
# Extract token from request
token = extract_token_from_request()
if not token:
return jsonify({
'error': 'Missing API token',
'message': 'API token required in Authorization header, X-API-Token header, or token parameter'
}), 401
# Validate token
user, api_token = validate_api_token(token)
# Store in Flask's g object for use in the route
g.current_user = user
g.current_token = api_token
# Log API access
logger.info(f"API access by user {user.username} (ID: {user.id}) with token: {api_token.name if api_token else 'JWT'}")
return f(*args, **kwargs)
except TokenExpiredError as e:
return jsonify({
'error': 'Token expired',
'message': str(e)
}), 401
except TokenInvalidError as e:
return jsonify({
'error': 'Invalid token',
'message': str(e)
}), 401
except Exception as e:
logger.error(f"API authentication error: {str(e)}")
return jsonify({
'error': 'Authentication failed',
'message': 'Internal authentication error'
}), 500
return decorated_function
def optional_api_token(f):
"""
Decorator that allows but doesn't require API token authentication
If token is provided and valid, user info is available in g.current_user
"""
@wraps(f)
def decorated_function(*args, **kwargs):
try:
token = extract_token_from_request()
if token:
try:
user, api_token = validate_api_token(token)
g.current_user = user
g.current_token = api_token
except (TokenExpiredError, TokenInvalidError):
# Token provided but invalid - continue without authentication
g.current_user = None
g.current_token = None
else:
g.current_user = None
g.current_token = None
return f(*args, **kwargs)
except Exception as e:
logger.error(f"Optional API authentication error: {str(e)}")
g.current_user = None
g.current_token = None
return f(*args, **kwargs)
return decorated_function
def get_current_api_user():
"""
Get the current authenticated API user
Returns:
User object or None if not authenticated
"""
return getattr(g, 'current_user', None)
def get_current_api_token():
"""
Get the current API token record
Returns:
APIToken object or None if not authenticated or using JWT
"""
return getattr(g, 'current_token', None)
\ No newline at end of file
......@@ -128,6 +128,68 @@ class Migration_002_AddDatabaseVersionTable(Migration):
logger.error(f"Migration 002 failed: {str(e)}")
raise
class Migration_003_CreateAPITokensTable(Migration):
"""Create API tokens table for external authentication"""
def __init__(self):
super().__init__("003", "Create API tokens table for external authentication")
def up(self):
"""Create api_tokens table"""
try:
# Check if table already exists
inspector = inspect(db.engine)
if 'api_tokens' in inspector.get_table_names():
logger.info("api_tokens table already exists, skipping creation")
return True
# Create the table using raw SQL to ensure compatibility
create_table_sql = '''
CREATE TABLE api_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
token_hash VARCHAR(255) NOT NULL UNIQUE,
is_active BOOLEAN DEFAULT TRUE,
expires_at DATETIME NOT NULL,
last_used_at DATETIME NULL,
last_used_ip VARCHAR(45) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_api_tokens_user_id (user_id),
INDEX idx_api_tokens_token_hash (token_hash),
INDEX idx_api_tokens_is_active (is_active),
INDEX idx_api_tokens_expires_at (expires_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
'''
with db.engine.connect() as conn:
conn.execute(text(create_table_sql))
conn.commit()
logger.info("Created api_tokens table successfully")
return True
except Exception as e:
logger.error(f"Migration 003 failed: {str(e)}")
raise
def down(self):
"""Drop api_tokens table"""
try:
with db.engine.connect() as conn:
conn.execute(text("DROP TABLE IF EXISTS api_tokens"))
conn.commit()
logger.info("Dropped api_tokens table")
return True
except Exception as e:
logger.error(f"Rollback of migration 003 failed: {str(e)}")
raise
def can_rollback(self) -> bool:
return True
class MigrationManager:
"""Manages database migrations and versioning"""
......@@ -135,6 +197,7 @@ class MigrationManager:
self.migrations = [
Migration_002_AddDatabaseVersionTable(),
Migration_001_RemoveFixtureIdUnique(),
Migration_003_CreateAPITokensTable(),
]
def ensure_version_table(self):
......
......@@ -959,4 +959,294 @@ def api_dashboard_data():
except Exception as e:
logger.error(f"Dashboard API error: {str(e)}")
return jsonify({'error': 'Failed to load dashboard data'}), 500
\ No newline at end of file
return jsonify({'error': 'Failed to load dashboard data'}), 500
# API Token Management Routes
@bp.route('/profile/tokens')
@login_required
@require_active_user
def user_tokens():
"""User API token management page"""
try:
from app.models import APIToken
# Get user's API tokens
tokens = APIToken.query.filter_by(user_id=current_user.id)\
.order_by(APIToken.created_at.desc()).all()
return render_template('main/user_tokens.html', tokens=tokens)
except Exception as e:
logger.error(f"User tokens page error: {str(e)}")
flash('Error loading API tokens', 'error')
return render_template('main/user_tokens.html', tokens=[])
@bp.route('/profile/tokens/create', methods=['POST'])
@login_required
@require_active_user
def create_api_token():
"""Create a new API token"""
try:
data = request.get_json()
if not data or not data.get('name'):
return jsonify({'error': 'Token name is required'}), 400
token_name = data['name'].strip()
if not token_name:
return jsonify({'error': 'Token name cannot be empty'}), 400
# Check if user already has a token with this name
from app.models import APIToken
existing_token = APIToken.query.filter_by(
user_id=current_user.id,
name=token_name
).first()
if existing_token:
return jsonify({'error': 'A token with this name already exists'}), 400
# Generate the token
api_token = current_user.generate_api_token(token_name)
logger.info(f"API token '{token_name}' created by user {current_user.username}")
return jsonify({
'message': 'API token created successfully',
'token': api_token.to_dict(include_token=True)
}), 201
except Exception as e:
logger.error(f"Create API token error: {str(e)}")
return jsonify({'error': 'Failed to create API token'}), 500
@bp.route('/profile/tokens/<int:token_id>/delete', methods=['DELETE'])
@login_required
@require_active_user
def delete_api_token(token_id):
"""Delete an API token"""
try:
from app.models import APIToken
# Get the token (ensure it belongs to current user)
token = APIToken.query.filter_by(
id=token_id,
user_id=current_user.id
).first_or_404()
token_name = token.name
db.session.delete(token)
db.session.commit()
logger.info(f"API token '{token_name}' deleted by user {current_user.username}")
return jsonify({
'message': f'API token "{token_name}" deleted successfully'
}), 200
except Exception as e:
logger.error(f"Delete API token error: {str(e)}")
return jsonify({'error': 'Failed to delete API token'}), 500
@bp.route('/profile/tokens/<int:token_id>/revoke', methods=['POST'])
@login_required
@require_active_user
def revoke_api_token(token_id):
"""Revoke (deactivate) an API token"""
try:
from app.models import APIToken
# Get the token (ensure it belongs to current user)
token = APIToken.query.filter_by(
id=token_id,
user_id=current_user.id
).first_or_404()
if not token.is_active:
return jsonify({'error': 'Token is already revoked'}), 400
token.revoke()
logger.info(f"API token '{token.name}' revoked by user {current_user.username}")
return jsonify({
'message': f'API token "{token.name}" revoked successfully',
'token': token.to_dict()
}), 200
except Exception as e:
logger.error(f"Revoke API token error: {str(e)}")
return jsonify({'error': 'Failed to revoke API token'}), 500
@bp.route('/profile/tokens/<int:token_id>/extend', methods=['POST'])
@login_required
@require_active_user
def extend_api_token(token_id):
"""Extend API token expiration"""
try:
from app.models import APIToken
# Get the token (ensure it belongs to current user)
token = APIToken.query.filter_by(
id=token_id,
user_id=current_user.id
).first_or_404()
data = request.get_json()
days = data.get('days', 365) if data else 365
# Validate days (between 1 and 1095 - 3 years max)
if not isinstance(days, int) or days < 1 or days > 1095:
return jsonify({'error': 'Days must be between 1 and 1095'}), 400
token.extend_expiration(days)
logger.info(f"API token '{token.name}' extended by {days} days by user {current_user.username}")
return jsonify({
'message': f'API token "{token.name}" extended by {days} days',
'token': token.to_dict()
}), 200
except Exception as e:
logger.error(f"Extend API token error: {str(e)}")
return jsonify({'error': 'Failed to extend API token'}), 500
# API Routes with Token Authentication
@bp.route('/api/matches')
def api_matches():
"""API endpoint to list matches (requires API token)"""
from app.auth.jwt_utils import require_api_token, get_current_api_user
@require_api_token
def _api_matches():
try:
from app.models import Match
# Get current API user
api_user = get_current_api_user()
# Pagination
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
# Base query
if api_user.is_admin:
query = Match.query
else:
query = Match.query.filter_by(created_by=api_user.id)
# Apply filters
fixture_id = request.args.get('fixture_id')
if fixture_id:
query = query.filter_by(fixture_id=fixture_id)
active_only = request.args.get('active_only', '').lower() == 'true'
if active_only:
query = query.filter_by(active_status=True)
# Pagination
matches_pagination = query.order_by(Match.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'matches': [match.to_dict() for match in matches_pagination.items],
'pagination': {
'page': matches_pagination.page,
'pages': matches_pagination.pages,
'per_page': matches_pagination.per_page,
'total': matches_pagination.total,
'has_next': matches_pagination.has_next,
'has_prev': matches_pagination.has_prev
}
}), 200
except Exception as e:
logger.error(f"API matches error: {str(e)}")
return jsonify({'error': 'Failed to retrieve matches'}), 500
return _api_matches()
@bp.route('/api/fixtures')
def api_fixtures():
"""API endpoint to list fixtures (requires API token)"""
from app.auth.jwt_utils import require_api_token, get_current_api_user
@require_api_token
def _api_fixtures():
try:
from app.models import Match
from sqlalchemy import func
# Get current API user
api_user = get_current_api_user()
# Base query for fixtures (grouped by fixture_id)
base_query = db.session.query(
Match.fixture_id,
Match.filename,
func.count(Match.id).label('match_count'),
func.min(Match.created_at).label('upload_date'),
func.sum(Match.active_status.cast(db.Integer)).label('active_matches')
).group_by(Match.fixture_id, Match.filename)
# Apply user filter
if not api_user.is_admin:
base_query = base_query.filter(Match.created_by == api_user.id)
# Get fixtures
fixtures = base_query.order_by(func.min(Match.created_at).desc()).all()
# Convert to dict format
fixtures_data = []
for fixture in fixtures:
fixtures_data.append({
'fixture_id': fixture.fixture_id,
'filename': fixture.filename,
'match_count': fixture.match_count,
'active_matches': fixture.active_matches,
'upload_date': fixture.upload_date.isoformat() if fixture.upload_date else None,
'status': 'complete' if fixture.active_matches == fixture.match_count else 'partial'
})
return jsonify({
'fixtures': fixtures_data,
'total': len(fixtures_data)
}), 200
except Exception as e:
logger.error(f"API fixtures error: {str(e)}")
return jsonify({'error': 'Failed to retrieve fixtures'}), 500
return _api_fixtures()
@bp.route('/api/match/<int:match_id>')
def api_match_detail(match_id):
"""API endpoint to get match details (requires API token)"""
from app.auth.jwt_utils import require_api_token, get_current_api_user
@require_api_token
def _api_match_detail():
try:
from app.models import Match
# Get current API user
api_user = get_current_api_user()
# Get match
if api_user.is_admin:
match = Match.query.get_or_404(match_id)
else:
match = Match.query.filter_by(id=match_id, created_by=api_user.id).first_or_404()
return jsonify({
'match': match.to_dict(include_outcomes=True)
}), 200
except Exception as e:
logger.error(f"API match detail error: {str(e)}")
return jsonify({'error': 'Failed to retrieve match details'}), 500
return _api_match_detail()
\ No newline at end of file
......@@ -6,6 +6,8 @@ from app import db
import uuid
import hashlib
import json
import jwt
import secrets
class User(UserMixin, db.Model):
"""User model for authentication"""
......@@ -26,6 +28,7 @@ class User(UserMixin, db.Model):
uploads = db.relationship('FileUpload', backref='uploader', lazy='dynamic', foreign_keys='FileUpload.uploaded_by')
sessions = db.relationship('UserSession', backref='user', lazy='dynamic', cascade='all, delete-orphan')
logs = db.relationship('SystemLog', backref='user', lazy='dynamic', foreign_keys='SystemLog.user_id')
api_tokens = db.relationship('APIToken', backref='user', lazy='dynamic', cascade='all, delete-orphan')
def set_password(self, password):
"""Set password hash"""
......@@ -40,6 +43,52 @@ class User(UserMixin, db.Model):
self.last_login = datetime.utcnow()
db.session.commit()
def generate_api_token(self, name, expires_in=None):
"""Generate a new API token for this user"""
from flask import current_app
# Generate a secure random token
token_value = secrets.token_urlsafe(32)
# Set expiration (default to 1 year if not specified)
if expires_in is None:
expires_at = datetime.utcnow() + timedelta(days=365)
else:
expires_at = datetime.utcnow() + expires_in
# Create the token record
api_token = APIToken(
user_id=self.id,
name=name,
token_hash=APIToken.hash_token(token_value),
expires_at=expires_at
)
db.session.add(api_token)
db.session.commit()
# Return the token record with the plain token value
api_token.plain_token = token_value
return api_token
def revoke_api_token(self, token_id):
"""Revoke an API token"""
token = self.api_tokens.filter_by(id=token_id).first()
if token:
token.is_active = False
db.session.commit()
return True
return False
def delete_api_token(self, token_id):
"""Delete an API token"""
token = self.api_tokens.filter_by(id=token_id).first()
if token:
db.session.delete(token)
db.session.commit()
return True
return False
def to_dict(self):
"""Convert to dictionary for JSON serialization"""
return {
......@@ -382,4 +431,97 @@ class UserSession(db.Model):
}
def __repr__(self):
return f'<UserSession {self.session_id} for User {self.user_id}>'
\ No newline at end of file
return f'<UserSession {self.session_id} for User {self.user_id}>'
class APIToken(db.Model):
"""API tokens for external application authentication"""
__tablename__ = 'api_tokens'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False) # User-friendly name for the token
token_hash = db.Column(db.String(255), nullable=False, unique=True, index=True)
is_active = db.Column(db.Boolean, default=True, index=True)
expires_at = db.Column(db.DateTime, nullable=False, index=True)
last_used_at = db.Column(db.DateTime)
last_used_ip = db.Column(db.String(45))
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __init__(self, **kwargs):
super(APIToken, self).__init__(**kwargs)
@staticmethod
def hash_token(token):
"""Hash a token for secure storage"""
return hashlib.sha256(token.encode('utf-8')).hexdigest()
@staticmethod
def verify_token(token, token_hash):
"""Verify a token against its hash"""
return APIToken.hash_token(token) == token_hash
def is_expired(self):
"""Check if token is expired"""
return datetime.utcnow() > self.expires_at
def is_valid(self):
"""Check if token is valid (active and not expired)"""
return self.is_active and not self.is_expired()
def update_last_used(self, ip_address=None):
"""Update last used timestamp and IP"""
self.last_used_at = datetime.utcnow()
if ip_address:
self.last_used_ip = ip_address
db.session.commit()
def revoke(self):
"""Revoke this token"""
self.is_active = False
db.session.commit()
def extend_expiration(self, days=365):
"""Extend token expiration"""
self.expires_at = datetime.utcnow() + timedelta(days=days)
db.session.commit()
@classmethod
def find_by_token(cls, token):
"""Find a token by its value"""
token_hash = cls.hash_token(token)
return cls.query.filter_by(token_hash=token_hash, is_active=True).first()
@classmethod
def cleanup_expired(cls):
"""Remove expired tokens from database"""
expired_tokens = cls.query.filter(cls.expires_at < datetime.utcnow()).all()
for token in expired_tokens:
db.session.delete(token)
db.session.commit()
return len(expired_tokens)
def to_dict(self, include_token=False):
"""Convert to dictionary for JSON serialization"""
data = {
'id': self.id,
'name': self.name,
'is_active': self.is_active,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None,
'last_used_ip': self.last_used_ip,
'created_at': self.created_at.isoformat() if self.created_at else None,
'is_expired': self.is_expired(),
'is_valid': self.is_valid()
}
# Only include the plain token when explicitly requested (e.g., during creation)
if include_token and hasattr(self, 'plain_token'):
data['token'] = self.plain_token
return data
def __repr__(self):
return f'<APIToken {self.name} for User {self.user_id}>'
\ No newline at end of file
......@@ -135,6 +135,7 @@
<a href="{{ url_for('main.fixtures') }}">Fixtures</a>
<a href="{{ url_for('main.uploads') }}">Uploads</a>
<a href="{{ url_for('main.statistics') }}">Statistics</a>
<a href="{{ url_for('main.user_tokens') }}">API Tokens</a>
{% if current_user.is_admin %}
<a href="{{ url_for('main.admin_panel') }}">Admin</a>
{% endif %}
......@@ -205,6 +206,7 @@
<a href="{{ url_for('upload.upload_fixture') }}" class="btn">Upload Fixture File</a>
<a href="{{ url_for('main.fixtures') }}" class="btn">Browse Fixtures</a>
<a href="{{ url_for('main.statistics') }}" class="btn">View Statistics</a>
<a href="{{ url_for('main.user_tokens') }}" class="btn">Manage API Tokens</a>
</div>
</div>
</div>
......
{% extends "base.html" %}
{% block title %}API Tokens - Fixture Manager{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h3 mb-0">🔑 API Tokens</h1>
<p class="text-muted">Manage your API tokens for external application access</p>
</div>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createTokenModal">
<i class="fas fa-plus"></i> Create New Token
</button>
</div>
</div>
</div>
<!-- API Documentation Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-info">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="fas fa-info-circle"></i> API Usage</h5>
</div>
<div class="card-body">
<p class="mb-2"><strong>Base URL:</strong> <code>{{ request.url_root }}api/</code></p>
<p class="mb-2"><strong>Authentication:</strong> Include your token in the Authorization header:</p>
<pre class="bg-light p-2 rounded"><code>Authorization: Bearer YOUR_TOKEN_HERE</code></pre>
<p class="mb-2"><strong>Available Endpoints:</strong></p>
<ul class="mb-0">
<li><code>GET /api/fixtures</code> - List all fixtures</li>
<li><code>GET /api/matches</code> - List all matches</li>
<li><code>GET /api/match/&lt;id&gt;</code> - Get match details</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Tokens List -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Your API Tokens</h5>
</div>
<div class="card-body">
{% if tokens %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Created</th>
<th>Expires</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tokensTableBody">
{% for token in tokens %}
<tr data-token-id="{{ token.id }}">
<td>
<strong>{{ token.name }}</strong>
</td>
<td>
{% if token.is_valid() %}
<span class="badge bg-success">Active</span>
{% elif token.is_expired() %}
<span class="badge bg-danger">Expired</span>
{% else %}
<span class="badge bg-secondary">Revoked</span>
{% endif %}
</td>
<td>
<small class="text-muted">
{{ token.created_at.strftime('%Y-%m-%d %H:%M') }}
</small>
</td>
<td>
<small class="text-muted">
{{ token.expires_at.strftime('%Y-%m-%d %H:%M') }}
</small>
</td>
<td>
<small class="text-muted">
{% if token.last_used_at %}
{{ token.last_used_at.strftime('%Y-%m-%d %H:%M') }}
{% if token.last_used_ip %}
<br>from {{ token.last_used_ip }}
{% endif %}
{% else %}
Never used
{% endif %}
</small>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
{% if token.is_active and not token.is_expired() %}
<button type="button" class="btn btn-outline-warning"
onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
title="Revoke Token">
<i class="fas fa-ban"></i>
</button>
<button type="button" class="btn btn-outline-info"
onclick="extendToken({{ token.id }}, '{{ token.name }}')"
title="Extend Expiration">
<i class="fas fa-clock"></i>
</button>
{% endif %}
<button type="button" class="btn btn-outline-danger"
onclick="deleteToken({{ token.id }}, '{{ token.name }}')"
title="Delete Token">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-key fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No API tokens yet</h5>
<p class="text-muted">Create your first API token to start accessing the API</p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createTokenModal">
<i class="fas fa-plus"></i> Create Your First Token
</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Create Token Modal -->
<div class="modal fade" id="createTokenModal" tabindex="-1" aria-labelledby="createTokenModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createTokenModalLabel">Create New API Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="createTokenForm">
<div class="modal-body">
<div class="mb-3">
<label for="tokenName" class="form-label">Token Name</label>
<input type="text" class="form-control" id="tokenName" name="name" required
placeholder="e.g., Mobile App, Dashboard Integration">
<div class="form-text">Choose a descriptive name to identify this token</div>
</div>
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Important:</strong> The token will only be shown once after creation. Make sure to copy and store it securely.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus"></i> Create Token
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Token Created Modal -->
<div class="modal fade" id="tokenCreatedModal" tabindex="-1" aria-labelledby="tokenCreatedModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="tokenCreatedModalLabel">
<i class="fas fa-check-circle"></i> Token Created Successfully
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-success">
<strong>Your new API token has been created!</strong>
</div>
<div class="mb-3">
<label for="newTokenValue" class="form-label"><strong>Token Value:</strong></label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="newTokenValue" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copyToken()">
<i class="fas fa-copy"></i> Copy
</button>
</div>
</div>
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle"></i>
<strong>Warning:</strong> This token will not be shown again. Make sure to copy and store it in a secure location.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">I've Saved the Token</button>
</div>
</div>
</div>
</div>
<!-- Extend Token Modal -->
<div class="modal fade" id="extendTokenModal" tabindex="-1" aria-labelledby="extendTokenModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="extendTokenModalLabel">Extend Token Expiration</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="extendTokenForm">
<div class="modal-body">
<p>Extend the expiration date for token: <strong id="extendTokenName"></strong></p>
<div class="mb-3">
<label for="extensionDays" class="form-label">Extend by (days)</label>
<select class="form-select" id="extensionDays" name="days">
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="180">180 days</option>
<option value="365" selected>365 days (1 year)</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-info">
<i class="fas fa-clock"></i> Extend Token
</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Global variables for token management
let currentTokenId = null;
// Create token form submission
document.getElementById('createTokenForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const tokenName = formData.get('name');
if (!tokenName.trim()) {
showAlert('Token name is required', 'danger');
return;
}
try {
const response = await fetch('{{ url_for("main.create_api_token") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ name: tokenName })
});
const data = await response.json();
if (response.ok) {
// Hide create modal
const createModal = bootstrap.Modal.getInstance(document.getElementById('createTokenModal'));
createModal.hide();
// Show token value in success modal
document.getElementById('newTokenValue').value = data.token.token;
const tokenCreatedModal = new bootstrap.Modal(document.getElementById('tokenCreatedModal'));
tokenCreatedModal.show();
// Reset form
this.reset();
// Reload page after modal is closed
document.getElementById('tokenCreatedModal').addEventListener('hidden.bs.modal', function() {
location.reload();
}, { once: true });
} else {
showAlert(data.error || 'Failed to create token', 'danger');
}
} catch (error) {
console.error('Error creating token:', error);
showAlert('Network error occurred', 'danger');
}
});
// Extend token form submission
document.getElementById('extendTokenForm').addEventListener('submit', async function(e) {
e.preventDefault();
if (!currentTokenId) return;
const formData = new FormData(this);
const days = parseInt(formData.get('days'));
try {
const response = await fetch(`{{ url_for("main.extend_api_token", token_id=0) }}`.replace('0', currentTokenId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ days: days })
});
const data = await response.json();
if (response.ok) {
showAlert(data.message, 'success');
const modal = bootstrap.Modal.getInstance(document.getElementById('extendTokenModal'));
modal.hide();
setTimeout(() => location.reload(), 1500);
} else {
showAlert(data.error || 'Failed to extend token', 'danger');
}
} catch (error) {
console.error('Error extending token:', error);
showAlert('Network error occurred', 'danger');
}
});
// Token management functions
async function revokeToken(tokenId, tokenName) {
if (!confirm(`Are you sure you want to revoke the token "${tokenName}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`{{ url_for("main.revoke_api_token", token_id=0) }}`.replace('0', tokenId), {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
});
const data = await response.json();
if (response.ok) {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showAlert(data.error || 'Failed to revoke token', 'danger');
}
} catch (error) {
console.error('Error revoking token:', error);
showAlert('Network error occurred', 'danger');
}
}
async function deleteToken(tokenId, tokenName) {
if (!confirm(`Are you sure you want to permanently delete the token "${tokenName}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`{{ url_for("main.delete_api_token", token_id=0) }}`.replace('0', tokenId), {
method: 'DELETE',
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
});
const data = await response.json();
if (response.ok) {
showAlert(data.message, 'success');
// Remove the row from table
const row = document.querySelector(`tr[data-token-id="${tokenId}"]`);
if (row) {
row.remove();
}
} else {
showAlert(data.error || 'Failed to delete token', 'danger');
}
} catch (error) {
console.error('Error deleting token:', error);
showAlert('Network error occurred', 'danger');
}
}
function extendToken(tokenId, tokenName) {
currentTokenId = tokenId;
document.getElementById('extendTokenName').textContent = tokenName;
const modal = new bootstrap.Modal(document.getElementById('extendTokenModal'));
modal.show();
}
function copyToken() {
const tokenInput = document.getElementById('newTokenValue');
tokenInput.select();
tokenInput.setSelectionRange(0, 99999); // For mobile devices
try {
document.execCommand('copy');
showAlert('Token copied to clipboard!', 'success');
} catch (err) {
console.error('Failed to copy token:', err);
showAlert('Failed to copy token. Please copy manually.', 'warning');
}
}
function showAlert(message, type) {
// Create alert element
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
// Insert at top of container
const container = document.querySelector('.container-fluid');
container.insertBefore(alertDiv, container.firstChild);
// Auto-dismiss after 5 seconds
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.remove();
}
}, 5000);
}
</script>
{% endblock %}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment