Skip to content

CCCT-2918: Lock the backup code instead of deactivating the account - #316

Open
OrangeAndGreen wants to merge 3 commits into
mainfrom
CCCT-2918-backup-code-lock
Open

OrangeAndGreen wants to merge 3 commits into
mainfrom
CCCT-2918-backup-code-lock

Conversation

@OrangeAndGreen

@OrangeAndGreen OrangeAndGreen commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Technical Summary

CCCT-2918
Corresponding mobile changes: CCCT-2912
Design doc

After three wrong backup codes we deactivate the account, so the user is refused before the Name page and never reaches email recovery. Now "locked" means only the backup code is disabled: the account stays active, any backup code is refused with LOCKED_ACCOUNT, and the app (dimagi/commcare-android#3962) moves the user to email recovery, which clears the lock.

Best reviewed commit by commit. The first two change nothing on their own, because today every locked account is also inactive:

  1. API 3.0, and clients before 3.0 are refused at session auth for any locked user, active or not, so old apps behave as they do now. Every version keeps the existing check on inactive locked accounts.
  2. The backup code check refuses a locked user before looking at the code, so the attempt isn't counted. The legacy recover_account and confirm_recovery_pin also refuse locked users, since an inactive account no longer shields them. Completing recovery clears the lock.
  3. The third wrong code sets is_locked only.

Worth a look: the version check sits in authenticate(request), because authenticate_credentials never sees the request. test_version_comes_from_accept_header checks that DRF has set request.version by then.

Logging and monitoring

The BigQuery user export (users/tasks.py) will show newly locked users as is_active=True. Reports that count locked users as inactive will change meaning.

Safety Assurance

Safety story

Accounts locked before this change stay inactive and are refused on every version exactly as now. A 2.0 app sees the same LOCKED_ACCOUNT at session start. The one change it sees is that its owner's existing device keeps working after someone else gets the code wrong three times. A non-matching name can't register over a locked account, because complete_profile already refuses when the number has an active account.

  • I am confident that this change will not break current and/or previous versions of CommCare apps

Automated test coverage

test_auth.py covers the session check for each version, with inactive and active locked users, plus one request through the Accept header. test_views.py covers a locked user refused before the code is checked (counter unchanged, session not marked verified) on both backup code endpoints, email recovery clearing the lock, and refusal on both legacy endpoints. The new view tests fail without the change.

QA Plan

QA together with the 2.65 build that includes #3962: with and without an email on the account, lock the backup code, retry recovery and check email recovery clears the lock. Then repeat on a pre-3.0 app build to check it is still refused.

Deployment

This has to be deployed before the 2.65 QA build that sends API 3.0, since the server returns 406 for a version it doesn't know.

Out of scope, in the backlog: reactivating accounts locked under the old scheme, and making the admin unlock page find active locked accounts. Until then, a developer can unlock one with unlock_and_generate_backup_code --inactive_user_id.

  • This PR can be deployed: any required configuration, commands, or other manual steps required before this PR can be deployed are done.

Labels & Review

  • The set of people pinged as reviewers is appropriate for the level of risk of the change

🤖 Generated with Claude Code

OrangeAndGreen and others added 3 commits October 9, 2026 10:35
Clients before 3.0 expect a locked account to be refused when configuring,
so they keep refusing any locked user. All versions keep the existing check
on inactive locked accounts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The backup code check now refuses a locked user before looking at the code,
and the legacy recover_account and confirm_recovery_pin endpoints refuse
locked users too. Completing recovery clears the lock. Locked users are
still inactive today, so none of this changes behaviour yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The third wrong backup code now sets is_locked only. The account stays
active, so the owner's existing device keeps working, and a 3.0 client can
reach email recovery after the backup code is locked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@OrangeAndGreen
OrangeAndGreen requested a review from snopoke October 9, 2026 15:07
@OrangeAndGreen OrangeAndGreen self-assigned this Oct 9, 2026
@OrangeAndGreen
OrangeAndGreen marked this pull request as ready for review October 9, 2026 15:07
@coderabbitai

coderabbitai Bot commented Oct 9, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0135f274-4ee8-42e9-be3b-c413070d6b1b

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant