Repository navigation
fix(users): POST /users creates an account that can sign in (#870) - #871
Merged
Conversation
The account gets default_org_id set to the caller's organization, which is where login looks; without it every account created here was refused with "user has no organization". The user and its membership are now written in one transaction (the membership error used to be discarded), a session with no active organization is refused instead of creating an account that belongs nowhere, and the role name is matched case-insensitively, with the accepted names returned on a miss.
3 tasks
The challenge needs exact per-token and per-account counters that hold across instances. AllowRate counts in wall-clock buckets, so a five-minute challenge token can straddle two of them and get up to eight tries. The store counts from the first failure, sets locks once (so the lock is audited and mailed once), and falls back to a per-instance memory store when Redis errors instead of failing open. The spec for the issue is committed alongside. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
The challenge checked a TOTP code, then the backup codes, and never counted a miss, so a stolen password plus enough requests was a login. Each attempt is now reserved against the challenge token and the account before the code is read, which keeps parallel requests from sharing one slot. A token gets five attempts and is blacklisted when the fifth fails. An account that fails ten times in fifteen minutes, over any tokens, is locked for a fixed fifteen minutes and the owner is told by mail. Backup codes are the same attempt as TOTP codes. If the store cannot answer, the challenge is refused rather than run unchecked. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
A lock means someone got past the password and then guessed codes. The owner is the only one who can fix that, by resetting the password, so they get a notice in their language the moment the lock is set. Sent through the async security mailer, so a slow SMTP server never delays the challenge response. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
The handler passes the challenge token's JTI and expiry to the use case and answers each refusal with a code the sign-in screen can act on: MFA_CHALLENGE_EXHAUSTED (401, sign in again) and MFA_LOCKED (429, with Retry-After). A lock is audited once, when it is set. Store and decryption errors now answer 500 without their text instead of a 400 that echoed it. The route gets its own per-IP limit, 20 per 5 minutes in a prefixed bucket, mounted before the token middleware so a flood never reaches token parsing and never spends the login budget. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
Logout deleted the refresh token and cleared the cookies, but the access token kept working for up to fifteen minutes. Whoever had copied it kept the session. The handler now reads the access token from the Authorization header or the session cookie, verifies its signature, and the use case blacklists its JTI for the time it has left. The access token is revoked before the refresh token, and both are always attempted, so a stale refresh token or a Redis outage on one does not spare the other. A forged token is ignored rather than trusted. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
alex-dembele
deleted the
689-securityauth-mfa-challenge-codes-can-be-guessed-without-limit-no-attempt-counter-no-rate-limit
branch
October 2, 2026 11:36
This was referenced Oct 2, 2026
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.
Closes #870
An account created with
POST /userscould never sign in. The handler wrote the user and a membership, but nodefault_org_id, which is what login resolves the organization from: every such account got 401 ("user has no organization").backend/internal/handler/user_handler.go,CreateUser:default_org_idto the caller's active organization;status: active,is_active,joined_at;IDexplicitly, as the invitation flow does, so the membership points at it on every database;rolecase-insensitively (analystworks, as the input's comment always said), and a miss returnsaccepted_roles. The comment now says what the field is: the global role. The account joins the organization asuser, and an admin promotes it withPUT /organization/members/:id/role, as before.Verification
New
user_create_test.go: the real handler behind the realRequireRole("admin")gate, then a sign-in through the realLoginUseCasewith the real Argon2id hasher, on the same database.TestCreateUser_Success_AccountSignsInToTheCallersOrganizationTestCreateUser_NotFound_UnknownRoleNamesTheAcceptedOnesTestCreateUser_Unauthorized_PlainMemberIsRefusedTestCreateUser_NoActiveOrganizationCreatesNothingTestCreateUser_MembershipFailureLeavesNoAccount(a trigger blocks the membership insert; no user row is left)Run against the old handler, the Success test fails with exactly
cannot sign in: user has no organization.Live on Postgres 18 (fresh database):
POST /usersas the owner with"role":"analyst"→ 201.default_org_idequals the membership's organization, and the membership isuser/active/is_active."role":"superuser"→ 404 withaccepted_roles: [Admin, Analyst, Manager, Viewer].POST /users→ 403./organization/membersasuser/active.Honest remainders
rolestays the legacy global role and grants nothing in the organization (see security(users): /users/:id status, role and delete act on the global account, not on the caller's membership #807). LettingPOST /userschoose the organization role would touch the auth design, which needs an owner decision, so it is not done here.internal/handler/risk_handler.gois not gofmt-clean on master. I left it alone (not this change).