Replaces the hand-rolled timestamp check and cleanup goroutine. The old
int64 type assertion never matched on redis (Get returns a string), so
passcode replay protection was silently disabled there.
Gives the account owner an out-of-band signal if someone with their
password starts moving the login address, with a link to the settings
page where the change can be cancelled. Sent after the confirmation mail
so a throttled request mails nothing.
Changing the email no longer overwrites the address and flips the account
to StatusEmailConfirmationRequired. The new address is stored as
pending_email and only applied when the confirm token is used (valid 24h),
so a lost or misaddressed confirmation mail can no longer lock the user
out. The change can be repeated to fix typos, cancelled, or the mail
resent; confirmation mails are throttled to one per minute per user, and
stale change tokens are swept by the cleanup cron.
Fixes#3481
The default-project creation triggered by the user-created event wrote the
stale in-memory user back with a full UpdateUser, racing registration and
sometimes resetting the status from "email confirmation required" back to
active. Only update the default_project_id column and keep the returned
user's status in sync with what was persisted.
GetUserFromClaims read the id claim straight into User.ID without checking the
token type. A link share JWT carries the raw, positive share id in that claim
and reaches every authenticated route, so the only thing preventing
impersonation was the incidental absence of a username claim in link share
tokens. Adding one would have reintroduced the confusion with a positive id,
bypassing the GetID negation entirely.
AuthTypeUser moves to pkg/user, which parses the claims and cannot import
pkg/modules/auth, so the value exists once.
Parametrised error translations (invalid api token permission, invalid
timezone, invalid claim data) rendered their placeholders as empty text
because the backend only baked the values into the English message and
the frontend translated the error code without any interpolation params.
Carry the values as i18n_params on error responses, keyed by the
placeholder names of the frontend's error translations, on both v1 and
the v2 problem+json body, and pass them through when translating the
toast. A 14002 toast now reads e.g. "The permission time_entries of
group tasks is invalid." instead of "The permission of group is
invalid."
Port GET /user/settings/totp/qrcode to v2 as an image/jpeg blob, modeled in
the OpenAPI spec. Extract the qr-to-jpeg encoding into user.GetTOTPQrCodeAsJpegForUser
so v1 and v2 share it; refactor v1 onto it. The handler reuses the existing
local-account guard, rejecting non-local users with 412.
Thread the request context through CheckUserCredentials so the
LoginFailedEvent carries IP, user agent and request id — without it,
failed logins were the one auth event useless for brute-force tracing.
All four callers have the request at hand.
LoginSucceededEvent fires from NewUserAuthTokenResponse (the chokepoint
where local, LDAP and OIDC logins converge), LoginFailedEvent from
handleFailedPassword on every failed password check, LogoutEvent from
the logout handler, and APIToken issued/revoked/used events from the
token model and auth middleware. The token events carry IDs only since
the freshly created token struct holds the raw token string and the
poison queue logs message payloads.
None of these events have a listener yet — the audit registration adds
them. Dispatching to a topic without subscribers is a no-op.
Extract the duplicated user-search business logic into two helpers both API
versions call, and refactor v1's handlers onto them:
- user.SearchUsers wraps ListUsers + email obfuscation (global search)
- models.SearchUsersForProject wraps the project read check + ListUsersFromProject
Each handler keeps its own forbidden mapping (v1 echo.ErrForbidden vs v2
huma) so v1 stays byte-identical on the wire.
Pull the business logic out of the v1 current-user account/settings handlers
into reusable functions so both v1 and the upcoming v2 handlers call one
implementation. No behavior change — the v1 handlers keep their HTTP-layer
quirks (input binding, validation, error mapping); only orchestration moves.
Homes are forced by the import graph:
- shared.GetAuthProviderName (new pkg/routes/api/shared, above openid+user so it
can combine both without a cycle; routes-only helper)
- user.ChangeUserEmail (CheckUserCredentials + UpdateEmail, both in user)
- models.ChangeUserPassword (needs models.DeleteAllUserSessions; user can't import models)
- models.UpdateUserGeneralSettings / UpdateUserAvatarProvider
(need avatar.FlushAllCaches; user can't import avatar)
The general settings get a single shared wire struct, models.UserGeneralSettings
(tagged for both swaggo/govalidator and Huma): it is the update request body and
the nested settings on GET /user for v1 (replacing v1's UserSettings) and v2.
ExtraSettingsLinks is readOnly — populated from the user on read, ignored on
write. A dedicated struct is required because user.User's settings fields are
json:"-" so they don't leak when it is embedded in other responses.
Port the BotUser resource from /api/v1's /user/bots routes to the
Huma-backed /api/v2, preserving every v1 behavior:
- Full CRUD at /user/bots and /user/bots/{bot} with v2 verbs (POST
creates, PUT updates; PATCH is synthesised by AutoPatch).
- ReadAll returns only the caller's own bots; read/update/delete of an
unowned or missing bot is refused with 403, since ownership is resolved
by loading the user (no existence disclosure, no 404 branch).
- Create requires a real user account and rejects link shares, the
bot- username prefix is enforced, and bots are created without an
email or password — all delegated to the unchanged model layer.
- ReadOne surfaces max_permission via the shared value-embed pattern and
carries an ETag for conditional requests.
doc/readOnly tags are added to the exposed user.User fields the bot
response surfaces, and to BotUser.Status, so the v2 OpenAPI schema is
documented. The model and v1 routes are untouched.
The webtest ports the v1 model-level permission matrix to the v2 HTTP
surface and adds the v2-only ETag/304 and merge-patch coverage.
Removes the `service.enablebotusers` config flag, the matching
`bot_users_enabled` field on /info, and the now-unused
`ErrBotUsersDisabled` error. Bot user routes and the frontend
settings tab are now always available.
https://claude.ai/code/session_01VhAR6xnoCdG1fpX52bzaCC
User search previously filtered bots only when they happened to match the
search string. That produced two bad behaviours:
1. Bots owned by other users could surface on an exact-username match,
leaking them into assignee pickers and similar UI.
2. A user could not reliably find their own bots by typing a partial
name, so bots became awkward to assign to tasks.
Change ListUsers to treat bot ownership explicitly: the existing match
branch excludes rows owned by someone else, and a second branch always
returns bots owned by the calling user. The own-bots branch also
respects any AdditionalCond passed in so project-scoped listings don't
start leaking bots from outside the project.
GuardLastAdmin counted only active, non-deletion-scheduled admins, but gated only on target.IsAdmin. Demoting or deleting an already-disabled or deletion-scheduled admin would then be blocked whenever exactly one active admin remained, even though removing a user who isn't in the reachable set can't reduce the count. Return early when the target isn't part of the counted set.
Verifies that HandleFailedTOTPAuth locks the account after 10 rolled-back
caller sessions (the regression from GHSA-fgfv-pv97-6cmj), and that the
persisted password reset token can unlock the account via ResetPassword.
The failed-TOTP handler shared the login request's xorm session, and the
login handler rolled that session back after a failed login. The status
change to StatusAccountLocked was silently discarded, so the account was
never locked regardless of how many failed TOTP attempts arrived.
HandleFailedTOTPAuth now opens its own session and commits independently
of the caller. The login handler rolls back its session before invoking
the handler so the lockout write can acquire a write lock on SQLite
shared-cache.
Also handles the Redis keyvalue backend returning the attempt counter as
a string instead of int64, which would have prevented the lockout path
from ever running on Redis.
See GHSA-fgfv-pv97-6cmj.
Add locked user fixture (user18, status=3) and test that both disabled
and locked users are rejected across all auth paths: API tokens,
CalDAV basic auth, CheckUserCredentials.
Ref: GHSA-94xm-jj8x-3cr4
Defense-in-depth: CheckUserCredentials now checks user status after
validating credentials. While current callers are already protected
by upstream checks, this prevents future auth bypass if new code
calls CheckUserCredentials without a subsequent status check.
Ref: GHSA-94xm-jj8x-3cr4
The username and email uniqueness checks don't need status filtering —
they just need to know if the name/email exists regardless of account
status. Use getUser (which skips the status check) instead of the
public wrappers, reducing cyclomatic complexity back under the threshold.
Make isErrUserStatusError public and replace all verbose
!IsErrAccountDisabled(err) && !IsErrAccountLocked(err) checks
with the shorter IsErrUserStatusError(err) call.
getUser now returns ErrAccountDisabled or ErrAccountLocked (alongside
the full user object) for users with StatusDisabled or StatusAccountLocked.
Callers that need disabled/locked users discard the error; all others
propagate it automatically.
GHSA-94xm-jj8x-3cr4
Separate from ErrAccountDisabled so callers can distinguish between
admin-disabled accounts (no recovery) and locked accounts (recoverable
via password reset).
Store a unix timestamp instead of a boolean, and treat entries older
than 90 seconds as expired. A background goroutine lazily cleans up
expired keys after each successful validation to prevent unbounded
growth in the keyvalue store.
Store used TOTP passcodes in the keyvalue store after successful
validation. On subsequent validation attempts, check if the passcode
was already used for the same user and reject it with
ErrTOTPPasscodeUsed. This prevents replay attacks where an intercepted
TOTP code could be reused within its 30-second validity window.
Add TestTOTPPasscodeCannotBeReused which verifies that a valid TOTP
passcode cannot be used twice within its validity window. Also add
ErrTOTPPasscodeUsed error type for the new behavior.
Add a TOTP fixture for user1 with a known secret to enable
testing TOTP validation logic. Update InitTests to load the
totp fixture alongside users and user_tokens.