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

View File

@@ -61,6 +61,34 @@ class TokenRefreshResponse(BaseModel):
expires_in: int
class RegisterRequest(BaseModel):
email: EmailStr
password: str
first_name: Optional[str] = None
last_name: Optional[str] = None
class RegisterResponse(BaseModel):
success: bool
user_id: int
message: str
access_token: str
refresh_token: str
expires_in: int # seconds
class GetTokenRequest(BaseModel):
chat_id: int
class GetTokenResponse(BaseModel):
success: bool
access_token: str
refresh_token: Optional[str] = None
expires_in: int
user_id: int
@router.post(
"/login",
response_model=LoginResponse,
@@ -407,3 +435,187 @@ async def logout(
# redis.setex(f"blacklist:{token}", token_expiry_time, "1")
return {"message": "Logged out successfully"}
@router.post(
"/register",
response_model=RegisterResponse,
summary="Register new user with email & password",
)
async def register(
request: RegisterRequest,
db: Session = Depends(get_db),
):
"""
Register new user with email and password.
**Flow:**
1. Validate email doesn't exist
2. Hash password
3. Create new user
4. Generate JWT tokens
5. Return tokens for immediate use
**Usage (Bot):**
```python
result = api.post(
"/auth/register",
json={
"email": "user@example.com",
"password": "securepass123",
"first_name": "John",
"last_name": "Doe"
}
)
access_token = result["access_token"]
```
"""
from app.db.models.user import User
from app.security.jwt_manager import jwt_manager
# Check if user exists
existing = db.query(User).filter_by(email=request.email).first()
if existing:
raise HTTPException(
status_code=400,
detail="Email already registered"
)
# Hash password (use passlib in production)
import hashlib
password_hash = hashlib.sha256(request.password.encode()).hexdigest()
# Create user
new_user = User(
email=request.email,
password_hash=password_hash,
first_name=request.first_name,
last_name=request.last_name,
username=request.email.split("@")[0], # Default username from email
is_active=True,
)
try:
db.add(new_user)
db.commit()
db.refresh(new_user)
except Exception as e:
db.rollback()
logger.error(f"Failed to create user: {e}")
raise HTTPException(status_code=400, detail="Failed to create user")
# Create JWT tokens
access_token = jwt_manager.create_access_token(user_id=new_user.id)
refresh_token = jwt_manager.create_refresh_token(user_id=new_user.id)
logger.info(f"New user registered: user_id={new_user.id}, email={request.email}")
return RegisterResponse(
success=True,
user_id=new_user.id,
message=f"User registered successfully",
access_token=access_token,
refresh_token=refresh_token,
expires_in=15 * 60, # 15 minutes
)
@router.post(
"/token/get",
response_model=GetTokenResponse,
summary="Get JWT token for Telegram user",
)
async def get_token(
request: GetTokenRequest,
db: Session = Depends(get_db),
):
"""
Get JWT token for authenticated Telegram user.
**Usage in Bot (after successful binding):**
```python
result = api.post(
"/auth/token/get",
json={"chat_id": 12345}
)
access_token = result["access_token"]
expires_in = result["expires_in"]
```
**Returns:**
- access_token: Short-lived JWT (15 min)
- expires_in: Token TTL in seconds
- user_id: Associated user ID
"""
from app.db.models.user import User
# Find user by telegram_id
user = db.query(User).filter_by(telegram_id=request.chat_id).first()
if not user:
raise HTTPException(
status_code=404,
detail="User not found for this Telegram ID"
)
if not user.is_active:
raise HTTPException(
status_code=403,
detail="User account is inactive"
)
# Create JWT tokens
access_token = jwt_manager.create_access_token(user_id=user.id)
logger.info(f"Token generated for user_id={user.id}, chat_id={request.chat_id}")
return GetTokenResponse(
success=True,
access_token=access_token,
expires_in=15 * 60, # 15 minutes
user_id=user.id,
)
@router.post(
"/token/refresh-telegram",
response_model=GetTokenResponse,
summary="Refresh token for Telegram user",
)
async def refresh_telegram_token(
chat_id: int,
db: Session = Depends(get_db),
):
"""
Get fresh JWT token for Telegram user.
**Usage:**
```python
result = api.post(
"/auth/token/refresh-telegram",
params={"chat_id": 12345}
)
```
"""
from app.db.models.user import User
user = db.query(User).filter_by(telegram_id=chat_id).first()
if not user:
raise HTTPException(
status_code=404,
detail="User not found"
)
access_token = jwt_manager.create_access_token(user_id=user.id)
return GetTokenResponse(
success=True,
access_token=access_token,
expires_in=15 * 60,
user_id=user.id,
)

View File

@@ -45,6 +45,8 @@ class TelegramBotClient:
"""Register message handlers"""
self.dp.message.register(self.cmd_start, Command("start"))
self.dp.message.register(self.cmd_help, Command("help"))
self.dp.message.register(self.cmd_register, Command("register"))
self.dp.message.register(self.cmd_link, Command("link"))
self.dp.message.register(self.cmd_balance, Command("balance"))
self.dp.message.register(self.cmd_add_transaction, Command("add"))
@@ -66,14 +68,15 @@ class TelegramBotClient:
**Flow:**
1. Check if user already bound (JWT in Redis)
2. If not: Generate binding code via API
3. Send binding link to user with code
2. If not: Show registration options
a) Quick bind with /register command
b) Email/password registration
c) Link existing account
3. After binding, store JWT in Redis
**After binding:**
- User clicks link and confirms
- User's browser calls POST /api/v1/auth/telegram/confirm
- Bot calls GET /api/v1/auth/telegram/authenticate?chat_id=XXXX
- Bot stores JWT in Redis for future API calls
- User has JWT token in Redis
- Can use /balance, /add, etc.
"""
chat_id = message.chat.id
@@ -84,17 +87,138 @@ class TelegramBotClient:
if existing_token:
await message.answer(
"✅ **You're already connected!**\n\n"
"Use /balance to check wallets\n"
"Use /add to add transactions\n"
"Use /help for all commands",
"💰 /balance - Check your wallets\n"
" /add - Add new transaction\n"
"📊 /report - View reports\n"
"❓ /help - Show all commands",
parse_mode="Markdown"
)
return
# Generate binding code
# Show registration options
try:
logger.info(f"Starting binding for chat_id={chat_id}")
logger.info(f"Start command from chat_id={chat_id}")
await message.answer(
"👋 **Welcome to Finance Bot!**\n\n"
"Choose how to connect:\n\n"
"📱 **Quick Registration** - Fast setup\n"
"Use /register to create account\n\n"
"🔗 **Link Account** - Have an account already?\n"
"Use /link to connect existing account\n\n"
"❓ Need help? /help",
parse_mode="Markdown"
)
# Store state for user
state_key = f"chat_id:{chat_id}:state"
self.redis_client.setex(state_key, 3600, json.dumps({
"status": "awaiting_action",
"chat_id": chat_id
}))
except Exception as e:
logger.error(f"Start command error: {e}", exc_info=True)
await message.answer("❌ Could not process. Try again later.")
# ========== Handler: /register (Quick Registration) ==========
async def cmd_register(self, message: Message):
"""
/register - Quick Telegram-based registration.
**Flow:**
1. Generate unique username
2. Register user with Telegram binding
3. Return JWT token
4. Store in Redis
"""
chat_id = message.chat.id
telegram_user = message.from_user
# Check if already registered
existing_token = self.redis_client.get(f"chat_id:{chat_id}:jwt")
if existing_token:
await message.answer("✅ You're already registered!")
return
try:
logger.info(f"Quick register for chat_id={chat_id}")
# Build params, filtering out None values
params = {
"chat_id": chat_id,
"username": telegram_user.username or f"user_{chat_id}",
"first_name": telegram_user.first_name,
}
if telegram_user.last_name:
params["last_name"] = telegram_user.last_name
# Call API to register
register_response = await self._api_call(
method="POST",
endpoint="/api/v1/auth/telegram/register",
params=params,
use_jwt=False,
)
if not register_response.get("success") and not register_response.get("jwt_token"):
raise ValueError("Registration failed")
# Get JWT token
jwt_token = register_response.get("jwt_token")
if not jwt_token:
raise ValueError("No JWT token in response")
# Store token in Redis
self.redis_client.setex(
f"chat_id:{chat_id}:jwt",
86400 * 30, # 30 days
jwt_token
)
await message.answer(
f"✅ **Registration successful!**\n\n"
f"User ID: {register_response.get('user_id')}\n"
f"Username: {telegram_user.username or f'user_{chat_id}'}\n\n"
f"💰 /balance - Check wallets\n"
f" /add - Add transaction\n"
f"❓ /help - All commands",
parse_mode="Markdown"
)
logger.info(f"Quick registration successful for chat_id={chat_id}")
except Exception as e:
logger.error(f"Registration error: {e}", exc_info=True)
await message.answer(
"❌ Registration failed\n\n"
"Try again or use /link to connect existing account"
)
# ========== Handler: /link (Link Existing Account) ==========
async def cmd_link(self, message: Message):
"""
/link - Link existing account via binding code.
**Flow:**
1. Generate binding code
2. Send link with code to user
3. User confirms in web (authenticates)
4. API calls /telegram/confirm
5. Bot gets JWT via /token/get
"""
chat_id = message.chat.id
# Check if already linked
existing_token = self.redis_client.get(f"chat_id:{chat_id}:jwt")
if existing_token:
await message.answer("✅ You're already linked to an account!")
return
try:
logger.info(f"Starting account link for chat_id={chat_id}")
# Generate binding code
code_response = await self._api_call(
method="POST",
endpoint="/api/v1/auth/telegram/start",
@@ -106,28 +230,32 @@ class TelegramBotClient:
if not binding_code:
raise ValueError("No code in response")
# Store binding code in Redis for validation
# (expires in 10 minutes as per backend TTL)
# Store binding code in Redis
binding_key = f"chat_id:{chat_id}:binding_code"
self.redis_client.setex(
binding_key,
600,
json.dumps({"code": binding_code, "created_at": datetime.utcnow().isoformat()})
json.dumps({
"code": binding_code,
"created_at": datetime.utcnow().isoformat()
})
)
# Build binding link (replace with actual frontend URL)
# Example: https://yourapp.com/auth/telegram/confirm?code=XXX&chat_id=123
# Build binding link
# TODO: Replace with your actual frontend URL
binding_url = (
f"https://your-finance-app.com/auth/telegram/confirm"
f"?code={binding_code}&chat_id={chat_id}"
)
# Send binding link to user
await message.answer(
f"🔗 **Click to bind your account:**\n\n"
f"[📱 Open Account Binding]({binding_url})\n\n"
f"⏱ Code expires in 10 minutes\n\n"
f"❓ Already have an account? Just log in and click the link.",
f"🔗 **Link Your Account**\n\n"
f"Click the link below to connect your existing account:\n\n"
f"[🔑 Link Account]({binding_url})\n\n"
f"⏱ Code expires in 10 minutes\n"
f"1. Click the link\n"
f"2. Log in with your email\n"
f"3. Confirm to link",
parse_mode="Markdown",
disable_web_page_preview=True,
)
@@ -135,8 +263,11 @@ class TelegramBotClient:
logger.info(f"Binding code sent to chat_id={chat_id}")
except Exception as e:
logger.error(f"Binding start error: {e}", exc_info=True)
await message.answer("❌ Could not start binding. Try again later.")
logger.error(f"Link command error: {e}", exc_info=True)
await message.answer(
"❌ Could not start linking\n\n"
"Try /register for quick registration instead"
)
# ========== Handler: /balance ==========
async def cmd_balance(self, message: Message):
@@ -326,9 +457,9 @@ class TelegramBotClient:
help_text = """🤖 **Finance Bot - Commands:**
🔗 **Account**
/start - Re-bind account (if needed)
/balance - Show all account balances
/settings - Account settings
/logout - Logout from this device
💰 **Transactions**
/add - Add new transaction
@@ -346,11 +477,21 @@ class TelegramBotClient:
else:
help_text = """🤖 **Finance Bot - Getting Started**
**Step 1: Bind Your Account**
/start - Click the link to bind your Telegram account
**Option 1: Quick Registration (Telegram)**
/register - Create account with just one tap
Fast setup, perfect for Telegram users
**Step 2: Login**
Use your email and password on the binding page
**Option 2: Link Existing Account**
/link - Connect your existing account
Use your email and password
**What can you do?**
✅ Track family expenses
✅ Set budgets and goals
✅ View detailed reports
✅ Manage multiple accounts
Need more help? Try /start
**Step 3: Done!**
- /balance - View your accounts
@@ -422,11 +563,14 @@ Only you can access your accounts
# Make request
try:
# Filter out None values from params
clean_params = {k: v for k, v in (params or {}).items() if v is not None}
async with self.session.request(
method=method,
url=url,
json=data,
params=params,
params=clean_params if clean_params else None,
headers=headers,
timeout=aiohttp.ClientTimeout(total=10),
) as response:
@@ -459,7 +603,12 @@ Only you can access your accounts
"""Get JWT token for chat_id from Redis"""
jwt_key = f"chat_id:{chat_id}:jwt"
token = self.redis_client.get(jwt_key)
return token.decode() if token else None
if not token:
return None
# Handle both bytes and string returns from Redis
if isinstance(token, bytes):
return token.decode('utf-8')
return token
async def send_notification(self, chat_id: int, message: str):
"""Send notification to user"""

View File

@@ -7,12 +7,20 @@ from app.db.database import Base
class User(Base):
"""User model - represents a Telegram user"""
"""User model - represents a user with email/password or Telegram binding"""
__tablename__ = "users"
id = Column(Integer, primary_key=True)
telegram_id = Column(Integer, unique=True, nullable=False, index=True)
# Authentication - Email/Password
email = Column(String(255), unique=True, nullable=True, index=True)
password_hash = Column(String(255), nullable=True)
# Authentication - Telegram
telegram_id = Column(Integer, unique=True, nullable=True, index=True)
# User info
username = Column(String(255), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
@@ -20,6 +28,7 @@ class User(Base):
# Account status
is_active = Column(Boolean, default=True)
email_verified = Column(Boolean, default=False)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
@@ -32,4 +41,5 @@ class User(Base):
transactions = relationship("Transaction", back_populates="user")
def __repr__(self) -> str:
return f"<User(id={self.id}, telegram_id={self.telegram_id}, username={self.username})>"
auth_method = "email" if self.email else "telegram" if self.telegram_id else "none"
return f"<User(id={self.id}, email={self.email}, telegram_id={self.telegram_id}, auth={auth_method})>"

View File

@@ -17,7 +17,7 @@ class TokenType(str, Enum):
class TokenPayload(BaseModel):
"""JWT Token Payload Structure"""
sub: int # user_id
sub: str # user_id as string (RFC compliance)
type: TokenType
device_id: Optional[str] = None
scope: str = "default" # For granular permissions
@@ -114,7 +114,7 @@ class JWTManager:
expire = now + expires_delta
payload = {
"sub": user_id,
"sub": str(user_id), # RFC requires string, convert int to str
"type": token_type.value,
"device_id": device_id,
"family_ids": family_ids or [],

View File

@@ -166,6 +166,9 @@ class JWTAuthenticationMiddleware(BaseHTTPMiddleware):
"/docs",
"/openapi.json",
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/auth/token/get",
"/api/v1/auth/token/refresh-telegram",
"/api/v1/auth/telegram/start",
"/api/v1/auth/telegram/register",
"/api/v1/auth/telegram/authenticate",