Skip to content

Commit 1356115

Browse files
authored
Merge pull request #31 from 8JP8/feature-settings-routing-2fa-15243479983392054904
Feature: Globe Scroll, Direct Routing, and Email 2FA
2 parents 8745304 + 15bcd17 commit 1356115

8 files changed

Lines changed: 349 additions & 30 deletions

File tree

backend/models/user.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ def create_user(self, username: str, email: str, password: Optional[str] = None,
9797
'email_verified': False, # Email verification status
9898
'email_verification_code': None, # Verification code
9999
'email_verification_expires': None, # Verification code expiry
100+
'login_email_code': None, # Code for Email 2FA on login
101+
'login_email_code_expires': None, # Expiry for login code
100102
'security_questions': security_questions or [],
101103
'is_admin': False, # Admin status
102104
'is_banned': False,
@@ -110,7 +112,8 @@ def create_user(self, username: str, email: str, password: Optional[str] = None,
110112
'preferences': {
111113
'theme': 'dark',
112114
'language': 'pt',
113-
'anonymous_mode': False
115+
'anonymous_mode': False,
116+
'email_2fa_enabled': False # Email 2FA (One-time code on login)
114117
},
115118
'backup_codes': [], # Will be generated during TOTP setup
116119
'recovery_code': None, # Recovery email code (for email verification)
@@ -702,6 +705,44 @@ def is_email_verified(self, user_id: str) -> bool:
702705
user = self.collection.find_one({'_id': ObjectId(user_id)})
703706
return user.get('email_verified', False) if user else False
704707

708+
# Login Email 2FA Methods
709+
def set_login_email_code(self, user_id: str, code: str, expires_in_minutes: int = 15) -> bool:
710+
"""Set email code for login verification."""
711+
from datetime import timedelta
712+
expires_at = datetime.utcnow() + timedelta(minutes=expires_in_minutes)
713+
714+
result = self.collection.update_one(
715+
{'_id': ObjectId(user_id)},
716+
{'$set': {
717+
'login_email_code': code,
718+
'login_email_code_expires': expires_at
719+
}}
720+
)
721+
return result.modified_count > 0
722+
723+
def verify_login_email_code(self, user_id: str, code: str) -> bool:
724+
"""Verify email code for login."""
725+
user = self.collection.find_one({'_id': ObjectId(user_id)})
726+
if not user:
727+
return False
728+
729+
# Check if code matches and hasn't expired
730+
if (user.get('login_email_code') == code and
731+
user.get('login_email_code_expires') and
732+
user['login_email_code_expires'] > datetime.utcnow()):
733+
734+
# Clear verification code
735+
self.collection.update_one(
736+
{'_id': ObjectId(user_id)},
737+
{'$set': {
738+
'login_email_code': None,
739+
'login_email_code_expires': None
740+
}}
741+
)
742+
return True
743+
744+
return False
745+
705746
# Passwordless Authentication Methods
706747
def verify_user_by_username_or_email(self, identifier: str) -> Optional[Dict[str, Any]]:
707748
"""Find user by username or email for passwordless auth."""

backend/routes/auth.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ def login():
169169

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

172+
if result.get('require_email_2fa'):
173+
# Return specific response for email 2FA step
174+
return jsonify(result), 200
175+
172176
if result['success']:
173177
return jsonify(result), 200
174178
else:
@@ -179,6 +183,37 @@ def login():
179183
return jsonify({'success': False, 'errors': ['Login failed. Please try again.']}), 500
180184

181185

186+
@auth_bp.route('/verify-login-email-2fa', methods=['POST'])
187+
@require_json
188+
@rate_limit('5/minute')
189+
def verify_login_email_2fa():
190+
"""Verify email code for 2FA login."""
191+
try:
192+
data = request.get_json()
193+
user_id = str(data.get('user_id', '')).strip()
194+
code = str(data.get('code', '')).strip()
195+
196+
if not user_id or not code:
197+
return jsonify({'success': False, 'errors': ['User ID and verification code are required']}), 400
198+
199+
from flask import current_app
200+
auth_service = AuthService(current_app.db)
201+
202+
# Get client IP for security
203+
ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR'))
204+
205+
result = auth_service.verify_login_email_2fa(user_id, code, ip_address)
206+
207+
if result['success']:
208+
return jsonify(result), 200
209+
else:
210+
return jsonify(result), 401
211+
212+
except Exception as e:
213+
logger.error(f"Login Email 2FA verification error: {str(e)}")
214+
return jsonify({'success': False, 'errors': ['Verification failed. Please try again.']}), 500
215+
216+
182217
@auth_bp.route('/login-backup', methods=['POST'])
183218
@require_json
184219
@rate_limit('5/minute')

backend/services/auth_service.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,40 @@ def login_user(self, username: str, password: str, totp_code: str, ip_address: s
213213
if not self.user_model.verify_totp(str(user['_id']), totp_code):
214214
return {'success': False, 'errors': ['Invalid authentication code']}
215215

216+
# Check if Email 2FA is enabled (User-configured)
217+
# If enabled, require an email code before completing login
218+
user_prefs = user.get('preferences', {})
219+
if user_prefs.get('email_2fa_enabled', False):
220+
# Generate verification code
221+
verification_code = ''.join(random.choices(string.digits, k=6))
222+
223+
# Store code in DB
224+
self.user_model.set_login_email_code(str(user['_id']), verification_code)
225+
226+
# Determine language
227+
lang = user_prefs.get('language', 'en')
228+
229+
# Send email
230+
email_result = self.email_service.send_verification_email(
231+
user['email'],
232+
user['username'],
233+
verification_code,
234+
lang
235+
)
236+
237+
if not email_result.get('success'):
238+
# Fallback or error if email fails?
239+
# For security, we should probably fail safe or log error.
240+
logger.error(f"Failed to send Login Email 2FA code: {email_result.get('error')}")
241+
return {'success': False, 'errors': ['Failed to send verification email. Please contact support.']}
242+
243+
return {
244+
'success': False,
245+
'require_email_2fa': True,
246+
'user_id': str(user['_id']),
247+
'message': 'Please enter the verification code sent to your email.'
248+
}
249+
216250
# Record IP address
217251
if ip_address:
218252
self.user_model.add_ip_address(str(user['_id']), ip_address)

frontend/components/UI/GlobeBackground.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ function GlobeBackgroundInner({ className = '' }: GlobeBackgroundProps) {
112112
const globeRef = useRef<any>(null);
113113
const pointerInteracting = useRef<number | null>(null);
114114
const pointerInteractionMovement = useRef(0);
115+
const touchStart = useRef<{ x: number, y: number } | null>(null);
115116
const [scrollY, setScrollY] = useState(0);
116117
const lastScrollY = useRef(0);
117118
const scrollVelocity = useRef(0);
@@ -317,9 +318,30 @@ function GlobeBackgroundInner({ className = '' }: GlobeBackgroundProps) {
317318
});
318319
}
319320
}}
321+
onTouchStart={(e) => {
322+
if (e.touches[0]) {
323+
touchStart.current = {
324+
x: e.touches[0].clientX,
325+
y: e.touches[0].clientY
326+
};
327+
pointerInteracting.current = e.touches[0].clientX;
328+
pointerInteractionMovement.current = 0;
329+
}
330+
}}
320331
onTouchMove={(e) => {
321-
if (pointerInteracting.current !== null && e.touches[0]) {
322-
const delta = e.touches[0].clientX - pointerInteracting.current;
332+
if (pointerInteracting.current !== null && e.touches[0] && touchStart.current) {
333+
const currentX = e.touches[0].clientX;
334+
const currentY = e.touches[0].clientY;
335+
const deltaX = currentX - touchStart.current.x;
336+
const deltaY = currentY - touchStart.current.y;
337+
338+
// If vertical movement is greater than horizontal movement,
339+
// assume it's a scroll and don't rotate the globe
340+
if (Math.abs(deltaY) > Math.abs(deltaX)) {
341+
return;
342+
}
343+
344+
const delta = currentX - pointerInteracting.current;
323345
pointerInteractionMovement.current = delta;
324346
api.start({
325347
r: delta / 100,

frontend/pages/index.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -388,28 +388,8 @@ export default function Home() {
388388

389389
// Handle Path-based Routing (SPA Fallback)
390390
// Only if we are effectively on the root page logic but the path is different
391-
if (path !== '/') {
392-
// Regex for /post/[id]
393-
const postMatch = path.match(/^\/post\/([a-zA-Z0-9_-]+)$/);
394-
if (postMatch) {
395-
const id = postMatch[1];
396-
const event = new CustomEvent('openPost', { detail: { postId: id } });
397-
setTimeout(() => window.dispatchEvent(event), 100);
398-
return;
399-
}
400-
401-
// Regex for /chat-room/[id]
402-
const chatMatch = path.match(/^\/chat-room\/([a-zA-Z0-9_-]+)$/);
403-
if (chatMatch) {
404-
const id = chatMatch[1];
405-
const event = new CustomEvent('openChatRoom', { detail: { chatRoomId: id } });
406-
setTimeout(() => window.dispatchEvent(event), 100);
407-
return;
408-
}
409-
410-
// If it's an unknown path served by index.html fallback, redirect to 404
411-
router.replace('/404');
412-
}
391+
// NOTE: This fallback logic is now minimal to avoid interfering with valid direct routes.
392+
// Next.js handles routing for /post/[id] and /chat-room/[id] natively.
413393
}, [router.isReady, router.query, router.asPath, topics]);
414394

415395

0 commit comments

Comments
 (0)