Skip to content

[Eng-32] feat :create otp based reset for password#3630

Open
nandkishorr wants to merge 12 commits intodevelopfrom
ENG-32-create-otp-based-reset-for-password
Open

[Eng-32] feat :create otp based reset for password#3630
nandkishorr wants to merge 12 commits intodevelopfrom
ENG-32-create-otp-based-reset-for-password

Conversation

@nandkishorr
Copy link
Copy Markdown
Contributor

@nandkishorr nandkishorr commented Apr 27, 2026

Proposed Changes

  • Added otp based reset password apis.
  • Api to request for an OTP to reset password
  • API that accepts the OTP and the password to reset to
  • configurable abuse limits is also implemented,

Merge Checklist

  • Tests added/fixed
  • Update docs in /docs
  • Linting Complete
  • Any other necessary step

Only PR's with test cases included and passing lint and test pipelines will be reviewed

@ohcnetwork/care-backend-maintainers @ohcnetwork/care-backend-admins

Summary by CodeRabbit

Release Notes

  • New Features

    • OTP-based password reset: Users can now securely reset passwords using one-time passwords delivered via SMS.
  • Refactor

    • Unified OTP system architecture across login and password recovery flows for improved consistency and reliability.
    • Enhanced validation error handling in API responses.

@nandkishorr nandkishorr requested a review from a team as a code owner April 27, 2026 18:04
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

Warning

Rate limit exceeded

@nandkishorr has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 50 minutes and 17 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b248b3d3-e1f8-4def-a99a-50179e3aa8a8

📥 Commits

Reviewing files that changed from the base of the PR and between e15ae5a and afeb68c.

📒 Files selected for processing (1)
  • care/users/api/otp_viewset/reset_password.py
📝 Walkthrough

Walkthrough

This PR introduces a centralized OTP flow for both login and password reset operations. The PatientMobileOTP model is renamed to MobileOTP, a new OTPResetPasswordView viewset handles password-reset requests, a shared send_otp function consolidates OTP delivery logic, and Pydantic-based validation schemas are introduced alongside API routing and exception handling updates.

Changes

Unified OTP Password Reset & Login Refactor

Layer / File(s) Summary
Model & Migration
care/facility/migrations/0485_rename_patientmobileotp_mobileotp.py, care/facility/models/patient.py
PatientMobileOTP model is renamed to MobileOTP via migration and model definition.
Configuration
config/settings/base.py
New setting OTP_SMS_RESET_PASSWORD_TEMPLATE_PATH added to configure SMS template file path from environment variable with fallback default.
Template Asset
care/templates/sms/otp_reset_sms.txt
New SMS template for password-reset OTP delivery with placeholder {{random_otp}} and non-sharing instruction.
Specs & Type System
care/emr/api/otp_viewsets/login.py
New OTPType enum distinguishes login and reset_password purposes; OTPLoginSpec inherits from OTPRequestBaseSpec and adds OTP field with length validation.
Core OTP Logic
care/emr/api/otp_viewsets/login.py, care/users/api/otp_viewset/reset_password.py
Centralized send_otp(phone_number, purpose) function handles retry limits, OTP generation, template selection, SMS delivery, and persistence. New OTPResetPasswordView implements send and confirm actions with rate limiting, user validation, OTP verification, and password reset logic using OTPResetSendSpec and OTPResetConfirmSpec.
API Integration
config/api_router.py
OTPResetPasswordView imported and registered as "otp/password_reset" route with basename "otp-password-reset".
Error Handling
config/exception_handler.py
Exception handler now catches Pydantic ValidationError and converts to DRF ValidationError for consistent error responses.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main feature: OTP-based password reset functionality, with clear intent despite minor formatting quirks.
Description check ✅ Passed The description addresses proposed changes and mentions the issue reference, but the Associated Issue section lacks an actual issue link and Architecture changes section is missing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ENG-32-create-otp-based-reset-for-password

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nandkishorr nandkishorr self-assigned this Apr 27, 2026
@nandkishorr nandkishorr requested a review from Copilot April 27, 2026 18:08
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
care/users/models.py (1)

271-276: Add an index for the OTP lookup query, and please pick a story for is_used.

A couple of things worth tightening up:

  1. Missing index on the hot path. OTPResetSendView and OTPResetConfirmView both filter on (phone_number, is_used, created_date). Without an index, this scans the table on every send/confirm. A composite index will scale much better.
  2. is_used is never set to True. The confirm view just .delete()s all OTPs for the phone number. Either keep the flag and mark it consumed (preferred — preserves an audit trail and avoids deleting unrelated rows), or drop the field entirely. Right now it's dead weight that pretends to do something.
  3. Consider an explicit expires_at (or rely strictly on OTP_REPEAT_WINDOW everywhere), and a Meta.verbose_name for admin niceties.
♻️ Proposed refactor
 class UserMobileOTP(BaseModel):
     is_used = models.BooleanField(default=False)
     phone_number = models.CharField(
         max_length=14, validators=[mobile_or_landline_number_validator]
     )
     otp = models.CharField(max_length=10)
+
+    class Meta:
+        verbose_name = "User Mobile OTP"
+        indexes = [
+            models.Index(
+                fields=["phone_number", "is_used", "-created_date"],
+                name="user_mobile_otp_lookup_idx",
+            ),
+        ]

