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