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
43 changes: 42 additions & 1 deletion backend/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ def create_user(self, username: str, email: str, password: Optional[str] = None,
'email_verified': False, # Email verification status
'email_verification_code': None, # Verification code
'email_verification_expires': None, # Verification code expiry
'login_email_code': None, # Code for Email 2FA on login
'login_email_code_expires': None, # Expiry for login code
'security_questions': security_questions or [],
'is_admin': False, # Admin status
'is_banned': False,
Expand All @@ -110,7 +112,8 @@ def create_user(self, username: str, email: str, password: Optional[str] = None,
'preferences': {
'theme': 'dark',
'language': 'pt',
'anonymous_mode': False
'anonymous_mode': False,
'email_2fa_enabled': False # Email 2FA (One-time code on login)
},
'backup_codes': [], # Will be generated during TOTP setup
'recovery_code': None, # Recovery email code (for email verification)
Expand Down Expand Up @@ -702,6 +705,44 @@ def is_email_verified(self, user_id: str) -> bool:
user = self.collection.find_one({'_id': ObjectId(user_id)})
return user.get('email_verified', False) if user else False

# Login Email 2FA Methods
def set_login_email_code(self, user_id: str, code: str, expires_in_minutes: int = 15) -> bool:
"""Set email code for login verification."""
from datetime import timedelta
expires_at = datetime.utcnow() + timedelta(minutes=expires_in_minutes)

result = self.collection.update_one(
{'_id': ObjectId(user_id)},
{'$set': {
'login_email_code': code,
'login_email_code_expires': expires_at
}}
)
return result.modified_count > 0

def verify_login_email_code(self, user_id: str, code: str) -> bool:
"""Verify email code for login."""
user = self.collection.find_one({'_id': ObjectId(user_id)})
if not user:
return False

# Check if code matches and hasn't expired
if (user.get('login_email_code') == code and
user.get('login_email_code_expires') and
user['login_email_code_expires'] > datetime.utcnow()):

# Clear verification code
self.collection.update_one(
{'_id': ObjectId(user_id)},
{'$set': {
'login_email_code': None,
'login_email_code_expires': None
}}
)
return True

return False

# Passwordless Authentication Methods
def verify_user_by_username_or_email(self, identifier: str) -> Optional[Dict[str, Any]]:
"""Find user by username or email for passwordless auth."""
Expand Down
35 changes: 35 additions & 0 deletions backend/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ def login():

result = auth_service.login_user(username, password, totp_code, ip_address)

if result.get('require_email_2fa'):
# Return specific response for email 2FA step
return jsonify(result), 200

if result['success']:
return jsonify(result), 200
else:
Expand All @@ -179,6 +183,37 @@ def login():
return jsonify({'success': False, 'errors': ['Login failed. Please try again.']}), 500


@auth_bp.route('/verify-login-email-2fa', methods=['POST'])
@require_json
@rate_limit('5/minute')
def verify_login_email_2fa():
"""Verify email code for 2FA login."""
try:
data = request.get_json()
user_id = str(data.get('user_id', '')).strip()
code = str(data.get('code', '')).strip()

if not user_id or not code:
return jsonify({'success': False, 'errors': ['User ID and verification code are required']}), 400

from flask import current_app
auth_service = AuthService(current_app.db)

# Get client IP for security
ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR'))

result = auth_service.verify_login_email_2fa(user_id, code, ip_address)

if result['success']:
return jsonify(result), 200
else:
return jsonify(result), 401

except Exception as e:
logger.error(f"Login Email 2FA verification error: {str(e)}")
return jsonify({'success': False, 'errors': ['Verification failed. Please try again.']}), 500


@auth_bp.route('/login-backup', methods=['POST'])
@require_json
@rate_limit('5/minute')
Expand Down
34 changes: 34 additions & 0 deletions backend/services/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,40 @@ def login_user(self, username: str, password: str, totp_code: str, ip_address: s
if not self.user_model.verify_totp(str(user['_id']), totp_code):
return {'success': False, 'errors': ['Invalid authentication code']}

# Check if Email 2FA is enabled (User-configured)
# If enabled, require an email code before completing login
user_prefs = user.get('preferences', {})
if user_prefs.get('email_2fa_enabled', False):
# Generate verification code
verification_code = ''.join(random.choices(string.digits, k=6))

# Store code in DB
self.user_model.set_login_email_code(str(user['_id']), verification_code)

# Determine language
lang = user_prefs.get('language', 'en')

# Send email
email_result = self.email_service.send_verification_email(
user['email'],
user['username'],
verification_code,
lang
)

if not email_result.get('success'):
# Fallback or error if email fails?
# For security, we should probably fail safe or log error.
logger.error(f"Failed to send Login Email 2FA code: {email_result.get('error')}")
return {'success': False, 'errors': ['Failed to send verification email. Please contact support.']}

return {
'success': False,
'require_email_2fa': True,
'user_id': str(user['_id']),
'message': 'Please enter the verification code sent to your email.'
}

# Record IP address
if ip_address:
self.user_model.add_ip_address(str(user['_id']), ip_address)
Expand Down
26 changes: 24 additions & 2 deletions frontend/components/UI/GlobeBackground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ function GlobeBackgroundInner({ className = '' }: GlobeBackgroundProps) {
const globeRef = useRef<any>(null);
const pointerInteracting = useRef<number | null>(null);
const pointerInteractionMovement = useRef(0);
const touchStart = useRef<{ x: number, y: number } | null>(null);
const [scrollY, setScrollY] = useState(0);
const lastScrollY = useRef(0);
const scrollVelocity = useRef(0);
Expand Down Expand Up @@ -317,9 +318,30 @@ function GlobeBackgroundInner({ className = '' }: GlobeBackgroundProps) {
});
}
}}
onTouchStart={(e) => {
if (e.touches[0]) {
touchStart.current = {
x: e.touches[0].clientX,
y: e.touches[0].clientY
};
pointerInteracting.current = e.touches[0].clientX;
pointerInteractionMovement.current = 0;
}
}}
onTouchMove={(e) => {
if (pointerInteracting.current !== null && e.touches[0]) {
const delta = e.touches[0].clientX - pointerInteracting.current;
if (pointerInteracting.current !== null && e.touches[0] && touchStart.current) {
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const deltaX = currentX - touchStart.current.x;
const deltaY = currentY - touchStart.current.y;

// If vertical movement is greater than horizontal movement,
// assume it's a scroll and don't rotate the globe
if (Math.abs(deltaY) > Math.abs(deltaX)) {
return;
}

const delta = currentX - pointerInteracting.current;
pointerInteractionMovement.current = delta;
api.start({
r: delta / 100,
Expand Down
24 changes: 2 additions & 22 deletions frontend/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,28 +388,8 @@ export default function Home() {

// Handle Path-based Routing (SPA Fallback)
// Only if we are effectively on the root page logic but the path is different
if (path !== '/') {
// Regex for /post/[id]
const postMatch = path.match(/^\/post\/([a-zA-Z0-9_-]+)$/);
if (postMatch) {
const id = postMatch[1];
const event = new CustomEvent('openPost', { detail: { postId: id } });
setTimeout(() => window.dispatchEvent(event), 100);
return;
}

// Regex for /chat-room/[id]
const chatMatch = path.match(/^\/chat-room\/([a-zA-Z0-9_-]+)$/);
if (chatMatch) {
const id = chatMatch[1];
const event = new CustomEvent('openChatRoom', { detail: { chatRoomId: id } });
setTimeout(() => window.dispatchEvent(event), 100);
return;
}

// If it's an unknown path served by index.html fallback, redirect to 404
router.replace('/404');
}
// NOTE: This fallback logic is now minimal to avoid interfering with valid direct routes.
// Next.js handles routing for /post/[id] and /chat-room/[id] natively.
}, [router.isReady, router.query, router.asPath, topics]);


Expand Down
Loading