diff --git a/src/gapido_auth/application/__init__.py b/src/gapido_auth/application/__init__.py new file mode 100644 index 0000000..148ef42 --- /dev/null +++ b/src/gapido_auth/application/__init__.py @@ -0,0 +1,2 @@ +"""Application use cases.""" + diff --git a/src/gapido_auth/application/auth_service.py b/src/gapido_auth/application/auth_service.py new file mode 100644 index 0000000..261bf38 --- /dev/null +++ b/src/gapido_auth/application/auth_service.py @@ -0,0 +1,170 @@ +from dataclasses import dataclass +from datetime import timedelta + +from gapido_auth.application.security import ( + JwtTokenCodec, + generate_otp_code, + generate_refresh_token, + hash_otp, + hash_refresh_token, + utc_now, +) +from gapido_auth.domain.entities import Role, SmsJob, TokenPair, User +from gapido_auth.domain.errors import ( + AuthenticationError, + InactiveUser, + InvalidOtp, + PermissionDenied, + RateLimitExceeded, + ValidationError, +) +from gapido_auth.domain.ports import ( + OtpStore, + RefreshSessionRepository, + SmsPublisher, + UserRepository, +) + + +@dataclass(frozen=True, slots=True) +class AuthConfig: + otp_secret: str + otp_ttl_seconds: int + otp_max_attempts: int + otp_request_limit: int + otp_request_window_seconds: int + refresh_token_ttl_seconds: int + sms_template: str + + +class AuthService: + def __init__( + self, + users: UserRepository, + refresh_sessions: RefreshSessionRepository, + otp_store: OtpStore, + sms_publisher: SmsPublisher, + token_codec: JwtTokenCodec, + config: AuthConfig, + ) -> None: + self._users = users + self._refresh_sessions = refresh_sessions + self._otp_store = otp_store + self._sms_publisher = sms_publisher + self._token_codec = token_codec + self._config = config + + async def request_otp(self, mobile: str, purpose: str, client_key: str) -> None: + _validate_mobile(mobile) + _validate_purpose(purpose) + mobile_key = f"otp-request:mobile:{mobile}:{purpose}" + peer_key = f"otp-request:peer:{client_key}:{purpose}" + mobile_allowed = await self._otp_store.allow_request( + mobile_key, + self._config.otp_request_limit, + self._config.otp_request_window_seconds, + ) + peer_allowed = await self._otp_store.allow_request( + peer_key, + self._config.otp_request_limit * 5, + self._config.otp_request_window_seconds, + ) + if not mobile_allowed or not peer_allowed: + raise RateLimitExceeded("too many OTP requests") + + code = generate_otp_code() + otp_hash = hash_otp(self._config.otp_secret, mobile, purpose, code) + await self._otp_store.store_otp( + mobile=mobile, + purpose=purpose, + otp_hash=otp_hash, + ttl_seconds=self._config.otp_ttl_seconds, + max_attempts=self._config.otp_max_attempts, + ) + await self._sms_publisher.publish( + SmsJob(mobile=mobile, code=code, template=self._config.sms_template, purpose=purpose) + ) + + async def verify_otp(self, mobile: str, code: str, purpose: str) -> TokenPair: + _validate_mobile(mobile) + _validate_purpose(purpose) + if not code.isdigit() or len(code) != 6: + raise ValidationError("OTP code must be 6 digits") + + candidate_hash = hash_otp(self._config.otp_secret, mobile, purpose, code) + is_valid = await self._otp_store.verify_otp(mobile, purpose, candidate_hash) + if not is_valid: + raise InvalidOtp("invalid OTP") + + user = await self._users.get_or_create_user(mobile=mobile, role=Role.USER) + return await self._issue_token_pair(user) + + async def refresh_token(self, refresh_token: str) -> TokenPair: + token_hash = hash_refresh_token(refresh_token) + now = utc_now() + session = await self._refresh_sessions.get_active_by_hash(token_hash, now) + if session is None: + raise AuthenticationError("invalid refresh token") + + user = await self._users.get_by_id(session.user_id) + if user is None or not user.is_active: + raise AuthenticationError("invalid refresh token") + + new_refresh_token = generate_refresh_token() + new_hash = hash_refresh_token(new_refresh_token) + expires_at = now + timedelta(seconds=self._config.refresh_token_ttl_seconds) + await self._refresh_sessions.create(user.id, new_hash, expires_at) + await self._refresh_sessions.revoke(token_hash, now, replaced_by_hash=new_hash) + + return TokenPair( + access_token=self._token_codec.encode_access_token(user.id, user.role), + refresh_token=new_refresh_token, + token_type="Bearer", + expires_in=self._token_codec.access_ttl_seconds, + role=user.role, + ) + + async def revoke_refresh_token(self, refresh_token: str) -> None: + await self._refresh_sessions.revoke(hash_refresh_token(refresh_token), utc_now()) + + async def get_authenticated_user(self, access_token: str) -> User: + claims = self._token_codec.decode_access_token(access_token) + user = await self._users.get_by_id(claims.user_id) + if user is None: + raise AuthenticationError("user not found") + if not user.is_active: + raise InactiveUser("user is inactive") + return user + + async def require_role(self, access_token: str, role: Role) -> User: + user = await self.get_authenticated_user(access_token) + if user.role != role: + raise PermissionDenied("insufficient permissions") + return user + + async def _issue_token_pair(self, user: User) -> TokenPair: + if not user.is_active: + raise InactiveUser("user is inactive") + + refresh_token = generate_refresh_token() + token_hash = hash_refresh_token(refresh_token) + expires_at = utc_now() + timedelta(seconds=self._config.refresh_token_ttl_seconds) + await self._refresh_sessions.create(user.id, token_hash, expires_at) + return TokenPair( + access_token=self._token_codec.encode_access_token(user.id, user.role), + refresh_token=refresh_token, + token_type="Bearer", + expires_in=self._token_codec.access_ttl_seconds, + role=user.role, + ) + + +def _validate_mobile(mobile: str) -> None: + normalized = mobile.removeprefix("+") + if not normalized.isdigit() or len(normalized) < 10 or len(normalized) > 15: + raise ValidationError("mobile must be an E.164-like phone number") + + +def _validate_purpose(purpose: str) -> None: + if not purpose or not purpose.replace("-", "").replace("_", "").isalnum(): + raise ValidationError("purpose is invalid") diff --git a/src/gapido_auth/application/security.py b/src/gapido_auth/application/security.py new file mode 100644 index 0000000..d1780da --- /dev/null +++ b/src/gapido_auth/application/security.py @@ -0,0 +1,83 @@ +import hashlib +import hmac +import secrets +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import jwt + +from gapido_auth.domain.entities import AccessClaims, Role +from gapido_auth.domain.errors import AuthenticationError + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def generate_otp_code() -> str: + return f"{secrets.randbelow(1_000_000):06d}" + + +def hash_otp(secret: str, mobile: str, purpose: str, code: str) -> str: + message = f"{mobile}:{purpose}:{code}".encode() + return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() + + +def generate_refresh_token() -> str: + return secrets.token_urlsafe(48) + + +def hash_refresh_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +class JwtTokenCodec: + def __init__(self, secret_key: str, issuer: str, access_ttl_seconds: int) -> None: + self._secret_key = secret_key + self._issuer = issuer + self._access_ttl_seconds = access_ttl_seconds + + @property + def access_ttl_seconds(self) -> int: + return self._access_ttl_seconds + + def encode_access_token(self, user_id: str, role: Role) -> str: + now = utc_now() + expires_at = now + timedelta(seconds=self._access_ttl_seconds) + payload = { + "iss": self._issuer, + "sub": user_id, + "role": role.value, + "typ": "access", + "iat": int(now.timestamp()), + "exp": int(expires_at.timestamp()), + "jti": str(uuid4()), + } + return jwt.encode(payload, self._secret_key, algorithm="HS256") + + def decode_access_token(self, token: str) -> AccessClaims: + try: + payload = jwt.decode( + token, + self._secret_key, + algorithms=["HS256"], + issuer=self._issuer, + options={"require": ["exp", "iat", "iss", "sub", "role", "typ"]}, + ) + except jwt.PyJWTError as exc: + raise AuthenticationError("invalid access token") from exc + + if payload.get("typ") != "access": + raise AuthenticationError("invalid token type") + + try: + role = Role(str(payload["role"])) + except ValueError as exc: + raise AuthenticationError("invalid role claim") from exc + + return AccessClaims( + user_id=str(payload["sub"]), + role=role, + expires_at=datetime.fromtimestamp(int(payload["exp"]), UTC), + ) + diff --git a/src/gapido_auth/config.py b/src/gapido_auth/config.py new file mode 100644 index 0000000..9fa8bf9 --- /dev/null +++ b/src/gapido_auth/config.py @@ -0,0 +1,38 @@ +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_env: str = "local" + + mongo_uri: str = "mongodb://localhost:27017" + mongo_db_name: str = "gapido_auth" + redis_url: str = "redis://localhost:6379/0" + rabbitmq_url: str = "amqp://guest:guest@localhost:5672/" + + jwt_secret_key: str = Field(default="change-me-use-a-long-random-secret", min_length=16) + jwt_issuer: str = "gapido-auth" + access_token_ttl_seconds: int = 900 + refresh_token_ttl_seconds: int = 604800 + + otp_ttl_seconds: int = 120 + otp_max_attempts: int = 5 + otp_request_limit: int = 3 + otp_request_window_seconds: int = 300 + + kavenegar_api_key: str = "replace-with-real-key" + kavenegar_login_template: str = "login-otp" + + admin_mobile: str = "989120000000" + grpc_host: str = "0.0.0.0" + grpc_port: int = 50051 + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + +@lru_cache +def get_settings() -> Settings: + return Settings() + diff --git a/src/gapido_auth/domain/__init__.py b/src/gapido_auth/domain/__init__.py new file mode 100644 index 0000000..eb39aac --- /dev/null +++ b/src/gapido_auth/domain/__init__.py @@ -0,0 +1,2 @@ +"""Domain models and ports.""" + diff --git a/src/gapido_auth/domain/entities.py b/src/gapido_auth/domain/entities.py new file mode 100644 index 0000000..1b3a2e7 --- /dev/null +++ b/src/gapido_auth/domain/entities.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + + +class Role(StrEnum): + ADMIN = "admin" + USER = "user" + + +@dataclass(frozen=True, slots=True) +class User: + id: str + mobile: str + role: Role + is_active: bool + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class RefreshSession: + id: str + user_id: str + token_hash: str + expires_at: datetime + revoked_at: datetime | None + replaced_by_hash: str | None + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class SmsJob: + mobile: str + code: str + template: str + purpose: str + + +@dataclass(frozen=True, slots=True) +class TokenPair: + access_token: str + refresh_token: str + token_type: str + expires_in: int + role: Role + + +@dataclass(frozen=True, slots=True) +class AccessClaims: + user_id: str + role: Role + expires_at: datetime + diff --git a/src/gapido_auth/domain/errors.py b/src/gapido_auth/domain/errors.py new file mode 100644 index 0000000..a0c20ff --- /dev/null +++ b/src/gapido_auth/domain/errors.py @@ -0,0 +1,38 @@ +class AppError(Exception): + """Base class for expected application failures.""" + + +class RateLimitExceeded(AppError): + pass + + +class ValidationError(AppError): + pass + + +class InvalidOtp(AppError): + pass + + +class OtpExpired(AppError): + pass + + +class OtpAttemptsExceeded(AppError): + pass + + +class AuthenticationError(AppError): + pass + + +class PermissionDenied(AppError): + pass + + +class InactiveUser(AppError): + pass + + +class ExternalServiceError(AppError): + pass diff --git a/src/gapido_auth/domain/ports.py b/src/gapido_auth/domain/ports.py new file mode 100644 index 0000000..5ce5158 --- /dev/null +++ b/src/gapido_auth/domain/ports.py @@ -0,0 +1,49 @@ +from datetime import datetime +from typing import Protocol + +from gapido_auth.domain.entities import RefreshSession, Role, SmsJob, User + + +class UserRepository(Protocol): + async def get_by_id(self, user_id: str) -> User | None: ... + + async def get_by_mobile(self, mobile: str) -> User | None: ... + + async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User: ... + + async def ensure_admin_user(self, mobile: str) -> User: ... + + +class RefreshSessionRepository(Protocol): + async def create( + self, user_id: str, token_hash: str, expires_at: datetime + ) -> RefreshSession: ... + + async def get_active_by_hash(self, token_hash: str, now: datetime) -> RefreshSession | None: ... + + async def revoke( + self, token_hash: str, now: datetime, replaced_by_hash: str | None = None + ) -> None: ... + + +class OtpStore(Protocol): + async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool: ... + + async def store_otp( + self, + mobile: str, + purpose: str, + otp_hash: str, + ttl_seconds: int, + max_attempts: int, + ) -> None: ... + + async def verify_otp(self, mobile: str, purpose: str, candidate_hash: str) -> bool: ... + + +class SmsPublisher(Protocol): + async def publish(self, job: SmsJob) -> None: ... + + +class SmsClient(Protocol): + async def send_otp(self, mobile: str, code: str, template: str) -> None: ...