bmc_hub/app/core/auth_service.py

314 lines
10 KiB
Python
Raw Normal View History

"""
Authentication Service - Håndterer login, JWT tokens, password hashing
Adapted from OmniSync for BMC Hub
"""
from typing import Optional, Dict, List
from datetime import datetime, timedelta
import hashlib
import secrets
import jwt
from app.core.database import execute_query, execute_insert, execute_update
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
# JWT Settings
SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-production')
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
class AuthService:
"""Service for authentication and authorization"""
@staticmethod
def hash_password(password: str) -> str:
"""
Hash password using SHA256
I produktion: Brug bcrypt eller argon2!
"""
return hashlib.sha256(password.encode()).hexdigest()
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify password against hash"""
return AuthService.hash_password(plain_password) == hashed_password
@staticmethod
def create_access_token(user_id: int, username: str, is_superadmin: bool = False) -> str:
"""
Create JWT access token
Args:
user_id: User ID
username: Username
is_superadmin: Whether user is superadmin
Returns:
JWT token string
"""
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
jti = secrets.token_urlsafe(32) # JWT ID for token revocation
payload = {
"sub": str(user_id),
"username": username,
"is_superadmin": is_superadmin,
"exp": expire,
"iat": datetime.utcnow(),
"jti": jti
}
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
# Store session for token revocation
execute_insert(
"""INSERT INTO sessions (user_id, token_jti, expires_at)
VALUES (%s, %s, %s)""",
(user_id, jti, expire)
)
return token
@staticmethod
def verify_token(token: str) -> Optional[Dict]:
"""
Verify and decode JWT token
Returns:
Dict with user info or None if invalid
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
# Check if token is revoked
jti = payload.get('jti')
if jti:
session = execute_query(
"SELECT revoked FROM sessions WHERE token_jti = %s",
(jti,),
fetchone=True
)
if session and session.get('revoked'):
logger.warning(f"⚠️ Revoked token used: {jti[:10]}...")
return None
return payload
except jwt.ExpiredSignatureError:
logger.warning("⚠️ Expired token")
return None
except jwt.InvalidTokenError as e:
logger.warning(f"⚠️ Invalid token: {e}")
return None
@staticmethod
def authenticate_user(username: str, password: str, ip_address: Optional[str] = None) -> Optional[Dict]:
"""
Authenticate user with username/password
Args:
username: Username
password: Plain text password
ip_address: Client IP address (for logging)
Returns:
User dict if successful, None otherwise
"""
# Get user
user = execute_query(
"""SELECT id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until
FROM users
WHERE username = %s OR email = %s""",
(username, username),
fetchone=True
)
if not user:
logger.warning(f"❌ Login failed: User not found - {username}")
return None
# Check if account is active
if not user['is_active']:
logger.warning(f"❌ Login failed: Account disabled - {username}")
return None
# Check if account is locked
if user['locked_until']:
locked_until = user['locked_until']
if datetime.now() < locked_until:
logger.warning(f"❌ Login failed: Account locked - {username}")
return None
else:
# Unlock account
execute_update(
"UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE id = %s",
(user['id'],)
)
# Verify password
if not AuthService.verify_password(password, user['password_hash']):
# Increment failed attempts
failed_attempts = user['failed_login_attempts'] + 1
if failed_attempts >= 5:
# Lock account for 30 minutes
locked_until = datetime.now() + timedelta(minutes=30)
execute_update(
"""UPDATE users
SET failed_login_attempts = %s, locked_until = %s
WHERE id = %s""",
(failed_attempts, locked_until, user['id'])
)
logger.warning(f"🔒 Account locked due to failed attempts: {username}")
else:
execute_update(
"UPDATE users SET failed_login_attempts = %s WHERE id = %s",
(failed_attempts, user['id'])
)
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
return None
# Success! Reset failed attempts and update last login
execute_update(
"""UPDATE users
SET failed_login_attempts = 0,
locked_until = NULL,
last_login_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(user['id'],)
)
logger.info(f"✅ User logged in: {username} from IP: {ip_address}")
return {
'user_id': user['id'],
'username': user['username'],
'email': user['email'],
'full_name': user['full_name'],
'is_superadmin': bool(user['is_superadmin'])
}
@staticmethod
def revoke_token(jti: str, user_id: int):
"""Revoke a JWT token"""
execute_update(
"UPDATE sessions SET revoked = TRUE WHERE token_jti = %s AND user_id = %s",
(jti, user_id)
)
logger.info(f"🔒 Token revoked for user {user_id}")
@staticmethod
def get_user_permissions(user_id: int) -> List[str]:
"""
Get all permissions for a user (through their groups)
Args:
user_id: User ID
Returns:
List of permission codes
"""
# Check if user is superadmin first
user = execute_query(
"SELECT is_superadmin FROM users WHERE id = %s",
(user_id,),
fetchone=True
)
# Superadmins have all permissions
if user and user['is_superadmin']:
all_perms = execute_query("SELECT code FROM permissions")
return [p['code'] for p in all_perms] if all_perms else []
# Get permissions through groups
perms = execute_query("""
SELECT DISTINCT p.code
FROM permissions p
JOIN group_permissions gp ON p.id = gp.permission_id
JOIN user_groups ug ON gp.group_id = ug.group_id
WHERE ug.user_id = %s
""", (user_id,))
return [p['code'] for p in perms] if perms else []
@staticmethod
def user_has_permission(user_id: int, permission_code: str) -> bool:
"""
Check if user has specific permission
Args:
user_id: User ID
permission_code: Permission code (e.g., 'customers.view')
Returns:
True if user has permission
"""
# Superadmins have all permissions
user = execute_query(
"SELECT is_superadmin FROM users WHERE id = %s",
(user_id,),
fetchone=True
)
if user and user['is_superadmin']:
return True
# Check if user has permission through groups
result = execute_query("""
SELECT COUNT(*) as cnt
FROM permissions p
JOIN group_permissions gp ON p.id = gp.permission_id
JOIN user_groups ug ON gp.group_id = ug.group_id
WHERE ug.user_id = %s AND p.code = %s
""", (user_id, permission_code), fetchone=True)
return bool(result and result['cnt'] > 0)
@staticmethod
def create_user(
username: str,
email: str,
password: str,
full_name: Optional[str] = None,
is_superadmin: bool = False
) -> Optional[int]:
"""
Create a new user
Returns:
New user ID or None if failed
"""
password_hash = AuthService.hash_password(password)
user_id = execute_insert(
"""INSERT INTO users
(username, email, password_hash, full_name, is_superadmin)
VALUES (%s, %s, %s, %s, %s) RETURNING id""",
(username, email, password_hash, full_name, is_superadmin)
)
logger.info(f"👤 User created: {username} (ID: {user_id})")
return user_id
@staticmethod
def change_password(user_id: int, new_password: str):
"""Change user password"""
password_hash = AuthService.hash_password(new_password)
execute_update(
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(password_hash, user_id)
)
# Revoke all existing sessions
execute_update(
"UPDATE sessions SET revoked = TRUE WHERE user_id = %s",
(user_id,)
)
logger.info(f"🔑 Password changed for user {user_id}")