Files
SonicForgeStudio/app/core/auth.py
T

100 lines
3.8 KiB
Python

import hashlib
import hmac
import json
import time
import base64
import uuid
import os
import secrets
from typing import Optional, Dict, Any
from app.models.user import get_db_connection
from app.config import settings
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
def hash_password(password: str, salt: Optional[str] = None) -> str:
"""
Hash password using PBKDF2 HMAC SHA-256 with a per-user random salt.
Guarantees raw passwords are NEVER stored or exposed in plaintext.
Returns format: 'salt_hex:hashed_key_hex' (colon-delimited, stores both values).
If salt is provided, uses that salt (for verification).
If salt is None, generates a new 32-byte random salt.
"""
if salt is None:
salt = secrets.token_hex(32)
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), 600000)
return f"{salt}:{key.hex()}"
def verify_password(plain_password: str, stored_value: str) -> bool:
"""Verify plain password against stored 'salt:hash' using constant-time comparison.
Also supports legacy-format hash (hex only, without salt) for backward compatibility."""
if ':' in stored_value:
parts = stored_value.split(':', 1)
salt = parts[0]
expected_hash = parts[1]
computed = hash_password(plain_password, salt)
computed_hash = computed.split(':', 1)[1]
return hmac.compare_digest(computed_hash, expected_hash)
else:
expected_hash = stored_value
computed_hash = hashlib.pbkdf2_hmac('sha256', plain_password.encode('utf-8'), b"sonicforge_crypto_salt_2026_secure_", 600000).hex()
return hmac.compare_digest(computed_hash, expected_hash)
def create_token(user_id: str, username: str, role: str, must_change_password: bool) -> str:
payload = {
"user_id": user_id,
"username": username,
"role": role,
"must_change_password": bool(must_change_password),
"exp": time.time() + (3600 * 24 * 7) # 7 days
}
payload_str = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8")
sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_str.encode("utf-8"), hashlib.sha256).hexdigest()
return f"{payload_str}.{sig}"
def decode_token(token: str) -> Optional[Dict[str, Any]]:
try:
parts = token.split(".")
if len(parts) != 2:
return None
payload_str, sig = parts[0], parts[1]
expected_sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_str.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected_sig):
return None
payload_bytes = base64.b64decode(payload_str.encode("utf-8"))
payload = json.loads(payload_bytes.decode("utf-8"))
if time.time() > payload.get("exp", 0):
return None
return payload
except Exception:
return None
def seed_admin():
"""Seed default admin account on initial launch only — never resets existing password."""
conn = get_db_connection()
cursor = conn.cursor()
default_pwd = (os.getenv("DEFAULT_ADMIN_PASSWORD") or "admin123").strip()
hashed_pwd = hash_password(default_pwd)
now = time.time()
cursor.execute("SELECT id FROM users WHERE username = ?", ("admin",))
if not cursor.fetchone():
admin_id = str(uuid.uuid4())
cursor.execute("""
INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
VALUES (?, ?, ?, ?, ?, 1, ?, 1)
""", (admin_id, "admin", "admin@sonicforge.studio", hashed_pwd, "admin", now))
cursor.execute("""
INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks)
VALUES (?, 10240, 64)
""", (admin_id,))
conn.commit()
conn.close()
# Auto seed on module load
seed_admin()