Harshit Ghosh commited on
Commit
8e35842
·
1 Parent(s): 97d1d40

Fix OTP: move to DB storage, cookie-free token URL params, fix notice banners

Browse files
Files changed (3) hide show
  1. auth_routes.py +97 -106
  2. models.py +27 -0
  3. templates/auth/verify_otp.html +28 -7
auth_routes.py CHANGED
@@ -24,13 +24,11 @@ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
24
  from sqlalchemy import func, or_
25
 
26
  from auth_utils import log_audit, validate_email, validate_password, validate_username
27
- from models import User, db, now_ist
28
 
29
  logger = logging.getLogger(__name__)
30
  auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
31
 
32
- OTP_SESSION_KEY = "pending_otp"
33
-
34
 
35
  def _parse_bool(raw: str | None, default: bool = False) -> bool:
36
  if raw is None:
@@ -50,73 +48,64 @@ def _generate_otp() -> str:
50
  return f"{secrets.randbelow(1_000_000):06d}"
51
 
52
 
53
- def _otp_payload_from_session() -> dict:
54
- payload = session.get(OTP_SESSION_KEY)
55
- return payload if isinstance(payload, dict) else {}
56
-
57
-
58
- def _store_otp(email: str, purpose: str, user_id: int | None = None) -> str:
59
- code = _generate_otp()
60
- expires_at = now_ist() + timedelta(minutes=10)
61
- session[OTP_SESSION_KEY] = {
62
- "email": email,
63
- "purpose": purpose,
64
- "user_id": user_id,
65
- "otp_hash": _hash_otp(code),
66
- "expires_at": expires_at.isoformat(),
67
- "attempts": 0,
68
- }
69
- session.modified = True
70
- return code
71
-
72
-
73
- def _clear_otp() -> None:
74
- session.pop(OTP_SESSION_KEY, None)
75
- session.modified = True
76
 
77
 
78
- def _extract_otp_from_form() -> str:
79
- direct = request.form.get("otp", "").strip()
80
- if direct:
81
- return direct
82
- digits = [request.form.get(f"d{i}", "").strip() for i in range(1, 7)]
83
- return "".join(digits)
84
 
85
 
86
- def _validate_otp(submitted_code: str, expected_purpose: str) -> tuple[bool, str, dict | None]:
87
- payload = _otp_payload_from_session()
88
- if not payload:
 
 
89
  return False, "OTP session is missing or expired. Please request a new code.", None
90
-
91
- if payload.get("purpose") != expected_purpose:
92
  return False, "OTP purpose mismatch. Please request a new code.", None
93
-
94
- expires_raw = payload.get("expires_at")
95
- if not expires_raw:
96
- _clear_otp()
97
- return False, "OTP is invalid. Please request a new code.", None
98
- try:
99
- expires_at = datetime.fromisoformat(expires_raw)
100
- except Exception:
101
- _clear_otp()
102
- return False, "OTP is invalid. Please request a new code.", None
103
-
104
- if now_ist() > expires_at:
105
- _clear_otp()
106
  return False, "OTP expired. Please request a new code.", None
107
-
108
- attempts = int(payload.get("attempts", 0))
109
- if attempts >= 5:
110
- _clear_otp()
111
  return False, "Too many failed attempts. Please request a new code.", None
 
 
 
 
 
 
112
 
113
- if _hash_otp(submitted_code) != payload.get("otp_hash"):
114
- payload["attempts"] = attempts + 1
115
- session[OTP_SESSION_KEY] = payload
116
- session.modified = True
117
- return False, "Invalid OTP code.", None
118
 
119
- return True, "", payload
 
 
120
 
121
 
122
  def _otp_body(code: str, purpose: str) -> str:
@@ -245,7 +234,7 @@ def register():
245
  db.session.add(user)
246
  db.session.commit()
247
 
