Major updates: LinkedIn auto-posting, timezone fixes, and Docker improvements

Features:
- Add LinkedIn OAuth integration and auto-posting functionality
- Add scheduler service for automated post publishing
- Add metadata field to generated_posts for LinkedIn URLs
- Add privacy policy page for LinkedIn API compliance
- Add company management features and employee accounts
- Add license key system for company registrations

Fixes:
- Fix timezone issues (use UTC consistently across app)
- Fix datetime serialization errors in database operations
- Fix scheduling timezone conversion (local time to UTC)
- Fix import errors (get_database -> db)

Infrastructure:
- Update Docker setup to use port 8001 (avoid conflicts)
- Add SSL support with nginx-proxy and Let's Encrypt
- Add LinkedIn setup documentation
- Add migration scripts for schema updates

Services:
- Add linkedin_service.py for LinkedIn API integration
- Add scheduler_service.py for background job processing
- Add storage_service.py for Supabase Storage
- Add email_service.py improvements
- Add encryption utilities for token storage

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 11:30:20 +01:00
parent b50594dbfa
commit f14515e9cf
94 changed files with 21601 additions and 5111 deletions

View File

@@ -1,123 +1,35 @@
"""User authentication with Supabase LinkedIn OAuth."""
import re
"""User authentication with Supabase Auth.
Uses Supabase's built-in authentication for:
- Email/password signup and login
- LinkedIn OAuth
- Session management via JWT tokens
"""
import secrets
from typing import Optional
from uuid import UUID
from fastapi import Request, Response
from loguru import logger
from supabase import create_client
from src.config import settings
from src.database import db
# Session management
USER_SESSION_COOKIE = "linkedin_user_session"
# Session management - using Supabase JWT tokens stored in cookies
USER_SESSION_COOKIE = "sb_session" # Supabase session cookie
REFRESH_TOKEN_COOKIE = "sb_refresh_token"
SESSION_SECRET = settings.session_secret or secrets.token_hex(32)
# Supabase client for auth operations
_supabase_client = None
def normalize_linkedin_url(url: str) -> str:
"""Normalize LinkedIn URL for comparison.
Extracts the username/vanityName from various LinkedIn URL formats.
"""
if not url:
return ""
# Match linkedin.com/in/username with optional trailing slash or query params
match = re.search(r'linkedin\.com/in/([^/?]+)', url.lower())
if match:
return match.group(1).rstrip('/')
return url.lower().strip()
async def get_customer_by_vanity_name(vanity_name: str) -> Optional[dict]:
"""Find customer by LinkedIn vanityName.
Constructs the LinkedIn URL from vanityName and matches against
Customer.linkedin_url (normalized).
"""
if not vanity_name:
return None
normalized_vanity = normalize_linkedin_url(f"https://www.linkedin.com/in/{vanity_name}/")
# Get all customers and match
customers = await db.list_customers()
for customer in customers:
customer_vanity = normalize_linkedin_url(customer.linkedin_url)
if customer_vanity == normalized_vanity:
return {
"id": str(customer.id),
"name": customer.name,
"linkedin_url": customer.linkedin_url,
"company_name": customer.company_name,
"email": customer.email
}
return None
async def get_customer_by_email(email: str) -> Optional[dict]:
"""Find customer by email address.
Fallback matching when LinkedIn vanityName is not available.
"""
if not email:
return None
email_lower = email.lower().strip()
# Get all customers and match by email
customers = await db.list_customers()
for customer in customers:
if customer.email and customer.email.lower().strip() == email_lower:
return {
"id": str(customer.id),
"name": customer.name,
"linkedin_url": customer.linkedin_url,
"company_name": customer.company_name,
"email": customer.email
}
return None
async def get_customer_by_name(name: str) -> Optional[dict]:
"""Find customer by name.
Fallback matching when email is not available.
Tries exact match first, then case-insensitive.
"""
if not name:
return None
name_lower = name.lower().strip()
# Get all customers and match by name
customers = await db.list_customers()
# First try exact match
for customer in customers:
if customer.name == name:
return {
"id": str(customer.id),
"name": customer.name,
"linkedin_url": customer.linkedin_url,
"company_name": customer.company_name,
"email": customer.email
}
# Then try case-insensitive
for customer in customers:
if customer.name.lower().strip() == name_lower:
return {
"id": str(customer.id),
"name": customer.name,
"linkedin_url": customer.linkedin_url,
"company_name": customer.company_name,
"email": customer.email
}
return None
def get_supabase():
"""Get or create Supabase client."""
global _supabase_client
if _supabase_client is None:
_supabase_client = create_client(settings.supabase_url, settings.supabase_key)
return _supabase_client
class UserSession:
@@ -125,19 +37,47 @@ class UserSession:
def __init__(
self,
customer_id: str,
customer_name: str,
linkedin_vanity_name: str,
user_id: Optional[str] = None,
linkedin_vanity_name: str = "",
linkedin_name: Optional[str] = None,
linkedin_picture: Optional[str] = None,
email: Optional[str] = None
email: Optional[str] = None,
account_type: str = "ghostwriter",
company_id: Optional[str] = None,
onboarding_status: str = "completed",
company_name: Optional[str] = None,
display_name: Optional[str] = None
):
self.customer_id = customer_id
self.customer_name = customer_name
self.user_id = user_id
self.linkedin_vanity_name = linkedin_vanity_name
self.linkedin_name = linkedin_name
self.linkedin_picture = linkedin_picture
self.email = email
self.account_type = account_type
self.company_id = company_id
self.onboarding_status = onboarding_status
self.company_name = company_name
self.display_name = display_name or linkedin_name
@property
def is_onboarding_complete(self) -> bool:
"""Check if user has completed onboarding."""
return self.onboarding_status == "completed"
@property
def is_company_owner(self) -> bool:
"""Check if user is a company owner."""
return self.account_type == "company"
@property
def is_employee(self) -> bool:
"""Check if user is an employee."""
return self.account_type == "employee"
@property
def is_ghostwriter(self) -> bool:
"""Check if user is a ghostwriter."""
return self.account_type == "ghostwriter"
def to_cookie_value(self) -> str:
"""Serialize session to cookie value."""
@@ -145,12 +85,16 @@ class UserSession:
import hashlib
data = {
"customer_id": self.customer_id,
"customer_name": self.customer_name,
"user_id": self.user_id,
"linkedin_vanity_name": self.linkedin_vanity_name,
"linkedin_name": self.linkedin_name,
"linkedin_picture": self.linkedin_picture,
"email": self.email
"email": self.email,
"account_type": self.account_type,
"company_id": self.company_id,
"onboarding_status": self.onboarding_status,
"company_name": self.company_name,
"display_name": self.display_name
}
# Create signed cookie value
@@ -184,12 +128,16 @@ class UserSession:
data = json.loads(json_data)
return cls(
customer_id=data["customer_id"],
customer_name=data["customer_name"],
linkedin_vanity_name=data["linkedin_vanity_name"],
user_id=data.get("user_id"),
linkedin_vanity_name=data.get("linkedin_vanity_name", ""),
linkedin_name=data.get("linkedin_name"),
linkedin_picture=data.get("linkedin_picture"),
email=data.get("email")
email=data.get("email"),
account_type=data.get("account_type", "ghostwriter"),
company_id=data.get("company_id"),
onboarding_status=data.get("onboarding_status", "completed"),
company_name=data.get("company_name"),
display_name=data.get("display_name")
)
except Exception as e:
logger.error(f"Failed to parse session cookie: {e}")
@@ -198,146 +146,354 @@ class UserSession:
def get_user_session(request: Request) -> Optional[UserSession]:
"""Get user session from request cookies."""
cookie = request.cookies.get(USER_SESSION_COOKIE)
if not cookie:
return None
return UserSession.from_cookie_value(cookie)
# Try legacy cookie first (contains full session data including profile info)
legacy_cookie = request.cookies.get("linkedin_user_session")
if legacy_cookie:
session = UserSession.from_cookie_value(legacy_cookie)
if session:
return session
# Fallback to Supabase session validation
access_token = request.cookies.get(USER_SESSION_COOKIE)
if access_token:
try:
supabase = get_supabase()
user_response = supabase.auth.get_user(access_token)
if user_response and user_response.user:
return _create_session_from_supabase_user(user_response.user)
except Exception as e:
logger.debug(f"Could not validate Supabase session: {e}")
return None
def set_user_session(response: Response, session: UserSession) -> None:
"""Set user session cookie."""
async def get_user_session_async(request: Request) -> Optional[UserSession]:
"""Async version of get_user_session with profile lookup."""
session = get_user_session(request)
if session and session.user_id:
# Fetch additional profile data if needed
try:
user = await db.get_user(UUID(session.user_id))
if user:
session.onboarding_status = user.onboarding_status.value if hasattr(user.onboarding_status, 'value') else user.onboarding_status
session.account_type = user.account_type.value if hasattr(user.account_type, 'value') else user.account_type
session.company_id = str(user.company_id) if user.company_id else None
except Exception as e:
logger.warning(f"Could not fetch profile data: {e}")
return session
def _create_session_from_supabase_user(user) -> UserSession:
"""Create UserSession from Supabase user object."""
user_metadata = user.user_metadata or {}
return UserSession(
user_id=str(user.id),
linkedin_vanity_name=user_metadata.get("vanityName", ""),
linkedin_name=user_metadata.get("name"),
linkedin_picture=user_metadata.get("picture"),
email=user.email,
account_type=user_metadata.get("account_type", "ghostwriter"),
company_id=None, # Will be fetched from profile
onboarding_status="pending", # Will be fetched from profile
company_name=None,
display_name=user_metadata.get("display_name") or user_metadata.get("name")
)
def set_user_session(response: Response, session: UserSession, access_token: str = None, refresh_token: str = None) -> None:
"""Set user session cookies."""
if access_token:
response.set_cookie(
key=USER_SESSION_COOKIE,
value=access_token,
httponly=True,
max_age=60 * 60,
samesite="lax",
secure=True
)
if refresh_token:
response.set_cookie(
key=REFRESH_TOKEN_COOKIE,
value=refresh_token,
httponly=True,
max_age=60 * 60 * 24 * 7,
samesite="lax",
secure=True
)
# Also set legacy cookie for backwards compatibility
response.set_cookie(
key=USER_SESSION_COOKIE,
key="linkedin_user_session",
value=session.to_cookie_value(),
httponly=True,
max_age=60 * 60 * 24 * 7, # 7 days
max_age=60 * 60 * 24 * 7,
samesite="lax"
)
def clear_user_session(response: Response) -> None:
"""Clear user session cookie."""
"""Clear all session cookies."""
response.delete_cookie(USER_SESSION_COOKIE)
response.delete_cookie(REFRESH_TOKEN_COOKIE)
response.delete_cookie("linkedin_user_session")
async def handle_oauth_callback(
access_token: str,
refresh_token: Optional[str] = None
) -> Optional[UserSession]:
"""Handle OAuth callback from Supabase.
refresh_token: Optional[str] = None,
allow_registration: bool = True,
account_type: str = "ghostwriter"
) -> tuple[Optional[UserSession], Optional[str], Optional[str]]:
"""Handle OAuth callback from Supabase Auth.
1. Get user info from Supabase using access token
2. Extract LinkedIn vanityName from user metadata
3. Match with Customer record
4. Create session if match found
Supabase Auth handles the user creation in auth.users automatically.
Our trigger creates the profile in the profiles table.
Returns UserSession if authorized, None if not.
Returns:
Tuple of (UserSession, access_token, refresh_token) if authorized,
(None, None, None) if not.
"""
from supabase import create_client
from src.database.models import Profile, AccountType, OnboardingStatus
try:
# Create a new client with the user's access token
supabase = create_client(settings.supabase_url, settings.supabase_key)
supabase = get_supabase()
# Get user info using the access token
user_response = supabase.auth.get_user(access_token)
if not user_response or not user_response.user:
logger.error("Failed to get user from Supabase")
return None
return None, None, None
user = user_response.user
user_metadata = user.user_metadata or {}
# Debug: Log full response
import json
logger.info(f"=== FULL OAUTH RESPONSE ===")
logger.info(f"=== OAUTH CALLBACK ===")
logger.info(f"user.id: {user.id}")
logger.info(f"user.email: {user.email}")
logger.info(f"user.phone: {user.phone}")
logger.info(f"user.app_metadata: {json.dumps(user.app_metadata, indent=2)}")
logger.info(f"user.user_metadata: {json.dumps(user.user_metadata, indent=2)}")
logger.info(f"--- Einzelne Felder ---")
logger.info(f"given_name: {user_metadata.get('given_name')}")
logger.info(f"family_name: {user_metadata.get('family_name')}")
logger.info(f"name: {user_metadata.get('name')}")
logger.info(f"email (metadata): {user_metadata.get('email')}")
logger.info(f"picture: {user_metadata.get('picture')}")
logger.info(f"sub: {user_metadata.get('sub')}")
logger.info(f"provider_id: {user_metadata.get('provider_id')}")
logger.info(f"=== END OAUTH RESPONSE ===")
# LinkedIn OIDC provides these fields
vanity_name = user_metadata.get("vanityName") # LinkedIn username (often not provided)
vanity_name = user_metadata.get("vanityName")
name = user_metadata.get("name")
picture = user_metadata.get("picture")
email = user.email
logger.info(f"OAuth callback for user: {name} (vanityName={vanity_name}, email={email})")
# Try to match with customer
customer = None
# Check if profile exists (should be created by trigger)
profile = await db.get_profile(UUID(str(user.id)))
# First try vanityName if available
if vanity_name:
customer = await get_customer_by_vanity_name(vanity_name)
if customer:
logger.info(f"Matched by vanityName: {vanity_name}")
if not profile:
logger.info(f"Profile not found for user {user.id}, creating...")
profile = Profile(
account_type=AccountType(account_type),
onboarding_status=OnboardingStatus.PENDING,
display_name=name
)
profile = await db.create_profile(UUID(str(user.id)), profile)
# Fallback to email matching
if not customer and email:
customer = await get_customer_by_email(email)
if customer:
logger.info(f"Matched by email: {email}")
# Get company name if applicable
company_name = None
if profile.company_id:
company = await db.get_company(profile.company_id)
if company:
company_name = company.name
# Fallback to name matching
if not customer and name:
customer = await get_customer_by_name(name)
if customer:
logger.info(f"Matched by name: {name}")
if not customer:
# Debug: List all customers to help diagnose
all_customers = await db.list_customers()
logger.warning(f"No customer found for LinkedIn user: {name} (email={email}, vanityName={vanity_name})")
logger.warning(f"Available customers:")
for c in all_customers:
logger.warning(f" - {c.name}: email={c.email}, linkedin={c.linkedin_url}")
return None
logger.info(f"User {name} matched with customer {customer['name']}")
# Use vanityName from OAuth or extract from customer's linkedin_url
effective_vanity_name = vanity_name
if not effective_vanity_name and customer.get("linkedin_url"):
effective_vanity_name = normalize_linkedin_url(customer["linkedin_url"])
return UserSession(
customer_id=customer["id"],
customer_name=customer["name"],
linkedin_vanity_name=effective_vanity_name or "",
session = UserSession(
user_id=str(user.id),
linkedin_vanity_name=vanity_name or "",
linkedin_name=name,
linkedin_picture=picture,
email=email
email=email,
account_type=profile.account_type.value if hasattr(profile.account_type, 'value') else profile.account_type,
company_id=str(profile.company_id) if profile.company_id else None,
onboarding_status=profile.onboarding_status.value if hasattr(profile.onboarding_status, 'value') else profile.onboarding_status,
company_name=company_name,
display_name=profile.display_name or name
)
return session, access_token, refresh_token
except Exception as e:
logger.exception(f"OAuth callback error: {e}")
return None
return None, None, None
async def handle_email_password_login(email: str, password: str) -> tuple[Optional[UserSession], Optional[str], Optional[str]]:
"""Handle email/password login via Supabase Auth."""
try:
supabase = get_supabase()
auth_response = supabase.auth.sign_in_with_password({
"email": email.lower(),
"password": password
})
if not auth_response or not auth_response.user:
logger.warning(f"Failed login attempt for: {email}")
return None, None, None
user = auth_response.user
session = auth_response.session
logger.info(f"Successful email/password login for: {email}")
# Get profile data
profile = await db.get_profile(UUID(str(user.id)))
if not profile:
logger.error(f"No profile found for user {user.id}")
return None, None, None
# Get company name if applicable
company_name = None
if profile.company_id:
company = await db.get_company(profile.company_id)
if company:
company_name = company.name
user_session = UserSession(
user_id=str(user.id),
linkedin_vanity_name="",
linkedin_name=profile.display_name,
linkedin_picture=None,
email=user.email,
account_type=profile.account_type.value if hasattr(profile.account_type, 'value') else profile.account_type,
company_id=str(profile.company_id) if profile.company_id else None,
onboarding_status=profile.onboarding_status.value if hasattr(profile.onboarding_status, 'value') else profile.onboarding_status,
company_name=company_name,
display_name=profile.display_name
)
return user_session, session.access_token, session.refresh_token
except Exception as e:
logger.warning(f"Email/password login error: {e}")
return None, None, None
async def create_email_password_user(
email: str,
password: str,
account_type: str = "ghostwriter",
company_id: Optional[str] = None,
display_name: Optional[str] = None
) -> tuple[Optional[UserSession], Optional[str], Optional[str], Optional[str]]:
"""Create a new user with email/password authentication via Supabase Auth."""
from src.database.models import Profile, AccountType, OnboardingStatus
from src.web.user.password_auth import validate_password_strength
try:
# Validate password
is_valid, error_msg = validate_password_strength(password)
if not is_valid:
logger.warning(f"Weak password for registration: {error_msg}")
return None, None, None, error_msg
supabase = get_supabase()
# Sign up via Supabase Auth
auth_response = supabase.auth.sign_up({
"email": email.lower(),
"password": password,
"options": {
"data": {
"account_type": account_type,
"display_name": display_name
}
}
})
if not auth_response or not auth_response.user:
logger.warning(f"Failed to create user: {email}")
return None, None, None, "Registrierung fehlgeschlagen"
user = auth_response.user
session = auth_response.session
logger.info(f"Created new user via Supabase Auth: {user.id}")
# Wait a moment for the trigger to create the profile
import asyncio
await asyncio.sleep(0.5)
# Update profile with additional data
profile = await db.get_profile(UUID(str(user.id)))
if not profile:
logger.warning(f"Profile not created by trigger, creating manually")
acc_type = AccountType(account_type)
profile = Profile(
account_type=acc_type,
display_name=display_name,
onboarding_status=OnboardingStatus.PENDING
)
if company_id:
profile.company_id = UUID(company_id)
profile = await db.create_profile(UUID(str(user.id)), profile)
elif company_id or display_name:
updates = {}
if company_id:
updates["company_id"] = company_id
if display_name:
updates["display_name"] = display_name
if updates:
profile = await db.update_profile(UUID(str(user.id)), updates)
# Get company name if applicable
company_name = None
if profile.company_id:
company = await db.get_company(profile.company_id)
if company:
company_name = company.name
user_session = UserSession(
user_id=str(user.id),
linkedin_vanity_name="",
linkedin_name=display_name,
linkedin_picture=None,
email=user.email,
account_type=account_type,
company_id=company_id,
onboarding_status="pending",
company_name=company_name,
display_name=display_name
)
access_token = session.access_token if session else None
refresh_token = session.refresh_token if session else None
return user_session, access_token, refresh_token, None
except Exception as e:
error_str = str(e)
logger.exception(f"Error creating email/password user: {e}")
if "already registered" in error_str.lower() or "already exists" in error_str.lower():
return None, None, None, "Diese E-Mail-Adresse ist bereits registriert"
return None, None, None, "Registrierung fehlgeschlagen"
async def sign_out(access_token: Optional[str] = None) -> bool:
"""Sign out user from Supabase Auth."""
try:
if access_token:
supabase = get_supabase()
supabase.auth.sign_out()
return True
except Exception as e:
logger.warning(f"Error signing out: {e}")
return False
def get_supabase_login_url(redirect_to: str) -> str:
"""Generate Supabase OAuth login URL for LinkedIn.
Args:
redirect_to: The URL to redirect to after OAuth (the callback endpoint)
Returns:
The Supabase OAuth URL to redirect the user to
"""
"""Generate Supabase OAuth login URL for LinkedIn."""
from urllib.parse import urlencode
# Supabase OAuth endpoint
base_url = f"{settings.supabase_url}/auth/v1/authorize"
params = {
@@ -346,3 +502,18 @@ def get_supabase_login_url(redirect_to: str) -> str:
}
return f"{base_url}?{urlencode(params)}"
async def refresh_session(refresh_token: str) -> tuple[Optional[str], Optional[str]]:
"""Refresh the user's session using a refresh token."""
try:
supabase = get_supabase()
response = supabase.auth.refresh_session(refresh_token)
if response and response.session:
return response.session.access_token, response.session.refresh_token
return None, None
except Exception as e:
logger.warning(f"Error refreshing session: {e}")
return None, None

View File

@@ -0,0 +1,141 @@
"""Password authentication utilities."""
import secrets
import hashlib
import hmac
from datetime import datetime, timedelta
from typing import Optional, Tuple
import bcrypt
from loguru import logger
def hash_password(password: str) -> str:
"""Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
# bcrypt automatically handles salting
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed.decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
"""Verify a password against its hash.
Args:
password: Plain text password to verify
password_hash: Stored hash to compare against
Returns:
True if password matches, False otherwise
"""
try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except Exception as e:
logger.error(f"Password verification error: {e}")
return False
def generate_verification_token() -> str:
"""Generate a secure email verification token.
Returns:
URL-safe token string (64 characters)
"""
return secrets.token_urlsafe(48)
def generate_invitation_token() -> str:
"""Generate a secure invitation token.
Returns:
URL-safe token string (32 characters)
"""
return secrets.token_urlsafe(24)
def generate_password_reset_token() -> str:
"""Generate a secure password reset token.
Returns:
URL-safe token string (64 characters)
"""
return secrets.token_urlsafe(48)
def get_verification_expiry(hours: int = 24) -> datetime:
"""Get expiry datetime for email verification token.
Args:
hours: Number of hours until expiry (default 24)
Returns:
Datetime when token expires (timezone-aware UTC)
"""
from datetime import timezone
return datetime.now(timezone.utc) + timedelta(hours=hours)
def get_invitation_expiry(days: int = 7) -> datetime:
"""Get expiry datetime for invitation token.
Args:
days: Number of days until expiry (default 7)
Returns:
Datetime when token expires (timezone-aware UTC)
"""
from datetime import timezone
return datetime.now(timezone.utc) + timedelta(days=days)
def is_token_expired(expires_at: datetime) -> bool:
"""Check if a token has expired.
Args:
expires_at: Expiry datetime of the token
Returns:
True if expired, False otherwise
"""
from datetime import timezone
now = datetime.now(timezone.utc)
# Handle both timezone-aware and naive datetimes
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return now > expires_at
def validate_password_strength(password: str) -> Tuple[bool, Optional[str]]:
"""Validate password meets minimum requirements.
Requirements:
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
Args:
password: Password to validate
Returns:
Tuple of (is_valid, error_message)
"""
if len(password) < 8:
return False, "Passwort muss mindestens 8 Zeichen lang sein"
if not any(c.isupper() for c in password):
return False, "Passwort muss mindestens einen Großbuchstaben enthalten"
if not any(c.islower() for c in password):
return False, "Passwort muss mindestens einen Kleinbuchstaben enthalten"
if not any(c.isdigit() for c in password):
return False, "Passwort muss mindestens eine Zahl enthalten"
return True, None

File diff suppressed because it is too large Load Diff