As per coding guidelines: "Implement database indexing and query optimization techniques for better performance."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@care/users/models.py` around lines 271 - 276, Add a composite DB index on
UserMobileOTP for (phone_number, is_used, created_date) and either use or remove
the is_used flag: update the model class UserMobileOTP to include a Meta.indexes
entry for that composite index and add Meta.verbose_name; then modify
OTPResetConfirmView (and OTPResetSendView lookups) to mark matching OTP rows as
consumed by setting is_used=True (instead of deleting all rows) and/or set an
explicit expires_at timestamp on creation so queries can filter on expiry rather
than relying solely on OTP_REPEAT_WINDOW. Ensure all references to created_date
filtering in OTPResetSendView and OTPResetConfirmView are updated to use the new
index and expires_at where applicable.
care/users/api/otp_viewset/reset_password.py (2)

119-125: Recommended: invalidate active sessions/tokens and minimize the save.

Two follow-ups on the post-reset bookkeeping:

  • After resetting a password, existing JWT refresh tokens and Django sessions remain valid. A common expectation for password-reset flows is that all other sessions are killed. Consider blacklisting outstanding refresh tokens for the user (simplejwt's OutstandingToken / BlacklistedToken) and bumping a password_changed_at field if you have one.
  • user.save() writes every field; pass update_fields=["password"] to avoid spurious side-effects from any custom save overrides.
  • Per the earlier comment on UserMobileOTP.is_used, prefer marking the consumed row as is_used=True instead of deleting all rows for the number — useful for audit/forensics.
♻️ Suggested patch
-        UserMobileOTP.objects.filter(
-            phone_number=data.phone_number,
-        ).delete()
-
-        user.set_password(data.password)
-        user.save()
+        otp_obj.is_used = True
+        otp_obj.save(update_fields=["is_used"])
+
+        user.set_password(data.password)
+        user.save(update_fields=["password"])
+        # TODO: invalidate outstanding refresh tokens / sessions for this user.
         return Response({"message": "Password reset successful"})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@care/users/api/otp_viewset/reset_password.py` around lines 119 - 125, Replace
the destructive delete and plain save with explicit post-reset bookkeeping: mark
the consumed UserMobileOTP row(s) using UserMobileOTP.is_used=True (rather than
deleting) for auditability, call user.set_password(data.password) and persist
only the password change via user.save(update_fields=["password"]), and
invalidate existing sessions/tokens by blacklisting the user's outstanding JWT
refresh tokens (use simplejwt's OutstandingToken/BlacklistedToken) and/or
updating a password_changed_at timestamp on the user record to force session
revocation. Ensure you reference UserMobileOTP, user.set_password,
user.save(update_fields=["password"]), OutstandingToken/BlacklistedToken and
password_changed_at when implementing these changes.

76-89: Narrow the exception and don't break atomicity between SMS and DB record.

A few smaller items here:

  • except Exception (Ruff BLE001) hides programming bugs. Catch the specific exception(s) raised by get_sms_content / sms.send_text_message instead.
  • logger.error(e) doesn't include the traceback — logger.exception("Failed to send reset OTP") is more useful and avoids stringifying the exception.
  • The DB record is created after a successful SMS send. If UserMobileOTP.objects.create then fails (rare but possible), you've SMS'd an OTP that nobody can verify. Consider creating the record first inside a transaction, then sending the SMS, and rolling back / marking failed on send error. Or at least swap to logger.exception and revisit ordering.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@care/users/api/otp_viewset/reset_password.py` around lines 76 - 89, The
try/except currently catches Exception and logs without traceback and sends SMS
before creating the DB record, which risks losing atomicity; instead, wrap the
creation of UserMobileOTP.objects.create in a transaction.atomic block, create
the OTP record first, then call get_sms_content and sms.send_text_message;
narrow the except to the specific exceptions those functions raise (e.g.,
template/rendering errors and your SMS client error) and use
logger.exception("Failed to send reset OTP") to log tracebacks; if sending
fails, roll back or delete/mark the created UserMobileOTP record so an unsync'd
SMS can't be verified.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@care/users/api/otp_viewset/reset_password.py`:
- Around line 22-26: rand_pass currently returns a hard-coded OTP when
settings.USE_SMS is False, allowing account takeover; instead always generate a
secure random OTP in rand_pass (use secrets.choice over string.digits for the
requested size) and only gate delivery on settings.USE_SMS (i.e., do not send
SMS when disabled), and for deterministic tests override/mock rand_pass in test
fixtures rather than relying on USE_SMS; if you must keep a deterministic
fallback, restrict that code path to settings.DEBUG and an explicit test-mode
flag (or when running under the test runner) and add a conspicuous warning,
referencing rand_pass and the OTPResetConfirmView delivery logic so generation
and delivery are separated.
- Around line 47-49: The OTPResetConfirmSpec currently allows any 4-char
password and bypasses Django validators; in OTPResetConfirmView.post, after you
locate the user, call
django.contrib.auth.password_validation.validate_password(data.password,
user=user) and catch django.core.exceptions.ValidationError to re-raise as
rest_framework.exceptions.ValidationError({"password":
validation_error.messages}) so validation failures are returned on the
"password" field; also consider increasing OTPResetConfirmSpec.password Field
min_length to at least 8 to match MinimumLengthValidator.
- Around line 60-72: The current flow leaks user existence because the sent_otps
count check runs before the user existence check (sent_otps, User,
settings.OTP_MAX_REPEATS_WINDOW) and is racy; reorder the logic so you first
check whether the phone_number exists (User.objects.filter(...).exists()) and
always return the same silent response for non-existent numbers, then apply the
repeat-window check only for existing users. To fix the TOCTOU race on
sent_otps, wrap the rate check+creation in a DB transaction
(transaction.atomic()) and use a locking read (select_for_update() on the
UserMobileOTP queryset) or replace with an atomic Redis counter; also add/stack
a coarser IP-based rate limit via django_ratelimit for the reset_password
endpoint to mitigate attacker rotation.
- Line 75: Remove the credential leak by deleting or disabling the
logger.info(random_otp) call; do not log OTP values. If a local debug aid is
required, guard it with settings.DEBUG and use logger.debug(...) (and log only a
one-way hash or an OTP id, not the OTP itself). Also revisit the rand_pass
fallback ("45612") in the same module and replace it with a secure, non-constant
fallback or raise an error so secrets are not predictable.
- Around line 32-40: The validator OTPBaseSpec.validate_phone_number currently
calls mobile_validator and catches Exception broadly, which mismatches
User.phone_number and UserMobileOTP.phone_number (which use
mobile_or_landline_number_validator) and hides intent; update
validate_phone_number to either call mobile_or_landline_number_validator if
landlines should be accepted, or keep mobile_validator but add an inline comment
stating landline-only numbers are intentionally rejected for OTP, and replace
the broad "except Exception" with catching the specific ValidationError from
PhoneNumberValidator so only validation failures are handled and original
exceptions propagate.
- Around line 100-117: In the post method of the OTP reset flow
(OTPResetConfirmSpec input handling in post), replace the explicit "No account
found" ValidationError with the same generic OTP error used for invalid OTPs so
phone enumeration is not possible; add tracking on UserMobileOTP (e.g., a
failed_attempts integer and last_failed_at) and increment it on each mismatch,
invalidating or marking is_used=True when failed_attempts reaches a constant
(e.g., OTP_MAX_FAILED_ATTEMPTS = 5) to prevent brute-force, and perform the OTP
comparison using secrets.compare_digest(otp_obj.otp, data.otp) for
timing-safety; optionally add/annotate a rate-limit decorator (django_ratelimit)
keyed by phone_number+IP to provide a second layer of protection.

---

Nitpick comments:
In `@care/users/api/otp_viewset/reset_password.py`:
- Around line 119-125: Replace the destructive delete and plain save with
explicit post-reset bookkeeping: mark the consumed UserMobileOTP row(s) using
UserMobileOTP.is_used=True (rather than deleting) for auditability, call
user.set_password(data.password) and persist only the password change via
user.save(update_fields=["password"]), and invalidate existing sessions/tokens
by blacklisting the user's outstanding JWT refresh tokens (use simplejwt's
OutstandingToken/BlacklistedToken) and/or updating a password_changed_at
timestamp on the user record to force session revocation. Ensure you reference
UserMobileOTP, user.set_password, user.save(update_fields=["password"]),
OutstandingToken/BlacklistedToken and password_changed_at when implementing
these changes.
- Around line 76-89: The try/except currently catches Exception and logs without
traceback and sends SMS before creating the DB record, which risks losing
atomicity; instead, wrap the creation of UserMobileOTP.objects.create in a
transaction.atomic block, create the OTP record first, then call get_sms_content
and sms.send_text_message; narrow the except to the specific exceptions those
functions raise (e.g., template/rendering errors and your SMS client error) and
use logger.exception("Failed to send reset OTP") to log tracebacks; if sending
fails, roll back or delete/mark the created UserMobileOTP record so an unsync'd
SMS can't be verified.

