Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 38 additions & 61 deletions backends/advanced-backend/Docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@

## Overview

Friend-Lite uses a comprehensive authentication system built on `fastapi-users` with support for multiple authentication methods including JWT tokens, cookies, and Google OAuth. The system provides secure user management with proper data isolation and role-based access control.
Friend-Lite uses a comprehensive authentication system built on `fastapi-users` with support for multiple authentication methods including JWT tokens, cookies, and Google OAuth. The system provides secure user management with proper data isolation and role-based access control using MongoDB ObjectIds for user identification.

## Architecture Components

### 1. User Model (`users.py`)

```python
class User(BeanieBaseUser, Document):
# Primary identifier - 6-character alphanumeric
user_id: str = Field(default_factory=generate_user_id)

# Standard fastapi-users fields
email: str
hashed_password: str
Expand All @@ -24,33 +21,38 @@ class User(BeanieBaseUser, Document):
display_name: Optional[str] = None
profile_picture: Optional[str] = None
oauth_accounts: list[OAuthAccount] = []

@property
def user_id(self) -> str:
"""Return string representation of MongoDB ObjectId for backward compatibility."""
return str(self.id)
```

**Key Features:**
- **Dual Identity**: Users have both `email` and `user_id` for authentication
- **user_id**: 6-character lowercase alphanumeric identifier (e.g., `abc123`)
- **Email**: Standard email address for authentication
- **Email-based Authentication**: Users authenticate using email addresses
- **MongoDB ObjectId**: Uses MongoDB's native ObjectId as unique identifier
- **MongoDB Integration**: Uses Beanie ODM for document storage
- **OAuth Support**: Integrated Google OAuth account linking
- **Backward Compatibility**: user_id property provides ObjectId as string

### 2. Authentication Manager (`auth.py`)

```python
class UserManager(BaseUserManager[User, PydanticObjectId]):
async def authenticate(self, credentials: dict) -> Optional[User]:
"""Authenticate with either email or user_id"""
"""Authenticate with email+password"""
username = credentials.get("username")
# Supports both email and user_id authentication
# Email-based authentication only

async def get_by_email_or_user_id(self, identifier: str) -> Optional[User]:
"""Get user by either email or 6-character user_id"""
async def get_by_email(self, email: str) -> Optional[User]:
"""Get user by email address"""
```

**Key Features:**
- **Flexible Authentication**: Login with either email or user_id
- **Auto-detection**: Automatically detects if identifier is user_id or email
- **Email Authentication**: Login with email address only
- **Password Management**: Secure password hashing and verification
- **User Creation**: Auto-generates unique user_id if not provided
- **Standard fastapi-users**: Uses standard user creation without custom IDs
- **MongoDB ObjectId**: Relies on MongoDB's native unique ID generation

### 3. Authentication Backends

Expand Down Expand Up @@ -78,7 +80,7 @@ class UserManager(BaseUserManager[User, PydanticObjectId]):

**Admin-Only Registration:**
```bash
# Create user with auto-generated user_id
# Create user with auto-generated MongoDB ObjectId
curl -X POST "http://localhost:8000/api/create_user" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
Expand All @@ -88,21 +90,7 @@ curl -X POST "http://localhost:8000/api/create_user" \
"display_name": "John Doe"
}'

# Response: {"user_id": "abc123", "email": "user@example.com", ...}
```

**User ID Specification:**
```bash
# Create user with specific user_id
curl -X POST "http://localhost:8000/api/create_user" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "userpass",
"user_id": "user01",
"display_name": "John Doe"
}'
# Response: {"id": "507f1f77bcf86cd799439011", "email": "user@example.com", ...}
```

### 2. Authentication Methods
Expand All @@ -114,13 +102,6 @@ curl -X POST "http://localhost:8000/auth/jwt/login" \
-d "username=user@example.com&password=userpass"
```

#### User ID-based Login
```bash
curl -X POST "http://localhost:8000/auth/jwt/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=abc123&password=userpass"
```

#### Google OAuth Login
```bash
# Redirect to Google OAuth
Expand All @@ -142,22 +123,23 @@ const ws = new WebSocket('ws://localhost:8000/ws_pcm?device_name=phone');

## Client ID Management

### Format: `user_id-device_name`
### Format: `user_id_suffix-device_name`

The system automatically generates client IDs by combining:
- **user_id**: 6-character user identifier
- **user_id_suffix**: Last 6 characters of MongoDB ObjectId
- **device_name**: Sanitized device identifier

