diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..61a6ca1 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,53 @@ +"""Security utilities: JWT tokens, password hashing. + +Uses bcrypt directly (passlib has compatibility issues with bcrypt>=4.1). +""" +from datetime import datetime, timedelta, timezone +from typing import Optional +from jose import jwt, JWTError +import bcrypt +from app.config import settings + + +def hash_password(password: str) -> str: + """Hash a plaintext password using bcrypt.""" + # bcrypt has a 72-byte limit, so we truncate + pwd_bytes = password.encode("utf-8")[:72] + salt = bcrypt.gensalt() + return bcrypt.hashpw(pwd_bytes, salt).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + """Verify a plaintext password against its bcrypt hash.""" + try: + pwd_bytes = plain.encode("utf-8")[:72] + hash_bytes = hashed.encode("utf-8") + return bcrypt.checkpw(pwd_bytes, hash_bytes) + except Exception: + return False + + +def create_access_token( + subject: str, + tenant_id: int, + role: str, + extra: Optional[dict] = None, +) -> str: + """Create a JWT access token.""" + now = datetime.now(timezone.utc) + expire = now + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + payload = { + "sub": subject, + "tid": tenant_id, + "role": role, + "iat": now, + "exp": expire, + } + if extra: + payload.update(extra) + return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + + +def decode_access_token(token: str) -> dict: + """Decode and validate a JWT token. Raises JWTError on failure.""" + return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])