In `@care/users/models.py`:
- Around line 271-276: Add a composite DB index on UserMobileOTP for
(phone_number, is_used, created_date) and either use or remove the is_used flag:
update the model class UserMobileOTP to include a Meta.indexes entry for that
composite index and add Meta.verbose_name; then modify OTPResetConfirmView (and
OTPResetSendView lookups) to mark matching OTP rows as consumed by setting
is_used=True (instead of deleting all rows) and/or set an explicit expires_at
timestamp on creation so queries can filter on expiry rather than relying solely
on OTP_REPEAT_WINDOW. Ensure all references to created_date filtering in
OTPResetSendView and OTPResetConfirmView are updated to use the new index and
expires_at where applicable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e4176c30-f594-496e-88f8-b595ce32a95b

📥 Commits

Reviewing files that changed from the base of the PR and between 88bee82 and f666b68.

📒 Files selected for processing (7)
  • care/templates/sms/otp_reset_sms.txt
  • care/users/api/otp_viewset/__init__.py
  • care/users/api/otp_viewset/reset_password.py
  • care/users/migrations/0028_usermobileotp.py
  • care/users/models.py
  • config/settings/base.py
  • config/urls.py

Comment thread care/users/api/otp_viewset/reset_password.py Outdated
Comment on lines +32 to +40
@field_validator("phone_number")
@classmethod
def validate_phone_number(cls, value):
try:
mobile_validator(value)
except Exception as e:
msg = "Invalid phone number"
raise ValueError(msg) from e
return value
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm what mobile_validator and mobile_or_landline_number_validator each accept
ast-grep --pattern $'mobile_validator = $_'
ast-grep --pattern $'mobile_or_landline_number_validator = $_'
rg -nP -C3 '\bclass\s+PhoneNumberValidator\b'

Repository: ohcnetwork/care

Length of output: 771


🏁 Script executed:

# Find where User model defines phone_number field
rg -n "class User\b" care/users/models.py -A 50 | head -100

Repository: ohcnetwork/care

Length of output: 2084


🏁 Script executed:

# Find where UserMobileOTP model defines phone_number field
rg -n "class UserMobileOTP" -A 30

Repository: ohcnetwork/care

Length of output: 447


🏁 Script executed:

# Check what exception PhoneNumberValidator/RegexValidator raises
rg -n "raise\s+(ValidationError|ValueError)" care/utils/models/validators.py -B 2 -A 2

Repository: ohcnetwork/care

Length of output: 1749


🏁 Script executed:

# Verify the OTPBaseSpec import and actual validator usage
cat -n care/users/api/otp_viewset/reset_password.py | head -50

Repository: ohcnetwork/care

Length of output: 1726


Validator mismatch and overly broad exception handling.

