Files
taylanbakircioglu 6aae0f4309 Initial commit
2025-10-27 12:14:03 +03:00

526 lines
18 KiB
Python

from fastapi import APIRouter, HTTPException, Depends, Request, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Optional
import logging
import hashlib
import time
from datetime import datetime, timedelta
# Import database and models
from database.connection import get_database_connection, close_database_connection
from models.user import LoginRequest, User, UserCreate, UserUpdate, UserPasswordUpdate
from utils.activity_log import log_user_activity
from auth_middleware import get_current_user_from_token
# Rate limiting temporarily disabled
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
logger = logging.getLogger(__name__)
# Security scheme
security = HTTPBearer()
@router.post("/login", summary="User Login", response_description="JWT access token and user information")
async def login(login_request: LoginRequest, request: Request):
"""
# User Login - Authenticate and Get Access Token
Authenticate user with username and password. Returns a JWT access token valid for 24 hours.
## Request Body
- **username**: User's username (required)
- **password**: User's password (required)
## Response
Returns JWT token, user information, roles, and permissions.
## Example Request
```bash
curl -X POST "{BASE_URL}/api/auth/login" \\
-H "Content-Type: application/json" \\
-d '{
"username": "admin",
"password": "admin123"
}'
```
> Replace `{BASE_URL}` with your deployment URL
## Example Response
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 86400,
"user": {
"id": 1,
"username": "admin",
"email": "admin@example.com",
"role": "admin",
"is_active": true,
"created_at": "2024-01-01T00:00:00",
"last_login_at": "2024-01-15T10:30:00"
},
"roles": [
{
"id": 1,
"name": "admin",
"display_name": "Administrator"
}
],
"permissions": {
"clusters": {"read": true, "write": true, "delete": true},
"agents": {"read": true, "write": true, "delete": true}
}
}
```
## Using the Token
Include the access token in subsequent requests:
```bash
curl -X GET "{BASE_URL}/api/clusters" \\
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
## Error Responses
- **401**: Invalid credentials or inactive account
- **500**: Server error during authentication
"""
try:
conn = await get_database_connection()
# Find user by username (with safe column handling)
try:
user = await conn.fetchrow("""
SELECT id, username, email, password_hash, is_active, role,
created_at, updated_at, last_login_at
FROM users
WHERE username = $1
""", login_request.username)
except Exception as schema_error:
logger.warning(f"Schema error, trying fallback query: {schema_error}")
# Fallback query without role column - try different column names
try:
user = await conn.fetchrow("""
SELECT id, username, email, password_hash, is_active,
created_at, updated_at, last_login_at
FROM users
WHERE username = $1
""", login_request.username)
except Exception as column_error:
logger.warning(f"last_login_at column error, trying last_login: {column_error}")
# Try with last_login instead of last_login_at
user = await conn.fetchrow("""
SELECT id, username, email, password_hash, is_active,
created_at, updated_at, last_login
FROM users
WHERE username = $1
""", login_request.username)
if not user:
await close_database_connection(conn)
logger.warning(f"Failed login attempt for username: {login_request.username}")
raise HTTPException(status_code=401, detail="Invalid username or password")
if not user['is_active']:
await close_database_connection(conn)
logger.warning(f"Login attempt for inactive account: {login_request.username}")
raise HTTPException(status_code=401, detail="Account is deactivated")
# Verify password
import bcrypt
if not bcrypt.checkpw(login_request.password.encode('utf-8'), user['password_hash'].encode('utf-8')):
await close_database_connection(conn)
logger.warning(f"Wrong password for user: {login_request.username}")
raise HTTPException(status_code=401, detail="Invalid username or password")
# Update last login (try different column names)
try:
await conn.execute("""
UPDATE users SET last_login_at = CURRENT_TIMESTAMP
WHERE id = $1
""", user['id'])
except Exception as update_error:
logger.warning(f"last_login_at update failed, trying last_login: {update_error}")
try:
await conn.execute("""
UPDATE users SET last_login = CURRENT_TIMESTAMP
WHERE id = $1
""", user['id'])
except Exception as fallback_error:
logger.warning(f"Could not update last login: {fallback_error}")
# Continue without updating last_login
# Get user roles and permissions before closing connection
conn_for_roles = conn
# Fetch user roles with their permissions
user_roles = await conn_for_roles.fetch("""
SELECT r.id, r.name, r.display_name, r.permissions
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = $1 AND ur.is_active = TRUE AND r.is_active = TRUE
""", user['id'])
# Build permissions dictionary
permissions = {}
roles_list = []
for role_row in user_roles:
roles_list.append({
'id': role_row['id'],
'name': role_row['name'],
'display_name': role_row['display_name']
})
# Parse permissions from JSON string if needed
role_permissions = role_row['permissions']
if isinstance(role_permissions, str):
import json
role_permissions = json.loads(role_permissions)
# Merge permissions (resource.action format)
if role_permissions:
for perm in role_permissions:
if '.' in perm:
resource, action = perm.split('.', 1)
if resource not in permissions:
permissions[resource] = {}
permissions[resource][action] = True
await close_database_connection(conn)
# Create JWT token
from jose import jwt
from config import JWT_SECRET_KEY, JWT_ALGORITHM
payload = {
"user_id": user['id'],
"username": user['username'],
"email": user.get('email'),
"role": user.get('role', 'admin'), # Default to admin if role column doesn't exist
"exp": datetime.utcnow() + timedelta(hours=24) # 24 hour expiry
}
token = jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
# Log login activity
await log_user_activity(
user_id=user['id'],
action='login',
resource_type='auth',
resource_id=str(user['id']),
details={
'login_method': 'username_password',
'success': True
},
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get('user-agent')
)
return {
"access_token": token,
"token_type": "bearer",
"expires_in": 86400, # 24 hours in seconds
"user": {
"id": user['id'],
"username": user['username'],
"email": user.get('email'),
"role": user.get('role', 'admin'),
"is_active": user.get('is_active', True),
"created_at": user['created_at'].isoformat() if user.get('created_at') else None,
"last_login_at": datetime.utcnow().isoformat()
},
"roles": roles_list,
"permissions": permissions
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Login error: {e}")
raise HTTPException(status_code=500, detail="Login failed")
@router.post("/logout", summary="User Logout", response_description="Logout confirmation")
async def logout(request: Request, authorization: str = Header(None)):
"""
# User Logout
Logout current user and log activity. Requires valid JWT token.
## Headers
- **Authorization**: Bearer {access_token}
## Example Request
```bash
curl -X POST "{BASE_URL}/api/auth/logout" \\
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
## Example Response
```json
{
"message": "Logout successful"
}
```
"""
try:
# Get current user for activity logging
current_user = await get_current_user_from_token(authorization)
if current_user and current_user.get('id'):
# Log logout activity
await log_user_activity(
user_id=current_user['id'],
action='logout',
resource_type='auth',
resource_id=str(current_user['id']),
details={
'logout_method': 'manual'
},
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get('user-agent')
)
return {"message": "Logout successful"}
except Exception as e:
logger.error(f"Logout error: {e}")
# Still return success even if logging fails
return {"message": "Logout successful"}
@router.get("/me", summary="Get Current User", response_description="Current user information")
async def get_current_user(authorization: str = Header(None)):
"""
# Get Current User Information
Get authenticated user's profile information. Requires valid JWT token.
## Headers
- **Authorization**: Bearer {access_token}
## Example Request
```bash
curl -X GET "{BASE_URL}/api/auth/me" \\
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
## Example Response
```json
{
"id": 1,
"username": "admin",
"email": "admin@example.com",
"role": "admin",
"is_active": true,
"created_at": "2024-01-01T00:00:00",
"updated_at": "2024-01-15T10:30:00",
"last_login_at": "2024-01-15T10:30:00"
}
```
"""
try:
current_user = await get_current_user_from_token(authorization)
if not current_user:
raise HTTPException(status_code=401, detail="Not authenticated")
conn = await get_database_connection()
# Get fresh user data (with safe column handling)
try:
user = await conn.fetchrow("""
SELECT id, username, email, role, is_active,
created_at, updated_at, last_login_at
FROM users
WHERE id = $1
""", current_user['id'])
except Exception as schema_error:
logger.warning(f"Schema error in /me endpoint, trying fallback: {schema_error}")
# Fallback query without role column - try different column names
try:
user = await conn.fetchrow("""
SELECT id, username, email, is_active,
created_at, updated_at, last_login_at
FROM users
WHERE id = $1
""", current_user['id'])
except Exception as column_error:
logger.warning(f"last_login_at column error in /me, trying last_login: {column_error}")
# Try with last_login instead of last_login_at
user = await conn.fetchrow("""
SELECT id, username, email, is_active,
created_at, updated_at, last_login
FROM users
WHERE id = $1
""", current_user['id'])
await close_database_connection(conn)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return {
"id": user['id'],
"username": user['username'],
"email": user.get('email'),
"role": user.get('role', 'admin'),
"is_active": user.get('is_active', True),
"created_at": user['created_at'].isoformat() if user.get('created_at') else None,
"updated_at": user['updated_at'].isoformat() if user.get('updated_at') else None,
"last_login_at": (user.get('last_login_at') or user.get('last_login', None)).isoformat() if (user.get('last_login_at') or user.get('last_login')) else None
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Get current user error: {e}")
raise HTTPException(status_code=500, detail="Failed to get user info")
@router.post("/change-password", summary="Change Password", response_description="Password change confirmation")
async def change_password(password_update: UserPasswordUpdate, request: Request, authorization: str = Header(None)):
"""
# Change User Password
Change authenticated user's password. Requires current password for verification.
## Headers
- **Authorization**: Bearer {access_token}
## Request Body
- **current_password**: Current password for verification
- **new_password**: New password (min 8 characters recommended)
## Example Request
```bash
curl -X POST "{BASE_URL}/api/auth/change-password" \\
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \\
-H "Content-Type: application/json" \\
-d '{
"current_password": "admin123",
"new_password": "newSecurePassword456"
}'
```
## Example Response
```json
{
"message": "Password changed successfully"
}
```
## Error Responses
- **400**: Current password is incorrect
- **401**: Not authenticated
- **404**: User not found
"""
try:
current_user = await get_current_user_from_token(authorization)
if not current_user:
raise HTTPException(status_code=401, detail="Not authenticated")
conn = await get_database_connection()
# Get current user data
user = await conn.fetchrow("""
SELECT id, password_hash FROM users WHERE id = $1
""", current_user['id'])
if not user:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="User not found")
# Verify current password
import bcrypt
if not bcrypt.checkpw(password_update.current_password.encode('utf-8'), user['password_hash'].encode('utf-8')):
await close_database_connection(conn)
raise HTTPException(status_code=400, detail="Current password is incorrect")
# Hash new password
new_password_hash = bcrypt.hashpw(password_update.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
# Update password
await conn.execute("""
UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
""", new_password_hash, current_user['id'])
await close_database_connection(conn)
# Log password change activity
await log_user_activity(
user_id=current_user['id'],
action='update',
resource_type='user',
resource_id=str(current_user['id']),
details={
'action': 'password_change',
'success': True
},
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get('user-agent')
)
return {"message": "Password changed successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Change password error: {e}")
raise HTTPException(status_code=500, detail="Failed to change password")
@router.get("/validate-token", summary="Validate Token", response_description="Token validation result")
async def validate_token(authorization: str = Header(None)):
"""
# Validate JWT Token
Check if a JWT token is valid and not expired.
## Headers
- **Authorization**: Bearer {access_token}
## Example Request
```bash
curl -X GET "{BASE_URL}/api/auth/validate-token" \\
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
## Example Response
```json
{
"valid": true,
"user": {
"id": 1,
"username": "admin",
"email": "admin@example.com",
"role": "admin"
}
}
```
## Error Responses
- **401**: Invalid or expired token
"""
try:
current_user = await get_current_user_from_token(authorization)
if not current_user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return {
"valid": True,
"user": {
"id": current_user['id'],
"username": current_user['username'],
"email": current_user.get('email'),
"role": current_user.get('role', 'admin')
}
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Token validation error: {e}")
raise HTTPException(status_code=401, detail="Token validation failed")