Skip to content

fix(users): POST /users creates an account that can sign in (#870) - #871

Merged
alex-dembele merged 7 commits into
masterfrom
689-securityauth-mfa-challenge-codes-can-be-guessed-without-limit-no-attempt-counter-no-rate-limit
Oct 2, 2026
Merged

alex-dembele merged 7 commits into
masterfrom
689-securityauth-mfa-challenge-codes-can-be-guessed-without-limit-no-attempt-counter-no-rate-limit

Conversation

@alex-dembele

Copy link
Copy Markdown
Member

Closes #870

An account created with POST /users could never sign in. The handler wrote the user and a membership, but no default_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:

  • sets default_org_id to the caller's active organization;
  • writes the user and the membership in one transaction (rule 7). The membership error used to be discarded. The membership now mirrors an invitation acceptance: status: active, is_active, joined_at;
  • assigns the user ID explicitly, as the invitation flow does, so the membership points at it on every database;
  • refuses (403) a session with no active organization, instead of creating an account that belongs nowhere;
  • matches role case-insensitively (analyst works, as the input's comment always said), and a miss returns accepted_roles. The comment now says what the field is: the global role. The account joins the organization as user, and an admin promotes it with PUT /organization/members/:id/role, as before.

Verification

go build ./...           ok
go test ./... -count=1   79 packages ok, 0 FAIL

New user_create_test.go: the real handler behind the real RequireRole("admin") gate, then a sign-in through the real LoginUseCase with the real Argon2id hasher, on the same database.

  • TestCreateUser_Success_AccountSignsInToTheCallersOrganization
  • TestCreateUser_NotFound_UnknownRoleNamesTheAcceptedOnes
  • TestCreateUser_Unauthorized_PlainMemberIsRefused
  • TestCreateUser_NoActiveOrganizationCreatesNothing
  • TestCreateUser_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):

  1. POST /users as the owner with "role":"analyst" → 201.
  2. That account signs in → token issued, organization "OpenDefender".
  3. In the database, default_org_id equals the membership's organization, and the membership is user / active / is_active.
  4. "role":"superuser" → 404 with accepted_roles: [Admin, Analyst, Manager, Viewer].
  5. The new member calling POST /users → 403.
  6. The account shows in /organization/members as user / active.

Honest remainders

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.
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant