GitHub Actions
commited on
Commit
·
806492a
1
Parent(s):
6e614bb
Deploy backend from GitHub Actions
Browse files🚀 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <[email protected]>
- .env.example +50 -0
- README_AUTH.md +342 -0
- auth/auth.py +215 -0
- database/auth.db +0 -0
- database/config.py +35 -0
- main.py +47 -0
- middleware/auth.py +192 -0
- middleware/csrf.py +184 -0
- models/auth.py +103 -0
- pyproject.toml +8 -0
- requirements.txt +7 -0
- routes/auth.py +210 -0
- tests/test_auth.py +413 -0
- uv.lock +279 -0
.env.example
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Google OAuth Configuration
|
| 2 |
+
GOOGLE_CLIENT_ID=your-google-client-id
|
| 3 |
+
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
| 4 |
+
AUTH_REDIRECT_URI=http://localhost:3000/auth/google/callback
|
| 5 |
+
FRONTEND_URL=http://localhost:3000
|
| 6 |
+
|
| 7 |
+
# JWT Configuration
|
| 8 |
+
JWT_SECRET_KEY=your-super-secret-jwt-key-at-least-32-characters-long
|
| 9 |
+
JWT_ALGORITHM=HS256
|
| 10 |
+
JWT_EXPIRE_MINUTES=10080 # 7 days
|
| 11 |
+
|
| 12 |
+
# Database Configuration
|
| 13 |
+
DATABASE_URL=sqlite:///./database/auth.db
|
| 14 |
+
|
| 15 |
+
# API Configuration
|
| 16 |
+
API_HOST=0.0.0.0
|
| 17 |
+
API_PORT=7860
|
| 18 |
+
LOG_LEVEL=INFO
|
| 19 |
+
|
| 20 |
+
# Rate Limiting
|
| 21 |
+
RATE_LIMIT_REQUESTS=60
|
| 22 |
+
RATE_LIMIT_WINDOW=60
|
| 23 |
+
|
| 24 |
+
# CORS Configuration
|
| 25 |
+
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080,https://mrowaisabdullah.github.io,https://huggingface.co
|
| 26 |
+
|
| 27 |
+
# OpenAI Configuration (already existing)
|
| 28 |
+
OPENAI_API_KEY=your-openai-api-key
|
| 29 |
+
OPENAI_MODEL=gpt-4.1-nano
|
| 30 |
+
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
| 31 |
+
|
| 32 |
+
# Qdrant Configuration (already existing)
|
| 33 |
+
QDRANT_URL=http://localhost:6333
|
| 34 |
+
QDRANT_API_KEY=your-qdrant-api-key-if-needed
|
| 35 |
+
|
| 36 |
+
# Content Configuration (already existing)
|
| 37 |
+
BOOK_CONTENT_PATH=./book_content
|
| 38 |
+
CHUNK_SIZE=1000
|
| 39 |
+
CHUNK_OVERLAP=200
|
| 40 |
+
|
| 41 |
+
# Conversation Context (already existing)
|
| 42 |
+
MAX_CONTEXT_MESSAGES=3
|
| 43 |
+
CONTEXT_WINDOW_SIZE=4000
|
| 44 |
+
|
| 45 |
+
# Ingestion Configuration (already existing)
|
| 46 |
+
BATCH_SIZE=100
|
| 47 |
+
MAX_CONCURRENT_REQUESTS=10
|
| 48 |
+
|
| 49 |
+
# Health Monitoring (already existing)
|
| 50 |
+
HEALTH_CHECK_INTERVAL=30
|
README_AUTH.md
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Authentication System Documentation
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
This document describes the authentication system implemented for the AI Book application. The system provides secure user authentication using Google OAuth, session management, and protection against common web vulnerabilities.
|
| 6 |
+
|
| 7 |
+
## Architecture
|
| 8 |
+
|
| 9 |
+
### Components
|
| 10 |
+
|
| 11 |
+
1. **Authentication Middleware** (`middleware/auth.py`)
|
| 12 |
+
- Handles session validation
|
| 13 |
+
- Manages anonymous user tracking
|
| 14 |
+
- Enforces message limits for anonymous users
|
| 15 |
+
- Implements session expiration checks
|
| 16 |
+
|
| 17 |
+
2. **CSRF Protection** (`middleware/csrf.py`)
|
| 18 |
+
- Implements double-submit cookie pattern
|
| 19 |
+
- Generates and validates CSRF tokens
|
| 20 |
+
- Protects against Cross-Site Request Forgery attacks
|
| 21 |
+
|
| 22 |
+
3. **Authentication Routes** (`routes/auth.py`)
|
| 23 |
+
- Google OAuth integration
|
| 24 |
+
- User profile management
|
| 25 |
+
- Session management (login/logout)
|
| 26 |
+
- User preferences CRUD operations
|
| 27 |
+
|
| 28 |
+
4. **Database Models** (`models/auth.py`)
|
| 29 |
+
- User, Account, Session entities
|
| 30 |
+
- ChatSession and ChatMessage for persistent chat history
|
| 31 |
+
- UserPreferences for user settings
|
| 32 |
+
|
| 33 |
+
## Security Features
|
| 34 |
+
|
| 35 |
+
### 1. CSRF Protection
|
| 36 |
+
- Double-submit cookie pattern implementation
|
| 37 |
+
- Automatic token generation and validation
|
| 38 |
+
- Exempt paths for safe endpoints
|
| 39 |
+
- Configurable token expiration (default: 1 hour)
|
| 40 |
+
|
| 41 |
+
### 2. Rate Limiting
|
| 42 |
+
- OAuth endpoints: 10 requests/minute
|
| 43 |
+
- Logout endpoint: 20 requests/minute
|
| 44 |
+
- Token refresh: 30 requests/minute
|
| 45 |
+
- Prevents brute force attacks
|
| 46 |
+
|
| 47 |
+
### 3. Session Management
|
| 48 |
+
- HTTP-only JWT cookies for token storage
|
| 49 |
+
- Session expiration with automatic cleanup
|
| 50 |
+
- Single session enforcement per user
|
| 51 |
+
- Secure session invalidation on logout
|
| 52 |
+
|
| 53 |
+
### 4. Anonymous Access Control
|
| 54 |
+
- Limited to 3 messages per anonymous session
|
| 55 |
+
- Session tracking with UUID generation
|
| 56 |
+
- Automatic cleanup of expired sessions
|
| 57 |
+
- Migration path to authenticated user
|
| 58 |
+
|
| 59 |
+
## API Endpoints
|
| 60 |
+
|
| 61 |
+
### Authentication Endpoints
|
| 62 |
+
|
| 63 |
+
| Method | Endpoint | Description | Rate Limit |
|
| 64 |
+
|--------|----------|-------------|------------|
|
| 65 |
+
| GET | `/auth/login/google` | Initiate Google OAuth | 10/min |
|
| 66 |
+
| GET | `/auth/google/callback` | Handle OAuth callback | 10/min |
|
| 67 |
+
| GET | `/auth/me` | Get current user info | - |
|
| 68 |
+
| POST | `/auth/logout` | Logout user | 20/min |
|
| 69 |
+
| GET | `/auth/preferences` | Get user preferences | - |
|
| 70 |
+
| PUT | `/auth/preferences` | Update user preferences | - |
|
| 71 |
+
| POST | `/auth/refresh` | Refresh access token | 30/min |
|
| 72 |
+
|
| 73 |
+
### Request/Response Examples
|
| 74 |
+
|
| 75 |
+
#### Get Current User
|
| 76 |
+
```bash
|
| 77 |
+
curl -X GET "http://localhost:7860/auth/me" \
|
| 78 |
+
-H "Authorization: Bearer <jwt_token>" \
|
| 79 |
+
-H "X-CSRF-Token: <csrf_token>"
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
Response:
|
| 83 |
+
```json
|
| 84 |
+
{
|
| 85 |
+
"id": 123,
|
| 86 |
+
"email": "[email protected]",
|
| 87 |
+
"name": "John Doe",
|
| 88 |
+
"image_url": "https://example.com/avatar.jpg",
|
| 89 |
+
"email_verified": true
|
| 90 |
+
}
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
#### Update User Preferences
|
| 94 |
+
```bash
|
| 95 |
+
curl -X PUT "http://localhost:7860/auth/preferences" \
|
| 96 |
+
-H "Authorization: Bearer <jwt_token>" \
|
| 97 |
+
-H "X-CSRF-Token: <csrf_token>" \
|
| 98 |
+
-H "Content-Type: application/json" \
|
| 99 |
+
-d '{
|
| 100 |
+
"theme": "dark",
|
| 101 |
+
"language": "en",
|
| 102 |
+
"notifications_enabled": true,
|
| 103 |
+
"chat_settings": {
|
| 104 |
+
"model": "gpt-4o-mini",
|
| 105 |
+
"temperature": 0.7,
|
| 106 |
+
"max_tokens": 1000
|
| 107 |
+
}
|
| 108 |
+
}'
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
## Environment Variables
|
| 112 |
+
|
| 113 |
+
Create a `.env` file in the backend directory:
|
| 114 |
+
|
| 115 |
+
```env
|
| 116 |
+
# Google OAuth Configuration
|
| 117 |
+
GOOGLE_CLIENT_ID=your-google-client-id
|
| 118 |
+
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
| 119 |
+
AUTH_REDIRECT_URI=http://localhost:3000/auth/google/callback
|
| 120 |
+
FRONTEND_URL=http://localhost:3000
|
| 121 |
+
|
| 122 |
+
# JWT Configuration
|
| 123 |
+
JWT_SECRET_KEY=your-super-secret-jwt-key-at-least-32-characters
|
| 124 |
+
JWT_ALGORITHM=HS256
|
| 125 |
+
JWT_EXPIRE_MINUTES=10080 # 7 days
|
| 126 |
+
|
| 127 |
+
# Database Configuration
|
| 128 |
+
DATABASE_URL=sqlite:///./database/auth.db
|
| 129 |
+
|
| 130 |
+
# API Configuration
|
| 131 |
+
API_HOST=0.0.0.0
|
| 132 |
+
API_PORT=7860
|
| 133 |
+
|
| 134 |
+
# CORS Configuration
|
| 135 |
+
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
## Google OAuth Setup
|
| 139 |
+
|
| 140 |
+
### 1. Create Google Cloud Project
|
| 141 |
+
|
| 142 |
+
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
| 143 |
+
2. Create a new project or select existing one
|
| 144 |
+
3. Enable the Google+ API and Google OAuth2 API
|
| 145 |
+
|
| 146 |
+
### 2. Create OAuth Credentials
|
| 147 |
+
|
| 148 |
+
1. Navigate to APIs & Services → Credentials
|
| 149 |
+
2. Click "Create Credentials" → OAuth 2.0 Client IDs
|
| 150 |
+
3. Select "Web application"
|
| 151 |
+
4. Add authorized redirect URIs:
|
| 152 |
+
- Development: `http://localhost:7860/auth/google/callback`
|
| 153 |
+
- Production: `https://yourdomain.com/auth/google/callback`
|
| 154 |
+
5. Save the Client ID and Client Secret
|
| 155 |
+
|
| 156 |
+
### 3. Configure Consent Screen
|
| 157 |
+
|
| 158 |
+
1. Go to OAuth consent screen
|
| 159 |
+
2. Add required scopes:
|
| 160 |
+
- `email`
|
| 161 |
+
- `profile`
|
| 162 |
+
- `openid`
|
| 163 |
+
3. Add test users for development
|
| 164 |
+
|
| 165 |
+
## Database Schema
|
| 166 |
+
|
| 167 |
+
### Users Table
|
| 168 |
+
```sql
|
| 169 |
+
CREATE TABLE users (
|
| 170 |
+
id INTEGER PRIMARY KEY,
|
| 171 |
+
email VARCHAR UNIQUE NOT NULL,
|
| 172 |
+
name VARCHAR NOT NULL,
|
| 173 |
+
image_url VARCHAR,
|
| 174 |
+
email_verified BOOLEAN DEFAULT FALSE,
|
| 175 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 176 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 177 |
+
);
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
### Sessions Table
|
| 181 |
+
```sql
|
| 182 |
+
CREATE TABLE sessions (
|
| 183 |
+
id INTEGER PRIMARY KEY,
|
| 184 |
+
user_id INTEGER REFERENCES users(id),
|
| 185 |
+
token VARCHAR UNIQUE NOT NULL,
|
| 186 |
+
expires_at TIMESTAMP NOT NULL,
|
| 187 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 188 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 189 |
+
);
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
### Chat Sessions Table
|
| 193 |
+
```sql
|
| 194 |
+
CREATE TABLE chat_sessions (
|
| 195 |
+
id INTEGER PRIMARY KEY,
|
| 196 |
+
user_id INTEGER REFERENCES users(id),
|
| 197 |
+
title VARCHAR DEFAULT 'New Chat',
|
| 198 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 199 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 200 |
+
);
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
## Testing
|
| 204 |
+
|
| 205 |
+
### Running Tests
|
| 206 |
+
|
| 207 |
+
```bash
|
| 208 |
+
# Install test dependencies
|
| 209 |
+
pip install pytest pytest-asyncio httpx
|
| 210 |
+
|
| 211 |
+
# Run all tests
|
| 212 |
+
pytest tests/test_auth.py -v
|
| 213 |
+
|
| 214 |
+
# Run specific test class
|
| 215 |
+
pytest tests/test_auth.py::TestAuthenticationEndpoints -v
|
| 216 |
+
|
| 217 |
+
# Run with coverage
|
| 218 |
+
pytest tests/test_auth.py --cov=auth --cov-report=html
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
### Test Coverage
|
| 222 |
+
|
| 223 |
+
The test suite covers:
|
| 224 |
+
- JWT token creation and validation
|
| 225 |
+
- CSRF protection mechanisms
|
| 226 |
+
- Rate limiting enforcement
|
| 227 |
+
- Session management
|
| 228 |
+
- Anonymous access controls
|
| 229 |
+
- User preferences CRUD
|
| 230 |
+
- Error handling and security
|
| 231 |
+
- OAuth flow simulation
|
| 232 |
+
|
| 233 |
+
## Security Best Practices Implemented
|
| 234 |
+
|
| 235 |
+
### 1. Token Security
|
| 236 |
+
- JWT tokens with expiration
|
| 237 |
+
- HTTP-only cookies for storage
|
| 238 |
+
- Secure token generation with secrets
|
| 239 |
+
|
| 240 |
+
### 2. CSRF Protection
|
| 241 |
+
- Double-submit cookie pattern
|
| 242 |
+
- Automatic token validation
|
| 243 |
+
- State-changing request protection
|
| 244 |
+
|
| 245 |
+
### 3. Session Security
|
| 246 |
+
- Single session enforcement
|
| 247 |
+
- Automatic session expiration
|
| 248 |
+
- Secure session invalidation
|
| 249 |
+
|
| 250 |
+
### 4. Input Validation
|
| 251 |
+
- Pydantic models for request validation
|
| 252 |
+
- SQL injection prevention through ORM
|
| 253 |
+
- XSS protection through output encoding
|
| 254 |
+
|
| 255 |
+
### 5. Rate Limiting
|
| 256 |
+
- Per-endpoint rate limits
|
| 257 |
+
- IP-based limiting
|
| 258 |
+
- Protection against brute force attacks
|
| 259 |
+
|
| 260 |
+
## Deployment Considerations
|
| 261 |
+
|
| 262 |
+
### Production Environment Variables
|
| 263 |
+
|
| 264 |
+
```env
|
| 265 |
+
# Production settings
|
| 266 |
+
CSRF_SECURE=True # Enable secure flag for HTTPS
|
| 267 |
+
JWT_SECRET_KEY=<production-secret-key>
|
| 268 |
+
DATABASE_URL=postgresql://user:pass@localhost/authdb
|
| 269 |
+
ALLOWED_ORIGINS=https://yourdomain.com
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
### Security Headers
|
| 273 |
+
|
| 274 |
+
The application sets security headers:
|
| 275 |
+
- `X-Content-Type-Options: nosniff`
|
| 276 |
+
- `X-Frame-Options: DENY`
|
| 277 |
+
- `X-XSS-Protection: 1; mode=block`
|
| 278 |
+
- `Strict-Transport-Security` (HTTPS only)
|
| 279 |
+
|
| 280 |
+
### Monitoring
|
| 281 |
+
|
| 282 |
+
Enable logging for security events:
|
| 283 |
+
- Failed authentication attempts
|
| 284 |
+
- Rate limit violations
|
| 285 |
+
- CSRF validation failures
|
| 286 |
+
- Session expiration events
|
| 287 |
+
|
| 288 |
+
## Troubleshooting
|
| 289 |
+
|
| 290 |
+
### Common Issues
|
| 291 |
+
|
| 292 |
+
1. **OAuth Callback URL Mismatch**
|
| 293 |
+
- Ensure redirect URI matches Google Console exactly
|
| 294 |
+
- Check for trailing slashes
|
| 295 |
+
|
| 296 |
+
2. **CORS Errors**
|
| 297 |
+
- Verify frontend URL in ALLOWED_ORIGINS
|
| 298 |
+
- Check credentials flag in CORS config
|
| 299 |
+
|
| 300 |
+
3. **CSRF Token Missing**
|
| 301 |
+
- Include X-CSRF-Token header in requests
|
| 302 |
+
- Ensure client reads token from cookies
|
| 303 |
+
|
| 304 |
+
4. **Session Not Persisting**
|
| 305 |
+
- Check database connection
|
| 306 |
+
- Verify JWT_SECRET_KEY is set
|
| 307 |
+
|
| 308 |
+
### Debug Mode
|
| 309 |
+
|
| 310 |
+
Enable debug logging:
|
| 311 |
+
```python
|
| 312 |
+
import logging
|
| 313 |
+
logging.basicConfig(level=logging.DEBUG)
|
| 314 |
+
```
|
| 315 |
+
|
| 316 |
+
## Future Enhancements
|
| 317 |
+
|
| 318 |
+
1. **Multi-Provider Authentication**
|
| 319 |
+
- GitHub OAuth
|
| 320 |
+
- Microsoft OAuth
|
| 321 |
+
- Email/password authentication
|
| 322 |
+
|
| 323 |
+
2. **Advanced Security**
|
| 324 |
+
- Two-factor authentication
|
| 325 |
+
- Device fingerprinting
|
| 326 |
+
- Anomaly detection
|
| 327 |
+
|
| 328 |
+
3. **Session Management**
|
| 329 |
+
- Session analytics
|
| 330 |
+
- Concurrent session limits
|
| 331 |
+
- Session revocation API
|
| 332 |
+
|
| 333 |
+
4. **User Management**
|
| 334 |
+
- Admin dashboard
|
| 335 |
+
- User roles and permissions
|
| 336 |
+
- Audit logging
|
| 337 |
+
|
| 338 |
+
## References
|
| 339 |
+
|
| 340 |
+
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
| 341 |
+
- [FastAPI Security Documentation](https://fastapi.tiangolo.com/tutorial/security/)
|
| 342 |
+
- [OAuth 2.0 Best Practices](https://oauth.net/articles/)
|
auth/auth.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from typing import Optional, Dict, Any
|
| 4 |
+
import json
|
| 5 |
+
import secrets
|
| 6 |
+
import hashlib
|
| 7 |
+
from jose import JWTError, jwt
|
| 8 |
+
from passlib.context import CryptContext
|
| 9 |
+
from fastapi import Depends, HTTPException, status
|
| 10 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
from authlib.integrations.starlette_client import OAuth
|
| 13 |
+
from starlette.config import Config
|
| 14 |
+
|
| 15 |
+
from database.config import get_db
|
| 16 |
+
from models.auth import User, Session, Account, UserPreferences
|
| 17 |
+
|
| 18 |
+
# JWT Settings
|
| 19 |
+
SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32))
|
| 20 |
+
ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
| 21 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES", 10080)) # 7 days
|
| 22 |
+
|
| 23 |
+
# Password hashing (not used for OAuth but kept for completeness)
|
| 24 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 25 |
+
|
| 26 |
+
# Security scheme
|
| 27 |
+
security = HTTPBearer()
|
| 28 |
+
|
| 29 |
+
# OAuth configuration
|
| 30 |
+
config = Config('.env')
|
| 31 |
+
oauth = OAuth(config)
|
| 32 |
+
|
| 33 |
+
# Configure Google OAuth
|
| 34 |
+
oauth.register(
|
| 35 |
+
name='google',
|
| 36 |
+
client_id=os.getenv("GOOGLE_CLIENT_ID"),
|
| 37 |
+
client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
|
| 38 |
+
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
|
| 39 |
+
client_kwargs={
|
| 40 |
+
'scope': 'openid email profile'
|
| 41 |
+
}
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 46 |
+
"""Verify a password against its hash"""
|
| 47 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_password_hash(password: str) -> str:
|
| 51 |
+
"""Generate password hash"""
|
| 52 |
+
return pwd_context.hash(password)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 56 |
+
"""Create JWT access token"""
|
| 57 |
+
to_encode = data.copy()
|
| 58 |
+
if expires_delta:
|
| 59 |
+
expire = datetime.utcnow() + expires_delta
|
| 60 |
+
else:
|
| 61 |
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 62 |
+
|
| 63 |
+
to_encode.update({"exp": expire})
|
| 64 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 65 |
+
return encoded_jwt
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def verify_token(token: str) -> Optional[Dict[str, Any]]:
|
| 69 |
+
"""Verify JWT token and return payload"""
|
| 70 |
+
try:
|
| 71 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 72 |
+
return payload
|
| 73 |
+
except JWTError:
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def get_current_user(
|
| 78 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 79 |
+
db: Session = Depends(get_db)
|
| 80 |
+
) -> User:
|
| 81 |
+
"""Get current authenticated user"""
|
| 82 |
+
token = credentials.credentials
|
| 83 |
+
payload = verify_token(token)
|
| 84 |
+
|
| 85 |
+
if payload is None:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 88 |
+
detail="Could not validate credentials",
|
| 89 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
user_id: int = payload.get("sub")
|
| 93 |
+
if user_id is None:
|
| 94 |
+
raise HTTPException(
|
| 95 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 96 |
+
detail="Could not validate credentials",
|
| 97 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 101 |
+
if user is None:
|
| 102 |
+
raise HTTPException(
|
| 103 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 104 |
+
detail="User not found",
|
| 105 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
return user
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
| 112 |
+
"""Get current active user (extension point for future features like account suspension)"""
|
| 113 |
+
return current_user
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def create_user_session(db: Session, user: User) -> str:
|
| 117 |
+
"""Create a new session for user and return token"""
|
| 118 |
+
# Delete existing sessions for this user (optional - remove if you want multiple sessions)
|
| 119 |
+
db.query(Session).filter(Session.user_id == user.id).delete()
|
| 120 |
+
|
| 121 |
+
# Create new session token
|
| 122 |
+
session_token = secrets.token_urlsafe(32)
|
| 123 |
+
expires_at = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 124 |
+
|
| 125 |
+
db_session = Session(
|
| 126 |
+
user_id=user.id,
|
| 127 |
+
token=session_token,
|
| 128 |
+
expires_at=expires_at
|
| 129 |
+
)
|
| 130 |
+
db.add(db_session)
|
| 131 |
+
db.commit()
|
| 132 |
+
db.refresh(db_session)
|
| 133 |
+
|
| 134 |
+
return session_token
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def get_or_create_user(db: Session, user_info: dict) -> User:
|
| 138 |
+
"""Get existing user or create new one from OAuth info"""
|
| 139 |
+
email = user_info.get('email')
|
| 140 |
+
if not email:
|
| 141 |
+
raise HTTPException(status_code=400, detail="Email is required")
|
| 142 |
+
|
| 143 |
+
# Check if user exists
|
| 144 |
+
user = db.query(User).filter(User.email == email).first()
|
| 145 |
+
|
| 146 |
+
if user:
|
| 147 |
+
# Update user info if needed
|
| 148 |
+
user.name = user_info.get('name', user.name)
|
| 149 |
+
user.image_url = user_info.get('picture', user.image_url)
|
| 150 |
+
user.email_verified = user_info.get('email_verified', user.email_verified)
|
| 151 |
+
user.updated_at = datetime.utcnow()
|
| 152 |
+
else:
|
| 153 |
+
# Create new user
|
| 154 |
+
user = User(
|
| 155 |
+
email=email,
|
| 156 |
+
name=user_info.get('name', email.split('@')[0]),
|
| 157 |
+
image_url=user_info.get('picture'),
|
| 158 |
+
email_verified=user_info.get('email_verified', False)
|
| 159 |
+
)
|
| 160 |
+
db.add(user)
|
| 161 |
+
db.commit()
|
| 162 |
+
db.refresh(user)
|
| 163 |
+
|
| 164 |
+
# Create default preferences
|
| 165 |
+
preferences = UserPreferences(user_id=user.id)
|
| 166 |
+
db.add(preferences)
|
| 167 |
+
db.commit()
|
| 168 |
+
|
| 169 |
+
return user
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def create_or_update_account(db: Session, user: User, provider: str, account_info: dict) -> Account:
|
| 173 |
+
"""Create or update OAuth account"""
|
| 174 |
+
provider_account_id = account_info.get('sub')
|
| 175 |
+
if not provider_account_id:
|
| 176 |
+
raise HTTPException(status_code=400, detail="Provider account ID is required")
|
| 177 |
+
|
| 178 |
+
# Check if account exists
|
| 179 |
+
account = db.query(Account).filter(
|
| 180 |
+
Account.user_id == user.id,
|
| 181 |
+
Account.provider == provider,
|
| 182 |
+
Account.provider_account_id == provider_account_id
|
| 183 |
+
).first()
|
| 184 |
+
|
| 185 |
+
if account:
|
| 186 |
+
# Update account info
|
| 187 |
+
account.access_token = account_info.get('access_token')
|
| 188 |
+
account.refresh_token = account_info.get('refresh_token')
|
| 189 |
+
account.expires_at = datetime.fromtimestamp(account_info.get('expires_at', 0)) if account_info.get('expires_at') else None
|
| 190 |
+
account.token_type = account_info.get('token_type')
|
| 191 |
+
account.scope = account_info.get('scope')
|
| 192 |
+
account.updated_at = datetime.utcnow()
|
| 193 |
+
else:
|
| 194 |
+
# Create new account
|
| 195 |
+
account = Account(
|
| 196 |
+
user_id=user.id,
|
| 197 |
+
provider=provider,
|
| 198 |
+
provider_account_id=provider_account_id,
|
| 199 |
+
access_token=account_info.get('access_token'),
|
| 200 |
+
refresh_token=account_info.get('refresh_token'),
|
| 201 |
+
expires_at=datetime.fromtimestamp(account_info.get('expires_at', 0)) if account_info.get('expires_at') else None,
|
| 202 |
+
token_type=account_info.get('token_type'),
|
| 203 |
+
scope=account_info.get('scope')
|
| 204 |
+
)
|
| 205 |
+
db.add(account)
|
| 206 |
+
|
| 207 |
+
db.commit()
|
| 208 |
+
db.refresh(account)
|
| 209 |
+
return account
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def invalidate_user_sessions(db: Session, user: User) -> None:
|
| 213 |
+
"""Invalidate all sessions for a user"""
|
| 214 |
+
db.query(Session).filter(Session.user_id == user.id).delete()
|
| 215 |
+
db.commit()
|
database/auth.db
ADDED
|
Binary file (65.5 kB). View file
|
|
|
database/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.orm import sessionmaker
|
| 3 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# Database URL from environment or default to SQLite
|
| 7 |
+
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./database/auth.db")
|
| 8 |
+
|
| 9 |
+
# Create engine
|
| 10 |
+
engine = create_engine(
|
| 11 |
+
DATABASE_URL,
|
| 12 |
+
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
# Create SessionLocal class
|
| 16 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 17 |
+
|
| 18 |
+
# Create Base class for models
|
| 19 |
+
Base = declarative_base()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def get_db():
|
| 23 |
+
"""Dependency to get database session"""
|
| 24 |
+
db = SessionLocal()
|
| 25 |
+
try:
|
| 26 |
+
yield db
|
| 27 |
+
finally:
|
| 28 |
+
db.close()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def create_tables():
|
| 32 |
+
"""Create all database tables"""
|
| 33 |
+
from models.auth import Base as AuthBase
|
| 34 |
+
AuthBase.metadata.create_all(bind=engine)
|
| 35 |
+
print("Database tables created successfully")
|
main.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, Depends
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 3 |
from fastapi.responses import StreamingResponse, JSONResponse
|
| 4 |
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 5 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
@@ -25,6 +26,13 @@ from rag.qdrant_client import QdrantManager
|
|
| 25 |
from rag.tasks import TaskManager
|
| 26 |
from api.exceptions import ContentNotFoundError, RAGException
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
# Import ChatKit server
|
| 29 |
# from chatkit_server import get_chatkit_server
|
| 30 |
|
|
@@ -85,6 +93,9 @@ class Settings(BaseSettings):
|
|
| 85 |
"http://localhost:3000,http://localhost:8080,https://mrowaisabdullah.github.io,https://huggingface.co"
|
| 86 |
)
|
| 87 |
|
|
|
|
|
|
|
|
|
|
| 88 |
# Conversation Context
|
| 89 |
max_context_messages: int = int(os.getenv("MAX_CONTEXT_MESSAGES", "3"))
|
| 90 |
context_window_size: int = int(os.getenv("CONTEXT_WINDOW_SIZE", "4000"))
|
|
@@ -119,6 +130,10 @@ async def lifespan(app: FastAPI):
|
|
| 119 |
"""Lifespan manager for FastAPI application."""
|
| 120 |
global chat_handler, qdrant_manager, document_ingestor, task_manager
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
logger.info("Starting up RAG backend...",
|
| 123 |
openai_configured=bool(settings.openai_api_key),
|
| 124 |
qdrant_url=settings.qdrant_url)
|
|
@@ -191,6 +206,38 @@ app.add_middleware(
|
|
| 191 |
allow_headers=["*"],
|
| 192 |
)
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
# Optional API key security for higher rate limits
|
| 196 |
security = HTTPBearer(auto_error=False)
|
|
|
|
| 1 |
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, Depends
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 4 |
from fastapi.responses import StreamingResponse, JSONResponse
|
| 5 |
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 6 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
|
|
| 26 |
from rag.tasks import TaskManager
|
| 27 |
from api.exceptions import ContentNotFoundError, RAGException
|
| 28 |
|
| 29 |
+
# Import security middleware
|
| 30 |
+
from middleware.csrf import CSRFMiddleware
|
| 31 |
+
from middleware.auth import AuthMiddleware
|
| 32 |
+
|
| 33 |
+
# Import auth routes
|
| 34 |
+
from routes import auth
|
| 35 |
+
|
| 36 |
# Import ChatKit server
|
| 37 |
# from chatkit_server import get_chatkit_server
|
| 38 |
|
|
|
|
| 93 |
"http://localhost:3000,http://localhost:8080,https://mrowaisabdullah.github.io,https://huggingface.co"
|
| 94 |
)
|
| 95 |
|
| 96 |
+
# JWT Configuration
|
| 97 |
+
jwt_secret_key: str = os.getenv("JWT_SECRET_KEY", "your-super-secret-jwt-key")
|
| 98 |
+
|
| 99 |
# Conversation Context
|
| 100 |
max_context_messages: int = int(os.getenv("MAX_CONTEXT_MESSAGES", "3"))
|
| 101 |
context_window_size: int = int(os.getenv("CONTEXT_WINDOW_SIZE", "4000"))
|
|
|
|
| 130 |
"""Lifespan manager for FastAPI application."""
|
| 131 |
global chat_handler, qdrant_manager, document_ingestor, task_manager
|
| 132 |
|
| 133 |
+
# Create database tables on startup
|
| 134 |
+
from database.config import create_tables
|
| 135 |
+
create_tables()
|
| 136 |
+
|
| 137 |
logger.info("Starting up RAG backend...",
|
| 138 |
openai_configured=bool(settings.openai_api_key),
|
| 139 |
qdrant_url=settings.qdrant_url)
|
|
|
|
| 206 |
allow_headers=["*"],
|
| 207 |
)
|
| 208 |
|
| 209 |
+
# Add session middleware for OAuth
|
| 210 |
+
app.add_middleware(
|
| 211 |
+
SessionMiddleware,
|
| 212 |
+
secret_key=settings.jwt_secret_key,
|
| 213 |
+
session_cookie="session_id",
|
| 214 |
+
max_age=3600, # 1 hour
|
| 215 |
+
same_site="lax",
|
| 216 |
+
https_only=False, # Set to True in production with HTTPS
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
# Add security middleware (order matters: CSRF before Auth)
|
| 220 |
+
app.add_middleware(
|
| 221 |
+
CSRFMiddleware,
|
| 222 |
+
cookie_name="csrf_token",
|
| 223 |
+
header_name="X-CSRF-Token",
|
| 224 |
+
secure=False, # Set to True in production with HTTPS
|
| 225 |
+
httponly=False,
|
| 226 |
+
samesite="lax",
|
| 227 |
+
max_age=3600,
|
| 228 |
+
exempt_paths=["/health", "/docs", "/openapi.json", "/ingest/status", "/collections"],
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
app.add_middleware(
|
| 232 |
+
AuthMiddleware,
|
| 233 |
+
anonymous_limit=3,
|
| 234 |
+
exempt_paths=["/health", "/docs", "/openapi.json", "/ingest/status", "/collections", "/auth"],
|
| 235 |
+
anonymous_header="X-Anonymous-Session-ID",
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Include auth routes
|
| 239 |
+
app.include_router(auth.router)
|
| 240 |
+
|
| 241 |
|
| 242 |
# Optional API key security for higher rate limits
|
| 243 |
security = HTTPBearer(auto_error=False)
|
middleware/auth.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication middleware for request handling.
|
| 3 |
+
|
| 4 |
+
This middleware handles session validation, anonymous session tracking,
|
| 5 |
+
and session expiration checks.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from fastapi import Request, HTTPException, status
|
| 12 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 13 |
+
from sqlalchemy.orm import Session
|
| 14 |
+
|
| 15 |
+
from database.config import get_db
|
| 16 |
+
from models.auth import Session as AuthSession
|
| 17 |
+
from auth.auth import verify_token
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AuthMiddleware(BaseHTTPMiddleware):
|
| 21 |
+
"""
|
| 22 |
+
Authentication middleware for request processing.
|
| 23 |
+
|
| 24 |
+
Handles:
|
| 25 |
+
- Session validation for authenticated requests
|
| 26 |
+
- Anonymous session tracking
|
| 27 |
+
- Session expiration checks
|
| 28 |
+
- User attachment to request state
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
app,
|
| 34 |
+
anonymous_limit: int = 3,
|
| 35 |
+
exempt_paths: list = None,
|
| 36 |
+
anonymous_header: str = "X-Anonymous-Session-ID",
|
| 37 |
+
):
|
| 38 |
+
super().__init__(app)
|
| 39 |
+
self.anonymous_limit = anonymous_limit
|
| 40 |
+
self.exempt_paths = exempt_paths or ["/health", "/docs", "/openapi.json", "/auth/login"]
|
| 41 |
+
self.anonymous_header = anonymous_header
|
| 42 |
+
|
| 43 |
+
# In-memory storage for anonymous sessions
|
| 44 |
+
# In production, use Redis or database
|
| 45 |
+
self._anonymous_sessions: dict[str, dict] = {}
|
| 46 |
+
self._user_sessions: dict[str, dict] = {}
|
| 47 |
+
|
| 48 |
+
async def dispatch(self, request: Request, call_next):
|
| 49 |
+
# Skip middleware for exempt paths
|
| 50 |
+
if self._is_path_exempt(request):
|
| 51 |
+
return await call_next(request)
|
| 52 |
+
|
| 53 |
+
# Try to authenticate with JWT token
|
| 54 |
+
user = await self._authenticate_user(request)
|
| 55 |
+
if user:
|
| 56 |
+
request.state.user = user
|
| 57 |
+
request.state.authenticated = True
|
| 58 |
+
request.state.session_id = await self._get_user_session_id(user.id, get_db())
|
| 59 |
+
return await call_next(request)
|
| 60 |
+
|
| 61 |
+
# Handle anonymous access
|
| 62 |
+
await self._handle_anonymous_request(request)
|
| 63 |
+
return await call_next(request)
|
| 64 |
+
|
| 65 |
+
def _is_path_exempt(self, request: Request) -> bool:
|
| 66 |
+
"""Check if request path is exempt from authentication."""
|
| 67 |
+
for path in self.exempt_paths:
|
| 68 |
+
if request.url.path.startswith(path):
|
| 69 |
+
return True
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
async def _authenticate_user(self, request: Request) -> Optional[dict]:
|
| 73 |
+
"""
|
| 74 |
+
Authenticate user from JWT token in cookie.
|
| 75 |
+
"""
|
| 76 |
+
token = request.cookies.get("access_token")
|
| 77 |
+
if not token:
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
payload = verify_token(token)
|
| 81 |
+
if not payload:
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
# In a real implementation, fetch user from database
|
| 85 |
+
# For now, return payload as user representation
|
| 86 |
+
return {
|
| 87 |
+
"id": int(payload.get("sub")),
|
| 88 |
+
"email": payload.get("email"),
|
| 89 |
+
"name": payload.get("name", ""),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
async def _get_user_session_id(self, user_id: int, db: Session) -> Optional[str]:
|
| 93 |
+
"""Get active session ID for user."""
|
| 94 |
+
session = db.query(AuthSession).filter(
|
| 95 |
+
AuthSession.user_id == user_id,
|
| 96 |
+
AuthSession.expires_at > datetime.utcnow()
|
| 97 |
+
).first()
|
| 98 |
+
return session.token if session else None
|
| 99 |
+
|
| 100 |
+
async def _handle_anonymous_request(self, request: Request):
|
| 101 |
+
"""Handle requests from anonymous users."""
|
| 102 |
+
# Get or create anonymous session
|
| 103 |
+
session_id = request.headers.get(self.anonymous_header)
|
| 104 |
+
|
| 105 |
+
if not session_id:
|
| 106 |
+
session_id = str(uuid.uuid4())
|
| 107 |
+
self._anonymous_sessions[session_id] = {
|
| 108 |
+
"message_count": 0,
|
| 109 |
+
"created_at": datetime.utcnow(),
|
| 110 |
+
"last_activity": datetime.utcnow(),
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
# Check message limit
|
| 114 |
+
session_data = self._anonymous_sessions.get(session_id, {})
|
| 115 |
+
if session_data.get("message_count", 0) >= self.anonymous_limit:
|
| 116 |
+
raise HTTPException(
|
| 117 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 118 |
+
detail=f"Anonymous limit of {self.anonymous_limit} messages exceeded. Please sign in to continue.",
|
| 119 |
+
headers={
|
| 120 |
+
"X-Anonymous-Limit-Reached": "true",
|
| 121 |
+
"X-Session-ID": session_id,
|
| 122 |
+
},
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Attach anonymous session to request
|
| 126 |
+
request.state.anonymous = True
|
| 127 |
+
request.state.session_id = session_id
|
| 128 |
+
request.state.authenticated = False
|
| 129 |
+
|
| 130 |
+
# Update last activity
|
| 131 |
+
session_data["last_activity"] = datetime.utcnow()
|
| 132 |
+
self._anonymous_sessions[session_id] = session_data
|
| 133 |
+
|
| 134 |
+
async def increment_message_count(self, session_id: str):
|
| 135 |
+
"""Increment message count for anonymous session."""
|
| 136 |
+
if session_id in self._anonymous_sessions:
|
| 137 |
+
self._anonymous_sessions[session_id]["message_count"] += 1
|
| 138 |
+
|
| 139 |
+
def get_anonymous_session(self, session_id: str) -> Optional[dict]:
|
| 140 |
+
"""Get anonymous session data."""
|
| 141 |
+
return self._anonymous_sessions.get(session_id)
|
| 142 |
+
|
| 143 |
+
def cleanup_expired_sessions(self):
|
| 144 |
+
"""Clean up expired anonymous sessions."""
|
| 145 |
+
now = datetime.utcnow()
|
| 146 |
+
expired = [
|
| 147 |
+
session_id for session_id, data in self._anonymous_sessions.items()
|
| 148 |
+
if (now - data["last_activity"]).seconds > 3600 # 1 hour
|
| 149 |
+
]
|
| 150 |
+
for session_id in expired:
|
| 151 |
+
del self._anonymous_sessions[session_id]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def get_current_session(request: Request) -> Optional[str]:
|
| 155 |
+
"""Get current session ID from request state."""
|
| 156 |
+
return getattr(request.state, "session_id", None)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def is_authenticated(request: Request) -> bool:
|
| 160 |
+
"""Check if request is from authenticated user."""
|
| 161 |
+
return getattr(request.state, "authenticated", False)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def is_anonymous(request: Request) -> bool:
|
| 165 |
+
"""Check if request is from anonymous user."""
|
| 166 |
+
return getattr(request.state, "anonymous", False)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def get_current_user(request: Request) -> Optional[dict]:
|
| 170 |
+
"""Get current user from request state."""
|
| 171 |
+
return getattr(request.state, "user", None)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
async def check_session_validity(session_id: str, db: Session) -> bool:
|
| 175 |
+
"""
|
| 176 |
+
Check if session is still valid (not expired).
|
| 177 |
+
"""
|
| 178 |
+
session = db.query(AuthSession).filter(
|
| 179 |
+
AuthSession.token == session_id
|
| 180 |
+
).first()
|
| 181 |
+
|
| 182 |
+
if not session:
|
| 183 |
+
return False
|
| 184 |
+
|
| 185 |
+
# Check if session has expired
|
| 186 |
+
if session.expires_at <= datetime.utcnow():
|
| 187 |
+
# Delete expired session
|
| 188 |
+
db.delete(session)
|
| 189 |
+
db.commit()
|
| 190 |
+
return False
|
| 191 |
+
|
| 192 |
+
return True
|
middleware/csrf.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CSRF Protection Middleware for Cookie-based Authentication
|
| 3 |
+
|
| 4 |
+
This middleware implements CSRF protection using the double-submit cookie pattern
|
| 5 |
+
to prevent Cross-Site Request Forgery attacks when using HTTP-only cookies.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import secrets
|
| 9 |
+
from typing import Callable
|
| 10 |
+
from fastapi import Request, Response, HTTPException, status
|
| 11 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 12 |
+
import time
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class CSRFMiddleware(BaseHTTPMiddleware):
|
| 16 |
+
"""
|
| 17 |
+
CSRF Protection Middleware for cookie-based authentication.
|
| 18 |
+
|
| 19 |
+
Implements the double-submit cookie pattern:
|
| 20 |
+
1. Generates CSRF token and stores in cookie
|
| 21 |
+
2. Client must include token in header for state-changing requests
|
| 22 |
+
3. Validates token on each protected request
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
app: Callable,
|
| 28 |
+
cookie_name: str = "csrf_token",
|
| 29 |
+
header_name: str = "X-CSRF-Token",
|
| 30 |
+
secure: bool = True,
|
| 31 |
+
httponly: bool = False,
|
| 32 |
+
samesite: str = "lax",
|
| 33 |
+
max_age: int = 3600, # 1 hour
|
| 34 |
+
exempt_paths: list = None,
|
| 35 |
+
safe_methods: list = None,
|
| 36 |
+
):
|
| 37 |
+
super().__init__(app)
|
| 38 |
+
self.cookie_name = cookie_name
|
| 39 |
+
self.header_name = header_name
|
| 40 |
+
self.secure = secure
|
| 41 |
+
self.httponly = httponly
|
| 42 |
+
self.samesite = samesite
|
| 43 |
+
self.max_age = max_age
|
| 44 |
+
self.exempt_paths = exempt_paths or ["/health", "/docs", "/openapi.json"]
|
| 45 |
+
self.safe_methods = safe_methods or ["GET", "HEAD", "OPTIONS", "TRACE"]
|
| 46 |
+
|
| 47 |
+
# Store tokens for validation (in production, use Redis or database)
|
| 48 |
+
self._tokens: dict[str, dict] = {}
|
| 49 |
+
self._cleanup_interval = 300 # 5 minutes
|
| 50 |
+
self._last_cleanup = time.time()
|
| 51 |
+
|
| 52 |
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
| 53 |
+
# Skip CSRF for exempt paths and safe methods
|
| 54 |
+
if (
|
| 55 |
+
self._is_path_exempt(request) or
|
| 56 |
+
request.method in self.safe_methods
|
| 57 |
+
):
|
| 58 |
+
return await call_next(request)
|
| 59 |
+
|
| 60 |
+
# Get or generate CSRF token
|
| 61 |
+
csrf_token = self._get_or_generate_token(request)
|
| 62 |
+
|
| 63 |
+
# Set CSRF cookie if not present
|
| 64 |
+
if self.cookie_name not in request.cookies:
|
| 65 |
+
response = await call_next(request)
|
| 66 |
+
self._set_csrf_cookie(response, csrf_token)
|
| 67 |
+
return response
|
| 68 |
+
|
| 69 |
+
# Validate CSRF token for state-changing requests
|
| 70 |
+
if request.method in ["POST", "PUT", "PATCH", "DELETE"]:
|
| 71 |
+
await self._validate_csrf_token(request, csrf_token)
|
| 72 |
+
|
| 73 |
+
# Add CSRF token to response headers for client access
|
| 74 |
+
response = await call_next(request)
|
| 75 |
+
response.headers[self.header_name] = csrf_token
|
| 76 |
+
|
| 77 |
+
return response
|
| 78 |
+
|
| 79 |
+
def _is_path_exempt(self, request: Request) -> bool:
|
| 80 |
+
"""Check if request path is exempt from CSRF protection."""
|
| 81 |
+
for path in self.exempt_paths:
|
| 82 |
+
if request.url.path.startswith(path):
|
| 83 |
+
return True
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
def _get_or_generate_token(self, request: Request) -> str:
|
| 87 |
+
"""Get existing CSRF token or generate new one."""
|
| 88 |
+
# In production, store tokens in database/Redis with user_id
|
| 89 |
+
# For now, use session-based storage
|
| 90 |
+
session_id = getattr(request.state, "session_id", None)
|
| 91 |
+
|
| 92 |
+
# Clean up expired tokens periodically
|
| 93 |
+
self._cleanup_expired_tokens()
|
| 94 |
+
|
| 95 |
+
if session_id and session_id in self._tokens:
|
| 96 |
+
token_data = self._tokens[session_id]
|
| 97 |
+
if token_data["expires"] > time.time():
|
| 98 |
+
return token_data["token"]
|
| 99 |
+
else:
|
| 100 |
+
del self._tokens[session_id]
|
| 101 |
+
|
| 102 |
+
# Generate new token
|
| 103 |
+
token = secrets.token_urlsafe(32)
|
| 104 |
+
expires = time.time() + self.max_age
|
| 105 |
+
|
| 106 |
+
if session_id:
|
| 107 |
+
self._tokens[session_id] = {
|
| 108 |
+
"token": token,
|
| 109 |
+
"expires": expires
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return token
|
| 113 |
+
|
| 114 |
+
def _set_csrf_cookie(self, response: Response, token: str):
|
| 115 |
+
"""Set CSRF token in response cookie."""
|
| 116 |
+
response.set_cookie(
|
| 117 |
+
key=self.cookie_name,
|
| 118 |
+
value=token,
|
| 119 |
+
max_age=self.max_age,
|
| 120 |
+
secure=self.secure,
|
| 121 |
+
httponly=self.httponly,
|
| 122 |
+
samesite=self.samesite,
|
| 123 |
+
path="/",
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
async def _validate_csrf_token(self, request: Request, expected_token: str):
|
| 127 |
+
"""Validate CSRF token from request header."""
|
| 128 |
+
# Get token from header
|
| 129 |
+
token = request.headers.get(self.header_name)
|
| 130 |
+
if not token:
|
| 131 |
+
raise HTTPException(
|
| 132 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 133 |
+
detail="CSRF token missing",
|
| 134 |
+
headers={"X-Error": "CSRF token required"},
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Validate token matches expected
|
| 138 |
+
if not secrets.compare_digest(token, expected_token):
|
| 139 |
+
raise HTTPException(
|
| 140 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 141 |
+
detail="Invalid CSRF token",
|
| 142 |
+
headers={"X-Error": "CSRF token validation failed"},
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Check token expiration if we have session info
|
| 146 |
+
session_id = getattr(request.state, "session_id", None)
|
| 147 |
+
if session_id and session_id in self._tokens:
|
| 148 |
+
token_data = self._tokens[session_id]
|
| 149 |
+
if token_data["expires"] <= time.time():
|
| 150 |
+
raise HTTPException(
|
| 151 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 152 |
+
detail="CSRF token expired",
|
| 153 |
+
headers={"X-Error": "CSRF token expired"},
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
def _cleanup_expired_tokens(self):
|
| 157 |
+
"""Clean up expired CSRF tokens."""
|
| 158 |
+
now = time.time()
|
| 159 |
+
if now - self._last_cleanup > self._cleanup_interval:
|
| 160 |
+
expired_tokens = [
|
| 161 |
+
session_id for session_id, data in self._tokens.items()
|
| 162 |
+
if data["expires"] <= now
|
| 163 |
+
]
|
| 164 |
+
for session_id in expired_tokens:
|
| 165 |
+
del self._tokens[session_id]
|
| 166 |
+
self._last_cleanup = now
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def get_csrf_token(request: Request) -> str:
|
| 170 |
+
"""
|
| 171 |
+
Get CSRF token from request headers.
|
| 172 |
+
|
| 173 |
+
Helper function for use in route handlers.
|
| 174 |
+
"""
|
| 175 |
+
return request.headers.get("X-CSRF-Token")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def validate_csrf_token(request: Request, token: str) -> bool:
|
| 179 |
+
"""
|
| 180 |
+
Validate CSRF token against expected token.
|
| 181 |
+
|
| 182 |
+
Helper function for use in route handlers.
|
| 183 |
+
"""
|
| 184 |
+
return request.headers.get("X-CSRF-Token") == token
|
models/auth.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional, Dict, Any
|
| 3 |
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON
|
| 4 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 5 |
+
from sqlalchemy.orm import relationship
|
| 6 |
+
from sqlalchemy.sql import func
|
| 7 |
+
|
| 8 |
+
Base = declarative_base()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class User(Base):
|
| 12 |
+
__tablename__ = "users"
|
| 13 |
+
|
| 14 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 15 |
+
email = Column(String, unique=True, index=True, nullable=False)
|
| 16 |
+
name = Column(String, nullable=False)
|
| 17 |
+
image_url = Column(String, nullable=True)
|
| 18 |
+
email_verified = Column(Boolean, default=False, nullable=False)
|
| 19 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
| 20 |
+
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
| 21 |
+
|
| 22 |
+
# Relationships
|
| 23 |
+
accounts = relationship("Account", back_populates="user")
|
| 24 |
+
sessions = relationship("Session", back_populates="user")
|
| 25 |
+
chat_sessions = relationship("ChatSession", back_populates="user")
|
| 26 |
+
preferences = relationship("UserPreferences", back_populates="user", uselist=False)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Account(Base):
|
| 30 |
+
__tablename__ = "accounts"
|
| 31 |
+
|
| 32 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 33 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
| 34 |
+
provider = Column(String, nullable=False) # 'google', 'github', etc.
|
| 35 |
+
provider_account_id = Column(String, nullable=False)
|
| 36 |
+
access_token = Column(Text, nullable=True)
|
| 37 |
+
refresh_token = Column(Text, nullable=True)
|
| 38 |
+
expires_at = Column(DateTime(timezone=True), nullable=True)
|
| 39 |
+
token_type = Column(String, nullable=True)
|
| 40 |
+
scope = Column(String, nullable=True)
|
| 41 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
| 42 |
+
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
| 43 |
+
|
| 44 |
+
# Relationships
|
| 45 |
+
user = relationship("User", back_populates="accounts")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class Session(Base):
|
| 49 |
+
__tablename__ = "sessions"
|
| 50 |
+
|
| 51 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 52 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
| 53 |
+
token = Column(String, unique=True, index=True, nullable=False)
|
| 54 |
+
expires_at = Column(DateTime(timezone=True), nullable=False)
|
| 55 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
| 56 |
+
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
| 57 |
+
|
| 58 |
+
# Relationships
|
| 59 |
+
user = relationship("User", back_populates="sessions")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ChatSession(Base):
|
| 63 |
+
__tablename__ = "chat_sessions"
|
| 64 |
+
|
| 65 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 66 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
| 67 |
+
title = Column(String, nullable=False, default="New Chat")
|
| 68 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
| 69 |
+
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
| 70 |
+
|
| 71 |
+
# Relationships
|
| 72 |
+
user = relationship("User", back_populates="chat_sessions")
|
| 73 |
+
messages = relationship("ChatMessage", back_populates="session", order_by="ChatMessage.created_at")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class ChatMessage(Base):
|
| 77 |
+
__tablename__ = "chat_messages"
|
| 78 |
+
|
| 79 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 80 |
+
session_id = Column(Integer, ForeignKey("chat_sessions.id"), nullable=False)
|
| 81 |
+
role = Column(String, nullable=False) # 'user', 'assistant', 'system'
|
| 82 |
+
content = Column(Text, nullable=False)
|
| 83 |
+
message_metadata = Column(JSON, nullable=True) # Store citations, model info, etc.
|
| 84 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
| 85 |
+
|
| 86 |
+
# Relationships
|
| 87 |
+
session = relationship("ChatSession", back_populates="messages")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class UserPreferences(Base):
|
| 91 |
+
__tablename__ = "user_preferences"
|
| 92 |
+
|
| 93 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 94 |
+
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
|
| 95 |
+
theme = Column(String, default="light", nullable=False) # 'light', 'dark', 'auto'
|
| 96 |
+
language = Column(String, default="en", nullable=False)
|
| 97 |
+
notifications_enabled = Column(Boolean, default=True, nullable=False)
|
| 98 |
+
chat_settings = Column(JSON, nullable=True) # Store chat-specific preferences
|
| 99 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
| 100 |
+
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
| 101 |
+
|
| 102 |
+
# Relationships
|
| 103 |
+
user = relationship("User", back_populates="preferences")
|
pyproject.toml
CHANGED
|
@@ -32,6 +32,14 @@ dependencies = [
|
|
| 32 |
"uvicorn[standard]>=0.27.0",
|
| 33 |
"pydantic>=2.5.3",
|
| 34 |
"pydantic-settings>=2.1.0",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
# OpenAI Integration
|
| 36 |
"openai>=1.6.1",
|
| 37 |
"tiktoken>=0.5.2",
|
|
|
|
| 32 |
"uvicorn[standard]>=0.27.0",
|
| 33 |
"pydantic>=2.5.3",
|
| 34 |
"pydantic-settings>=2.1.0",
|
| 35 |
+
# Database
|
| 36 |
+
"sqlalchemy>=2.0.0",
|
| 37 |
+
"alembic>=1.12.0",
|
| 38 |
+
# Authentication
|
| 39 |
+
"python-jose[cryptography]>=3.3.0",
|
| 40 |
+
"passlib[bcrypt]>=1.7.4",
|
| 41 |
+
"authlib>=1.2.1",
|
| 42 |
+
"itsdangerous>=2.1.0",
|
| 43 |
# OpenAI Integration
|
| 44 |
"openai>=1.6.1",
|
| 45 |
"tiktoken>=0.5.2",
|
requirements.txt
CHANGED
|
@@ -17,3 +17,10 @@ backoff>=2.2.1
|
|
| 17 |
psutil>=5.9.6
|
| 18 |
# ChatKit Python SDK
|
| 19 |
openai-chatkit>=0.1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
psutil>=5.9.6
|
| 18 |
# ChatKit Python SDK
|
| 19 |
openai-chatkit>=0.1.0
|
| 20 |
+
|
| 21 |
+
# Authentication dependencies
|
| 22 |
+
sqlalchemy>=2.0.0
|
| 23 |
+
alembic>=1.12.0
|
| 24 |
+
python-jose[cryptography]>=3.3.0
|
| 25 |
+
passlib[bcrypt]>=1.7.4
|
| 26 |
+
authlib>=1.2.1
|
routes/auth.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
|
| 3 |
+
from fastapi.responses import RedirectResponse
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
from database.config import get_db
|
| 9 |
+
from auth.auth import (
|
| 10 |
+
oauth, create_access_token, get_or_create_user,
|
| 11 |
+
create_or_update_account, create_user_session,
|
| 12 |
+
invalidate_user_sessions, get_current_active_user
|
| 13 |
+
)
|
| 14 |
+
from models.auth import User, UserPreferences
|
| 15 |
+
from slowapi import Limiter
|
| 16 |
+
from slowapi.util import get_remote_address
|
| 17 |
+
|
| 18 |
+
# Initialize rate limiter for auth endpoints
|
| 19 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/auth", tags=["authentication"])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# Pydantic models
|
| 25 |
+
class UserResponse(BaseModel):
|
| 26 |
+
id: int
|
| 27 |
+
email: str
|
| 28 |
+
name: str
|
| 29 |
+
image_url: Optional[str] = None
|
| 30 |
+
email_verified: bool
|
| 31 |
+
|
| 32 |
+
class Config:
|
| 33 |
+
from_attributes = True
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class LoginResponse(BaseModel):
|
| 37 |
+
user: UserResponse
|
| 38 |
+
access_token: str
|
| 39 |
+
token_type: str = "bearer"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class PreferencesResponse(BaseModel):
|
| 43 |
+
theme: str = "light"
|
| 44 |
+
language: str = "en"
|
| 45 |
+
notifications_enabled: bool = True
|
| 46 |
+
chat_settings: Optional[dict] = None
|
| 47 |
+
|
| 48 |
+
class Config:
|
| 49 |
+
from_attributes = True
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.get("/login/google")
|
| 53 |
+
@limiter.limit("10/minute") # Limit OAuth initiation attempts
|
| 54 |
+
async def login_via_google(request: Request):
|
| 55 |
+
"""Initiate Google OAuth login"""
|
| 56 |
+
redirect_uri = os.getenv("AUTH_REDIRECT_URI", "http://localhost:3000/auth/google/callback")
|
| 57 |
+
return await oauth.google.authorize_redirect(request, redirect_uri)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.get("/google/callback")
|
| 61 |
+
@limiter.limit("10/minute") # Limit OAuth callback attempts
|
| 62 |
+
async def google_callback(
|
| 63 |
+
request: Request,
|
| 64 |
+
db: Session = Depends(get_db)
|
| 65 |
+
):
|
| 66 |
+
"""Handle Google OAuth callback"""
|
| 67 |
+
try:
|
| 68 |
+
# Get access token from Google
|
| 69 |
+
token = await oauth.google.authorize_access_token(request)
|
| 70 |
+
|
| 71 |
+
# Get user info from Google
|
| 72 |
+
user_info = token.get('userinfo')
|
| 73 |
+
if not user_info:
|
| 74 |
+
# Fallback: fetch user info manually
|
| 75 |
+
user_info = await oauth.google.parse_id_token(request, token)
|
| 76 |
+
|
| 77 |
+
if not user_info.get('email'):
|
| 78 |
+
raise HTTPException(
|
| 79 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 80 |
+
detail="Email is required from OAuth provider"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Get or create user
|
| 84 |
+
user = get_or_create_user(db, user_info)
|
| 85 |
+
|
| 86 |
+
# Create or update OAuth account
|
| 87 |
+
create_or_update_account(db, user, 'google', token)
|
| 88 |
+
|
| 89 |
+
# Create user session and JWT token
|
| 90 |
+
session_token = create_user_session(db, user)
|
| 91 |
+
access_token = create_access_token(data={"sub": str(user.id), "email": user.email})
|
| 92 |
+
|
| 93 |
+
# Redirect to frontend with token
|
| 94 |
+
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
|
| 95 |
+
redirect_url = f"{frontend_url}/auth/callback?token={access_token}"
|
| 96 |
+
|
| 97 |
+
return RedirectResponse(url=redirect_url)
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 102 |
+
detail=f"OAuth authentication failed: {str(e)}"
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@router.get("/me", response_model=UserResponse)
|
| 107 |
+
async def get_current_user_info(
|
| 108 |
+
current_user: User = Depends(get_current_active_user)
|
| 109 |
+
):
|
| 110 |
+
"""Get current user information"""
|
| 111 |
+
return UserResponse(
|
| 112 |
+
id=current_user.id,
|
| 113 |
+
email=current_user.email,
|
| 114 |
+
name=current_user.name,
|
| 115 |
+
image_url=current_user.image_url,
|
| 116 |
+
email_verified=current_user.email_verified
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@router.post("/logout")
|
| 121 |
+
@limiter.limit("20/minute") # Limit logout attempts
|
| 122 |
+
async def logout(
|
| 123 |
+
request: Request,
|
| 124 |
+
response: Response,
|
| 125 |
+
current_user: User = Depends(get_current_active_user),
|
| 126 |
+
db: Session = Depends(get_db)
|
| 127 |
+
):
|
| 128 |
+
"""Logout current user"""
|
| 129 |
+
# Invalidate all sessions for this user
|
| 130 |
+
invalidate_user_sessions(db, current_user)
|
| 131 |
+
|
| 132 |
+
# Clear HTTP-only cookie if using one
|
| 133 |
+
response.delete_cookie(key="access_token")
|
| 134 |
+
|
| 135 |
+
return {"message": "Successfully logged out"}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.get("/preferences", response_model=PreferencesResponse)
|
| 139 |
+
async def get_user_preferences(
|
| 140 |
+
current_user: User = Depends(get_current_active_user),
|
| 141 |
+
db: Session = Depends(get_db)
|
| 142 |
+
):
|
| 143 |
+
"""Get user preferences"""
|
| 144 |
+
preferences = db.query(UserPreferences).filter(
|
| 145 |
+
UserPreferences.user_id == current_user.id
|
| 146 |
+
).first()
|
| 147 |
+
|
| 148 |
+
if not preferences:
|
| 149 |
+
# Create default preferences
|
| 150 |
+
preferences = UserPreferences(user_id=current_user.id)
|
| 151 |
+
db.add(preferences)
|
| 152 |
+
db.commit()
|
| 153 |
+
db.refresh(preferences)
|
| 154 |
+
|
| 155 |
+
return PreferencesResponse(
|
| 156 |
+
theme=preferences.theme,
|
| 157 |
+
language=preferences.language,
|
| 158 |
+
notifications_enabled=preferences.notifications_enabled,
|
| 159 |
+
chat_settings=preferences.chat_settings
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@router.put("/preferences", response_model=PreferencesResponse)
|
| 164 |
+
async def update_user_preferences(
|
| 165 |
+
preferences_update: PreferencesResponse,
|
| 166 |
+
current_user: User = Depends(get_current_active_user),
|
| 167 |
+
db: Session = Depends(get_db)
|
| 168 |
+
):
|
| 169 |
+
"""Update user preferences"""
|
| 170 |
+
preferences = db.query(UserPreferences).filter(
|
| 171 |
+
UserPreferences.user_id == current_user.id
|
| 172 |
+
).first()
|
| 173 |
+
|
| 174 |
+
if not preferences:
|
| 175 |
+
preferences = UserPreferences(user_id=current_user.id)
|
| 176 |
+
db.add(preferences)
|
| 177 |
+
|
| 178 |
+
# Update fields
|
| 179 |
+
preferences.theme = preferences_update.theme
|
| 180 |
+
preferences.language = preferences_update.language
|
| 181 |
+
preferences.notifications_enabled = preferences_update.notifications_enabled
|
| 182 |
+
preferences.chat_settings = preferences_update.chat_settings
|
| 183 |
+
|
| 184 |
+
db.commit()
|
| 185 |
+
db.refresh(preferences)
|
| 186 |
+
|
| 187 |
+
return PreferencesResponse(
|
| 188 |
+
theme=preferences.theme,
|
| 189 |
+
language=preferences.language,
|
| 190 |
+
notifications_enabled=preferences.notifications_enabled,
|
| 191 |
+
chat_settings=preferences.chat_settings
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@router.post("/refresh")
|
| 196 |
+
@limiter.limit("30/minute") # Limit token refresh attempts
|
| 197 |
+
async def refresh_token(
|
| 198 |
+
request: Request,
|
| 199 |
+
current_user: User = Depends(get_current_active_user),
|
| 200 |
+
db: Session = Depends(get_db)
|
| 201 |
+
):
|
| 202 |
+
"""Refresh access token"""
|
| 203 |
+
# Create new session and token
|
| 204 |
+
session_token = create_user_session(db, current_user)
|
| 205 |
+
access_token = create_access_token(data={"sub": str(current_user.id), "email": current_user.email})
|
| 206 |
+
|
| 207 |
+
return {
|
| 208 |
+
"access_token": access_token,
|
| 209 |
+
"token_type": "bearer"
|
| 210 |
+
}
|
tests/test_auth.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Comprehensive test suite for authentication system.
|
| 3 |
+
|
| 4 |
+
Tests cover:
|
| 5 |
+
- JWT token handling
|
| 6 |
+
- Google OAuth flow (mocked)
|
| 7 |
+
- CSRF protection
|
| 8 |
+
- Rate limiting
|
| 9 |
+
- Session management
|
| 10 |
+
- User preferences
|
| 11 |
+
- Anonymous access limits
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
import json
|
| 16 |
+
import time
|
| 17 |
+
from datetime import datetime, timedelta
|
| 18 |
+
from unittest.mock import Mock, patch, AsyncMock
|
| 19 |
+
from fastapi.testclient import TestClient
|
| 20 |
+
from sqlalchemy import create_engine
|
| 21 |
+
from sqlalchemy.orm import sessionmaker
|
| 22 |
+
from jose import jwt
|
| 23 |
+
|
| 24 |
+
# Import modules to test
|
| 25 |
+
from main import app
|
| 26 |
+
from database.config import get_db, Base
|
| 27 |
+
from models.auth import User, Session, UserPreferences
|
| 28 |
+
from auth.auth import create_access_token, verify_token
|
| 29 |
+
from middleware.csrf import CSRFMiddleware
|
| 30 |
+
from middleware.auth import AuthMiddleware
|
| 31 |
+
|
| 32 |
+
# Setup test database
|
| 33 |
+
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
|
| 34 |
+
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
| 35 |
+
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 36 |
+
|
| 37 |
+
# Create test client
|
| 38 |
+
client = TestClient(app)
|
| 39 |
+
|
| 40 |
+
# Override dependency for testing
|
| 41 |
+
def override_get_db():
|
| 42 |
+
try:
|
| 43 |
+
db = TestingSessionLocal()
|
| 44 |
+
yield db
|
| 45 |
+
finally:
|
| 46 |
+
db.close()
|
| 47 |
+
|
| 48 |
+
app.dependency_overrides[get_db] = override_get_db
|
| 49 |
+
|
| 50 |
+
# Setup and teardown
|
| 51 |
+
@pytest.fixture(scope="module")
|
| 52 |
+
def setup_database():
|
| 53 |
+
"""Create test database tables."""
|
| 54 |
+
Base.metadata.create_all(bind=engine)
|
| 55 |
+
yield
|
| 56 |
+
Base.metadata.drop_all(bind=engine)
|
| 57 |
+
|
| 58 |
+
@pytest.fixture
|
| 59 |
+
def db_session():
|
| 60 |
+
"""Create a fresh database session for each test."""
|
| 61 |
+
db = TestingSessionLocal()
|
| 62 |
+
try:
|
| 63 |
+
yield db
|
| 64 |
+
finally:
|
| 65 |
+
db.close()
|
| 66 |
+
|
| 67 |
+
@pytest.fixture
|
| 68 |
+
def test_user(db_session):
|
| 69 |
+
"""Create a test user."""
|
| 70 |
+
user = User(
|
| 71 |
+
email="[email protected]",
|
| 72 |
+
name="Test User",
|
| 73 |
+
email_verified=True
|
| 74 |
+
)
|
| 75 |
+
db_session.add(user)
|
| 76 |
+
db_session.commit()
|
| 77 |
+
db_session.refresh(user)
|
| 78 |
+
return user
|
| 79 |
+
|
| 80 |
+
@pytest.fixture
|
| 81 |
+
def auth_headers(test_user):
|
| 82 |
+
"""Create authentication headers for test user."""
|
| 83 |
+
token = create_access_token(data={"sub": str(test_user.id), "email": test_user.email})
|
| 84 |
+
return {"Authorization": f"Bearer {token}"}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class TestJWTTokenHandling:
|
| 88 |
+
"""Test JWT token creation and validation."""
|
| 89 |
+
|
| 90 |
+
def test_create_access_token(self, test_user):
|
| 91 |
+
"""Test JWT token creation."""
|
| 92 |
+
token = create_access_token(data={"sub": str(test_user.id), "email": test_user.email})
|
| 93 |
+
assert token is not None
|
| 94 |
+
assert isinstance(token, str)
|
| 95 |
+
|
| 96 |
+
def test_verify_valid_token(self, test_user):
|
| 97 |
+
"""Test successful token verification."""
|
| 98 |
+
token = create_access_token(data={"sub": str(test_user.id), "email": test_user.email})
|
| 99 |
+
payload = verify_token(token)
|
| 100 |
+
assert payload is not None
|
| 101 |
+
assert payload["sub"] == str(test_user.id)
|
| 102 |
+
assert payload["email"] == test_user.email
|
| 103 |
+
|
| 104 |
+
def test_verify_invalid_token(self):
|
| 105 |
+
"""Test rejection of invalid token."""
|
| 106 |
+
payload = verify_token("invalid_token")
|
| 107 |
+
assert payload is None
|
| 108 |
+
|
| 109 |
+
def test_verify_expired_token(self, test_user):
|
| 110 |
+
"""Test rejection of expired token."""
|
| 111 |
+
# Create token that's already expired
|
| 112 |
+
expired_token = jwt.encode(
|
| 113 |
+
{"sub": str(test_user.id), "exp": datetime.utcnow() - timedelta(minutes=1)},
|
| 114 |
+
"test_secret",
|
| 115 |
+
algorithm="HS256"
|
| 116 |
+
)
|
| 117 |
+
with patch('auth.auth.JWT_SECRET_KEY', "test_secret"):
|
| 118 |
+
payload = verify_token(expired_token)
|
| 119 |
+
assert payload is None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class TestCSRFProtection:
|
| 123 |
+
"""Test CSRF middleware functionality."""
|
| 124 |
+
|
| 125 |
+
def test_csrf_token_generation(self):
|
| 126 |
+
"""Test CSRF token generation."""
|
| 127 |
+
middleware = CSRFMiddleware(app)
|
| 128 |
+
token = middleware._get_or_generate_token(Mock())
|
| 129 |
+
assert token is not None
|
| 130 |
+
assert len(token) > 0
|
| 131 |
+
|
| 132 |
+
def test_csrf_token_validation_success(self):
|
| 133 |
+
"""Test successful CSRF token validation."""
|
| 134 |
+
middleware = CSRFMiddleware(app)
|
| 135 |
+
token = "test_token"
|
| 136 |
+
request = Mock()
|
| 137 |
+
request.headers = {"X-CSRF-Token": token}
|
| 138 |
+
|
| 139 |
+
# Mock the expected token
|
| 140 |
+
middleware._get_or_generate_token = Mock(return_value=token)
|
| 141 |
+
|
| 142 |
+
# Should not raise exception
|
| 143 |
+
import asyncio
|
| 144 |
+
asyncio.run(middleware._validate_csrf_token(request, token))
|
| 145 |
+
|
| 146 |
+
def test_csrf_token_validation_failure(self):
|
| 147 |
+
"""Test CSRF token validation failure."""
|
| 148 |
+
middleware = CSRFMiddleware(app)
|
| 149 |
+
request = Mock()
|
| 150 |
+
request.headers = {"X-CSRF-Token": "wrong_token"}
|
| 151 |
+
|
| 152 |
+
with pytest.raises(Exception): # HTTPException in real implementation
|
| 153 |
+
import asyncio
|
| 154 |
+
asyncio.run(middleware._validate_csrf_token(request, "correct_token"))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class TestAuthenticationEndpoints:
|
| 158 |
+
"""Test authentication API endpoints."""
|
| 159 |
+
|
| 160 |
+
def test_get_current_user_unauthorized(self):
|
| 161 |
+
"""Test accessing user info without authentication."""
|
| 162 |
+
response = client.get("/auth/me")
|
| 163 |
+
assert response.status_code == 401
|
| 164 |
+
|
| 165 |
+
def test_get_current_authorized(self, auth_headers):
|
| 166 |
+
"""Test accessing user info with valid authentication."""
|
| 167 |
+
# Mock the get_current_active_user dependency
|
| 168 |
+
with patch('routes.auth.get_current_active_user') as mock_user:
|
| 169 |
+
mock_user.return_value = Mock(id=1, email="[email protected]", name="Test User")
|
| 170 |
+
|
| 171 |
+
response = client.get("/auth/me", headers=auth_headers)
|
| 172 |
+
assert response.status_code == 200
|
| 173 |
+
data = response.json()
|
| 174 |
+
assert data["email"] == "[email protected]"
|
| 175 |
+
|
| 176 |
+
def test_logout_success(self, auth_headers):
|
| 177 |
+
"""Test successful logout."""
|
| 178 |
+
with patch('routes.auth.get_current_active_user') as mock_user:
|
| 179 |
+
mock_user.return_value = Mock(id=1, email="[email protected]")
|
| 180 |
+
|
| 181 |
+
response = client.post("/auth/logout", headers=auth_headers)
|
| 182 |
+
assert response.status_code == 200
|
| 183 |
+
data = response.json()
|
| 184 |
+
assert "message" in data
|
| 185 |
+
|
| 186 |
+
def test_google_oauth_redirect(self):
|
| 187 |
+
"""Test Google OAuth initiation."""
|
| 188 |
+
response = client.get("/auth/login/google")
|
| 189 |
+
assert response.status_code in [302, 200] # Redirect or mock response
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
class TestRateLimiting:
|
| 193 |
+
"""Test rate limiting functionality."""
|
| 194 |
+
|
| 195 |
+
def test_rate_limit_headers(self, auth_headers):
|
| 196 |
+
"""Test that rate limit headers are present."""
|
| 197 |
+
with patch('routes.auth.get_current_active_user') as mock_user:
|
| 198 |
+
mock_user.return_value = Mock(id=1, email="[email protected]")
|
| 199 |
+
|
| 200 |
+
response = client.get("/auth/me", headers=auth_headers)
|
| 201 |
+
# Rate limiting headers should be present if implemented
|
| 202 |
+
# This test would need adjustment based on actual rate limiting implementation
|
| 203 |
+
|
| 204 |
+
def test_rate_limit_exceeded(self):
|
| 205 |
+
"""Test behavior when rate limit is exceeded."""
|
| 206 |
+
# Make multiple rapid requests
|
| 207 |
+
for _ in range(50): # Assuming rate limit is less than 50 requests
|
| 208 |
+
response = client.get("/auth/login/google")
|
| 209 |
+
if response.status_code == 429:
|
| 210 |
+
assert "rate limit" in response.text.lower()
|
| 211 |
+
break
|
| 212 |
+
else:
|
| 213 |
+
# If we didn't hit the rate limit, that's also valid for testing
|
| 214 |
+
assert True
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class TestSessionManagement:
|
| 218 |
+
"""Test session creation and validation."""
|
| 219 |
+
|
| 220 |
+
def test_create_user_session(self, db_session, test_user):
|
| 221 |
+
"""Test creating a user session."""
|
| 222 |
+
from auth.auth import create_user_session
|
| 223 |
+
|
| 224 |
+
token = create_user_session(db_session, test_user)
|
| 225 |
+
assert token is not None
|
| 226 |
+
|
| 227 |
+
# Verify session in database
|
| 228 |
+
session = db_session.query(Session).filter(
|
| 229 |
+
Session.user_id == test_user.id
|
| 230 |
+
).first()
|
| 231 |
+
assert session is not None
|
| 232 |
+
assert session.token == token
|
| 233 |
+
|
| 234 |
+
def test_invalidate_user_sessions(self, db_session, test_user):
|
| 235 |
+
"""Test invalidating all user sessions."""
|
| 236 |
+
from auth.auth import create_user_session, invalidate_user_sessions
|
| 237 |
+
|
| 238 |
+
# Create multiple sessions
|
| 239 |
+
token1 = create_user_session(db_session, test_user)
|
| 240 |
+
token2 = create_user_session(db_session, test_user)
|
| 241 |
+
|
| 242 |
+
# Invalidate sessions
|
| 243 |
+
invalidate_user_sessions(db_session, test_user)
|
| 244 |
+
|
| 245 |
+
# Check sessions are invalidated (deleted)
|
| 246 |
+
sessions = db_session.query(Session).filter(
|
| 247 |
+
Session.user_id == test_user.id
|
| 248 |
+
).all()
|
| 249 |
+
assert len(sessions) == 0
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class TestAnonymousAccess:
|
| 253 |
+
"""Test anonymous user access and limits."""
|
| 254 |
+
|
| 255 |
+
def test_anonymous_session_creation(self):
|
| 256 |
+
"""Test creating anonymous session."""
|
| 257 |
+
middleware = AuthMiddleware(app)
|
| 258 |
+
|
| 259 |
+
# Mock request without session ID
|
| 260 |
+
request = Mock()
|
| 261 |
+
request.headers = {}
|
| 262 |
+
request.state = Mock()
|
| 263 |
+
|
| 264 |
+
import asyncio
|
| 265 |
+
asyncio.run(middleware._handle_anonymous_request(request))
|
| 266 |
+
|
| 267 |
+
# Should have session_id in state
|
| 268 |
+
assert hasattr(request.state, 'session_id')
|
| 269 |
+
assert request.state.anonymous is True
|
| 270 |
+
|
| 271 |
+
def test_anonymous_message_limit(self):
|
| 272 |
+
"""Test anonymous user message limit."""
|
| 273 |
+
middleware = AuthMiddleware(app, anonymous_limit=2)
|
| 274 |
+
|
| 275 |
+
# Create session with 2 messages (at limit)
|
| 276 |
+
session_id = "test_session"
|
| 277 |
+
middleware._anonymous_sessions[session_id] = {
|
| 278 |
+
"message_count": 2,
|
| 279 |
+
"created_at": datetime.utcnow(),
|
| 280 |
+
"last_activity": datetime.utcnow()
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
# Mock request with session at limit
|
| 284 |
+
request = Mock()
|
| 285 |
+
request.headers = {"X-Anonymous-Session-ID": session_id}
|
| 286 |
+
request.state = Mock()
|
| 287 |
+
|
| 288 |
+
# Should raise exception for exceeding limit
|
| 289 |
+
with pytest.raises(Exception): # HTTPException in real implementation
|
| 290 |
+
import asyncio
|
| 291 |
+
asyncio.run(middleware._handle_anonymous_request(request))
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
class TestUserPreferences:
|
| 295 |
+
"""Test user preferences management."""
|
| 296 |
+
|
| 297 |
+
def test_get_user_preferences_not_found(self, auth_headers):
|
| 298 |
+
"""Test getting preferences when none exist."""
|
| 299 |
+
with patch('routes.auth.get_current_active_user') as mock_user:
|
| 300 |
+
mock_user.return_value = Mock(id=999, email="[email protected]")
|
| 301 |
+
|
| 302 |
+
response = client.get("/auth/preferences", headers=auth_headers)
|
| 303 |
+
# Should create default preferences
|
| 304 |
+
assert response.status_code == 200
|
| 305 |
+
data = response.json()
|
| 306 |
+
assert "theme" in data
|
| 307 |
+
assert data["theme"] == "light"
|
| 308 |
+
|
| 309 |
+
def test_update_user_preferences(self, auth_headers):
|
| 310 |
+
"""Test updating user preferences."""
|
| 311 |
+
with patch('routes.auth.get_current_active_user') as mock_user:
|
| 312 |
+
mock_user.return_value = Mock(id=1, email="[email protected]")
|
| 313 |
+
|
| 314 |
+
preferences = {
|
| 315 |
+
"theme": "dark",
|
| 316 |
+
"language": "en",
|
| 317 |
+
"notifications_enabled": False,
|
| 318 |
+
"chat_settings": {"model": "gpt-4"}
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
response = client.put("/auth/preferences",
|
| 322 |
+
json=preferences,
|
| 323 |
+
headers=auth_headers)
|
| 324 |
+
assert response.status_code == 200
|
| 325 |
+
data = response.json()
|
| 326 |
+
assert data["theme"] == "dark"
|
| 327 |
+
assert data["notifications_enabled"] is False
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
class TestSecurityHeaders:
|
| 331 |
+
"""Test security-related headers."""
|
| 332 |
+
|
| 333 |
+
def test_cors_headers(self):
|
| 334 |
+
"""Test CORS headers are present."""
|
| 335 |
+
response = client.options("/auth/me")
|
| 336 |
+
assert "access-control-allow-origin" in response.headers
|
| 337 |
+
|
| 338 |
+
def test_csrf_cookie_set(self):
|
| 339 |
+
"""Test CSRF cookie is set on first request."""
|
| 340 |
+
response = client.get("/auth/me")
|
| 341 |
+
# CSRF middleware should set cookie
|
| 342 |
+
# This test depends on actual implementation details
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
class TestErrorHandling:
|
| 346 |
+
"""Test error handling in authentication."""
|
| 347 |
+
|
| 348 |
+
def test_invalid_token_format(self):
|
| 349 |
+
"""Test handling of malformed tokens."""
|
| 350 |
+
response = client.get("/auth/me",
|
| 351 |
+
headers={"Authorization": "Bearer invalid.token.format"})
|
| 352 |
+
assert response.status_code == 401
|
| 353 |
+
|
| 354 |
+
def test_missing_authorization_header(self):
|
| 355 |
+
"""Test request without Authorization header."""
|
| 356 |
+
response = client.get("/auth/me")
|
| 357 |
+
assert response.status_code == 401
|
| 358 |
+
|
| 359 |
+
def test_sql_injection_attempts(self):
|
| 360 |
+
"""Test SQL injection protection."""
|
| 361 |
+
malicious_input = "'; DROP TABLE users; --"
|
| 362 |
+
response = client.post("/auth/logout",
|
| 363 |
+
json={"email": malicious_input})
|
| 364 |
+
# Should handle gracefully without executing SQL
|
| 365 |
+
assert response.status_code in [401, 422]
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
# Integration Tests
|
| 369 |
+
class TestAuthenticationFlow:
|
| 370 |
+
"""Test complete authentication flow."""
|
| 371 |
+
|
| 372 |
+
def test_full_oauth_flow_simulation(self):
|
| 373 |
+
"""Simulate complete OAuth flow."""
|
| 374 |
+
# This would mock the entire OAuth flow
|
| 375 |
+
with patch('routes.auth.oauth.google.authorize_redirect') as mock_auth:
|
| 376 |
+
with patch('routes.auth.get_or_create_user') as mock_user:
|
| 377 |
+
with patch('routes.auth.create_user_session') as mock_session:
|
| 378 |
+
with patch('routes.auth.create_access_token') as mock_token:
|
| 379 |
+
|
| 380 |
+
# Setup mocks
|
| 381 |
+
mock_auth.return_value = Mock()
|
| 382 |
+
mock_user.return_value = Mock(id=1, email="[email protected]")
|
| 383 |
+
mock_session.return_value = "session_token"
|
| 384 |
+
mock_token.return_value = "jwt_token"
|
| 385 |
+
|
| 386 |
+
# Test OAuth initiation
|
| 387 |
+
response = client.get("/auth/login/google")
|
| 388 |
+
assert response.status_code in [200, 302]
|
| 389 |
+
|
| 390 |
+
def test_session_expiry(self, db_session, test_user):
|
| 391 |
+
"""Test session expiration handling."""
|
| 392 |
+
from auth.auth import create_user_session, check_session_validity
|
| 393 |
+
|
| 394 |
+
# Create expired session
|
| 395 |
+
expired_time = datetime.utcnow() - timedelta(days=1)
|
| 396 |
+
|
| 397 |
+
# Direct database manipulation for testing
|
| 398 |
+
session = Session(
|
| 399 |
+
user_id=test_user.id,
|
| 400 |
+
token="expired_token",
|
| 401 |
+
expires_at=expired_time
|
| 402 |
+
)
|
| 403 |
+
db_session.add(session)
|
| 404 |
+
db_session.commit()
|
| 405 |
+
|
| 406 |
+
# Check validity
|
| 407 |
+
is_valid = check_session_validity("expired_token", db_session)
|
| 408 |
+
assert is_valid is False
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
if __name__ == "__main__":
|
| 412 |
+
# Run tests
|
| 413 |
+
pytest.main([__file__, "-v", "--tb=short"])
|
uv.lock
CHANGED
|
@@ -140,6 +140,20 @@ wheels = [
|
|
| 140 |
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 },
|
| 141 |
]
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
[[package]]
|
| 144 |
name = "annotated-doc"
|
| 145 |
version = "0.0.4"
|
|
@@ -180,6 +194,18 @@ wheels = [
|
|
| 180 |
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 },
|
| 181 |
]
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
[[package]]
|
| 184 |
name = "babel"
|
| 185 |
version = "2.17.0"
|
|
@@ -212,6 +238,76 @@ wheels = [
|
|
| 212 |
{ url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058 },
|
| 213 |
]
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
[[package]]
|
| 216 |
name = "black"
|
| 217 |
version = "25.11.0"
|
|
@@ -611,6 +707,18 @@ wheels = [
|
|
| 611 |
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 },
|
| 612 |
]
|
| 613 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
[[package]]
|
| 615 |
name = "faker"
|
| 616 |
version = "38.2.0"
|
|
@@ -764,6 +872,53 @@ wheels = [
|
|
| 764 |
{ url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 },
|
| 765 |
]
|
| 766 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
[[package]]
|
| 768 |
name = "griffe"
|
| 769 |
version = "1.15.0"
|
|
@@ -972,6 +1127,15 @@ wheels = [
|
|
| 972 |
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
|
| 973 |
]
|
| 974 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 975 |
[[package]]
|
| 976 |
name = "jinja2"
|
| 977 |
version = "3.1.6"
|
|
@@ -1165,6 +1329,18 @@ wheels = [
|
|
| 1165 |
{ url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604 },
|
| 1166 |
]
|
| 1167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1168 |
[[package]]
|
| 1169 |
name = "markdown"
|
| 1170 |
version = "3.10"
|
|
@@ -1677,6 +1853,20 @@ wheels = [
|
|
| 1677 |
{ url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 },
|
| 1678 |
]
|
| 1679 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1680 |
[[package]]
|
| 1681 |
name = "pathspec"
|
| 1682 |
version = "0.12.1"
|
|
@@ -1872,6 +2062,15 @@ wheels = [
|
|
| 1872 |
{ url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608 },
|
| 1873 |
]
|
| 1874 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1875 |
[[package]]
|
| 1876 |
name = "pycparser"
|
| 1877 |
version = "2.23"
|
|
@@ -2111,6 +2310,25 @@ wheels = [
|
|
| 2111 |
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 },
|
| 2112 |
]
|
| 2113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2114 |
[[package]]
|
| 2115 |
name = "python-multipart"
|
| 2116 |
version = "0.0.20"
|
|
@@ -2240,19 +2458,25 @@ source = { editable = "." }
|
|
| 2240 |
dependencies = [
|
| 2241 |
{ name = "aiofiles" },
|
| 2242 |
{ name = "aiohttp" },
|
|
|
|
|
|
|
| 2243 |
{ name = "backoff" },
|
| 2244 |
{ name = "fastapi" },
|
| 2245 |
{ name = "httpx" },
|
|
|
|
| 2246 |
{ name = "limits" },
|
| 2247 |
{ name = "openai" },
|
| 2248 |
{ name = "openai-chatkit" },
|
|
|
|
| 2249 |
{ name = "psutil" },
|
| 2250 |
{ name = "pydantic" },
|
| 2251 |
{ name = "pydantic-settings" },
|
| 2252 |
{ name = "python-dotenv" },
|
|
|
|
| 2253 |
{ name = "python-multipart" },
|
| 2254 |
{ name = "qdrant-client" },
|
| 2255 |
{ name = "slowapi" },
|
|
|
|
| 2256 |
{ name = "structlog" },
|
| 2257 |
{ name = "tiktoken" },
|
| 2258 |
{ name = "uvicorn", extra = ["standard"] },
|
|
@@ -2297,18 +2521,22 @@ dev = [
|
|
| 2297 |
requires-dist = [
|
| 2298 |
{ name = "aiofiles", specifier = ">=23.2.1" },
|
| 2299 |
{ name = "aiohttp", specifier = ">=3.8.0" },
|
|
|
|
|
|
|
| 2300 |
{ name = "backoff", specifier = ">=2.2.1" },
|
| 2301 |
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.12.1" },
|
| 2302 |
{ name = "faker", marker = "extra == 'test'", specifier = ">=20.1.0" },
|
| 2303 |
{ name = "fastapi", specifier = ">=0.109.0" },
|
| 2304 |
{ name = "httpx", specifier = ">=0.26.0" },
|
| 2305 |
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.26.0" },
|
|
|
|
| 2306 |
{ name = "limits", specifier = ">=3.13.1" },
|
| 2307 |
{ name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.5.3" },
|
| 2308 |
{ name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.4.8" },
|
| 2309 |
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" },
|
| 2310 |
{ name = "openai", specifier = ">=1.6.1" },
|
| 2311 |
{ name = "openai-chatkit", specifier = ">=1.4.0" },
|
|
|
|
| 2312 |
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" },
|
| 2313 |
{ name = "psutil", specifier = ">=5.9.6" },
|
| 2314 |
{ name = "pydantic", specifier = ">=2.5.3" },
|
|
@@ -2322,10 +2550,12 @@ requires-dist = [
|
|
| 2322 |
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" },
|
| 2323 |
{ name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" },
|
| 2324 |
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
|
|
|
| 2325 |
{ name = "python-multipart", specifier = ">=0.0.6" },
|
| 2326 |
{ name = "qdrant-client", specifier = ">=1.7.0" },
|
| 2327 |
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.8" },
|
| 2328 |
{ name = "slowapi", specifier = ">=0.1.9" },
|
|
|
|
| 2329 |
{ name = "structlog", specifier = ">=23.2.0" },
|
| 2330 |
{ name = "tiktoken", specifier = ">=0.5.2" },
|
| 2331 |
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" },
|
|
@@ -2574,6 +2804,18 @@ wheels = [
|
|
| 2574 |
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191 },
|
| 2575 |
]
|
| 2576 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2577 |
[[package]]
|
| 2578 |
name = "ruff"
|
| 2579 |
version = "0.14.8"
|
|
@@ -2630,6 +2872,43 @@ wheels = [
|
|
| 2630 |
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
|
| 2631 |
]
|
| 2632 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2633 |
[[package]]
|
| 2634 |
name = "sse-starlette"
|
| 2635 |
version = "3.0.3"
|
|
|
|
| 140 |
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 },
|
| 141 |
]
|
| 142 |
|
| 143 |
+
[[package]]
|
| 144 |
+
name = "alembic"
|
| 145 |
+
version = "1.17.2"
|
| 146 |
+
source = { registry = "https://pypi.org/simple" }
|
| 147 |
+
dependencies = [
|
| 148 |
+
{ name = "mako" },
|
| 149 |
+
{ name = "sqlalchemy" },
|
| 150 |
+
{ name = "typing-extensions" },
|
| 151 |
+
]
|
| 152 |
+
sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064 }
|
| 153 |
+
wheels = [
|
| 154 |
+
{ url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554 },
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
[[package]]
|
| 158 |
name = "annotated-doc"
|
| 159 |
version = "0.0.4"
|
|
|
|
| 194 |
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 },
|
| 195 |
]
|
| 196 |
|
| 197 |
+
[[package]]
|
| 198 |
+
name = "authlib"
|
| 199 |
+
version = "1.6.5"
|
| 200 |
+
source = { registry = "https://pypi.org/simple" }
|
| 201 |
+
dependencies = [
|
| 202 |
+
{ name = "cryptography" },
|
| 203 |
+
]
|
| 204 |
+
sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553 }
|
| 205 |
+
wheels = [
|
| 206 |
+
{ url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608 },
|
| 207 |
+
]
|
| 208 |
+
|
| 209 |
[[package]]
|
| 210 |
name = "babel"
|
| 211 |
version = "2.17.0"
|
|
|
|
| 238 |
{ url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058 },
|
| 239 |
]
|
| 240 |
|
| 241 |
+
[[package]]
|
| 242 |
+
name = "bcrypt"
|
| 243 |
+
version = "5.0.0"
|
| 244 |
+
source = { registry = "https://pypi.org/simple" }
|
| 245 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386 }
|
| 246 |
+
wheels = [
|
| 247 |
+
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806 },
|
| 248 |
+
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626 },
|
| 249 |
+
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853 },
|
| 250 |
+
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793 },
|
| 251 |
+
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930 },
|
| 252 |
+
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194 },
|
| 253 |
+
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381 },
|
| 254 |
+
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750 },
|
| 255 |
+
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757 },
|
| 256 |
+
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740 },
|
| 257 |
+
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197 },
|
| 258 |
+
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974 },
|
| 259 |
+
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498 },
|
| 260 |
+
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853 },
|
| 261 |
+
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626 },
|
| 262 |
+
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862 },
|
| 263 |
+
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544 },
|
| 264 |
+
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787 },
|
| 265 |
+
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753 },
|
| 266 |
+
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587 },
|
| 267 |
+
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178 },
|
| 268 |
+
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295 },
|
| 269 |
+
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700 },
|
| 270 |
+
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034 },
|
| 271 |
+
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766 },
|
| 272 |
+
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449 },
|
| 273 |
+
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310 },
|
| 274 |
+
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761 },
|
| 275 |
+
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553 },
|
| 276 |
+
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009 },
|
| 277 |
+
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029 },
|
| 278 |
+
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907 },
|
| 279 |
+
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500 },
|
| 280 |
+
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412 },
|
| 281 |
+
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486 },
|
| 282 |
+
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940 },
|
| 283 |
+
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776 },
|
| 284 |
+
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922 },
|
| 285 |
+
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367 },
|
| 286 |
+
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187 },
|
| 287 |
+
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752 },
|
| 288 |
+
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881 },
|
| 289 |
+
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931 },
|
| 290 |
+
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313 },
|
| 291 |
+
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290 },
|
| 292 |
+
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253 },
|
| 293 |
+
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084 },
|
| 294 |
+
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185 },
|
| 295 |
+
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656 },
|
| 296 |
+
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662 },
|
| 297 |
+
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240 },
|
| 298 |
+
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152 },
|
| 299 |
+
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284 },
|
| 300 |
+
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643 },
|
| 301 |
+
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698 },
|
| 302 |
+
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725 },
|
| 303 |
+
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912 },
|
| 304 |
+
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953 },
|
| 305 |
+
{ url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180 },
|
| 306 |
+
{ url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791 },
|
| 307 |
+
{ url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746 },
|
| 308 |
+
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375 },
|
| 309 |
+
]
|
| 310 |
+
|
| 311 |
[[package]]
|
| 312 |
name = "black"
|
| 313 |
version = "25.11.0"
|
|
|
|
| 707 |
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 },
|
| 708 |
]
|
| 709 |
|
| 710 |
+
[[package]]
|
| 711 |
+
name = "ecdsa"
|
| 712 |
+
version = "0.19.1"
|
| 713 |
+
source = { registry = "https://pypi.org/simple" }
|
| 714 |
+
dependencies = [
|
| 715 |
+
{ name = "six" },
|
| 716 |
+
]
|
| 717 |
+
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793 }
|
| 718 |
+
wheels = [
|
| 719 |
+
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607 },
|
| 720 |
+
]
|
| 721 |
+
|
| 722 |
[[package]]
|
| 723 |
name = "faker"
|
| 724 |
version = "38.2.0"
|
|
|
|
| 872 |
{ url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 },
|
| 873 |
]
|
| 874 |
|
| 875 |
+
[[package]]
|
| 876 |
+
name = "greenlet"
|
| 877 |
+
version = "3.3.0"
|
| 878 |
+
source = { registry = "https://pypi.org/simple" }
|
| 879 |
+
sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651 }
|
| 880 |
+
wheels = [
|
| 881 |
+
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908 },
|
| 882 |
+
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113 },
|
| 883 |
+
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338 },
|
| 884 |
+
{ url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098 },
|
| 885 |
+
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206 },
|
| 886 |
+
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668 },
|
| 887 |
+
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483 },
|
| 888 |
+
{ url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164 },
|
| 889 |
+
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379 },
|
| 890 |
+
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294 },
|
| 891 |
+
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742 },
|
| 892 |
+
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297 },
|
| 893 |
+
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885 },
|
| 894 |
+
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424 },
|
| 895 |
+
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017 },
|
| 896 |
+
{ url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964 },
|
| 897 |
+
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140 },
|
| 898 |
+
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219 },
|
| 899 |
+
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211 },
|
| 900 |
+
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311 },
|
| 901 |
+
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833 },
|
| 902 |
+
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256 },
|
| 903 |
+
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483 },
|
| 904 |
+
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833 },
|
| 905 |
+
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671 },
|
| 906 |
+
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360 },
|
| 907 |
+
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160 },
|
| 908 |
+
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388 },
|
| 909 |
+
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166 },
|
| 910 |
+
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193 },
|
| 911 |
+
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653 },
|
| 912 |
+
{ url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387 },
|
| 913 |
+
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638 },
|
| 914 |
+
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145 },
|
| 915 |
+
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236 },
|
| 916 |
+
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506 },
|
| 917 |
+
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783 },
|
| 918 |
+
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857 },
|
| 919 |
+
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034 },
|
| 920 |
+
]
|
| 921 |
+
|
| 922 |
[[package]]
|
| 923 |
name = "griffe"
|
| 924 |
version = "1.15.0"
|
|
|
|
| 1127 |
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
|
| 1128 |
]
|
| 1129 |
|
| 1130 |
+
[[package]]
|
| 1131 |
+
name = "itsdangerous"
|
| 1132 |
+
version = "2.2.0"
|
| 1133 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1134 |
+
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
|
| 1135 |
+
wheels = [
|
| 1136 |
+
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
|
| 1137 |
+
]
|
| 1138 |
+
|
| 1139 |
[[package]]
|
| 1140 |
name = "jinja2"
|
| 1141 |
version = "3.1.6"
|
|
|
|
| 1329 |
{ url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604 },
|
| 1330 |
]
|
| 1331 |
|
| 1332 |
+
[[package]]
|
| 1333 |
+
name = "mako"
|
| 1334 |
+
version = "1.3.10"
|
| 1335 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1336 |
+
dependencies = [
|
| 1337 |
+
{ name = "markupsafe" },
|
| 1338 |
+
]
|
| 1339 |
+
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 }
|
| 1340 |
+
wheels = [
|
| 1341 |
+
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 },
|
| 1342 |
+
]
|
| 1343 |
+
|
| 1344 |
[[package]]
|
| 1345 |
name = "markdown"
|
| 1346 |
version = "3.10"
|
|
|
|
| 1853 |
{ url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 },
|
| 1854 |
]
|
| 1855 |
|
| 1856 |
+
[[package]]
|
| 1857 |
+
name = "passlib"
|
| 1858 |
+
version = "1.7.4"
|
| 1859 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1860 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844 }
|
| 1861 |
+
wheels = [
|
| 1862 |
+
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554 },
|
| 1863 |
+
]
|
| 1864 |
+
|
| 1865 |
+
[package.optional-dependencies]
|
| 1866 |
+
bcrypt = [
|
| 1867 |
+
{ name = "bcrypt" },
|
| 1868 |
+
]
|
| 1869 |
+
|
| 1870 |
[[package]]
|
| 1871 |
name = "pathspec"
|
| 1872 |
version = "0.12.1"
|
|
|
|
| 2062 |
{ url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608 },
|
| 2063 |
]
|
| 2064 |
|
| 2065 |
+
[[package]]
|
| 2066 |
+
name = "pyasn1"
|
| 2067 |
+
version = "0.6.1"
|
| 2068 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2069 |
+
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 }
|
| 2070 |
+
wheels = [
|
| 2071 |
+
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 },
|
| 2072 |
+
]
|
| 2073 |
+
|
| 2074 |
[[package]]
|
| 2075 |
name = "pycparser"
|
| 2076 |
version = "2.23"
|
|
|
|
| 2310 |
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 },
|
| 2311 |
]
|
| 2312 |
|
| 2313 |
+
[[package]]
|
| 2314 |
+
name = "python-jose"
|
| 2315 |
+
version = "3.5.0"
|
| 2316 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2317 |
+
dependencies = [
|
| 2318 |
+
{ name = "ecdsa" },
|
| 2319 |
+
{ name = "pyasn1" },
|
| 2320 |
+
{ name = "rsa" },
|
| 2321 |
+
]
|
| 2322 |
+
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726 }
|
| 2323 |
+
wheels = [
|
| 2324 |
+
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624 },
|
| 2325 |
+
]
|
| 2326 |
+
|
| 2327 |
+
[package.optional-dependencies]
|
| 2328 |
+
cryptography = [
|
| 2329 |
+
{ name = "cryptography" },
|
| 2330 |
+
]
|
| 2331 |
+
|
| 2332 |
[[package]]
|
| 2333 |
name = "python-multipart"
|
| 2334 |
version = "0.0.20"
|
|
|
|
| 2458 |
dependencies = [
|
| 2459 |
{ name = "aiofiles" },
|
| 2460 |
{ name = "aiohttp" },
|
| 2461 |
+
{ name = "alembic" },
|
| 2462 |
+
{ name = "authlib" },
|
| 2463 |
{ name = "backoff" },
|
| 2464 |
{ name = "fastapi" },
|
| 2465 |
{ name = "httpx" },
|
| 2466 |
+
{ name = "itsdangerous" },
|
| 2467 |
{ name = "limits" },
|
| 2468 |
{ name = "openai" },
|
| 2469 |
{ name = "openai-chatkit" },
|
| 2470 |
+
{ name = "passlib", extra = ["bcrypt"] },
|
| 2471 |
{ name = "psutil" },
|
| 2472 |
{ name = "pydantic" },
|
| 2473 |
{ name = "pydantic-settings" },
|
| 2474 |
{ name = "python-dotenv" },
|
| 2475 |
+
{ name = "python-jose", extra = ["cryptography"] },
|
| 2476 |
{ name = "python-multipart" },
|
| 2477 |
{ name = "qdrant-client" },
|
| 2478 |
{ name = "slowapi" },
|
| 2479 |
+
{ name = "sqlalchemy" },
|
| 2480 |
{ name = "structlog" },
|
| 2481 |
{ name = "tiktoken" },
|
| 2482 |
{ name = "uvicorn", extra = ["standard"] },
|
|
|
|
| 2521 |
requires-dist = [
|
| 2522 |
{ name = "aiofiles", specifier = ">=23.2.1" },
|
| 2523 |
{ name = "aiohttp", specifier = ">=3.8.0" },
|
| 2524 |
+
{ name = "alembic", specifier = ">=1.12.0" },
|
| 2525 |
+
{ name = "authlib", specifier = ">=1.2.1" },
|
| 2526 |
{ name = "backoff", specifier = ">=2.2.1" },
|
| 2527 |
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.12.1" },
|
| 2528 |
{ name = "faker", marker = "extra == 'test'", specifier = ">=20.1.0" },
|
| 2529 |
{ name = "fastapi", specifier = ">=0.109.0" },
|
| 2530 |
{ name = "httpx", specifier = ">=0.26.0" },
|
| 2531 |
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.26.0" },
|
| 2532 |
+
{ name = "itsdangerous", specifier = ">=2.1.0" },
|
| 2533 |
{ name = "limits", specifier = ">=3.13.1" },
|
| 2534 |
{ name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.5.3" },
|
| 2535 |
{ name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.4.8" },
|
| 2536 |
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" },
|
| 2537 |
{ name = "openai", specifier = ">=1.6.1" },
|
| 2538 |
{ name = "openai-chatkit", specifier = ">=1.4.0" },
|
| 2539 |
+
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
| 2540 |
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" },
|
| 2541 |
{ name = "psutil", specifier = ">=5.9.6" },
|
| 2542 |
{ name = "pydantic", specifier = ">=2.5.3" },
|
|
|
|
| 2550 |
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" },
|
| 2551 |
{ name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" },
|
| 2552 |
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
| 2553 |
+
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
|
| 2554 |
{ name = "python-multipart", specifier = ">=0.0.6" },
|
| 2555 |
{ name = "qdrant-client", specifier = ">=1.7.0" },
|
| 2556 |
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.8" },
|
| 2557 |
{ name = "slowapi", specifier = ">=0.1.9" },
|
| 2558 |
+
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
| 2559 |
{ name = "structlog", specifier = ">=23.2.0" },
|
| 2560 |
{ name = "tiktoken", specifier = ">=0.5.2" },
|
| 2561 |
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" },
|
|
|
|
| 2804 |
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191 },
|
| 2805 |
]
|
| 2806 |
|
| 2807 |
+
[[package]]
|
| 2808 |
+
name = "rsa"
|
| 2809 |
+
version = "4.9.1"
|
| 2810 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2811 |
+
dependencies = [
|
| 2812 |
+
{ name = "pyasn1" },
|
| 2813 |
+
]
|
| 2814 |
+
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 }
|
| 2815 |
+
wheels = [
|
| 2816 |
+
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 },
|
| 2817 |
+
]
|
| 2818 |
+
|
| 2819 |
[[package]]
|
| 2820 |
name = "ruff"
|
| 2821 |
version = "0.14.8"
|
|
|
|
| 2872 |
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
|
| 2873 |
]
|
| 2874 |
|
| 2875 |
+
[[package]]
|
| 2876 |
+
name = "sqlalchemy"
|
| 2877 |
+
version = "2.0.44"
|
| 2878 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2879 |
+
dependencies = [
|
| 2880 |
+
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
| 2881 |
+
{ name = "typing-extensions" },
|
| 2882 |
+
]
|
| 2883 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830 }
|
| 2884 |
+
wheels = [
|
| 2885 |
+
{ url = "https://files.pythonhosted.org/packages/e3/81/15d7c161c9ddf0900b076b55345872ed04ff1ed6a0666e5e94ab44b0163c/sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd", size = 2140517 },
|
| 2886 |
+
{ url = "https://files.pythonhosted.org/packages/d4/d5/4abd13b245c7d91bdf131d4916fd9e96a584dac74215f8b5bc945206a974/sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa", size = 2130738 },
|
| 2887 |
+
{ url = "https://files.pythonhosted.org/packages/cb/3c/8418969879c26522019c1025171cefbb2a8586b6789ea13254ac602986c0/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e", size = 3304145 },
|
| 2888 |
+
{ url = "https://files.pythonhosted.org/packages/94/2d/fdb9246d9d32518bda5d90f4b65030b9bf403a935cfe4c36a474846517cb/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e", size = 3304511 },
|
| 2889 |
+
{ url = "https://files.pythonhosted.org/packages/7d/fb/40f2ad1da97d5c83f6c1269664678293d3fe28e90ad17a1093b735420549/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399", size = 3235161 },
|
| 2890 |
+
{ url = "https://files.pythonhosted.org/packages/95/cb/7cf4078b46752dca917d18cf31910d4eff6076e5b513c2d66100c4293d83/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b", size = 3261426 },
|
| 2891 |
+
{ url = "https://files.pythonhosted.org/packages/f8/3b/55c09b285cb2d55bdfa711e778bdffdd0dc3ffa052b0af41f1c5d6e582fa/sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3", size = 2105392 },
|
| 2892 |
+
{ url = "https://files.pythonhosted.org/packages/c7/23/907193c2f4d680aedbfbdf7bf24c13925e3c7c292e813326c1b84a0b878e/sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5", size = 2130293 },
|
| 2893 |
+
{ url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675 },
|
| 2894 |
+
{ url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726 },
|
| 2895 |
+
{ url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603 },
|
| 2896 |
+
{ url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842 },
|
| 2897 |
+
{ url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558 },
|
| 2898 |
+
{ url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570 },
|
| 2899 |
+
{ url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447 },
|
| 2900 |
+
{ url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912 },
|
| 2901 |
+
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479 },
|
| 2902 |
+
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212 },
|
| 2903 |
+
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353 },
|
| 2904 |
+
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222 },
|
| 2905 |
+
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614 },
|
| 2906 |
+
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248 },
|
| 2907 |
+
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275 },
|
| 2908 |
+
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901 },
|
| 2909 |
+
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718 },
|
| 2910 |
+
]
|
| 2911 |
+
|
| 2912 |
[[package]]
|
| 2913 |
name = "sse-starlette"
|
| 2914 |
version = "3.0.3"
|