[Eng-32] feat :create otp based reset for password#3630
[Eng-32] feat :create otp based reset for password#3630nandkishorr wants to merge 12 commits intodevelopfrom
Conversation
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThis PR introduces a centralized OTP flow for both login and password reset operations. The ChangesUnified OTP Password Reset & Login Refactor
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
There was a problem hiding this comment.
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 foris_used.A couple of things worth tightening up:
- Missing index on the hot path.
OTPResetSendViewandOTPResetConfirmViewboth 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.is_usedis never set toTrue. 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.- Consider an explicit
expires_at(or rely strictly onOTP_REPEAT_WINDOWeverywhere), and aMeta.verbose_namefor 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'sOutstandingToken/BlacklistedToken) and bumping apassword_changed_atfield if you have one.user.save()writes every field; passupdate_fields=["password"]to avoid spurious side-effects from any customsaveoverrides.- Per the earlier comment on
UserMobileOTP.is_used, prefer marking the consumed row asis_used=Trueinstead 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(RuffBLE001) hides programming bugs. Catch the specific exception(s) raised byget_sms_content/sms.send_text_messageinstead.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.createthen 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 tologger.exceptionand 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
📒 Files selected for processing (7)
care/templates/sms/otp_reset_sms.txtcare/users/api/otp_viewset/__init__.pycare/users/api/otp_viewset/reset_password.pycare/users/migrations/0028_usermobileotp.pycare/users/models.pyconfig/settings/base.pyconfig/urls.py
| @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 |
There was a problem hiding this comment.
🧩 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 -100Repository: ohcnetwork/care
Length of output: 2084
🏁 Script executed:
# Find where UserMobileOTP model defines phone_number field
rg -n "class UserMobileOTP" -A 30Repository: 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 2Repository: 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 -50Repository: 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.
| 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"}) |
There was a problem hiding this comment.
Major: brute-force window on a 5-digit OTP, plus enumeration via "No account found".
A couple of things make this confirm endpoint nervous:
- No per-OTP / per-phone failed-attempt cap. With
OTP_LENGTH = 5and up toOTP_MAX_REPEATS_WINDOW = 10valid OTPs sittling within a 6-hour window, the effective guess space is10^5 / 10 = 10,000per 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. Addingdjango_ratelimitkeyed onphone_number+ IP would be a sensible second layer. - 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"}). - Timing-unsafe OTP comparison.
otp_obj.otp != data.otp(line 116) is not constant-time. Usesecrets.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.
There was a problem hiding this comment.
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
UserMobileOTPmodel + 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. |
| 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"}) |
There was a problem hiding this comment.
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).
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
…github.com/ohcnetwork/care into ENG-32-create-otp-based-reset-for-password
vigneshhari
left a comment
There was a problem hiding this comment.
Rewrite this whole MR only using existing models and existing code. No changes more than 150 lines allowed.
| 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" |
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
♻️ Duplicate comments (6)
care/users/api/otp_viewset/reset_password.py (6)
128-129:⚠️ Potential issue | 🟠 Major | ⚡ Quick winConfirm 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 liftRate-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 winCritical: 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 winNarrow exception handling in phone validator
Catching
Exceptionon 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 liftNo 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 winUser 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 winUse immutable class attributes for DRF auth/permission lists
Using mutable lists for
authentication_classesandpermission_classestriggers RuffRUF012and 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
📒 Files selected for processing (1)
care/users/api/otp_viewset/reset_password.py
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
care/emr/api/otp_viewsets/login.pycare/facility/migrations/0485_rename_patientmobileotp_mobileotp.pycare/facility/models/patient.pycare/users/api/otp_viewset/reset_password.pyconfig/api_router.pyconfig/exception_handler.py
| 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 |
There was a problem hiding this comment.
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.
| 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"}) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find settings file(s) and locate OTP_REPEAT_WINDOW
find . -name "settings*.py" -o -name "*.py" | head -20Repository: ohcnetwork/care
Length of output: 760
🏁 Script executed:
# Search for OTP_REPEAT_WINDOW definition
rg "OTP_REPEAT_WINDOW" --type py -B2 -A2Repository: 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" -iRepository: 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.
| 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.
| otp_object = MobileOTP.objects.filter( | ||
| phone_number=data.phone_number, otp=data.otp, is_used=False | ||
| ).first() |
There was a problem hiding this comment.
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.
Proposed Changes
Merge Checklist
/docsOnly 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
Refactor