bmc_hub/app/core/auth_service.py
Christian 92b888b78f Add migrations for seeding tags and enhancing todo steps
- Created migration 146 to seed case type tags with various categories and keywords.
- Created migration 147 to seed brand and type tags, including a comprehensive list of brands and case types.
- Added migration 148 to introduce a new column `is_next` in `sag_todo_steps` for persistent next-task selection.
- Implemented a new script `run_migrations.py` to facilitate running SQL migrations against the PostgreSQL database with options for dry runs and error handling.
2026-03-20 00:24:58 +01:00

576 lines
20 KiB
Python

"""
Authentication Service - Håndterer login, JWT tokens, password hashing
Adapted from OmniSync for BMC Hub
"""
from typing import Optional, Dict, List, Tuple
from datetime import datetime, timedelta
import hashlib
import secrets
import jwt
import pyotp
from passlib.context import CryptContext
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
_users_column_cache: Dict[str, bool] = {}
def _users_column_exists(column_name: str) -> bool:
if column_name in _users_column_cache:
return _users_column_cache[column_name]
result = execute_query_single(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = %s
LIMIT 1
""",
(column_name,),
)
exists = bool(result)
_users_column_cache[column_name] = exists
return exists
# JWT Settings
SECRET_KEY = settings.JWT_SECRET_KEY
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt_sha256", "bcrypt"], deprecated="auto")
class AuthService:
"""Service for authentication and authorization"""
@staticmethod
def is_2fa_supported() -> bool:
"""Return True only when required 2FA columns exist in users table."""
return _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret")
@staticmethod
def hash_password(password: str) -> str:
"""
Hash password using bcrypt
"""
return pwd_context.hash(password)
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify password against hash"""
if not hashed_password:
return False
try:
if not hashed_password.startswith("$"):
return False
return pwd_context.verify(plain_password, hashed_password)
except Exception:
return False
@staticmethod
def verify_legacy_sha256(plain_password: str, hashed_password: str) -> bool:
"""Verify legacy SHA256 hash and upgrade when used"""
if not hashed_password or len(hashed_password) != 64:
return False
try:
return hashlib.sha256(plain_password.encode()).hexdigest() == hashed_password
except Exception:
return False
@staticmethod
def upgrade_password_hash(user_id: int, plain_password: str):
"""Upgrade legacy password hash to bcrypt"""
new_hash = AuthService.hash_password(plain_password)
execute_update(
"UPDATE users SET password_hash = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(new_hash, user_id)
)
@staticmethod
def verify_totp_code(secret: str, code: str) -> bool:
"""Verify TOTP code"""
if not secret or not code:
return False
try:
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
except Exception:
return False
@staticmethod
def generate_2fa_secret() -> str:
"""Generate a new TOTP secret"""
return pyotp.random_base32()
@staticmethod
def get_2fa_provisioning_uri(username: str, secret: str) -> str:
"""Generate provisioning URI for authenticator apps"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name="BMC Hub")
@staticmethod
def setup_user_2fa(user_id: int, username: str) -> Dict:
"""Create and store a new TOTP secret (not enabled until verified)"""
if not AuthService.is_2fa_supported():
raise RuntimeError("2FA columns missing in users table")
secret = AuthService.generate_2fa_secret()
execute_update(
"UPDATE users SET totp_secret = %s, is_2fa_enabled = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(secret, user_id)
)
return {
"secret": secret,
"provisioning_uri": AuthService.get_2fa_provisioning_uri(username, secret)
}
@staticmethod
def enable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Enable 2FA after verifying TOTP code"""
if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
return False
user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,)
)
if not user or not user.get("totp_secret"):
return False
if not AuthService.verify_totp_code(user["totp_secret"], otp_code):
return False
execute_update(
"UPDATE users SET is_2fa_enabled = TRUE, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
return True
@staticmethod
def disable_user_2fa(user_id: int, otp_code: str) -> bool:
"""Disable 2FA after verifying TOTP code"""
if not (_users_column_exists("totp_secret") and _users_column_exists("is_2fa_enabled")):
return False
user = execute_query_single(
"SELECT totp_secret FROM users WHERE user_id = %s",
(user_id,)
)
if not user or not user.get("totp_secret"):
return False
if not AuthService.verify_totp_code(user["totp_secret"], otp_code):
return False
execute_update(
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
return True
@staticmethod
def admin_reset_user_2fa(user_id: int) -> bool:
"""Admin reset: disable 2FA and remove TOTP secret without OTP"""
user = execute_query_single(
"SELECT user_id FROM users WHERE user_id = %s",
(user_id,)
)
if not user:
return False
if _users_column_exists("is_2fa_enabled") and _users_column_exists("totp_secret"):
execute_update(
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
return True
@staticmethod
def create_access_token(
user_id: int,
username: str,
is_superadmin: bool = False,
is_shadow_admin: 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,
"shadow_admin": is_shadow_admin,
"exp": expire,
"iat": datetime.utcnow(),
"jti": jti
}
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
# Store session for token revocation (skip for shadow admin)
if not is_shadow_admin:
execute_update(
"""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])
if payload.get("shadow_admin"):
return payload
# Check if token is revoked
jti = payload.get('jti')
if jti:
session = execute_query_single(
"SELECT revoked FROM sessions WHERE token_jti = %s",
(jti,))
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,
otp_code: Optional[str] = None
) -> Tuple[Optional[Dict], Optional[str]]:
"""
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
"""
# Normalize username once (used by both normal and shadow login paths)
shadow_username = (settings.SHADOW_ADMIN_USERNAME or "shadowadmin").strip().lower()
request_username = (username or "").strip().lower()
# Get user
is_2fa_expr = "is_2fa_enabled" if _users_column_exists("is_2fa_enabled") else "FALSE AS is_2fa_enabled"
totp_expr = "totp_secret" if _users_column_exists("totp_secret") else "NULL::text AS totp_secret"
last_2fa_expr = "last_2fa_at" if _users_column_exists("last_2fa_at") else "NULL::timestamp AS last_2fa_at"
user = execute_query_single(
f"""SELECT user_id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until,
{is_2fa_expr}, {totp_expr}, {last_2fa_expr}
FROM users
WHERE username = %s OR email = %s""",
(username, username),
)
if not user:
# Shadow Admin fallback (only when no regular user matches)
if settings.SHADOW_ADMIN_ENABLED and request_username == shadow_username:
if not settings.SHADOW_ADMIN_PASSWORD or not settings.SHADOW_ADMIN_TOTP_SECRET:
logger.error("❌ Shadow admin enabled but not configured")
return None, "Shadow admin not configured"
if not secrets.compare_digest(password, settings.SHADOW_ADMIN_PASSWORD):
logger.warning(f"❌ Shadow admin login failed from IP: {ip_address}")
return None, "Invalid username or password"
if not settings.AUTH_DISABLE_2FA:
if not otp_code:
return None, "2FA code required"
if not AuthService.verify_totp_code(settings.SHADOW_ADMIN_TOTP_SECRET, otp_code):
logger.warning(f"❌ Shadow admin 2FA failed from IP: {ip_address}")
return None, "Invalid 2FA code"
else:
logger.warning(f"⚠️ 2FA disabled via settings for shadow admin login from IP: {ip_address}")
logger.warning(f"⚠️ Shadow admin login used from IP: {ip_address}")
return {
"user_id": 0,
"username": settings.SHADOW_ADMIN_USERNAME,
"email": settings.SHADOW_ADMIN_EMAIL,
"full_name": settings.SHADOW_ADMIN_FULL_NAME,
"is_superadmin": True,
"is_shadow_admin": True,
"is_2fa_enabled": True,
"has_2fa_configured": True
}, None
logger.warning(f"❌ Login failed: User not found - {username}")
return None, "Invalid username or password"
# Check if account is active
if not user['is_active']:
logger.warning(f"❌ Login failed: Account disabled - {username}")
return None, "Account disabled"
# 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, "Account locked"
else:
# Unlock account
execute_update(
"UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE user_id = %s",
(user['user_id'],)
)
# Verify password
if AuthService.verify_password(password, user['password_hash']):
pass
elif AuthService.verify_legacy_sha256(password, user['password_hash']):
AuthService.upgrade_password_hash(user['user_id'], password)
else:
# 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 user_id = %s""",
(failed_attempts, locked_until, user['user_id'])
)
logger.warning(f"🔒 Account locked due to failed attempts: {username}")
else:
execute_update(
"UPDATE users SET failed_login_attempts = %s WHERE user_id = %s",
(failed_attempts, user['user_id'])
)
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
return None, "Invalid username or password"
# 2FA check (only once per grace window)
if settings.AUTH_DISABLE_2FA:
logger.warning(f"⚠️ 2FA disabled via settings for login: {username}")
elif user.get('is_2fa_enabled'):
if not user.get('totp_secret'):
return None, "2FA not configured"
last_2fa_at = user.get("last_2fa_at")
grace_hours = max(1, int(settings.TWO_FA_GRACE_HOURS))
grace_window = timedelta(hours=grace_hours)
now = datetime.utcnow()
within_grace = bool(last_2fa_at and (now - last_2fa_at) < grace_window)
if not within_grace:
if not otp_code:
return None, "2FA code required"
if not AuthService.verify_totp_code(user['totp_secret'], otp_code):
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
return None, "Invalid 2FA code"
if _users_column_exists("last_2fa_at"):
execute_update(
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user['user_id'],)
)
# 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 user_id = %s""",
(user['user_id'],)
)
logger.info(f"✅ User logged in: {username} from IP: {ip_address}")
return {
'user_id': user['user_id'],
'username': user['username'],
'email': user['email'],
'full_name': user['full_name'],
'is_superadmin': bool(user['is_superadmin']),
'is_shadow_admin': False,
'is_2fa_enabled': bool(user.get('is_2fa_enabled')),
'has_2fa_configured': bool(user.get('totp_secret'))
}, None
@staticmethod
def revoke_token(jti: str, user_id: int, is_shadow_admin: bool = False):
"""Revoke a JWT token"""
if is_shadow_admin:
logger.info("🔒 Shadow admin logout - no session to revoke")
return
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_all_permissions() -> List[str]:
"""Get all permission codes"""
perms = execute_query("SELECT code FROM permissions")
return [p['code'] for p in perms] if perms else []
@staticmethod
def is_user_2fa_enabled(user_id: int) -> bool:
"""Check if user has 2FA enabled"""
if not _users_column_exists("is_2fa_enabled"):
return False
user = execute_query_single(
"SELECT is_2fa_enabled FROM users WHERE user_id = %s",
(user_id,)
)
return bool(user and user.get("is_2fa_enabled"))
@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_single(
"SELECT is_superadmin FROM users WHERE user_id = %s",
(user_id,))
# Superadmins have all permissions
if user and user['is_superadmin']:
return AuthService.get_all_permissions()
# 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_single(
"SELECT is_superadmin FROM users WHERE user_id = %s",
(user_id,))
if user and user['is_superadmin']:
return True
# Check if user has permission through groups
result = execute_query_single("""
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))
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 user_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 user_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}")