**Examples:**
- `abc123-phone` (user: abc123, device: phone)
- `admin-desktop` (user: admin, device: desktop)
- `user01-havpe` (user: user01, device: havpe)
- `a39011-phone` (user ObjectId ending in a39011, device: phone)
- `cd7994-desktop` (user ObjectId ending in cd7994, device: desktop)
- `f86cd7-havpe` (user ObjectId ending in f86cd7, device: havpe)

### Benefits:
- **User Association**: Clear mapping between clients and users
- **Device Tracking**: Multiple devices per user
- **Data Isolation**: Each user only accesses their own data
- **Audit Trail**: Track activity by user and device
- **Unique IDs**: MongoDB ObjectId ensures global uniqueness

## Security Features

Expand All @@ -172,7 +154,7 @@ The system automatically generates client IDs by combining:
- **Secure Transport**: HTTPS recommended for production

### 3. Data Isolation
- **User Scoping**: All data scoped to user_id
- **User Scoping**: All data scoped to user's MongoDB ObjectId
- **Client Filtering**: Users only see their own clients
- **Admin Override**: Superusers can access all data

Expand Down Expand Up @@ -236,12 +218,11 @@ GOOGLE_CLIENT_SECRET=your-google-client-secret

### 1. Client Implementation
```python
# Prefer user_id for programmatic access
AUTH_USERNAME = "abc123" # 6-character user_id
# Use email for authentication
AUTH_USERNAME = "user@example.com" # Email address
AUTH_PASSWORD = "secure_password"

# Use single AUTH_USERNAME variable
# System auto-detects if it's email or user_id
# Use single AUTH_USERNAME variable for email authentication
```

### 2. Token Management
Expand Down Expand Up @@ -280,9 +261,9 @@ ADMIN_EMAIL=admin@yourdomain.com
# Check credentials
curl -X POST "http://localhost:8000/auth/jwt/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=test&password=test"
-d "username=user@example.com&password=test"

# Verify user exists
# Verify user exists by email
# Check password is correct
# Ensure user is active
```
Expand Down Expand Up @@ -317,35 +298,31 @@ curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/users/me

## Migration Guide

### From Basic Auth to fastapi-users
### From Custom user_id to Email-Only Authentication

1. **Update Environment Variables**
```bash
# Old
AUTH_EMAIL=user@example.com
AUTH_USER_ID=abc123
AUTH_USERNAME=abc123 # Custom user_id

# New
AUTH_USERNAME=abc123 # Can be email or user_id
AUTH_USERNAME=user@example.com # Email address only
```

2. **Update Client Code**
```python
# Old
username = AUTH_USER_ID if AUTH_USER_ID else AUTH_EMAIL
username = AUTH_USERNAME # Could be email or user_id

# New
username = AUTH_USERNAME
username = AUTH_USERNAME # Email address only
```

3. **Test Authentication**
```bash
# Verify both email and user_id work
# Only email authentication works now
curl -X POST "http://localhost:8000/auth/jwt/login" \
-d "username=user@example.com&password=pass"

