security.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. """Black-box security shortcuts to generate JWT tokens and password hashing and verification."""
  2. import hashlib
  3. import secrets
  4. import time
  5. from datetime import UTC, datetime, timedelta
  6. import jwt
  7. from passlib.context import CryptContext
  8. from pydantic import BaseModel
  9. from sqlalchemy import func, select
  10. from sqlalchemy.orm import Session
  11. from app.core import config
  12. from app.models import PasswordResetRequestLog
  13. from app.schemas.responses import AccessTokenResponse
  14. JWT_ALGORITHM = "HS256"
  15. ACCESS_TOKEN_EXPIRE_SECS = config.settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
  16. REFRESH_TOKEN_EXPIRE_SECS = config.settings.REFRESH_TOKEN_EXPIRE_MINUTES * 60
  17. PWD_CONTEXT = CryptContext(
  18. schemes=["bcrypt"],
  19. deprecated="auto",
  20. bcrypt__rounds=config.settings.SECURITY_BCRYPT_ROUNDS,
  21. )
  22. class JWTTokenPayload(BaseModel):
  23. sub: str | int
  24. refresh: bool
  25. issued_at: int
  26. expires_at: int
  27. RESET_TOKEN_TTL = {"reset": timedelta(hours=1), "invite": timedelta(days=7)}
  28. def generate_reset_token() -> tuple[str, str]:
  29. """Returns (raw_token_for_email, sha256_hash_for_db)."""
  30. raw = secrets.token_urlsafe(32)
  31. return raw, hashlib.sha256(raw.encode()).hexdigest()
  32. def create_jwt_token(subject: str | int, exp_secs: int, refresh: bool):
  33. """Creates jwt access or refresh token for user.
  34. Args:
  35. subject: anything unique to user, id or email etc.
  36. exp_secs: expire time in seconds
  37. refresh: if True, this is refresh token
  38. """
  39. issued_at = int(time.time())
  40. expires_at = issued_at + exp_secs
  41. to_encode: dict[str, int | str | bool] = {
  42. "issued_at": issued_at,
  43. "expires_at": expires_at,
  44. "sub": subject,
  45. "refresh": refresh,
  46. }
  47. encoded_jwt = jwt.encode(
  48. to_encode,
  49. key=config.settings.SECRET_KEY,
  50. algorithm=JWT_ALGORITHM,
  51. )
  52. return encoded_jwt, expires_at, issued_at
  53. def generate_access_token_response(subject: str | int):
  54. """Generate tokens and return AccessTokenResponse"""
  55. access_token, expires_at, issued_at = create_jwt_token(
  56. subject, ACCESS_TOKEN_EXPIRE_SECS, refresh=False
  57. )
  58. refresh_token, refresh_expires_at, refresh_issued_at = create_jwt_token(
  59. subject, REFRESH_TOKEN_EXPIRE_SECS, refresh=True
  60. )
  61. return AccessTokenResponse(
  62. token_type="Bearer",
  63. access_token=access_token,
  64. expires_at=expires_at,
  65. issued_at=issued_at,
  66. refresh_token=refresh_token,
  67. refresh_token_expires_at=refresh_expires_at,
  68. refresh_token_issued_at=refresh_issued_at,
  69. )
  70. def verify_password(plain_password: str, hashed_password: str) -> bool:
  71. """Verifies plain and hashed password matches
  72. Applies passlib context based on bcrypt algorithm on plain password.
  73. It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
  74. """
  75. return PWD_CONTEXT.verify(plain_password, hashed_password)
  76. def get_password_hash(password: str) -> str:
  77. """Creates hash from password
  78. Applies passlib context based on bcrypt algorithm on plain password.
  79. It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
  80. """
  81. return PWD_CONTEXT.hash(password)
  82. def is_reset_rate_limited(session: Session, email: str) -> bool:
  83. window_start = datetime.now(UTC) - timedelta(hours=config.settings.RESET_REQUEST_WINDOW_HOUR)
  84. count = session.scalar(
  85. select(func.count())
  86. .select_from(PasswordResetRequestLog)
  87. .where(
  88. PasswordResetRequestLog.email == email,
  89. PasswordResetRequestLog.requested_at >= window_start,
  90. )
  91. )
  92. return count >= config.settings.RESET_REQUEST_LIMIT
  93. def log_reset_request(session: Session, email: str) -> None:
  94. session.add(PasswordResetRequestLog(email=email))
  95. session.commit()