feat(auth): add OTP and token use cases

This commit is contained in:
2026-07-14 09:32:48 +03:30
parent b33716863b
commit 4fc0ee1107
8 changed files with 436 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
"""Application use cases."""

View File

@@ -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")

View File

@@ -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),
)

38
src/gapido_auth/config.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1,2 @@
"""Domain models and ports."""

View File

@@ -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

View File

@@ -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

View File

@@ -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: ...