feat(auth): add OTP and token use cases
This commit is contained in:
2
src/gapido_auth/application/__init__.py
Normal file
2
src/gapido_auth/application/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Application use cases."""
|
||||
|
||||
170
src/gapido_auth/application/auth_service.py
Normal file
170
src/gapido_auth/application/auth_service.py
Normal 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")
|
||||
83
src/gapido_auth/application/security.py
Normal file
83
src/gapido_auth/application/security.py
Normal 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
38
src/gapido_auth/config.py
Normal 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()
|
||||
|
||||
2
src/gapido_auth/domain/__init__.py
Normal file
2
src/gapido_auth/domain/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Domain models and ports."""
|
||||
|
||||
54
src/gapido_auth/domain/entities.py
Normal file
54
src/gapido_auth/domain/entities.py
Normal 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
|
||||
|
||||
38
src/gapido_auth/domain/errors.py
Normal file
38
src/gapido_auth/domain/errors.py
Normal 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
|
||||
49
src/gapido_auth/domain/ports.py
Normal file
49
src/gapido_auth/domain/ports.py
Normal 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: ...
|
||||
Reference in New Issue
Block a user