OTPBaseSpec.validate_phone_number uses mobile_validator while both User.phone_number and UserMobileOTP.phone_number use mobile_or_landline_number_validator. A user with a landline-only number will encounter a generic "Invalid phone number" error here—likely by design (landlines don't receive SMS), but a one-line comment stating that intent would save future readers from needless codebase archaeology.

Also: catching Exception is wider than necessary. PhoneNumberValidator raises ValidationError, so catch that specifically instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@care/users/api/otp_viewset/reset_password.py` around lines 32 - 40, The
validator OTPBaseSpec.validate_phone_number currently calls mobile_validator and
catches Exception broadly, which mismatches User.phone_number and
UserMobileOTP.phone_number (which use mobile_or_landline_number_validator) and
hides intent; update validate_phone_number to either call
mobile_or_landline_number_validator if landlines should be accepted, or keep
mobile_validator but add an inline comment stating landline-only numbers are
intentionally rejected for OTP, and replace the broad "except Exception" with
catching the specific ValidationError from PhoneNumberValidator so only
validation failures are handled and original exceptions propagate.

Comment thread care/users/api/otp_viewset/reset_password.py Outdated
Comment thread care/users/api/otp_viewset/reset_password.py Outdated
Comment thread care/users/api/otp_viewset/reset_password.py Outdated
Comment on lines +100 to +117
def post(self, request):
data = OTPResetConfirmSpec(**request.data)
user = User.objects.filter(phone_number=data.phone_number).first()
if not user:
raise ValidationError({"phone_number": "No account found"})
otp_obj = (
UserMobileOTP.objects.filter(
phone_number=data.phone_number,
is_used=False,
created_date__gte=(
timezone.now() - timedelta(hours=settings.OTP_REPEAT_WINDOW)
),
)
.order_by("-created_date")
.first()
)
if not otp_obj or otp_obj.otp != data.otp:
raise ValidationError({"otp": "Invalid OTP"})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Major: brute-force window on a 5-digit OTP, plus enumeration via "No account found".

A couple of things make this confirm endpoint nervous:

  1. No per-OTP / per-phone failed-attempt cap. With OTP_LENGTH = 5 and up to OTP_MAX_REPEATS_WINDOW = 10 valid OTPs sittling within a 6-hour window, the effective guess space is 10^5 / 10 = 10,000 per try. There is no throttle on this endpoint, so an attacker can simply iterate. Please track failed attempts per phone number (or per OTP row) and invalidate the OTP after, say, 5 failed attempts. Adding django_ratelimit keyed on phone_number + IP would be a sensible second layer.
  2. Enumeration leak. "No account found" (line 104) tells an attacker which phone numbers belong to registered users — exactly the disclosure the send endpoint took pains to avoid. Please return the same generic error you'd return for a wrong OTP (e.g., {"otp": "Invalid OTP"}).
  3. Timing-unsafe OTP comparison. otp_obj.otp != data.otp (line 116) is not constant-time. Use secrets.compare_digest(otp_obj.otp, data.otp). Less critical than the brute-force concern, but cheap to fix.
🛡️ Suggested patch (excerpt)
+import secrets
@@
-        user = User.objects.filter(phone_number=data.phone_number).first()
-        if not user:
-            raise ValidationError({"phone_number": "No account found"})
+        user = User.objects.filter(phone_number=data.phone_number).first()
@@
-        if not otp_obj or otp_obj.otp != data.otp:
-            raise ValidationError({"otp": "Invalid OTP"})
+        if not user or not otp_obj or not secrets.compare_digest(
+            otp_obj.otp, data.otp
+        ):
+            # TODO: increment a per-phone failure counter and lock after N attempts.
+            raise ValidationError({"otp": "Invalid OTP"})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@care/users/api/otp_viewset/reset_password.py` around lines 100 - 117, In the
post method of the OTP reset flow (OTPResetConfirmSpec input handling in post),
replace the explicit "No account found" ValidationError with the same generic
OTP error used for invalid OTPs so phone enumeration is not possible; add
tracking on UserMobileOTP (e.g., a failed_attempts integer and last_failed_at)
and increment it on each mismatch, invalidating or marking is_used=True when
failed_attempts reaches a constant (e.g., OTP_MAX_FAILED_ATTEMPTS = 5) to
prevent brute-force, and perform the OTP comparison using
secrets.compare_digest(otp_obj.otp, data.otp) for timing-safety; optionally
add/annotate a rate-limit decorator (django_ratelimit) keyed by phone_number+IP
to provide a second layer of protection.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds OTP-by-SMS based password reset endpoints for users, including a new OTP persistence model and an SMS template/config setting.

Changes:

  • Added unauthenticated APIs to send a password-reset OTP and confirm OTP + set a new password.
  • Introduced UserMobileOTP model + migration to store OTPs for password reset.
  • Added reset-password OTP SMS template + settings for selecting the template and new URL routes.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
config/urls.py Registers new OTP password reset endpoints under /api/v1/otp/password_reset/....
config/settings/base.py Adds setting/env var for reset-password OTP SMS template path.
care/users/models.py Adds UserMobileOTP model to persist OTPs for password reset.
care/users/migrations/0028_usermobileotp.py Creates DB table for UserMobileOTP.
care/users/api/otp_viewset/reset_password.py Implements OTP send/confirm API views and request specs.
care/templates/sms/otp_reset_sms.txt Adds SMS template content for reset-password OTP message.

Comment thread care/users/api/otp_viewset/reset_password.py Outdated
Comment on lines +52 to +92
class OTPResetSendView(GenericAPIView):
authentication_classes = []
permission_classes = []

@extend_schema(request=OTPResetSendSpec)
def post(self, request):
data = OTPResetSendSpec(**request.data)

sent_otps = UserMobileOTP.objects.filter(
created_date__gte=(
timezone.now() - timedelta(hours=settings.OTP_REPEAT_WINDOW)
),
is_used=False,
phone_number=data.phone_number,
)
if sent_otps.count() >= settings.OTP_MAX_REPEATS_WINDOW:
raise ValidationError(
{"phone_number": "Max OTP requests exceeded. Try again later."}
)
if not User.objects.filter(phone_number=data.phone_number).exists():
return Response({"otp": "generated"})

random_otp = rand_pass(settings.OTP_LENGTH)
logger.info(random_otp)
try:
content = get_sms_content(
settings.OTP_SMS_RESET_PASSWORD_TEMPLATE_PATH,
{"random_otp": random_otp},
)
sms.send_text_message(
content=content,
recipients=[data.phone_number],
)
except Exception as e:
logger.error(e)
return Response(
{"error": "Error while sending OTP. Contact admin."}, status=400
)

UserMobileOTP.objects.create(phone_number=data.phone_number, otp=random_otp)
return Response({"otp": "generated"})
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New password-reset OTP endpoints are introduced but no tests are added. There is existing coverage for password reset behavior in care/emr/tests/test_reset_password_api.py; add analogous API tests for OTP send/confirm (success, invalid OTP, expired OTP, rate limit/max repeats, and password validation).

Copilot uses AI. Check for mistakes.
Comment thread care/users/api/otp_viewset/reset_password.py Outdated
Comment thread care/users/api/otp_viewset/reset_password.py
Comment thread care/users/api/otp_viewset/reset_password.py Outdated
Comment thread care/users/api/otp_viewset/reset_password.py Outdated
Comment thread care/users/api/otp_viewset/reset_password.py
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 27, 2026

Codecov Report

❌ Patch coverage is 30.55556% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.54%. Comparing base (589baaa) to head (afeb68c).

Files with missing lines Patch % Lines
care/emr/api/otp_viewsets/login.py 24.24% 25 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #3630      +/-   ##
===========================================
- Coverage    75.55%   75.54%   -0.02%     
===========================================
  Files          479      479              
  Lines        22958    22972      +14     
  Branches      2369     2372       +3     
===========================================
+ Hits         17347    17355       +8     
- Misses        5040     5046       +6     
  Partials       571      571              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nandkishorr nandkishorr changed the title [Eng 32] feat :create otp based reset for password [Eng-32] feat :create otp based reset for password Apr 27, 2026
Copy link
Copy Markdown
Member

@vigneshhari vigneshhari left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrite this whole MR only using existing models and existing code. No changes more than 150 lines allowed.

Comment thread config/settings/base.py
OTP_SMS_TEMPLATE_PATH = env("OTP_SMS_TEMPLATE", default="sms/otp_sms.txt")

OTP_SMS_RESET_PASSWORD_TEMPLATE_PATH = env(
"OTP_SMS_RESET_PASSWORD_TEMPLATE", default="sms/otp_reset_sms.txt"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes it very hard for someone to override the content, what was the purpose of making this a file rather than just env value ?

Comment thread care/users/api/otp_viewset/reset_password.py Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (6)
care/users/api/otp_viewset/reset_password.py (6)

128-129: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Confirm endpoint still enumerates users

Line 129 returns a user-existence-specific error ("No User linked..."). This leaks registration status and undercuts the send endpoint’s generic behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/users/api/otp_viewset/reset_password.py` around lines 128 - 129, The
confirm endpoint currently raises a user-specific ValidationError ("No User
linked to this phone number"), which leaks registration status; update the raise
in the reset_password confirm flow (the raise ValidationError call) to return
the same generic, non-enumerating error message used by the send endpoint (e.g.
a generic "If this phone number is registered, you will receive an OTP" or the
existing send-endpoint message) so both paths are indistinguishable; locate the
raise ValidationError in reset_password.py (the confirm handler) and replace the
specific message with the shared generic message constant/string used by the
send endpoint.

76-107: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Rate-limit check and OTP creation are racy (TOCTOU)

The count-then-create flow is non-atomic; concurrent requests can exceed OTP_MAX_REPEATS_WINDOW. Wrap the check+create in a transaction with locking (or move to an atomic external counter).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/users/api/otp_viewset/reset_password.py` around lines 76 - 107, The
current count-then-create for UserMobileOTP is vulnerable to TOCTOU races; wrap
the logic that queries recent OTPs and creates a new UserMobileOTP in a single
atomic DB transaction and acquire a lock on the relevant rows to serialize
concurrent requests. Concretely, enclose the check +
UserMobileOTP.objects.create(...) inside transaction.atomic() and use
UserMobileOTP.objects.select_for_update().filter(phone_number=data.phone_number,
created_date__gte=(timezone.now() -
timedelta(hours=settings.OTP_REPEAT_WINDOW)), is_used=False) to lock the
matching rows before re-counting and deciding whether to raise the
OTP_MAX_REPEATS_WINDOW ValidationError, then create the OTP only after the
locked check passes.

28-32: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical: deterministic OTP fallback enables trivial account takeover

On Line 29-30, returning "45612" when SMS is disabled makes OTPs predictable in non-SMS environments. That’s a direct auth bypass, and yes, it’s as bad as it sounds.

Suggested fix
 def rand_pass(size):
-    if not settings.USE_SMS:
-        return "45612"
-
     return "".join(secrets.choice(string.digits) for _ in range(size))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/users/api/otp_viewset/reset_password.py` around lines 28 - 32, The
rand_pass function currently returns a hardcoded "45612" when settings.USE_SMS
is false, creating a predictable OTP; change rand_pass to never return a
constant fallback—use a secure random generator (e.g., secrets.choice over
string.digits) to build a numeric OTP of the requested size even when
settings.USE_SMS is false (or alternatively raise an explicit exception if
non-SMS flows should not generate OTPs). Update the rand_pass implementation
(referencing the function name rand_pass and the settings.USE_SMS check and the
size parameter) so it uses secrets to generate a size-length digit string
instead of the deterministic "45612".

41-45: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Narrow exception handling in phone validator

Catching Exception on Line 43 is too broad and can mask unrelated failures. Catch the specific validation exception instead.

Suggested fix
+from django.core.exceptions import ValidationError as DjangoValidationError
@@
-        except Exception as e:
+        except DjangoValidationError as e:
             msg = "Invalid phone number"
             raise ValueError(msg) from e
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/users/api/otp_viewset/reset_password.py` around lines 41 - 45, The code
currently catches all Exceptions around mobile_validator(value) and re-raises
ValueError; narrow this by catching the specific exception type the validator
raises (e.g., ValidationError or the validator's custom exception) instead of
Exception. Update the except clause for the mobile_validator call to import and
catch that exact exception (for example except ValidationError as e:) and then
raise ValueError(msg) from e so only validation failures are handled while other
errors bubble up.

130-143: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

No failed-attempt cap on OTP verification

There’s still no per-phone/per-OTP failed-attempt lockout in confirm flow. With a short numeric OTP, this leaves a practical brute-force window.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/users/api/otp_viewset/reset_password.py` around lines 130 - 143, The
confirm-flow currently directly compares OTPs and lacks a per-phone/OTP
failed-attempt lockout; modify the logic around UserMobileOTP in
reset_password.py to enforce a max-failed-attempts and temporary lockout: add
fields to the UserMobileOTP model (e.g., failed_attempts integer and
lockout_until datetime) or reuse existing equivalents, check lockout_until
(timezone.now()) before validating, and if locked raise a ValidationError like
{"otp":"Too many attempts, try later"}; on failed match increment
failed_attempts atomically (use F() or select_for_update) and if failed_attempts
reaches settings.OTP_MAX_FAILED_ATTEMPTS set lockout_until = timezone.now() +
timedelta(minutes=settings.OTP_LOCKOUT_WINDOW) (and optionally mark is_used),
while on success reset failed_attempts and clear lockout_until and proceed as
before. Ensure you reference UserMobileOTP, the OTP-check block in
reset_password.py, and use settings keys (OTP_MAX_FAILED_ATTEMPTS,
OTP_LOCKOUT_WINDOW) and timezone.now() for time comparisons.

76-88: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

User enumeration still leaks via rate-limit branch ordering

Line 83-86 can return a distinct “max OTP requests exceeded” response before the non-existent-user path at Line 87-88. Attackers can still differentiate registered numbers over repeated probes.

Suggested fix
-        sent_otps = UserMobileOTP.objects.filter(
+        if not User.objects.filter(phone_number=data.phone_number).exists():
+            return Response({"otp": "generated"})
+
+        sent_otps = UserMobileOTP.objects.filter(
             created_date__gte=(
                 timezone.now() - timedelta(hours=settings.OTP_REPEAT_WINDOW)
             ),
             is_used=False,
             phone_number=data.phone_number,
         )
         if sent_otps.count() >= settings.OTP_MAX_REPEATS_WINDOW:
-            raise ValidationError(
-                {"error": "Max OTP requests exceeded. Try again later."}
-            )
-        if not User.objects.filter(phone_number=data.phone_number).exists():
             return Response({"otp": "generated"})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/users/api/otp_viewset/reset_password.py` around lines 76 - 88, Reorder
the checks to avoid user enumeration: first verify whether the account exists
via User.objects.filter(phone_number=data.phone_number).exists() and if the user
does not exist return the same generic response ("otp": "generated") as used for
other branches; only for existing users proceed to evaluate sent_otps =
UserMobileOTP.objects.filter(...), compare against
settings.OTP_MAX_REPEATS_WINDOW and raise the rate-limit ValidationError if
exceeded. This ensures the UserMobileOTP/User existence logic (sent_otps, User,
settings.OTP_MAX_REPEATS_WINDOW) cannot be probed to distinguish registered vs
unregistered numbers.
🧹 Nitpick comments (1)
care/users/api/otp_viewset/reset_password.py (1)

59-60: ⚡ Quick win

Use immutable class attributes for DRF auth/permission lists

Using mutable lists for authentication_classes and permission_classes triggers Ruff RUF012 and is avoidable. Tuples are the safer/default choice.

Suggested fix
-    authentication_classes = []
-    permission_classes = []
+    authentication_classes = ()
+    permission_classes = ()
@@
-    authentication_classes = []
-    permission_classes = []
+    authentication_classes = ()
+    permission_classes = ()

As per coding guidelines, "**/*.py: Use Ruff for linting and formatting Python code ... After code changes, run 'ruff check --fix .' and 'ruff format .'".

Also applies to: 112-113

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/users/api/otp_viewset/reset_password.py` around lines 59 - 60, The
class-level attributes authentication_classes and permission_classes are defined
as mutable lists which triggers RUF012; change them to immutable tuples (e.g.,
authentication_classes = () and permission_classes = ()) wherever they appear
(including in reset_password.py and the other occurrence around lines 112-113)
and then run the project linter/formatter (ruff check --fix . and ruff format .)
to apply/verify fixes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@care/users/api/otp_viewset/reset_password.py`:
- Around line 128-129: The confirm endpoint currently raises a user-specific
ValidationError ("No User linked to this phone number"), which leaks
registration status; update the raise in the reset_password confirm flow (the
raise ValidationError call) to return the same generic, non-enumerating error
message used by the send endpoint (e.g. a generic "If this phone number is
registered, you will receive an OTP" or the existing send-endpoint message) so
both paths are indistinguishable; locate the raise ValidationError in
reset_password.py (the confirm handler) and replace the specific message with
the shared generic message constant/string used by the send endpoint.
- Around line 76-107: The current count-then-create for UserMobileOTP is
vulnerable to TOCTOU races; wrap the logic that queries recent OTPs and creates
a new UserMobileOTP in a single atomic DB transaction and acquire a lock on the
relevant rows to serialize concurrent requests. Concretely, enclose the check +
UserMobileOTP.objects.create(...) inside transaction.atomic() and use
UserMobileOTP.objects.select_for_update().filter(phone_number=data.phone_number,
created_date__gte=(timezone.now() -
timedelta(hours=settings.OTP_REPEAT_WINDOW)), is_used=False) to lock the
matching rows before re-counting and deciding whether to raise the
OTP_MAX_REPEATS_WINDOW ValidationError, then create the OTP only after the
locked check passes.
- Around line 28-32: The rand_pass function currently returns a hardcoded
"45612" when settings.USE_SMS is false, creating a predictable OTP; change
rand_pass to never return a constant fallback—use a secure random generator
(e.g., secrets.choice over string.digits) to build a numeric OTP of the
requested size even when settings.USE_SMS is false (or alternatively raise an
explicit exception if non-SMS flows should not generate OTPs). Update the
rand_pass implementation (referencing the function name rand_pass and the
settings.USE_SMS check and the size parameter) so it uses secrets to generate a
size-length digit string instead of the deterministic "45612".
- Around line 41-45: The code currently catches all Exceptions around
mobile_validator(value) and re-raises ValueError; narrow this by catching the
specific exception type the validator raises (e.g., ValidationError or the
validator's custom exception) instead of Exception. Update the except clause for
the mobile_validator call to import and catch that exact exception (for example
except ValidationError as e:) and then raise ValueError(msg) from e so only
validation failures are handled while other errors bubble up.
- Around line 130-143: The confirm-flow currently directly compares OTPs and
lacks a per-phone/OTP failed-attempt lockout; modify the logic around
UserMobileOTP in reset_password.py to enforce a max-failed-attempts and
temporary lockout: add fields to the UserMobileOTP model (e.g., failed_attempts
integer and lockout_until datetime) or reuse existing equivalents, check
lockout_until (timezone.now()) before validating, and if locked raise a
ValidationError like {"otp":"Too many attempts, try later"}; on failed match
increment failed_attempts atomically (use F() or select_for_update) and if
failed_attempts reaches settings.OTP_MAX_FAILED_ATTEMPTS set lockout_until =
timezone.now() + timedelta(minutes=settings.OTP_LOCKOUT_WINDOW) (and optionally
mark is_used), while on success reset failed_attempts and clear lockout_until
and proceed as before. Ensure you reference UserMobileOTP, the OTP-check block
in reset_password.py, and use settings keys (OTP_MAX_FAILED_ATTEMPTS,
OTP_LOCKOUT_WINDOW) and timezone.now() for time comparisons.
- Around line 76-88: Reorder the checks to avoid user enumeration: first verify
whether the account exists via
User.objects.filter(phone_number=data.phone_number).exists() and if the user
does not exist return the same generic response ("otp": "generated") as used for
other branches; only for existing users proceed to evaluate sent_otps =
UserMobileOTP.objects.filter(...), compare against
settings.OTP_MAX_REPEATS_WINDOW and raise the rate-limit ValidationError if
exceeded. This ensures the UserMobileOTP/User existence logic (sent_otps, User,
settings.OTP_MAX_REPEATS_WINDOW) cannot be probed to distinguish registered vs
unregistered numbers.

---

Nitpick comments:
In `@care/users/api/otp_viewset/reset_password.py`:
- Around line 59-60: The class-level attributes authentication_classes and
permission_classes are defined as mutable lists which triggers RUF012; change
them to immutable tuples (e.g., authentication_classes = () and
permission_classes = ()) wherever they appear (including in reset_password.py
and the other occurrence around lines 112-113) and then run the project
linter/formatter (ruff check --fix . and ruff format .) to apply/verify fixes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9b11e806-cb03-451b-b10d-af6b115ef9e6

📥 Commits

Reviewing files that changed from the base of the PR and between f666b68 and 7454a65.

📒 Files selected for processing (1)
  • care/users/api/otp_viewset/reset_password.py

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@care/emr/api/otp_viewsets/login.py`:
- Around line 34-73: The MobileOTP rows lack a stored purpose so different flows
can consume each other's OTPs; modify the MobileOTP model to add a purpose field
(enum/char matching OTPType) and an index on (phone_number, purpose, otp,
is_used) via a migration, then update send_otp to include purpose when creating
MobileOTP and to filter sent_otps by purpose, and update OTPLoginView.login and
OTPResetPasswordView.confirm (and any cleanup/delete logic) to include purpose
in their queryset filters and deletes so each flow only reads/deletes OTPs with
the matching purpose.
- Around line 35-41: The rate-limit window uses
timedelta(settings.OTP_REPEAT_WINDOW) which treats the value as days; update the
calculation so the window is built with hours—e.g. replace the timedelta usage
in the sent_otps query (the MobileOTP filter where created_date__gte is
computed) to use timedelta(hours=settings.OTP_REPEAT_WINDOW) so the
OTP_REPEAT_WINDOW setting is applied as hours when enforcing
OTP_MAX_REPEATS_WINDOW.
- Around line 115-117: The current lookup for MobileOTP (using
MobileOTP.objects.filter(...).first()) doesn't enforce the OTP validity window;
change the query to also filter created_date >= timezone.now() -
OTP_REPEAT_WINDOW (use Django timezone) so only recent OTPs are accepted, and
immediately expire the OTP upon successful validation by setting
otp_object.is_used = True and saving (or perform an atomic update like
MobileOTP.objects.filter(pk=otp_object.pk, is_used=False).update(is_used=True)
to avoid races). Reference MobileOTP, OTP_REPEAT_WINDOW, otp_object, and the
data.phone_number/data.otp fields when applying these changes.

In `@config/exception_handler.py`:
- Around line 12-13: The Pydantic error conversion is leaking submitted values
via exc.errors(); update the conversion where PydanticValidationError is mapped
to DRFValidationError (the block that currently does exc =
DRFValidationError(detail=exc.errors())) to filter or remap the error list and
remove any "input" keys or nested submitted values (specifically strip
password/code fields originating from PasswordVerifyRequest and
TOTPVerifyRequest) before constructing the DRFValidationError; ensure you
iterate exc.errors(), remove or redact any "input" entries (or replace them with
safe placeholders), and pass the sanitized list into
DRFValidationError(detail=...).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b6380c58-61ee-4c34-87fc-aef2fdeaa6c4

📥 Commits

Reviewing files that changed from the base of the PR and between 7454a65 and e15ae5a.

📒 Files selected for processing (6)
  • care/emr/api/otp_viewsets/login.py
  • care/facility/migrations/0485_rename_patientmobileotp_mobileotp.py
  • care/facility/models/patient.py
  • care/users/api/otp_viewset/reset_password.py
  • config/api_router.py
  • config/exception_handler.py

Comment on lines +34 to +73
def send_otp(phone_number, purpose):
sent_otps = MobileOTP.objects.filter(
created_date__gte=(timezone.now() - timedelta(settings.OTP_REPEAT_WINDOW)),
is_used=False,
phone_number=phone_number,
)
if sent_otps.count() >= settings.OTP_MAX_REPEATS_WINDOW:
raise ValidationError({"phone_number": "Max Retries has exceeded"})

random_otp = ""
if settings.USE_SMS:
random_otp = rand_pass(settings.OTP_LENGTH)
try:
if purpose == OTPType.login:
content = get_sms_content(
settings.OTP_SMS_TEMPLATE_PATH,
{"random_otp": random_otp},
)
elif purpose == OTPType.reset_password:
content = get_sms_content(
settings.OTP_SMS_RESET_PASSWORD_TEMPLATE_PATH,
{"random_otp": random_otp},
)

sms.send_text_message(
content=content,
recipients=[phone_number],
)
except Exception as e:
logger.error(e)
return Response(
{"error": "Error while sending OTP. Contact admin."}, status=400
)
elif settings.IS_PRODUCTION:
random_otp = rand_pass(settings.OTP_LENGTH)
else:
random_otp = "45612"

MobileOTP.objects.create(phone_number=phone_number, otp=random_otp)
return None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Persist the OTP purpose instead of dropping it at write time.

purpose only affects the SMS template, then Line 72 stores a plain MobileOTP row with no discriminator. OTPLoginView.login in this file and OTPResetPasswordView.confirm in care/users/api/otp_viewset/reset_password.py both read from the same table by phone/OTP/is_used only, so a login OTP can satisfy the reset-password flow and the reset cleanup will wipe login OTPs as collateral. Add a purpose field/index and filter every create/read/delete by it.

🧰 Tools
🪛 Ruff (0.15.12)

[warning] 62-62: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/api/otp_viewsets/login.py` around lines 34 - 73, The MobileOTP rows
lack a stored purpose so different flows can consume each other's OTPs; modify
the MobileOTP model to add a purpose field (enum/char matching OTPType) and an
index on (phone_number, purpose, otp, is_used) via a migration, then update
send_otp to include purpose when creating MobileOTP and to filter sent_otps by
purpose, and update OTPLoginView.login and OTPResetPasswordView.confirm (and any
cleanup/delete logic) to include purpose in their queryset filters and deletes
so each flow only reads/deletes OTPs with the matching purpose.

Comment on lines +35 to +41
sent_otps = MobileOTP.objects.filter(
created_date__gte=(timezone.now() - timedelta(settings.OTP_REPEAT_WINDOW)),
is_used=False,
phone_number=phone_number,
)
if sent_otps.count() >= settings.OTP_MAX_REPEATS_WINDOW:
raise ValidationError({"phone_number": "Max Retries has exceeded"})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# Find settings file(s) and locate OTP_REPEAT_WINDOW
find . -name "settings*.py" -o -name "*.py" | head -20

Repository: ohcnetwork/care

Length of output: 760


🏁 Script executed:

# Search for OTP_REPEAT_WINDOW definition
rg "OTP_REPEAT_WINDOW" --type py -B2 -A2

Repository: ohcnetwork/care

Length of output: 1263


🏁 Script executed:

# Search for the login.py file to understand context
fd "login.py" --path "*otp_viewsets*"

Repository: ohcnetwork/care

Length of output: 275


🏁 Script executed:

# Check for other OTP-related timeouts or rate limiting
rg "timedelta\(" --type py -B1 -A1 | grep -i "otp\|repeat\|rate\|window" -i

Repository: ohcnetwork/care

Length of output: 1841


Use hours= in the repeat-window calculation.

Line 36 calls timedelta(settings.OTP_REPEAT_WINDOW), which interprets the value as days, not hours. With OTP_REPEAT_WINDOW = 6 (intended for 6 hours per the setting comment), this rate-limit window becomes 6 days instead—much more generous than intended. The reset-password endpoint already uses the correct pattern.

Suggested fix
     sent_otps = MobileOTP.objects.filter(
-        created_date__gte=(timezone.now() - timedelta(settings.OTP_REPEAT_WINDOW)),
+        created_date__gte=(
+            timezone.now() - timedelta(hours=settings.OTP_REPEAT_WINDOW)
+        ),
         is_used=False,
         phone_number=phone_number,
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sent_otps = MobileOTP.objects.filter(
created_date__gte=(timezone.now() - timedelta(settings.OTP_REPEAT_WINDOW)),
is_used=False,
phone_number=phone_number,
)
if sent_otps.count() >= settings.OTP_MAX_REPEATS_WINDOW:
raise ValidationError({"phone_number": "Max Retries has exceeded"})
sent_otps = MobileOTP.objects.filter(
created_date__gte=(
timezone.now() - timedelta(hours=settings.OTP_REPEAT_WINDOW)
),
is_used=False,
phone_number=phone_number,
)
if sent_otps.count() >= settings.OTP_MAX_REPEATS_WINDOW:
raise ValidationError({"phone_number": "Max Retries has exceeded"})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/api/otp_viewsets/login.py` around lines 35 - 41, The rate-limit
window uses timedelta(settings.OTP_REPEAT_WINDOW) which treats the value as
days; update the calculation so the window is built with hours—e.g. replace the
timedelta usage in the sent_otps query (the MobileOTP filter where
created_date__gte is computed) to use
timedelta(hours=settings.OTP_REPEAT_WINDOW) so the OTP_REPEAT_WINDOW setting is
applied as hours when enforcing OTP_MAX_REPEATS_WINDOW.

Comment on lines +115 to 117
otp_object = MobileOTP.objects.filter(
phone_number=data.phone_number, otp=data.otp, is_used=False
).first()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Expire login OTPs when validating them.

Lines 115-117 accept any unused row matching the phone number and OTP, with no created_date bound. That makes stale OTPs valid indefinitely until someone happens to use them, despite OTP_REPEAT_WINDOW being defined as the validity window.

Suggested fix
-        otp_object = MobileOTP.objects.filter(
-            phone_number=data.phone_number, otp=data.otp, is_used=False
-        ).first()
+        otp_object = (
+            MobileOTP.objects.filter(
+                phone_number=data.phone_number,
+                otp=data.otp,
+                is_used=False,
+                created_date__gte=(
+                    timezone.now() - timedelta(hours=settings.OTP_REPEAT_WINDOW)
+                ),
+            )
+            .order_by("-created_date")
+            .first()
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@care/emr/api/otp_viewsets/login.py` around lines 115 - 117, The current
lookup for MobileOTP (using MobileOTP.objects.filter(...).first()) doesn't
enforce the OTP validity window; change the query to also filter created_date >=
timezone.now() - OTP_REPEAT_WINDOW (use Django timezone) so only recent OTPs are
accepted, and immediately expire the OTP upon successful validation by setting
otp_object.is_used = True and saving (or perform an atomic update like
MobileOTP.objects.filter(pk=otp_object.pk, is_used=False).update(is_used=True)
to avoid races). Reference MobileOTP, OTP_REPEAT_WINDOW, otp_object, and the
data.phone_number/data.otp fields when applying these changes.

Comment thread config/exception_handler.py Outdated
@nandkishorr nandkishorr requested a review from vigneshhari May 6, 2026 04:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants