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:
@@ -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
|
||||
|
||||
141
src/web/user/password_auth.py
Normal file
141
src/web/user/password_auth.py
Normal 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
Reference in New Issue
Block a user