2026.09.76 — 2026-09-30
Breaking
Tokens are minted from a session and record whether it verified a second factor (bb51742b)
An API token now records whether the session it was minted from had verified a second factor, and stops authenticating once its owner's role requires one and the token was minted without.
- POST /api/v1/tokens needs a browser session: a call authenticated with a token cannot mint tokens and is refused with 403
session-required. The token is marked as minted with a second factor when that session verified one (an authenticator code, a passkey, a recovery code; a trusted device does not count). A user whose role requires a second factor cannot mint from a session that has not verified one: 403mfa-required. - A token minted without a second factor is rejected with 401, as if revoked, from the moment its owner is required to hold one: when a role that requires it is granted or made to require it. Users of another identity provider are never required, so their tokens are not affected.
- POST /api/v1/tokens needs a browser session: a call authenticated with a token cannot mint tokens and is refused with 403
One public address for Studio, artemis-studio.public-url (66a2c37a)
The address people open Studio at is now a Studio-wide setting, artemis-studio.public-url, read from ARTEMIS_STUDIO_PUBLIC_URL as before. Alert notifications link back to it, and passkeys will use it as their relying party. Startup now fails with a clear message when it is set to something that is not an http or https address with a host.
Sign in to local accounts with a second factor (fe4e30e3)
A local account can now set up an authenticator app (TOTP, RFC 6238: SHA-1, 6 digits, 30 seconds, one step of clock drift) and receives 10 single-use recovery codes with its first factor. A correct password no longer signs in an account that has one: it starts a sign-in that the code, or a recovery code, must finish within 5 minutes. A wrong code counts toward the account lock and the sign-in throttle exactly as a wrong password does, a code works once even when submitted twice at the same instant, and the account lock is cleared only when the whole sign-in finishes. Secrets are sealed with the instance's secret key, and the recovery codes are stored only as hashes.
A role that requires two-step verification is now enforced for accounts that sign in with a password here: such a user with no factor yet gets a restricted session that can only set one up (423
mfa-enrolment-requiredfor everything else), and the same step-up that already guards plugin administration now asks a user with a factor for their password and then the code. Users of an external identity provider are never asked, because their provider does its own MFA. The first factor is trust on first use and is audited with the source address (MFA_ENROL); later ones need a fresh step-up. Also audited:SECOND_FACTOR,SECOND_FACTOR_FAILED,RECOVERY_CODE_USE,RECOVERY_CODES_REGENERATE.New API:
GET /api/v1/auth/mfa: whether a factor is required and enrolled, recovery codes left, and whether passkeys are available (not yet)POST /api/v1/auth/mfa/totp: start setting up an authenticator; returns the secret and theotpauth://URI for a QR code, and changes nothing until confirmedPOST /api/v1/auth/mfa/totp/confirm {code}: activates it; returns the recovery codes when it is the first factorPOST /api/v1/auth/mfa/recovery-codes: new codes, the old ones stop workingPOST /api/v1/auth/second-factor {totpCode | recoveryCode}: finishes a sign-in or a step-up; needs the CSRF token like any POST
Trust forwarded client addresses only from configured proxies (a9af3dfe)
Sign-in limits, the audit trail and session facts use the client address, and Studio took it from X-Forwarded-For sent by any client, so anyone could pick a fresh address for every attempt and never be throttled. Studio now uses Tomcat's RemoteIpValve (server.forward-headers-strategy: native): X-Forwarded-For, -Proto, -Host and -Port count only when the connection comes from a peer matching server.tomcat.remoteip.internal-proxies, which defaults to loopback, 10/8, 172.16/12, 192.168/16 and fc00::/7.
Added
Tell the account page whether two-step verification is the account's to manage (129223df)
GET /auth/mfa gains
local: false for an account that signs in through another provider, which answers 200 with nothing set up. The account section reads it instead of guessing from how a fresh sign-in is asked for.Reset a user's two-step verification and require it per role (6577526c)
The users list says each user's second step in words ("Authenticator app, Passkey", "Not set up", "Required, not set up"). "Reset two-step verification" names what it removes (their authenticator app, passkeys, recovery codes and trusted devices, their API keys, and every session), asks the administrator to type the username, and asks for a fresh sign-in inline; a refusal for one's own account, or for a session that has not verified a factor itself, says what to do instead.
Roles show whether they require two-step verification, and the role editor gains the switch. A built-in role shows its name and permissions as facts and lets only the switch change. Creating an API key from a session without a verified second factor, or from another key, now says why and what to do instead of failing silently.
Set up and manage two-step verification (723a6b17)
A session whose role requires a second factor and has none is taken to /enrol-second-factor: choose an authenticator app (a QR code, its key in groups of four with a copy button, and a code to confirm) or a passkey (unavailable, with the server's reason, when Studio has no public address or the browser cannot). The recovery codes of the first factor are shown once, with Copy all and a .txt download, and the dialog stays open (Escape included) until the person confirms they saved them.
The account page gains a Two-step verification section right after the password: whether it is on and whether the role requires it, the authenticator app (set up, replace, remove), passkeys, how many recovery codes are left with a warning at three or fewer and a regenerate, and the browsers trusted at sign-in with a revoke for each and for all. Removing a factor states what it costs, refuses in advance what the role forbids, and asks for a fresh sign-in inline when the server does.
The QR code is drawn by uqr, a zero-dependency MIT library of about 80 kB unpacked (qrcode pulls in three packages and a PNG encoder, qr is seven times larger); the SVG is rendered from its module matrix, dark on a light quiet zone in both colour schemes.
Ask for a second factor at sign-in and when confirming it is you (6adb54cd)
An account with an authenticator app, a passkey or recovery codes now gets a second step after its password: the six-digit code, "Use a passkey" (the browser's own WebAuthn JSON helpers, no library), or a recovery code. The sign-in offers "Trust this device for N days" when the administrator allows it. A timed-out sign-in returns to the password with a message, a locked account gets the wrong-password words, and too many attempts says to wait.
Confirming it is you (plugin installs, factor changes) moves from the plugins screen into the shell's auth code and asks for the second factor after the password; a wrong code there no longer sends the person to the sign-in page.
The client label helper moves beside it so passkey names can use it, and the time-in-words cell is shared.
Remove second factors, reset a user's, and recover an account at startup (9712eaf7)
Removing a factor, resetting a user's, and recovering a locked-out administrator are now covered, each audited.
- DELETE /api/v1/auth/mfa/totp and DELETE /api/v1/auth/mfa/webauthn/{id} remove an authenticator app or a passkey. They need a recent step-up. A user whose role requires a second factor cannot remove their last one: 409
last-factor-required("Add another way to sign in first."). Removing the last factor of anyone else deletes their recovery codes and revokes their trusted devices. Audited as MFA_REMOVE. - DELETE /api/v1/users/{id}/second-factors (user:admin, recent step-up) removes a user's authenticator app, passkeys, recovery codes and trusted devices, revokes their API tokens and ends their sessions, so they enrol again at their next sign-in if their role requires a factor. It is refused for one's own account (409
self-reset: use a recovery code or ask another administrator), and when the target must hold a factor the administrator's own session must have verified one (403mfa-required). Audited as MFA_RESET. The user list now carries each user's enrolled factors (secondFactors) and whether they are required to hold one (secondFactorRequired). - Break-glass: start Studio with ARTEMIS_STUDIO_IDENTITY_LOCAL_RECOVER set to a local username and that account is unlocked, loses its authenticator app, passkeys, recovery codes and trusted devices, must change its password at its next sign-in, and has its sessions ended. It is audited as ACCOUNT_RECOVER, and Studio logs a warning to remove the property, because the next restart recovers the account again. A name that is no local account is logged as an error and Studio starts anyway.
- DELETE /api/v1/auth/mfa/totp and DELETE /api/v1/auth/mfa/webauthn/{id} remove an authenticator app or a passkey. They need a recent step-up. A user whose role requires a second factor cannot remove their last one: 409
Trust a browser after two-step verification (45f08742)
After giving a second factor at sign-in a user can tick "trust this device": for a period the administrator sets, later sign-ins from that browser need only the password.
- New runtime setting identity-local.mfa.trusted-device-lifetime (Settings, Password login), default 30d. 0 turns trusted devices off: the option is not offered, existing devices are ignored and their cookie is cleared. The setting takes 0 through a new duration kind, DURATION_OR_OFF; the other duration settings must still be positive.
- POST /api/v1/auth/second-factor takes
trustDevice, and the response that asks for the second factor says for how many days a device may be trusted (trustDeviceDays, 0 when off). The browser gets an as_trusted_device cookie: 32 random bytes, HttpOnly, SameSite=Strict, limited to /api/v1/auth, Secure whenever the request came over HTTPS, and only its SHA-256 is stored. A device counts until the earlier of its own expiry and its creation plus the lifetime now in force. - A trusted device signs in with the password alone but does not make the session fresh, so anything that asks users to confirm it is them again still asks for the password and a second factor. It also lets its owner in while the account is locked by failed sign-ins from elsewhere; a wrong password still counts as a failure.
- Users see their trusted devices in GET /api/v1/auth/mfa (client, address, created, last used, expires, which one is this browser) and revoke one or all with DELETE /api/v1/auth/mfa/trusted-devices[/{id}]. Changing a password and disabling an account revoke every trusted device. Adding and revoking one are audited as TRUSTED_DEVICE_ADD and TRUSTED_DEVICE_REVOKE.
Enrol and sign in with passkeys (ce697efa)
Local accounts can now use WebAuthn passkeys as a second factor, next to an authenticator app. An account can hold several passkeys, each with a label the user gives it.
- POST /api/v1/auth/mfa/webauthn/options and POST /api/v1/auth/mfa/webauthn create and register a passkey. The first factor an account holds, a passkey included, gets its ten recovery codes; adding a passkey to an account that has a factor needs a recent step-up, and completing enrolment by passkey lifts the enrol-only restriction like an authenticator app does.
- Sign-in and step-up take a passkey too: POST /api/v1/auth/second-factor/options returns the request options once a password was right (public, and CSRF-protected like the second-factor step), and POST /api/v1/auth/second-factor accepts the browser's answer as
webauthn. A challenge answers one attempt, and an assertion counts only when it is from one of the signing-in user's own passkeys. - GET /api/v1/auth/mfa lists the account's passkeys and reports whether they are available.
Passkeys are available once ARTEMIS_STUDIO_PUBLIC_URL (artemis-studio.public-url) is set: it names the relying party, its host is the relying party id and its origin the only one allowed. Without it the account reports "Set ARTEMIS_STUDIO_PUBLIC_URL to the address people open Studio at to enable passkeys." and an authenticator app still works. A passkey belongs to that host, so changing the host strands passkeys enrolled under the old one; recovery codes and authenticator apps are not affected.
Built on Spring Security's WebAuthn support (spring-security-webauthn, which brings webauthn4j) and its JDBC repositories, called from Studio's own JSON endpoints. The user_entities and user_credentials tables are created by Liquibase.
One public address for Studio, artemis-studio.public-url (66a2c37a)
The address people open Studio at is now a Studio-wide setting, artemis-studio.public-url, read from ARTEMIS_STUDIO_PUBLIC_URL as before. Alert notifications link back to it, and passkeys will use it as their relying party. Startup now fails with a clear message when it is set to something that is not an http or https address with a host.
Let an administrator make a role require two-step verification (810e08f0)
Every role now has a "requires two-step verification" setting,
requiresMfain the roles API (GET/POST/PUT /api/v1/roles). It isfalsefor a new role unless the request says otherwise, andtruefor the built-in ADMIN role. On a built-in role it is the one thing an administrator may change: the name and permissions must be sent unchanged, or the request is refused with 409builtin-role.Changing the setting ends the sessions of the role's members, and so does granting a user a role that requires it, so the requirement applies at their next sign-in. Both use the same rule as any other change to a role.
The setting is enforced by the sign-in change that follows.
List and end signed-in sessions (c7355abe)
Account -> Sessions shows where you are signed in (browser, address, when you signed in and when you last did something) and marks the current session in words. Each other session has a Sign out action, and "Sign out all other sessions" ends the rest at once; signing out of the current one is signing out. Administration -> Users has a Sessions action per user that opens a drawer to end one of that user's sessions or all of them, and needs
user:admin.End sessions after an idle timeout and an absolute lifetime (a21786e2)
A session now ends after 30 minutes without the user doing anything (Settings -> Sessions -> Idle timeout,
security.session.idle-timeout) and 12 hours after signing in, however active it is (Absolute session lifetime,security.session.absolute-lifetime). Both apply at once, without a restart. Previously a session lasted 8 hours after its last request, and because the console polls and holds an event stream, a tab left open never went idle.Only what the user did counts as activity: a request that changes something, and a request carrying
X-Studio-Activity: 1, which the console adds while the pointer or keyboard was used in the last minute. Polling and the event stream do not, so an unattended tab signs out. A script that must stay signed in sends the header or makes changes. Spring Session's inactivity timeout is set to the idle timeout as a backstop.The sign-in screen says so when a signed-in page is sent back because its session ended.
Let an administrator unlock a locked account (a20d947d)
Users now report lockedUntil, and PUT /api/v1/users/{id}/unlock (user:admin) lifts an account's lock and failed-sign-in count and clears the sign-in throttle for that username, audited as ACCOUNT_UNLOCK with the administrator and the target. In Admin, Security, Users a locked account shows "Locked until <time>" with an Unlock action whose progress and outcome are announced.
Show the password policy's reason beside the new password (0907ba74)
Changing your password, and creating a user, now show why a password was refused next to the field instead of a generic error.
Fixed
Match passkeys to the address the way a browser writes it (2db184b2)
The passkey relying party id and the one allowed origin were taken from ARTEMIS_STUDIO_PUBLIC_URL as typed. A browser reports the host in lower case and leaves a default port out of the origin, so an address written as https://Studio.Example.com or https://studio.example.com:443 made every passkey registration and sign-in fail with an origin mismatch. The host is now lower-cased and a default port dropped before they are used.
Issue recovery codes once when two first factors are enrolled at once (37573d2a)
Confirming an authenticator app and registering a passkey from two sessions at the same moment each found the account without a factor, and each issued recovery codes. The second set replaced the first, so one browser showed codes that no longer worked. Enrolling a factor now takes a lock on the user first, so the second enrolment sees the first and is not the first factor.
Delete a user's passkeys with the user (230e7057)
The passkey tables had no link to the account, so removing a user row left their passkey entity and credentials behind. The entity now carries the user's id as a uuid, computed by the database from the name Spring Security's repository writes, with a foreign key to the account that cascades on delete. Studio has no user-deletion endpoint yet; this keeps the data whole for the day it does, and for anyone removing a user directly.
Forget half-finished sign-ins when a session is established (aae6d794)
Signing in through single sign-on did not clear a half-finished password sign-in, step-up or passkey challenge that an earlier user had left in the same browser session. Establishing a session now clears them for every provider, not only for the password login.
Keep event streams open through a step-up, enrolment or password change (07d00108)
Confirming it is you, enrolling a second factor and changing a password each give the session a new id. The live event stream, keyed by the old id, was then closed by the next session check as if the session had ended, so the console lost its live updates until the browser reconnected. Streams now follow their session to its new id. A new sign-in in the same browser still ends the streams of whoever was signed in before.
Meet the AA contrast floor for a red alert's title (bb562684)
"Not reset" measured 3.83:1 in dark and 4.51:1 in light: a red alert's words sit on the red tint of its own background, lighter than the card. Its colour is now red-3 in dark and a darkened --as-danger in light.
List every session of a user (4e96af21)
Spring Session's per-user lookup joins sessions to their attributes with no ORDER BY and assumes one session's rows are adjacent. On PostgreSQL they were not always, so a session came back without its facts and was left out of the list (a signed-in browser missing from Sessions, in the account page and the administrator's drawer). The list takes the ids from the lookup and reads each session by id.
Meet the AA contrast floor for field errors and red text (cf75e395)
A rejected form's message ("Use at least 12 characters.") measured 3.44:1 in dark and 3.28:1 in light. Mantine's error, red text and light red alert colours now use --as-danger: 4.73:1 and 5.1:1. The recovery-code, passkey-name and endpoint fields also stop setting a monospace face on their labels.
Meet the AA contrast floor in highlighted code, in both schemes (ef9018f0)
The JSON keys of the MCP client configuration measured 3.42:1 in the dark scheme: Mantine's highlighter themes are fixed hex values. Shiki now runs its CSS-variables theme, whose colours are the editor's semantic --as-code-* tokens. Measured in the browser on the code block's own background, the lowest token is 4.61:1 in dark and 4.75:1 in light.
Make the account sections read alike, and say when there are no API keys (f4d770cf)
Row actions in Sessions match the passkeys and trusted devices they sit beside, a trusted device's address moves to its facts line like a session's, and the API keys section says what a key is for (or why it could not load) instead of showing a table header with nothing under it.
Start the dev server, which failed optimizing ELK's worker URL (d0da54f6)
Offer to reset two-step verification only where there is something to reset (05593a0a)
Record a client's IPv6 address in its short form (ab8e2c67)
Tomcat spells IPv6 loopback 0:0:0:0:0:0:0:1 and passes a forwarded address through as the proxy wrote it, so sessions, trusted devices, audit events and limiter keys showed it that way. One filter ahead of the security chain now answers getRemoteAddr() with the RFC 5952 form (::1).
List sessions as rows, with each time readable by assistive technology (6772df71)
The four-column sessions table wrapped its absolute times to three lines in the account column. A session is now a row like a passkey or a trusted device: client, address and both times on one line, the action at the inline end. Relative times carry the exact time as text and on hover.
Meet the AA contrast floor on filled buttons and links (68882299)
A filled button's white label measured 4.03:1 on the light scheme's pine-6 and 3.15:1 on the dark scheme's pine-5, and a link on the white body 4.03:1; the light scheme's filled red carried its label at 3.85:1. All are under the 4.5:1 floor for body text. The light primary shade is now pine-7 (5.3:1), the dark scheme's filled buttons take a black label (5.9:1 to 7.6:1), and the light filled red is the AA red already used for text (--as-danger).
Create no session for a call authenticated with an API token (b41ad906)
Every request authenticated with a bearer token created a server-side session for it: Spring Security's SessionManagementFilter saw an authentication the security context repository did not hold and saved it into a new session, so each call wrote a row to spring_session and answered with a SESSION cookie. The bearer filter now records its authentication on the request, which is what marks it as already saved, so a token call has no session. It also means an event stream opened with a token is no longer tied to a session that has no sign-in facts, and so ended at the next session check.
Tell a new event stream it is open at once (70b24ded)
GET /api/v1/stream sent nothing until its first event or heartbeat, up to sse.heartbeat-interval (20 s by default) away, so the browser's EventSource did not fire onopen and the console showed "connecting" for that long. The stream now sends a ping as soon as it is registered.
Keep a session active after its password is changed (3b56bf1f)
Changing your password re-establishes the session, and that reset the session's last-activity time to its sign-in time. For anyone who had been signed in for longer than the idle timeout (30 minutes by default), the very next request was then treated as idle and answered 401, signing them out right after a successful password change. Re-establishing a session now counts as activity, as the request that caused it is.
Treat a session from before session facts as signed out (84c997c1)
A session that was signed in before this release carries no session facts, and a step-up from it failed with a 500. Such a session is now ended on its next request, which is answered as a signed-out caller (401), so the browser returns to the sign-in screen. Users signed in during the upgrade sign in once more.
Security
Refuse a username that differs from another only in case (e4f9f62d)
Studio accepted "Admin" beside "admin" as two accounts, which read as one person in the audit trail and the user list. Usernames are now unique ignoring case: creating a user whose name matches another in any case is refused as a duplicate, and a single sign-on user whose name is taken that way is qualified with the provider id.
The migration adds a case-insensitive unique index. An installation that already has two usernames differing only in case must rename or disable one before upgrading.
Do not keep recovery codes, keys or typed credentials in the query cache (27054870)
TanStack Query keeps a mutation's result and its variables for five minutes after the screen that ran it is gone. For two-step verification that meant the authenticator key and the recovery codes (results), and the password, authenticator code, recovery code and passkey answer typed at sign-in, step-up and password change (variables), stayed readable in memory long after they were shown or sent. These mutations now forget everything the moment nothing observes them, and the enrolment and recovery-code screens drop the result as soon as they have handed the codes to their dialog.
Stop an API key from reading its owner's second-factor status (44c4a4bf)
GET /api/v1/auth/mfa accepted an API key and returned the owner's passkey labels, remaining recovery codes and the address and browser of every trusted device. It now answers a key with 403, like the endpoints that change second factors.
Stop an API key from listing or ending its owner's sessions (303dba70)
The account's own session endpoints (list, end one, end all others) accepted an API key, whatever it was narrowed to. A leaked read-only key could show where its owner was signed in, and end every other session of theirs. They now answer such a key with 403 session-required, like the endpoints that manage keys. An administrator's endpoints for another user's sessions are unchanged and still need user:admin.
Audit a sign-in as successful only once its second factor is given (36c37155)
A correct password for an account with a second factor closed its LOGIN audit row as a success, although nothing was signed in. The trail read as a completed login for anyone who had only the password, and stayed so when the factor was wrong or never given. The row is now closed as failed with "password accepted, second factor not given" and turns into a success only when the factor completes the sign-in, so a stolen password that stops at the second step shows in the audit trail as a failed login.
Revoke a recovered account's API tokens (0af6ed8b)
Break-glass recovery (artemis-studio.identity-local.recover) cleared the account's lock, second factors and trusted devices and ended its sessions, but left its API tokens working. An administrator's reset of a user's factors already revokes them, so someone who held a token from before the recovery kept access after it. Recovery now revokes every token of the account in the same transaction.
Mask second-factor codes and passkey assertions like passwords (07915ec0)
A value under a key named totp, recovery code, trusted device or webauthn is now masked as [redacted] in logs, audit parameters and error details, the way passwords and tokens already are (ADR-0133). A recovery code stays valid until it is used, so a copy of one in a log line would have been as good as a password. The leak test plants a code and a recovery code in refused requests and in a log line and finds neither.
End an event stream when the API token that opened it stops being accepted (b2607e96)
A stream opened with an API token kept receiving events after the token was revoked or expired, or after its owner was disabled or came to require a second factor the token was minted without. The periodic check that ends the streams of ended sessions now also asks, for each stream opened with a token, whether that token would still authenticate, through a new kernel interface (PersonalTokens) the API tokens module implements, and closes the ones that would not, within 10 seconds.
Tokens are minted from a session and record whether it verified a second factor (bb51742b)
An API token now records whether the session it was minted from had verified a second factor, and stops authenticating once its owner's role requires one and the token was minted without.
- POST /api/v1/tokens needs a browser session: a call authenticated with a token cannot mint tokens and is refused with 403
session-required. The token is marked as minted with a second factor when that session verified one (an authenticator code, a passkey, a recovery code; a trusted device does not count). A user whose role requires a second factor cannot mint from a session that has not verified one: 403mfa-required. - A token minted without a second factor is rejected with 401, as if revoked, from the moment its owner is required to hold one: when a role that requires it is granted or made to require it. Users of another identity provider are never required, so their tokens are not affected.
- POST /api/v1/tokens needs a browser session: a call authenticated with a token cannot mint tokens and is refused with 403
Keep recovery codes as a keyed hash (8a107130)
A recovery code has 50 bits, so a plain SHA-256 of it falls to an offline guess from a copy of the table. Codes are now stored as an HMAC-SHA256 under a key of their own: 32 random bytes made once per installation, sealed by SecretVault and re-wrapped, never changed, by a key rotation (ADR-0132), so a retired key version cannot orphan the hashes. A copy of the table alone cannot attack them.
Sign in to local accounts with a second factor (fe4e30e3)
A local account can now set up an authenticator app (TOTP, RFC 6238: SHA-1, 6 digits, 30 seconds, one step of clock drift) and receives 10 single-use recovery codes with its first factor. A correct password no longer signs in an account that has one: it starts a sign-in that the code, or a recovery code, must finish within 5 minutes. A wrong code counts toward the account lock and the sign-in throttle exactly as a wrong password does, a code works once even when submitted twice at the same instant, and the account lock is cleared only when the whole sign-in finishes. Secrets are sealed with the instance's secret key, and the recovery codes are stored only as hashes.
A role that requires two-step verification is now enforced for accounts that sign in with a password here: such a user with no factor yet gets a restricted session that can only set one up (423
mfa-enrolment-requiredfor everything else), and the same step-up that already guards plugin administration now asks a user with a factor for their password and then the code. Users of an external identity provider are never asked, because their provider does its own MFA. The first factor is trust on first use and is audited with the source address (MFA_ENROL); later ones need a fresh step-up. Also audited:SECOND_FACTOR,SECOND_FACTOR_FAILED,RECOVERY_CODE_USE,RECOVERY_CODES_REGENERATE.New API:
GET /api/v1/auth/mfa: whether a factor is required and enrolled, recovery codes left, and whether passkeys are available (not yet)POST /api/v1/auth/mfa/totp: start setting up an authenticator; returns the secret and theotpauth://URI for a QR code, and changes nothing until confirmedPOST /api/v1/auth/mfa/totp/confirm {code}: activates it; returns the recovery codes when it is the first factorPOST /api/v1/auth/mfa/recovery-codes: new codes, the old ones stop workingPOST /api/v1/auth/second-factor {totpCode | recoveryCode}: finishes a sign-in or a step-up; needs the CSRF token like any POST
End event streams when their session ends (14750b7a)
An open event stream (GET /api/v1/stream, and the SQL console's stream) used to keep delivering events after the session that opened it ended: signing out, the idle timeout or absolute lifetime, ending it from the sessions list, an administrator ending it, or the user's access being revoked. The connection had been authenticated once and was never looked at again.
Each stream now records the session that opened it, and ends with it:
- Signing out, ending a session and revoking a user's sessions close that session's streams on this instance immediately.
- A check every 10 seconds reads each open stream's session from the session store, once per session however many streams it holds, and closes the streams of one that is gone or past its idle timeout or absolute lifetime. This also covers a session ended on another instance. The stream itself never counts as activity, so an unattended tab still goes idle.
The console reconnects a closed stream on its own: a session that is still signed in gets a new stream, and one that is not is answered 401. A stream opened with an API token has no session and is unaffected.
Lock accounts after repeated failed sign-ins (7fef327d)
Ten consecutive wrong passwords now lock a local account for 15 minutes, recorded in the database so the lock holds across instances and survives the failed login's own rollback. A locked account answers exactly like a wrong password (same 401, even with the correct password), so nothing tells an attacker which accounts exist or are locked. A failure while locked neither extends the lock nor counts. Once the lock passes, the count starts again. Single sign-on accounts are never locked by local sign-in attempts.
A sign-in counts as a success, and clears the count, only when it completes. Each lock is audited as ACCOUNT_LOCK with the source address, a throttled attempt is now audited as a failed sign-in with reason "throttled", and the attempt is recorded before the throttle check. Unknown usernames now cost the same password hash check as known ones.
Bound the sign-in throttle and limit each address across accounts (a2d6be3d)
The failed-sign-in throttle kept one entry per username and source forever, so a flood of made-up usernames grew memory without limit, and one address could try every account without ever being slowed. It now expires entries, caps their number, and additionally throttles an address after 30 failed sign-ins in 10 minutes whichever accounts it tried, unknown usernames included. A completed sign-in clears the username-and-source key but not the address count.
Trust forwarded client addresses only from configured proxies (a9af3dfe)
Sign-in limits, the audit trail and session facts use the client address, and Studio took it from X-Forwarded-For sent by any client, so anyone could pick a fresh address for every attempt and never be throttled. Studio now uses Tomcat's RemoteIpValve (server.forward-headers-strategy: native): X-Forwarded-For, -Proto, -Host and -Port count only when the connection comes from a peer matching server.tomcat.remoteip.internal-proxies, which defaults to loopback, 10/8, 172.16/12, 192.168/16 and fc00::/7.
Enforce a password policy and end other sessions on a password change (cccd3795)
A new local password must now be at least 12 characters (the runtime setting
identity-local.password.min-length), at most 72 bytes, not the username, and not one of the 100,000 most common passwords, which ship in the jar. It applies when a user changes their own password and when an administrator creates a user. A refused password answers 400 with problem typepassword-policyand the reason as its detail.Set
artemis-studio.identity-local.breach-lookup.enabled=trueto also check new passwords against Have I Been Pwned. Only the first five characters of the password's SHA-1 leave Studio, and a lookup that fails or takes longer than 2 seconds lets the password through. It is off by default.Changing your password now ends your other sessions and keeps the current one. It no longer counts as re-authenticating: a session older than five minutes still needs a step-up for sensitive actions afterwards. Wrong current passwords are throttled like sign-in attempts and answer 429 once throttled.
A session now records explicitly how it was authenticated (when, with which second-factor method, from which address and client), and a password sign-in clears any earlier signed-in state and pending sign-in steps before it starts.