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:
emailmust be a valid email (400 {"error": "Invalid email."}).codemust be exactly six digits (400 {"error": "Invalid code."}).new_passwordmust 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.pyorrequirements.txt. - Keep your changes minimal. Fix the defects; don't rewrite modules.
- Additional hidden tests check the behaviour described in this README.