Repository navigation
Recover a forgotten password without email or a shell - #66
Merged
Merged
Conversation
The login page has said "forgotten passwords are reset from the command line" since it was written, and there was no command. An operator who forgot their password had to hash one by hand and write it into the users table. There is deliberately no emailed reset link. A fresh instance may be unable to send email until somebody can sign in and connect AWS, and that is the moment a password is most likely to be forgotten; a reset that depends on mail would fail exactly there. Whoever can run this command already holds the database and SECRET_KEY, so it adds no access, and it can say plainly when an address matches no account. The decision lives in core as `reset_password`, tested without HTTP like the rest of the user logic. The command wraps it and ends the account's sessions, because a reset means the password was lost or taken, and in the second case every session already open is what is being reset away. The password is committed before anything is revoked, so a Redis failure leaves a changed password with old sessions standing, which running the command again repairs, rather than revoked sessions and the old password still in force. The password is read from a hidden prompt and is not an argument or an environment variable; either would put it in shell history or a process listing. It must meet the same 12 character floor as sign-up, which moved from the auth route into core so the two cannot drift apart. A deactivated account is reset but not reactivated, and the command says so. Registered as the `seskit` script of apps/api, and runnable as `python -m seskit_api.cli`. The SDK is a library with no script of its own, so the name is free. Tests cover the new password working and the old one not, address matching the way login matches it, the account's sessions ending and another account's surviving, a short password changing nothing, and an unknown address. Those need Postgres and Redis and were not run locally; the prompt tests need neither and were. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A section in the troubleshooting page for the command that the login page has long promised. It says why there is no emailed link, since that is the question a reader arrives with, that the password is typed at a prompt rather than passed, and that a reset signs the account out everywhere. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A forgotten dashboard password had no way back that worked on the platforms SESKit is deployed to. Email cannot be the answer: a fresh instance may be unable to send any until somebody can sign in and connect AWS, which is when a password is most likely to be forgotten. A command cannot be either: a deployment made by pushing to Git gives its owner no shell to run one in. So recovery rests on something the owner already holds, in two forms. A recovery code is shown once, right after sign-up, as K7QD-4M2X-9PHB-TWE6: 80 bits from an alphabet without I, L, O and U, with the look-alikes mapped onto what was meant so a code copied by eye or retyped in lowercase still works. Only its SHA-256 is kept, for the reason api_keys.py gives at length - Argon2 exists to make a guessable secret expensive, and 80 random bits are not guessable. Using it replaces it in the same step, because an account that spent its only code would be locked out of doing this again. Settings makes a new one, and asks for the password first: a signed-in session is also what a stolen cookie looks like, and an unguarded button would let the thief keep the account after the owner changes their password. RECOVERY_KEY is the operator's equivalent, accepted in the same form field. It exists because a code only helps an account that has one, and every account made before this change does not. Whoever sets the deployment's environment already controls the instance, so accepting the key grants nothing they did not hold. It is off when empty, and an instance refuses to boot with one shorter than 24 characters rather than quietly ignoring it, since an ignored key leaves the operator believing they have a way back in. Every way of being wrong - no such account, a deactivated one, no code on file, a wrong code, a wrong key - returns the same answer and does the same work, so the page cannot be used to find out which addresses have accounts. A success signs the account out everywhere before starting the new session, because someone recovering may be doing so as the result of a takeover. Attempts are limited per email alone, not per email and address as login is. The address is whatever X-Forwarded-For claims, so a limit that includes it is one the attacker chooses the size of; a test sends every guess from a different claimed address and expects the same cut-off. The cost is that someone can use up a known email's allowance and delay that person's recovery for one window, which is the right way round for a limit guarding account takeover: signing in is unaffected and the delay ends by itself. The code is the response to the POST rather than a redirect, since a redirect would need somewhere to leave it, and is sent no-store. /settings had been a link in the sidebar leading nowhere; it now holds the recovery code status. The 12 character minimum, already moved to core for the reset-password command, is shared with the new form. The migration adds one nullable column. Existing users have NULL and recover through RECOVERY_KEY, or by signing in and being issued a code from Settings. The unit tests (generation, normalisation, matching, the key, the settings check) and an offline render of every new page were run locally, along with the service driven against a stand-in session. The twenty-six that need Postgres and Redis were not; CI runs them. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The troubleshooting section now leads with what a deployment without a shell can do - the recovery code, then RECOVERY_KEY - and keeps the command for people who have one. It says what each costs and guarantees: a code works once and announces its replacement, recovery signs the account out everywhere, attempts pause for a window, and an instance refuses to start with a key under 24 characters. The installation guide and the first-email walkthrough tell a new owner to save the code at the one moment it is shown, which is the only place it can be said usefully. RECOVERY_KEY gets its row in the configuration reference and a commented example in .env.example. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signing up now answers with the page that shows the recovery code, as a 200 with the session cookie on it, instead of redirecting to the dashboard. Five helpers asserted the redirect - the shared signed_in_client fixture and one _sign_in per page test file - so 234 tests failed in setup for one reason. That was a change this branch made on purpose and should have updated in the same commit as the route. The recovery tests had a second, separate fault: the keyed_client fixture asked for app_client through getfixturevalue, which cannot start an async fixture from inside another one and fails with "Runner.run() cannot be called from a running event loop". It now takes the environment fixture and app_client as arguments, which pytest sets up left to right, and which is the order that matters: the settings fixture reads RECOVERY_KEY when it first runs. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The previous commit updated five helpers that asserted the sign-up redirect and missed a sixth: test_aws.py has its own _sign_in. Thirty-three tests in that file failed in CI for the same reason as the rest. I had searched for the pattern in the four files that came to mind rather than across tests/, which is how one was missed; a repository-wide search for the POST now shows every remaining assertion on a 303 is a GET to /signup, which still redirects. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
A forgotten dashboard password had no way back that works on the platforms SESKit is deployed to. Email cannot be the answer: a fresh instance may be unable to send any until someone can sign in and connect AWS, which is when a password gets forgotten. A command cannot be either: a deployment made by pushing to Git gives its owner no shell. So recovery rests on something the owner already holds.
Four commits, in order:
seskit reset-password(b17d75e, docsf13f0d3). For anyone with a shell. Hidden prompts, never an argument; ends the account's sessions. The 12 character minimum moves into core so sign-up and reset share it.RECOVERY_KEY(b9cf42c, docsb37f701):K7QD-4M2X-9PHB-TWE6is shown once after sign-up (80 bits, SHA-256 stored, look-alike characters resolved). Using it replaces it in the same step.RECOVERY_KEYis the operator's equivalent, accepted in the same field, for accounts with no code. Off when empty; the instance refuses to boot with one under 24 characters./forgot-password, and/settings(the sidebar link that went nowhere) to see status and make a new code. A new code asks for the password again, because a session is also what a stolen cookie looks like.X-Forwarded-Forclaims; a test sends every guess from a different claimed address and expects the same cut-off.One nullable column on
users. Existing accounts have NULL and recover throughRECOVERY_KEY, or by signing in and making a code from Settings.Checks
No checkboxes: the template's three need things I could not do from this machine.
Run locally:
ruff,ruff format,mypyand every pre-commit hook on each commit; the 17 recovery tests that need no database; an offline render of every new page, including an escaping check; and the recovery service driven against a stand-in session.Not run: the 26 recovery tests and 7 reset-command tests that need Postgres and Redis,
alembic upgrade headandtests/test_migrations.pyfor the new migration,mkdocs build --strict, and anything by hand in a browser. CI covers the first three; the last I have not done, so the sign-up and recovery pages have not been seen rendered with the real stylesheet.🤖 Generated with Claude Code