Files
OpenHands/enterprise/server/routes/email.py
2026-03-10 13:11:33 -06:00

195 lines
6.8 KiB
Python

import re
from typing import cast
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel, field_validator
from server.auth.constants import KEYCLOAK_CLIENT_ID
from server.auth.keycloak_manager import get_keycloak_admin
from server.auth.saas_user_auth import SaasUserAuth
from server.constants import IS_LOCAL_ENV
from server.routes.auth import set_response_cookie
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from server.utils.url_utils import get_web_url
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth.user_auth import get_user_auth
# Email validation regex pattern
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
api_router = APIRouter(prefix='/api/email')
class EmailUpdate(BaseModel):
email: str
@field_validator('email')
def validate_email(cls, v):
if not EMAIL_REGEX.match(v):
raise ValueError('Invalid email format')
return v
class ResendEmailVerificationRequest(BaseModel):
user_id: str | None = None
is_auth_flow: bool = False
@api_router.post('')
async def update_email(
email_data: EmailUpdate, request: Request, user_id: str = Depends(get_user_id)
):
# Email validation is now handled by the Pydantic model
# If we get here, the email has already passed validation
try:
keycloak_admin = get_keycloak_admin()
user = keycloak_admin.get_user(user_id)
email = email_data.email
# Additional validation check just to be safe
if not EMAIL_REGEX.match(email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid email format'
)
await keycloak_admin.a_update_user(
user_id=user_id,
payload={
'email': email,
'emailVerified': False,
'enabled': user['enabled'], # Retain existing values
'username': user['username'], # Required field
},
)
await UserStore.update_user_email(
user_id=user_id, email=email, email_verified=False
)
user_auth = cast(SaasUserAuth, await get_user_auth(request))
await user_auth.refresh() # refresh so access token has updated email
user_auth.email = email
user_auth.email_verified = False
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'message': 'Email changed'}
)
# need to set auth cookie to the new tokens
if user_auth.access_token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Access token not found',
)
set_response_cookie(
request=request,
response=response,
keycloak_access_token=user_auth.access_token.get_secret_value(),
keycloak_refresh_token=user_auth.refresh_token.get_secret_value(),
secure=not IS_LOCAL_ENV,
accepted_tos=user_auth.accepted_tos or False,
)
await verify_email(request=request, user_id=user_id)
logger.info(f'Updating email address for {user_id} to {email}')
return response
except ValueError as e:
# Handle validation errors from Pydantic
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.exception(f'Error updating email: {str(e)}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An error occurred while updating the email',
)
@api_router.put('/resend')
async def resend_email_verification(
request: Request,
body: ResendEmailVerificationRequest | None = None,
):
# Get user_id from body if provided, otherwise from auth
user_id: str | None = None
if body and body.user_id:
user_id = body.user_id
else:
try:
user_id = await get_user_id(request)
except Exception:
pass
if not user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='user_id is required in request body or user must be authenticated',
)
# Check rate limit (uses user_id if available, otherwise falls back to IP)
# Use 30 seconds for user-based rate limiting to match frontend cooldown
await check_rate_limit_by_user_id(
request=request,
key_prefix='email_resend',
user_id=user_id,
user_rate_limit_seconds=30,
ip_rate_limit_seconds=60, # 1 minute for IP-based limiting (more lenient)
)
# Get is_auth_flow from body if provided, default to False
is_auth_flow = body.is_auth_flow if body else False
await verify_email(request=request, user_id=user_id, is_auth_flow=is_auth_flow)
logger.info(f'Resending verification email for {user_id}')
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Email verification message sent'},
)
@api_router.get('/verified')
async def verified_email(request: Request):
user_auth = cast(SaasUserAuth, await get_user_auth(request))
await user_auth.refresh() # refresh so access token has updated email
user_auth.email_verified = True
await UserStore.update_user_email(user_id=user_auth.user_id, email_verified=True)
redirect_uri = f'{get_web_url(request)}/settings/user'
response = RedirectResponse(redirect_uri, status_code=302)
# need to set auth cookie to the new tokens
if user_auth.access_token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail='Access token not found'
)
set_response_cookie(
request=request,
response=response,
keycloak_access_token=user_auth.access_token.get_secret_value(),
keycloak_refresh_token=user_auth.refresh_token.get_secret_value(),
secure=False if request.url.hostname == 'localhost' else True,
accepted_tos=user_auth.accepted_tos or False,
)
logger.info(f'Email {user_auth.email} verified.')
return response
async def verify_email(request: Request, user_id: str, is_auth_flow: bool = False):
keycloak_admin = get_keycloak_admin()
if is_auth_flow:
redirect_uri = f'{get_web_url(request)}/login?email_verified=true'
else:
redirect_uri = f'{get_web_url(request)}/api/email/verified'
logger.info(f'Redirect URI: {redirect_uri}')
await keycloak_admin.a_send_verify_email(
user_id=user_id,
redirect_uri=redirect_uri,
client_id=KEYCLOAK_CLIENT_ID,
)