feat: Complete API authentication system with email & Telegram support

- Add email/password registration endpoint (/api/v1/auth/register)
- Add JWT token endpoints for Telegram users (/api/v1/auth/token/get, /api/v1/auth/token/refresh-telegram)
- Enhance User model to support both email and Telegram authentication
- Fix JWT token handling: convert sub to string (RFC compliance with PyJWT 2.10.1+)
- Fix bot API calls: filter None values from query parameters
- Fix JWT extraction from Redis: handle both bytes and string returns
- Add public endpoints to JWT middleware: /api/v1/auth/register, /api/v1/auth/token/*
- Update bot commands: /register (one-tap), /link (account linking), /start (options)
- Create complete database schema migration with email auth support
- Remove deprecated version attribute from docker-compose.yml
- Add service dependency: bot waits for web service startup

Features:
- Dual authentication: email/password OR Telegram ID
- JWT tokens with 15-min access + 30-day refresh lifetime
- Redis-based token storage with TTL
- Comprehensive API documentation and integration guides
- Test scripts and Python examples
- Full deployment checklist

Database changes:
- User model: added email, password_hash, email_verified (nullable fields)
- telegram_id now nullable to support email-only users
- Complete schema with families, accounts, categories, transactions, budgets, goals

Status: Production-ready with all tests passing
This commit is contained in:
2025-12-11 21:00:34 +09:00
parent b642d1e9e9
commit 23a9d975a9
21 changed files with 4832 additions and 480 deletions

426
examples/bot_api_usage.py Normal file
View File

@@ -0,0 +1,426 @@
"""
Example: Using Finance Bot API from Python
This file shows how to use the new API endpoints
to authenticate bot users and make API calls.
"""
import aiohttp
import asyncio
import json
import logging
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
class FinanceBotAPIClient:
"""
Client for Finance Bot API with authentication support.
Features:
- Email/password registration
- Telegram user quick registration
- Telegram binding/linking
- Token management and refresh
- Authenticated API calls
"""
def __init__(self, api_base_url: str = "http://web:8000"):
self.api_base_url = api_base_url
self.session: Optional[aiohttp.ClientSession] = None
async def start(self):
"""Start HTTP session"""
self.session = aiohttp.ClientSession()
async def close(self):
"""Close HTTP session"""
if self.session:
await self.session.close()
# ============ EMAIL AUTHENTICATION ============
async def register_email_user(
self,
email: str,
password: str,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
) -> Dict[str, Any]:
"""
Register new user with email and password.
Returns:
{
"success": True,
"user_id": 123,
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"expires_in": 900
}
"""
url = f"{self.api_base_url}/api/v1/auth/register"
payload = {
"email": email,
"password": password,
"first_name": first_name,
"last_name": last_name,
}
async with self.session.post(url, json=payload) as resp:
return await resp.json()
async def login_email_user(
self,
email: str,
password: str,
) -> Dict[str, Any]:
"""
Login user with email and password.
Returns:
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"user_id": 123,
"expires_in": 900
}
"""
url = f"{self.api_base_url}/api/v1/auth/login"
payload = {
"email": email,
"password": password,
}
async with self.session.post(url, json=payload) as resp:
return await resp.json()
# ============ TELEGRAM AUTHENTICATION ============
async def quick_register_telegram_user(
self,
chat_id: int,
username: Optional[str] = None,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
) -> Dict[str, Any]:
"""
Quick register Telegram user (one API call).
Perfect for bot /register command.
Returns:
{
"success": True,
"user_id": 123,
"jwt_token": "eyJ...",
"created": True
}
"""
url = f"{self.api_base_url}/api/v1/auth/telegram/register"
params = {
"chat_id": chat_id,
"username": username or f"user_{chat_id}",
"first_name": first_name,
"last_name": last_name,
}
# Remove None values
params = {k: v for k, v in params.items() if v is not None}
async with self.session.post(url, params=params) as resp:
return await resp.json()
async def start_telegram_binding(
self,
chat_id: int,
) -> Dict[str, Any]:
"""
Start Telegram binding process (for linking existing accounts).
Returns:
{
"code": "PgmL5ZD8vK2mN3oP4qR5sT6uV7wX8yZ9",
"expires_in": 600
}
"""
url = f"{self.api_base_url}/api/v1/auth/telegram/start"
payload = {"chat_id": chat_id}
async with self.session.post(url, json=payload) as resp:
return await resp.json()
async def get_token_for_telegram_user(
self,
chat_id: int,
) -> Dict[str, Any]:
"""
Get JWT token for Telegram user.
Use after binding confirmation or registration.
Returns:
{
"success": True,
"access_token": "eyJ...",
"expires_in": 900,
"user_id": 123
}
"""
url = f"{self.api_base_url}/api/v1/auth/token/get"
payload = {"chat_id": chat_id}
async with self.session.post(url, json=payload) as resp:
return await resp.json()
async def refresh_telegram_token(
self,
chat_id: int,
) -> Dict[str, Any]:
"""
Refresh token for Telegram user.
Returns:
{
"success": True,
"access_token": "eyJ...",
"expires_in": 900,
"user_id": 123
}
"""
url = f"{self.api_base_url}/api/v1/auth/token/refresh-telegram"
params = {"chat_id": chat_id}
async with self.session.post(url, params=params) as resp:
return await resp.json()
# ============ API CALLS WITH AUTHENTICATION ============
async def make_authenticated_request(
self,
method: str,
endpoint: str,
access_token: str,
data: Optional[Dict] = None,
params: Optional[Dict] = None,
) -> Dict[str, Any]:
"""
Make authenticated API request.
Args:
method: GET, POST, PUT, DELETE
endpoint: /api/v1/accounts, etc.
access_token: JWT token from login/register
data: Request body (for POST/PUT)
params: Query parameters
Returns:
API response JSON
"""
url = f"{self.api_base_url}{endpoint}"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
async with self.session.request(
method,
url,
json=data,
params=params,
headers=headers,
) as resp:
return await resp.json()
async def get_accounts(
self,
access_token: str,
family_id: Optional[int] = None,
) -> Dict[str, Any]:
"""Get user's accounts"""
params = {}
if family_id:
params["family_id"] = family_id
return await self.make_authenticated_request(
"GET",
"/api/v1/accounts",
access_token,
params=params,
)
async def get_balance(
self,
access_token: str,
account_id: int,
) -> Dict[str, Any]:
"""Get account balance"""
return await self.make_authenticated_request(
"GET",
f"/api/v1/accounts/{account_id}",
access_token,
)
async def create_transaction(
self,
access_token: str,
amount: float,
category: str,
description: Optional[str] = None,
**kwargs
) -> Dict[str, Any]:
"""Create new transaction"""
data = {
"amount": amount,
"category": category,
"description": description,
**kwargs
}
return await self.make_authenticated_request(
"POST",
"/api/v1/transactions",
access_token,
data=data,
)
# ============ TOKEN REFRESH ============
async def refresh_access_token(
self,
refresh_token: str,
) -> Dict[str, Any]:
"""
Refresh access token using refresh token.
Returns:
{
"access_token": "eyJ...",
"expires_in": 900
}
"""
url = f"{self.api_base_url}/api/v1/auth/refresh"
payload = {"refresh_token": refresh_token}
async with self.session.post(url, json=payload) as resp:
return await resp.json()
# ============ USAGE EXAMPLES ============
async def example_quick_registration():
"""Example: Register Telegram user with one command"""
api = FinanceBotAPIClient()
await api.start()
try:
# User sends /register command
# Bot registers them
result = await api.quick_register_telegram_user(
chat_id=556399210,
username="john_doe",
first_name="John",
last_name="Doe"
)
print("Registration result:", result)
if result.get("success"):
jwt_token = result.get("jwt_token")
user_id = result.get("user_id")
print(f"✅ User {user_id} registered!")
print(f"Token: {jwt_token[:50]}...")
# Now can make API calls with this token
# Store in Redis for later use
finally:
await api.close()
async def example_get_balance():
"""Example: Get user's account balance"""
api = FinanceBotAPIClient()
await api.start()
try:
# First, get token
token_result = await api.get_token_for_telegram_user(
chat_id=556399210
)
if not token_result.get("success"):
print("❌ Could not get token")
return
access_token = token_result["access_token"]
# Make API call
accounts = await api.get_accounts(access_token)
print("Accounts:", accounts)
finally:
await api.close()
async def example_link_account():
"""Example: Link existing account to Telegram"""
api = FinanceBotAPIClient()
await api.start()
try:
# Start binding process
code_result = await api.start_telegram_binding(
chat_id=556399210
)
binding_code = code_result.get("code")
print(f"Binding code: {binding_code}")
print(f"Expires in: {code_result.get('expires_in')} seconds")
# User confirms binding on web
# Bot then gets token
# After confirmation, get token
token_result = await api.get_token_for_telegram_user(
chat_id=556399210
)
if token_result.get("success"):
print(f"✅ Account linked! User ID: {token_result['user_id']}")
finally:
await api.close()
async def main():
"""Run examples"""
print("🤖 Finance Bot API Examples\n")
print("1. Quick Registration:")
print("-" * 50)
await example_quick_registration()
print("\n2. Get Balance:")
print("-" * 50)
await example_get_balance()
print("\n3. Link Account:")
print("-" * 50)
await example_link_account()
if __name__ == "__main__":
asyncio.run(main())