curl -X POST "http://localhost:8000/auth/jwt/login" \
-d "username=abc123&password=pass"
```

## Advanced Features
Expand Down Expand Up @@ -373,9 +350,9 @@ async def get_all_data(user: User = Depends(current_superuser)):
### 3. Multi-Device Support
```python
# Single user, multiple devices
# Client IDs: user123-phone, user123-tablet, user123-desktop
# Client IDs: a39011-phone, cd7994-tablet, f86cd7-desktop
# Separate conversation streams per device
# Unified user dashboard
```

This authentication system provides enterprise-grade security with developer-friendly APIs, supporting both simple email/password authentication and modern OAuth flows while maintaining proper data isolation and user management capabilities.
This authentication system provides enterprise-grade security with developer-friendly APIs, supporting email/password authentication and modern OAuth flows while maintaining proper data isolation and user management capabilities using MongoDB's robust ObjectId system.
89 changes: 30 additions & 59 deletions backends/advanced-backend/src/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import re
from httpx_oauth.clients.google import GoogleOAuth2
import logging
from users import User, UserCreate, get_user_db, get_user_by_user_id, generate_user_id
from users import User, UserCreate, get_user_db
from dotenv import load_dotenv

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -76,48 +76,25 @@ def parse_id(self, value: str) -> PydanticObjectId:
return value
return PydanticObjectId(value)

async def get_by_email_or_user_id(self, identifier: str) -> Optional[User]:
async def get_by_email(self, email: str) -> Optional[User]:
"""
Get user by either email or user_id.
Get user by email address.

Args:
identifier: Either email address or user_id
email: Email address

Returns:
User if found, None otherwise
"""
# Check if it looks like an email (contains @ symbol)
if '@' in identifier:
return await self.user_db.get_by_email(identifier)
else:
# Try to find by user_id first (for properly formatted user_ids)
user = await get_user_by_user_id(identifier)
if user:
return user

# If not found, try to find by user_id field directly using the existing database connection
# This handles cases where user_id might not follow the 6-character rule (legacy users)
try:
# Use the existing database connection from the user_db
collection = self.user_db.collection
user_doc = await collection.find_one({"user_id": identifier})
if user_doc:
# Convert to User object
user = User.parse_obj(user_doc)
return user

except Exception as e:
logger.warning(f"Failed to search for user by user_id field: {e}")

return None
return await self.user_db.get_by_email(email)

async def authenticate(self, credentials) -> Optional[User]:
"""
Authenticate user with either email+password or user_id+password.
Authenticate user with email+password.

Args:
credentials: OAuth2PasswordRequestForm with username and password
'username' can be either email or user_id
'username' should be email address

Returns:
User if authentication successful, None otherwise
Expand All @@ -133,7 +110,7 @@ async def authenticate(self, credentials) -> Optional[User]:
if not username or not password:
return None

user = await self.get_by_email_or_user_id(username)
user = await self.get_by_email(username)
if not user:
return None

Expand All @@ -153,27 +130,24 @@ async def authenticate(self, credentials) -> Optional[User]:
return user

async def create(self, user_create: UserCreate, safe: bool = True, request: Optional[Request] = None) -> User:
"""Create user with auto-generated user_id if not provided."""
# Ensure user_id is set and unique
if not user_create.user_id:
# Generate unique user_id
max_attempts = 10
for _ in range(max_attempts):
candidate_user_id = generate_user_id()
existing_user = await get_user_by_user_id(candidate_user_id)
if not existing_user:
user_create.user_id = candidate_user_id
break
else:
raise ValueError("Could not generate unique user_id after multiple attempts")
else:
# Validate provided user_id is unique
existing_user = await get_user_by_user_id(user_create.user_id)
if existing_user:
raise ValueError(f"User ID '{user_create.user_id}' already exists")
"""Create user using standard fastapi-users approach with proper superuser handling."""
# Call parent create method - MongoDB ObjectId will be auto-generated
user = await super().create(user_create, safe=safe, request=request)

# Update user with superuser and verified status if needed
# This is required because the base implementation may not preserve these fields
update_needed = False
if user_create.is_superuser != user.is_superuser:
user.is_superuser = user_create.is_superuser
update_needed = True
if user_create.is_verified != user.is_verified:
user.is_verified = user_create.is_verified
update_needed = True

# Call parent create method
return await super().create(user_create, safe=safe, request=request)
if update_needed:
await user.save()

return user

async def on_after_register(self, user: User, request: Optional[Request] = None):
"""Called after a user registers."""
Expand Down Expand Up @@ -267,21 +241,18 @@ async def create_admin_user_if_needed():
user_db_gen = get_user_db()
user_db = await user_db_gen.__anext__()

# Check if admin user already exists (check both email and user_id)
existing_admin_email = await user_db.get_by_email(ADMIN_EMAIL)
existing_admin_user_id = await get_user_by_user_id(ADMIN_USERNAME)
# Check if admin user already exists by email
existing_admin = await user_db.get_by_email(ADMIN_EMAIL)

if existing_admin_email or existing_admin_user_id:
existing_user = existing_admin_email or existing_admin_user_id
print(f"✅ Admin user already exists: {existing_user.user_id} ({existing_user.email})")
if existing_admin:
print(f"✅ Admin user already exists: {existing_admin.user_id} ({existing_admin.email})")
return

# Create admin user with specific user_id
# Create admin user
user_manager_gen = get_user_manager(user_db)
user_manager = await user_manager_gen.__anext__()

admin_create = UserCreate(
user_id="admin1", # Use a proper 6-character user_id
email=ADMIN_EMAIL,
password=ADMIN_PASSWORD,
is_superuser=True,
Expand Down
Loading