Bugnik
← All problems

43Account Password Reset API

Python · DjangoEasyBug fixSuggested time: 60 min

Account password reset API (emailed verification codes, expiry, attempt limits, rate limit)

New to repository-based OA questions? Read the guide.

Open in IDE

Before you start

You'll get more out of this problem if you're comfortable with:

Language

  • Python 3 functions, modules and f-strings

Libraries and frameworks

  • Django models and querysets
  • Django REST Framework function views and serializers

Build and test tools

  • Reading Django system-check errors and unittest failures

Reasoning

  • One-time codes: expiry, attempt limits and rate limits

Background

The Identity team at a large online retailer owns the "Forgot your password?" flow of the customer website. A customer asks for a reset, gets a six-digit code by email and sends it back with a new password. The service is a Django REST Framework API. A refactor before the holiday season left it in poor shape, and the team needs it working again.

How the system works

Accounts

An account has an email (stored in lower case, unique) and a hashed password. Emails in requests are matched after removing surrounding spaces and ignoring case: " Ana@Example.COM " is ana@example.com.

Requesting a code

POST /api/password-resets with {"email": ...}.

  • An invalid email is 400 {"error": "Invalid email."}.
  • The answer is always 202 {"status": "If an account uses this email, we have sent it a code."}, whether or not an account uses the email, so the endpoint can't be used to find out who has an account.
  • For an existing account, a new code is created: six random digits, leading zeros kept (004821), stored with the time it was created. Sending the email is another team's job.
  • An account gets at most 3 codes in any 60 minutes: codes created in the 60 minutes before now count, a code created exactly 60 minutes ago no longer does. A request over the limit is 429 {"error": "Too many reset requests. Try again later."}.

Confirming a reset

POST /api/password-resets/confirm with {"email", "code", "new_password"}. The fields are checked first:

  • email must be a valid email (400 {"error": "Invalid email."}).
  • code must be exactly six digits (400 {"error": "Invalid code."}).
  • new_password must be 10 to 64 characters, both included, with at least one letter and one digit (400 {"error": "Invalid new_password."}).

Then the code. Only the account's newest code can be used: asking for a new code makes every earlier one useless. When two codes were created at the same moment, the one created last is the newest. The code is refused with 400 {"error": "The code is invalid or has expired."} when:

  • no account uses the email, or the account has no code;
  • the newest code has already been used;
  • the newest code was already entered wrongly 5 times;
  • the code is no longer fresh: a code works from the moment it was created until 10 minutes later, both included (sent at 09:00:00, it works at 09:10:00 but not at 09:10:01);
  • the code sent doesn't match the newest code. This counts as one wrong attempt on that code.

Finally the new password must not contain the part of the email before the @ (ignoring case, only when that part is at least 3 characters long) and must differ from the current password (400 {"error": "Invalid new_password."}). This doesn't use up the code.

When everything passes, the new password is saved (hashed), the account's password_changed_at becomes now, the code is marked as used, and the answer is 200 {"status": "Your password has been changed."}.

Logging in

POST /api/login with {"email", "password"}: 200 {"account_id": <id>}, or 401 {"error": "Wrong email or password."}.

Errors

Every error is {"error": "<message>"}. A body that isn't JSON is 400 {"error": "Invalid request."}.

Project structure

manage.py                      Django's command-line tool
requirements.txt               pinned packages
config/settings.py             project settings
config/urls.py                 mounts the API under /api/
apps/accounts/
  models.py                    Account, ResetCode
  serializers.py               request validation
  views.py                     the three endpoints
  urls.py                      their paths
  services.py                  reset and login rules
  codes.py                     code generation and freshness
  clock.py                     the current time
  errors.py                    error responses
tests/                         unit and API tests (Django's test runner)

Your task

Customers and the security team report that:

  • The latest version doesn't start.
  • Some customers can't finish a reset even with the code from their latest email.
  • Reset codes are accepted when they shouldn't be.

Find and fix the defects so that all tests pass.

  • Do not modify tests/, manage.py or requirements.txt.
  • Keep your changes minimal. Fix the defects; don't rewrite modules.
  • Additional hidden tests check the behaviour described in this README.