opencompletion.com/auth.py
Russell e4cabf4be6
otp (#41)
* Add email OTP authentication system and private room management

## Authentication System
- Implement email OTP-based authentication with User and OTPToken models
- Add auth.py module with OTP generation, email sending, and session management
- Create authentication endpoints: /auth/send-otp, /auth/verify-otp, /auth/claim-name, /auth/status, /auth/logout
- Add authentication modal UI with email verification and display name claiming
- Support SMTP configuration via environment variables (optional)

## Room Privacy & Ownership
- Add is_private, is_archived, owner_id, and forked_from_id fields to Room model
- Implement private rooms (single-user, owner-only access)
- Add room forking: users can fork public rooms to public or private
- Add room archive/delete endpoints (owner-only operations)
- Implement access control for private room viewing

## UI Improvements
- Add public/private room tabs in sidebar
- Show authentication prompt in private rooms tab for non-authenticated users
- Add room action buttons (fork, archive, delete) with proper permissions
- Update homepage with statistics (public/private rooms, active users, active rooms)
- Remove model/voice from URL query strings, use localStorage exclusively

## Database Migration
- Create migration 2025011100 for User, OTPToken tables and Room model updates
- Add indexes for email, display_name, is_private, is_archived, owner_id

## Breaking Changes
- URL parameters now only include username (model/voice moved to localStorage)
- Private rooms require authentication to access
- Room creation can now require authentication (for private rooms)

* Update README with authentication and private room documentation

* Simplify README to be less verbose

* Add missing session import to fix linter errors

* Fix migration dependency to resolve multiple heads conflict

* asdf

* Fix SQLAlchemy auto-correlation error in homepage statistics query

* Change tagline from AI-Powered to Machine Learning Powered

* Add dedicated authentication page instead of modal

- Create new /auth route with full-page authentication flow
- Remove modal code from index.html
- Update Sign in link to point to /auth page
- Auth page has 4-step flow: email, OTP, display name, success
- Better UX with gradient background and cleaner design

* Fix migration: remove batch_alter_table to avoid circular dependency

- Use op.add_column() directly instead of batch_alter_table()
- Remove foreign key constraints (defined in models, not needed in migration)
- User and OTPToken tables created by db.create_all() in make init-db
- Fixes CircularDependencyError during migration

* Fix migration: check if columns exist before adding

- Use inspector to check existing columns and indexes
- Only add columns/indexes if they don't already exist
- Handles case where db.create_all() was run before migration
- Fixes 'duplicate column name' error

* Add profile page, room browsing, and updated_at timestamp

Features:
- Profile page with username change and dark/light mode settings
- Browse page for discovering public and private rooms
- Room updated_at timestamp (integer Unix epoch) that updates on new messages
- Dynamic room tabs based on current room type (public/private)
- Fork and delete room actions moved to right sidebar utility belt
- Remove archive feature and success alerts from room actions

Technical changes:
- Add updated_at column to Room model (integer timestamp)
- Add /profile route with authentication requirement
- Add /browse route for room discovery
- Add API endpoints for username availability check and update
- Update room.updated_at on message creation in app.py:1189
- Wider right sidebar (25% instead of 15%) for better button layout
- Profile link on homepage and browse page for authenticated users

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: russell@unturf. <russell@unturf.com>
2025-11-11 22:56:58 -05:00

195 lines
5.6 KiB
Python

"""Authentication module for email OTP-based authentication"""
import os
import random
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
from functools import wraps
from flask import session, jsonify, request
from models import db, User, OTPToken
def generate_otp():
"""Generate a 6-digit OTP code"""
return ''.join([str(random.randint(0, 9)) for _ in range(6)])
def send_otp_email(email, otp_code):
"""Send OTP code to user's email via SMTP
Requires environment variables:
- SMTP_HOST: SMTP server hostname (e.g., smtp.gmail.com)
- SMTP_PORT: SMTP server port (e.g., 587)
- SMTP_USER: SMTP username/email
- SMTP_PASSWORD: SMTP password or app-specific password
- SMTP_FROM_EMAIL: Email address to send from
- SMTP_FROM_NAME: Display name for sender
"""
smtp_host = os.environ.get('SMTP_HOST', 'localhost')
smtp_port = int(os.environ.get('SMTP_PORT', '587'))
smtp_user = os.environ.get('SMTP_USER')
smtp_password = os.environ.get('SMTP_PASSWORD')
from_email = os.environ.get('SMTP_FROM_EMAIL', smtp_user)
from_name = os.environ.get('SMTP_FROM_NAME', 'OpenCompletion')
if not smtp_user or not smtp_password:
print("[WARNING] SMTP not configured. OTP code:", otp_code)
print(f"[WARNING] To enable email, set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD")
# In development, still return success and print OTP
return True
# Create message
msg = MIMEMultipart('alternative')
msg['Subject'] = f'Your OpenCompletion verification code: {otp_code}'
msg['From'] = f'{from_name} <{from_email}>'
msg['To'] = email
# Plain text version
text = f"""
Your OpenCompletion verification code is: {otp_code}
This code will expire in 10 minutes.
If you didn't request this code, you can safely ignore this email.
"""
# HTML version
html = f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2>Your OpenCompletion Verification Code</h2>
<p>Enter this code to complete your authentication:</p>
<h1 style="background-color: #f0f0f0; padding: 15px; text-align: center; letter-spacing: 5px;">
{otp_code}
</h1>
<p style="color: #666;">This code will expire in 10 minutes.</p>
<p style="color: #999; font-size: 12px;">
If you didn't request this code, you can safely ignore this email.
</p>
</body>
</html>
"""
# Attach both versions
msg.attach(MIMEText(text, 'plain'))
msg.attach(MIMEText(html, 'html'))
try:
# Send via SMTP
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_password)
server.send_message(msg)
return True
except Exception as e:
print(f"[ERROR] Failed to send OTP email: {e}")
return False
def create_otp_token(email):
"""Create and store an OTP token for the given email"""
# Invalidate any existing unused OTP tokens for this email
existing_tokens = OTPToken.query.filter_by(email=email, used=False).all()
for token in existing_tokens:
token.used = True
# Generate new OTP
otp_code = generate_otp()
otp_token = OTPToken(email=email, otp_code=otp_code)
db.session.add(otp_token)
db.session.commit()
return otp_token
def verify_otp(email, otp_code):
"""Verify an OTP code for the given email
Returns:
- OTPToken object if valid
- None if invalid
"""
otp_token = OTPToken.query.filter_by(
email=email,
otp_code=otp_code,
used=False
).first()
if otp_token and otp_token.is_valid():
# Mark as used
otp_token.used = True
db.session.commit()
return otp_token
return None
def get_or_create_user(email):
"""Get existing user by email or return None if doesn't exist"""
return User.query.filter_by(email=email).first()
def create_user(email, display_name):
"""Create a new user with email and display name"""
# Check if display name is already taken
existing_user = User.query.filter_by(display_name=display_name).first()
if existing_user:
return None, "Display name already taken"
# Check if email already exists
existing_email = User.query.filter_by(email=email).first()
if existing_email:
return None, "Email already registered"
user = User(email=email, display_name=display_name)
db.session.add(user)
db.session.commit()
return user, None
def login_user(user):
"""Create session for authenticated user"""
session['user_id'] = user.id
session['user_email'] = user.email
session['display_name'] = user.display_name
session.permanent = True # Use permanent session
# Update last login
user.last_login = datetime.utcnow()
db.session.commit()
def logout_user():
"""Clear user session"""
session.pop('user_id', None)
session.pop('user_email', None)
session.pop('display_name', None)
def get_current_user():
"""Get currently authenticated user from session"""
user_id = session.get('user_id')
if user_id:
return User.query.get(user_id)
return None
def require_auth(f):
"""Decorator to require authentication for a route"""
@wraps(f)
def decorated_function(*args, **kwargs):
user = get_current_user()
if not user:
return jsonify({'error': 'Authentication required'}), 401
return f(*args, **kwargs)
return decorated_function
def is_authenticated():
"""Check if current request is authenticated"""
return 'user_id' in session