248
- otp_code = _store_otp(email=user.email, purpose="verify_email", user_id=user.id)
249
  sent = _send_email(
250
  user.email,
251
  "Your ICH Screening verification code",
@@ -255,12 +244,9 @@ def register():
255
  logger.info("DEV OTP for %s: %s", user.email, otp_code)
256
 
257
  log_audit('user_registered', user_id=user.id, status='success')
258
- if sent:
259
- flash('Registration successful. We sent a verification code to your email.', 'success')
260
- else:
261
- flash('Registration successful, but email delivery failed. Configure SMTP and resend OTP.', 'warning')
262
-
263
- return redirect(url_for('auth.verify_otp', purpose='verify_email', email=user.email))
264
 
265
  except Exception as e:
266
  db.session.rollback()
@@ -298,7 +284,7 @@ def login():
298
  return render_template('auth/login.html'), 401
299
 
300
  if not user.is_active:
301
- otp_code = _store_otp(email=user.email, purpose="verify_email", user_id=user.id)
302
  sent = _send_email(
303
  user.email,
304
  "Your ICH Screening verification code",
@@ -307,11 +293,9 @@ def login():
307
  if _auth_email_debug_enabled():
308
  logger.info("DEV OTP resend/login for %s: %s", user.email, otp_code)
309
  log_audit('login_failed', user_id=user.id, status='failure', details='Email not verified')
310
- if sent:
311
- flash('Please verify your email. A fresh OTP code was sent.', 'info')
312
- else:
313
- flash('Please verify your email. OTP generation worked but SMTP is not configured.', 'warning')
314
- return redirect(url_for('auth.verify_otp', purpose='verify_email', email=user.email))
315
 
316
  if not user.check_password(password):
317
  logger.warning("Failed login attempt for identifier: %s", identifier)
@@ -384,60 +368,67 @@ def forgot_password():
384
 
385
  @auth_bp.route('/verify-otp', methods=['GET', 'POST'])
386
  def verify_otp():
387
- """Verify one-time password for account email verification."""
388
- purpose = request.args.get('purpose', 'verify_email')
389
- payload = _otp_payload_from_session()
390
- email = payload.get('email') or request.args.get('email', '')
 
391
 
392
  if request.method == 'POST':
393
- submitted = _extract_otp_from_form()
 
 
 
 
 
394
  if len(submitted) != 6 or not submitted.isdigit():
395
- flash('Please enter the 6-digit OTP code.', 'error')
396
- return render_template('auth/verify_otp.html', email=email, purpose=purpose), 400
397
 
398
- ok, msg, verified_payload = _validate_otp(submitted, purpose)
399
  if not ok:
400
- flash(msg, 'error')
401
- return render_template('auth/verify_otp.html', email=email, purpose=purpose), 400
 
402
 
403
- user_id = verified_payload.get("user_id") if verified_payload else None
404
- user = User.query.get(int(user_id)) if user_id else None
405
  if not user:
406
- _clear_otp()
407
- flash('Verification session is invalid. Please register again.', 'error')
408
- return redirect(url_for('auth.register'))
409
 
410
  user.is_active = True
411
- db.session.commit()
412
- _clear_otp()
413
  log_audit('email_verified', user_id=user.id, status='success')
414
  flash('Email verified. You can now sign in.', 'success')
415
  return redirect(url_for('auth.login'))
416
 
417
- return render_template('auth/verify_otp.html', email=email, purpose=purpose)
 
418
 
419
 
420
  @auth_bp.route('/resend-otp', methods=['POST'])
421
  def resend_otp():
422
- """Resend OTP for the current verification session."""
423
- payload = _otp_payload_from_session()
424
- if not payload:
425
- flash('No active OTP session. Please register again.', 'error')
426
- return redirect(url_for('auth.register'))
427
-
428
- email = payload.get("email", "")
429
- purpose = payload.get("purpose", "verify_email")
430
- user_id = payload.get("user_id")
431
- new_code = _store_otp(email=email, purpose=purpose, user_id=user_id)
 
 
 
 
432
  sent = _send_email(email, "Your ICH Screening verification code", _otp_body(new_code, purpose))
433
  if _auth_email_debug_enabled():
434
  logger.info("DEV OTP resend for %s: %s", email, new_code)
435
 
436
- if sent:
437
- flash('A new OTP code was sent to your email.', 'success')
438
- else:
439
- flash('Failed to send OTP email. Please configure SMTP settings.', 'error')
440
- return redirect(url_for('auth.verify_otp', purpose=purpose, email=email))
441
 
442
 
443
  @auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
 
24
  from sqlalchemy import func, or_
25
 
26
  from auth_utils import log_audit, validate_email, validate_password, validate_username
27
+ from models import PendingOtp, User, db, now_ist
28
 
29
  logger = logging.getLogger(__name__)
30
  auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
31
 
 
 
32
 
33
  def _parse_bool(raw: str | None, default: bool = False) -> bool:
34
  if raw is None:
 
48
  return f"{secrets.randbelow(1_000_000):06d}"
49
 
50
 
51
+ # ── DB-based OTP helpers (session-cookie-free) ────────────────────────────────
52
+
53
+ def _store_otp(email: str, purpose: str, user_id: int | None = None,
54
+ pending_value: str | None = None) -> tuple[str, str]:
55
+ """Generate a new OTP, persist it in the DB, return (code, token)."""
56
+ # Delete any existing pending OTPs for this email+purpose to keep the table clean
57
+ PendingOtp.query.filter_by(email=email, purpose=purpose).delete()
58
+ code = _generate_otp()
59
+ token = secrets.token_urlsafe(32)
60
+ row = PendingOtp(
61
+ token = token,
62
+ email = email,
63
+ purpose = purpose,
64
+ otp_hash = _hash_otp(code),
65
+ expires_at = now_ist() + timedelta(minutes=10),
66
+ attempts = 0,
67
+ user_id = user_id,
68
+ pending_value = pending_value,
69
+ )
70
+ db.session.add(row)
71
+ db.session.commit()
72
+ return code, token
 
73
 
74
 
75
+ def _otp_row_from_token(token: str | None) -> PendingOtp | None:
76
+ """Look up a PendingOtp row by its opaque token."""
77
+ if not token:
78
+ return None
79
+ return PendingOtp.query.filter_by(token=token).first()
 
80
 
81
 
82
+ def _validate_otp(submitted_code: str, expected_purpose: str,
83
+ token: str | None) -> tuple[bool, str, PendingOtp | None]:
84
+ """Validate a submitted OTP code. Returns (ok, message, row)."""
85
+ row = _otp_row_from_token(token)
86
+ if not row:
87
  return False, "OTP session is missing or expired. Please request a new code.", None
88
+ if row.purpose != expected_purpose:
 
89
  return False, "OTP purpose mismatch. Please request a new code.", None
90
+ if row.is_expired():
91
+ db.session.delete(row)
92
+ db.session.commit()
 
 
 
 
 
 
 
 
 
 
93
  return False, "OTP expired. Please request a new code.", None
94
+ if row.attempts >= 5:
95
+ db.session.delete(row)
96
+ db.session.commit()
 
97
  return False, "Too many failed attempts. Please request a new code.", None
98
+ if _hash_otp(submitted_code) != row.otp_hash:
99
+ row.attempts += 1
100
+ db.session.commit()
101
+ remaining = 5 - row.attempts
102
+ return False, f"Invalid OTP code. {remaining} attempt(s) remaining.", None
103
+ return True, "", row
104
 
 
 
 
 
 
105
 
106
+ def _clear_otp_row(row: PendingOtp) -> None:
107
+ db.session.delete(row)
108
+ db.session.commit()
109
 
110
 
111
  def _otp_body(code: str, purpose: str) -> str:
 
234
  db.session.add(user)
235
  db.session.commit()
236
 
237
+ otp_code, otp_token = _store_otp(email=user.email, purpose="verify_email", user_id=user.id)
238
  sent = _send_email(
239
  user.email,
240
  "Your ICH Screening verification code",
 
244
  logger.info("DEV OTP for %s: %s", user.email, otp_code)
245
 
246
  log_audit('user_registered', user_id=user.id, status='success')
247
+ notice = 'otp_sent' if sent else 'otp_email_failed'
248
+ return redirect(url_for('auth.verify_otp', purpose='verify_email',
249
+ email=user.email, otp_token=otp_token, notice=notice))
 
 
 
250
 
251
  except Exception as e:
252
  db.session.rollback()
 
284
  return render_template('auth/login.html'), 401
285
 
286
  if not user.is_active:
287
+ otp_code, otp_token = _store_otp(email=user.email, purpose="verify_email", user_id=user.id)
288
  sent = _send_email(
289
  user.email,
290
  "Your ICH Screening verification code",
 
293
  if _auth_email_debug_enabled():
294
  logger.info("DEV OTP resend/login for %s: %s", user.email, otp_code)
295
  log_audit('login_failed', user_id=user.id, status='failure', details='Email not verified')
296
+ notice = 'otp_resent' if sent else 'otp_email_failed'
297
+ return redirect(url_for('auth.verify_otp', purpose='verify_email',
298
+ email=user.email, otp_token=otp_token, notice=notice))
 
 
299
 
300
  if not user.check_password(password):
301
  logger.warning("Failed login attempt for identifier: %s", identifier)
 
368
 
369
  @auth_bp.route('/verify-otp', methods=['GET', 'POST'])
370
  def verify_otp():
371
+ """Verify one-time password uses DB row via otp_token URL param (cookie-free)."""
372
+ purpose = request.args.get('purpose', 'verify_email')
373
+ otp_token = request.args.get('otp_token') or request.form.get('otp_token', '')
374
+ email = request.args.get('email', '')
375
+ notice = request.args.get('notice', '')
376
 
377
  if request.method == 'POST':
378
+ # Reconstruct digits → 6-char string
379
+ direct = request.form.get('otp', '').strip()
380
+ if not direct:
381
+ direct = ''.join(request.form.get(f'd{i}', '').strip() for i in range(1, 7))
382
+ submitted = direct
383
+
384
  if len(submitted) != 6 or not submitted.isdigit():
385
+ return redirect(url_for('auth.verify_otp', purpose=purpose, email=email,
386
+ otp_token=otp_token, notice='invalid_digits'))
387
 
388
+ ok, msg, row = _validate_otp(submitted, purpose, otp_token)
389
  if not ok:
390
+ logger.warning("OTP validation failed for %s: %s", email, msg)
391
+ return redirect(url_for('auth.verify_otp', purpose=purpose, email=email,
392
+ otp_token=otp_token, notice='invalid_code'))
393
 
394
+ user = User.query.get(int(row.user_id)) if row.user_id else None
 
395
  if not user:
396
+ _clear_otp_row(row)
397
+ return redirect(url_for('auth.register') + '?notice=session_invalid')
 
398
 
399
  user.is_active = True
400
+ _clear_otp_row(row)
 
401
  log_audit('email_verified', user_id=user.id, status='success')
402
  flash('Email verified. You can now sign in.', 'success')
403
  return redirect(url_for('auth.login'))
404
 
405
+ return render_template('auth/verify_otp.html', email=email, purpose=purpose,
406
+ otp_token=otp_token, notice=notice)
407
 
408
 
409
  @auth_bp.route('/resend-otp', methods=['POST'])
410
  def resend_otp():
411
+ """Resend OTP recovers context from the otp_token in the form, not the session."""
412
+ old_token = request.form.get('otp_token', '')
413
+ email = request.form.get('email', '')
414
+ purpose = request.form.get('purpose', 'verify_email')
415
+
416
+ # Look up the old row to get user_id
417
+ old_row = _otp_row_from_token(old_token)
418
+ user_id = old_row.user_id if old_row else None
419
+
420
+ # If we have no row and no email, we can't recover
421
+ if not email:
422
+ return redirect(url_for('auth.register') + '?notice=session_invalid')
423
+
424
+ new_code, new_token = _store_otp(email=email, purpose=purpose, user_id=user_id)
425
  sent = _send_email(email, "Your ICH Screening verification code", _otp_body(new_code, purpose))
426
  if _auth_email_debug_enabled():
427
  logger.info("DEV OTP resend for %s: %s", email, new_code)
428
 
429
+ notice = 'otp_resent' if sent else 'otp_email_failed'
430
+ return redirect(url_for('auth.verify_otp', purpose=purpose, email=email,
431
+ otp_token=new_token, notice=notice))
 
 
432
 
433
 
434
  @auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
models.py CHANGED
@@ -28,6 +28,9 @@ class User(UserMixin, db.Model):
28
  created_at = db.Column(db.DateTime, default=now_ist, nullable=False)
29
  updated_at = db.Column(db.DateTime, default=now_ist, onupdate=now_ist)
30
  is_active = db.Column(db.Boolean, default=True, nullable=False)
 
 
 
31
 
32
  # Relationships
33
  screening_uploads = db.relationship('ScreeningUpload', backref='user', lazy=True, cascade='all, delete-orphan')
@@ -119,3 +122,27 @@ class AuditLog(db.Model):
119
 
120
  def __repr__(self):
121
  return f'<AuditLog {self.action} - user {self.user_id} - {self.timestamp}>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  created_at = db.Column(db.DateTime, default=now_ist, nullable=False)
29
  updated_at = db.Column(db.DateTime, default=now_ist, onupdate=now_ist)
30
  is_active = db.Column(db.Boolean, default=True, nullable=False)
31
+ # Avatar (Cloudinary)
32
+ avatar_url = db.Column(db.String(500), nullable=True)
33
+ avatar_public_id = db.Column(db.String(255), nullable=True)
34
 
35
  # Relationships
36
  screening_uploads = db.relationship('ScreeningUpload', backref='user', lazy=True, cascade='all, delete-orphan')
 
122
 
123
  def __repr__(self):
124
  return f'<AuditLog {self.action} - user {self.user_id} - {self.timestamp}>'
125
+
126
+
127
+ class PendingOtp(db.Model):
128
+ """Server-side OTP storage — avoids relying on session cookies (broken in cross-origin iframes)."""
129
+ __tablename__ = 'pending_otps'
130
+
131
+ id = db.Column(db.Integer, primary_key=True)
132
+ # Opaque lookup token sent to the browser as a URL param (never the raw code)
133
+ token = db.Column(db.String(64), unique=True, nullable=False, index=True)
134
+ email = db.Column(db.String(120), nullable=False, index=True)
135
+ purpose = db.Column(db.String(50), nullable=False) # verify_email | change_username | change_email
136
+ otp_hash = db.Column(db.String(64), nullable=False) # SHA-256 of the 6-digit code
137
+ expires_at = db.Column(db.DateTime, nullable=False)
138
+ attempts = db.Column(db.Integer, default=0, nullable=False)
139
+ # Optional: store pending new value (e.g. new username / new email)
140
+ pending_value = db.Column(db.String(255), nullable=True)
141
+ # Optional FK — may be NULL for pre-registration flows
142
+ user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=True, index=True)
143
+
144
+ def is_expired(self) -> bool:
145
+ return now_ist() > self.expires_at
146
+
147
+ def __repr__(self):
148
+ return f'<PendingOtp {self.purpose} for {self.email} expires {self.expires_at}>'
templates/auth/verify_otp.html CHANGED
@@ -42,6 +42,21 @@
42
  <p>Sent to <strong>{{ email or 'your registered email' }}</strong></p>
43
  </div>
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  {% with messages = get_flashed_messages(with_categories=true) %}
46
  {% if messages %}
47
  <div class="auth-alerts">
@@ -52,21 +67,27 @@
52
  {% endif %}
53
  {% endwith %}
54
 
55
- <form method="POST" class="auth-form" id="otpForm">
 
 
 
56
  <input type="hidden" name="otp" id="otpCombined"/>
57
  <div class="otp-grid" style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px;">
58
- <input type="text" inputmode="numeric" maxlength="1" name="d1" class="otp-digit" required/>
59
- <input type="text" inputmode="numeric" maxlength="1" name="d2" class="otp-digit" required/>
60
- <input type="text" inputmode="numeric" maxlength="1" name="d3" class="otp-digit" required/>
61
- <input type="text" inputmode="numeric" maxlength="1" name="d4" class="otp-digit" required/>
62
- <input type="text" inputmode="numeric" maxlength="1" name="d5" class="otp-digit" required/>
63
- <input type="text" inputmode="numeric" maxlength="1" name="d6" class="otp-digit" required/>
64
  </div>
65
 
66
  <button type="submit" class="btn-auth-submit" style="margin-top:16px;">Verify &amp; Activate Account</button>
67
  </form>
68
 
69
  <form method="POST" action="{{ url_for('auth.resend_otp') }}" style="margin-top:12px;">
 
 
 
70
  <button type="submit" class="btn-auth-submit" style="background:#0f1b31;border:1px solid #2a3f68;">Didn't receive it? Resend code</button>
71
  </form>
72
 
 
42
  <p>Sent to <strong>{{ email or 'your registered email' }}</strong></p>
43
  </div>
44
 
45
+ {# --- Notice banners rendered from URL param (cookie-free) --- #}
46
+ {% set notice = request.args.get('notice', '') %}
47
+ {% if notice == 'otp_sent' %}
48
+ <div class="alert alert-success">✅ Registration successful. A verification code was sent to your email.</div>
49
+ {% elif notice == 'otp_resent' %}
50
+ <div class="alert alert-success">✅ A fresh verification code was sent to your email.</div>
51
+ {% elif notice == 'otp_email_failed' %}
52
+ <div class="alert alert-info">⚠️ Account created, but the email could not be sent. Please use "Resend code" below.</div>
53
+ {% elif notice == 'invalid_digits' %}
54
+ <div class="alert alert-error">Please enter the full 6-digit code.</div>
55
+ {% elif notice == 'invalid_code' %}
56
+ <div class="alert alert-error">Invalid or expired code. Please try again or request a new one.</div>
57
+ {% endif %}
58
+
59
+ {# --- Legacy flash messages (still shown for local dev) --- #}
60
  {% with messages = get_flashed_messages(with_categories=true) %}
61
  {% if messages %}
62
  <div class="auth-alerts">
 
67
  {% endif %}
68
  {% endwith %}
69
 
70
+ <form method="POST" class="auth-form" id="otpForm"
71
+ action="{{ url_for('auth.verify_otp', purpose=purpose, email=email, otp_token=otp_token) }}">
72
+ {# Carry the token in a hidden input so it survives the POST #}
73
+ <input type="hidden" name="otp_token" value="{{ otp_token }}"/>
74
  <input type="hidden" name="otp" id="otpCombined"/>
75
  <div class="otp-grid" style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px;">
76
+ <input type="text" inputmode="numeric" maxlength="1" name="d1" class="otp-digit" autocomplete="off" required/>
77
+ <input type="text" inputmode="numeric" maxlength="1" name="d2" class="otp-digit" autocomplete="off" required/>
78
+ <input type="text" inputmode="numeric" maxlength="1" name="d3" class="otp-digit" autocomplete="off" required/>
79
+ <input type="text" inputmode="numeric" maxlength="1" name="d4" class="otp-digit" autocomplete="off" required/>
80
+ <input type="text" inputmode="numeric" maxlength="1" name="d5" class="otp-digit" autocomplete="off" required/>
81
+ <input type="text" inputmode="numeric" maxlength="1" name="d6" class="otp-digit" autocomplete="off" required/>
82
  </div>
83
 
84
  <button type="submit" class="btn-auth-submit" style="margin-top:16px;">Verify &amp; Activate Account</button>
85
  </form>
86
 
87
  <form method="POST" action="{{ url_for('auth.resend_otp') }}" style="margin-top:12px;">
88
+ <input type="hidden" name="otp_token" value="{{ otp_token }}"/>
89
+ <input type="hidden" name="email" value="{{ email }}"/>
90
+ <input type="hidden" name="purpose" value="{{ purpose }}"/>
91
  <button type="submit" class="btn-auth-submit" style="background:#0f1b31;border:1px solid #2a3f68;">Didn't receive it? Resend code</button>
92
  </form>
93