You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The user and authentication code has grown into several overlapping representations of the same
concept. Identity data lives in user, login_source, external_login_user and user_open_id
at the same time, user carries both a "which source authenticates me" pointer and a duplicated
copy of that source's type, and the OAuth2 provider tables sit in the same package as the
OAuth2 consumer login source. As a result, simple questions ("is this user local?", "what is this
user's external identity?", "may this user change their password?") are answered differently in
different places.
This proposal covers the current state, the concrete problems with code references, a target design,
and — as a first-class part of the design rather than an afterthought — the legacy-data migration
contract that guarantees no user is ever locked out and no data is ever lost.
Baseline for all code references: c186cc4b8d.
Non-negotiable guarantees
Any step of this refactor that does not satisfy all five is not acceptable.
G1 — no lockout. Every account that can sign in before an upgrade signs in afterwards, with the
same credential or the same provider, without any user-visible action.
G2 — expand / migrate / contract. No column or table is dropped in the release that switches
the read path. Every risky step spans at least two releases: release N writes both the old and the
new location, release N+1 reads the new one, release N+2 drops the old one.
G3 — rollback safety. Because release N keeps writing the old columns, downgrading the binary
within the same minor line still works. Rollback stops being possible only at the contract release,
which is called out explicitly in the release notes.
G4 — idempotent migrations. Every backfill is restartable and safe to re-run
(INSERT ... WHERE NOT EXISTS semantics), so an interrupted upgrade can simply be retried.
G5 — nothing is discarded. Rows that cannot be mapped unambiguously are parked in a side
table and reported to the administrator; they are never deleted and never silently rewritten.
Current state
Persistence
Table
Struct
Purpose
login_source
auth.Source, models/auth/source.go:109
IdP configuration, with a polymorphic cfg TEXT column
user
user.User, models/user/user.go:82
Account, pluslogin_type/login_source/login_name and the local password columns (models/user/user.go:92)
external_login_user
models/user/external_login_user.go:46
Linked external identity, PK (external_id, login_source_id), plus profile snapshot and OAuth tokens
Source configs are registered from the service layer into a registry owned by the model layer
(models/auth/source.go:99, RegisterTypeConfig) and deserialised through an XORM BeforeSet hook
(models/auth/source.go:132).
Interactive sign-in
services/auth/signin.go:26 (UserSignIn) resolves the account by name or e-mail, then:
if a local user row exists, authenticates only against user.login_source
(services/auth/signin.go:74);
otherwise loops over every active source implementing PasswordAuthenticator and tries the
submitted credentials against each (services/auth/signin.go:104).
Browser flows (OAuth2, OpenID, 2FA, WebAuthn, account linking) are orchestrated across routers/web/auth/{auth,oauth,linkaccount,openid,2fa,webauthn}.go by passing state through
individual session keys, cleared in one place at services/auth/session.go:57.
Problems
P1 — user.login_type duplicates login_source.type.
Both must be written together (services/user/update.go:198), and lookups use the redundant triple (login_type, login_source, login_name) (models/user/user.go:1309). If the two diverge, the
sign-in path's behaviour is undefined, and no DB constraint keeps them consistent.
P2 — login_source = 0 is a ghost source. auth.GetSourceByID(ctx, 0) fabricates an in-memory Source with IsActive = true
(models/auth/source.go:290), with a FIXME stating that disabling built-in password authentication
is not possible. Both NoType and Plain are registered to the same DB source
(services/auth/source/db/source.go:35), so "local" is expressible in two different ways and IsLocal() has to be written as LoginType <= auth.Plain (models/user/user.go:238).
P3 — user.login_name is semantically polymorphic.
It is an LDAP uid/DN, an SMTP e-mail address, or an OAuth2 subject
(routers/web/auth/oauth.go:510), yet the admin UI renders one generic input for all of them
(templates/admin/user/edit.tmpl:56). For OAuth2 users the same identity is stored twice: in user.login_name and in external_login_user.external_id.
P4 — "one primary source" and "many linked identities" coexist as two models.
A user has exactly one login_source, but may also have any number of external_login_user and user_open_id rows. Feature gating therefore falls back to the coarse LoginType > auth.Plain
check, whose own comment says the external-login table should be consulted instead
(models/user/user.go:1456).
P5 — auth.Type conflates three dimensions.
Protocol (LDAP/SMTP/PAM/OAuth2), binding mode (LDAP vs DLDAP are the same protocol with
different bind strategies) and trigger style (SSPI is request-header authentication and does not
implement PasswordAuthenticator at all, yet it is stored as a login source) share one enum
(models/auth/source.go:29).
P6 — inverted layering around source configuration. models/auth owns the Config interface and the type registry while every implementation lives in services/auth/source/*. The consequences are the XORM BeforeSet reflection hook
(models/auth/source.go:132), a MustSourceCfg helper that panics on type mismatch
(models/auth/source.go:183), and models/user importing models/auth solely for one enum
(models/user/user.go:99).
P7 — auto-provisioning logic is duplicated per source.
Each source creates users itself: services/auth/source/ldap/source_authenticate.go:87, services/auth/source/pam/source_authenticate.go:58, services/auth/source/smtp/source_authenticate.go:74, plus services/auth/source/ldap/source_sync.go:120 for the sync path. There is no single place to express
activation, e-mail conflict, visibility or group-mapping policy.
P8 — the all-sources fallback loop is unconditional.
For any unknown username the submitted password is replayed against every active password source in
order (services/auth/signin.go:104). This enables account enumeration against upstream systems, can
trigger upstream lockouts, and adds latency proportional to the number of configured sources. It
cannot be turned off.
P9 — sign-in orchestration is an implicit state machine. twofaUid, twofaRemember, webauthnAssertion, linkAccount, linkAccountData, openidPendingURI, openid_verified_uri, openid_signin_remember, openid_determined_email, openid_determined_username are individual session keys shared between six router files and cleaned
up in one hand-maintained list (services/auth/session.go:57). Forgetting one is a security bug
rather than a compile error. The admin form even encodes the source as the string "<type>-<id>" and
parses it back by hand (routers/web/admin/users.go:141).
P10 — models/auth mixes unrelated domains.
Login sources, personal access tokens, remember-me tokens, TOTP, WebAuthn, sessions and the OAuth2 provider implementation all live in one package, which makes the dependency graph and the naming
(Source ⇄ login_source, models/auth ⇄ services/auth) hard to reason about.
P11 — the local/external dichotomy that the code is built on does not exist. user.passwd is the local password of a local account, and login_source only says who verifies a
submitted password — it does not make the account "non-local". The code contradicts itself on this:
The OAuth2 source's Authenticate delegates straight back to the DB authenticator
(services/auth/source/oauth2/source_authenticate.go:15), so an OAuth2 user's password is verified locally. An OAuth2 account is simultaneously a local password account and an externally linked
account — which is exactly why UpdateAuth permits password changes for IsLocal() || IsOAuth2()
(services/user/update.go:212).
Yet IsLocal() is LoginType <= auth.Plain (models/user/user.go:238), so it returns false for
those same OAuth2 users, and IsFeatureDisabledWithLoginType gates on LoginType > auth.Plain
(models/user/user.go:1460). With EXTERNAL_USER_DISABLE_FEATURES = manage_credentials an OAuth2
user is therefore denied management of the very password they sign in with
(routers/web/user/setting/account.go:54).
Meanwhile SMTP and PAM auto-registration copies the remote password into the local account
(services/auth/source/smtp/source_authenticate.go:73, services/auth/source/pam/source_authenticate.go:57), hashed by CreateUser
(models/user/user.go:757). Those users do have a local password hash — one that is never used
for verification and never refreshed. LDAP, by contrast, stores none
(services/auth/source/ldap/source_authenticate.go:82-90). This is a separate defect from the
OAuth2 case and is addressed by Step 1b below.
To be precise about OAuth2, since the two cases are easy to conflate: OAuth2 has no upstream
password, so nothing is copied. A password on an OAuth2 account is a genuinely local one, chosen on
the link-register form (routers/web/auth/linkaccount.go:234) and deliberately left empty when
external-only registration is configured (routers/web/auth/linkaccount.go:213-219, whose comment
already asks for a DB-level marker for "second-factor-only" accounts — which is what user_credential
provides).
So IsLocal() means neither "is a local account" (all of them are), nor "has a local password"
(SMTP/PAM users have one), nor "password is verified locally" (OAuth2 is, and returns false). Every
"is this user local" branch in the codebase is built on this one overloaded predicate.
Scale of the coupling: ~135 non-test references to LoginType / .LoginSource / LoginName across
~40 files, including templates, API structs (modules/structs/user.go:22, modules/structs/admin_user.go:12) and conversion code (services/convert/user.go:79).
Legacy data shapes the migration must handle
Audited against the current code; every one of these is reachable today and is the reason the
migration plan below looks the way it does.
D1 — "local" has two encodings.login_type is 0 (NoType) on old rows and 1 (Plain) on
newer ones, which is exactly why IsLocal() is LoginType <= auth.Plain
(models/user/user.go:238). Both must map to the same local source.
D2 — local users can carry a stale login_name.UpdateAuth writes LoginName whenever the
form provides it (services/user/update.go:207) and the admin form keeps the field around
(templates/admin/user/new.tmpl:51). A local user with a leftover login_name must not produce
an external identity row.
D3 — accounts bound to a source can still have a local password, and for OAuth2 it is the one
actually used. The OAuth2 source verifies passwords through the DB authenticator
(services/auth/source/oauth2/source_authenticate.go:15) and password updates are permitted for IsLocal() || IsOAuth2() (services/user/update.go:212). Credential backfill therefore cannot be
restricted to login_source = 0, or OAuth2 users lose the password they sign in with.
D3b — SMTP and PAM users carry a copy of their remote password, and this is a defect, not just
legacy shape. Auto-registration stores the submitted upstream password
(services/auth/source/smtp/source_authenticate.go:73, services/auth/source/pam/source_authenticate.go:57), hashed by CreateUser
(models/user/user.go:757); it is then never used for verification and never refreshed. Three
consequences: a hash of a third-party credential sits in Gitea's database for the lifetime of the
account, so a DB leak exposes the user's corporate SMTP/PAM password rather than a Gitea-only one; IsPasswordSet() reports true, which drives UI branching (templates/user/settings/account.tmpl:10);
and if an administrator later moves such an account to the local source, the user's old remote
password silently becomes a valid Gitea password. LDAP already does the right thing and stores
nothing. These copies are therefore purged rather than migrated — see Step 1b.
D4 — there is no unique index on (login_type, login_source, login_name). Duplicates and empty login_name values are possible in the wild, and a naive UNIQUE(source_id, subject) index would
either fail to build or force row deletion. Precedent for handling this: modelmigration/fixtures/Test_AddUniqueIndexForProjectIssue.
D5 — OAuth2 identities are stored twice.user.login_name (subject) and external_login_user.external_id (routers/web/auth/oauth.go:510 and :519). Installations
upgraded from before external linking may have only the former. Backfill must deduplicate on (source_id, subject) and prefer the external_login_user row, which additionally carries the
profile snapshot and tokens.
D6 — OpenID has no login source at all.user_open_id (models/user/openid.go:18) is keyed
only by a globally unique URI and has a Show flag controlling profile visibility. Consolidating
it requires a synthetic OpenID source row and a home for Show, which is user-visible data.
D7 — dangling login_source references.DeleteSource refuses to remove a source in use
(services/auth/source.go:16), but hand-edited databases and pre-guard installations can still have
users pointing at a missing row. Today those users cannot sign in; the migration must park them
rather than fabricate a source.
D8 — login_name is collation-sensitive. For SMTP it is an e-mail address, for LDAP a uid or a
DN. Deduplication must be collation-aware and the value preserved byte-for-byte, because it is the
upstream bind identifier — silently lowercasing it can break LDAP binds.
D9 — login_name is not only an auth field. The Actions bot encodes a task ID into it
(models/user/user_system.go:59-61, parsed back at :69) with LoginSource = -1. These are
in-memory rows, not DB rows, but the struct field cannot simply disappear; this needs its own small
preparatory PR moving the task ID to a dedicated field.
D10 — user.Passwd participates in the activation/reset signature. makeTimeLimitCodeHashData includes u.Passwd (models/user/user.go:919). Moving the password to user_credential must keep feeding the identical value into that hash, or every outstanding
activation and password-reset link breaks.
D11 — login_name is a public API and admin-search surface.models/user/search.go:122 filters
by it and modules/structs/user.go:22 / modules/structs/admin_user.go:12 expose login_name and source_id. These must keep working after identities move.
Target design
Principles
Every user row is a local account. A source never classifies the account; it only states who
verifies a submitted password and which external identity the account is linked to.
Three orthogonal concepts, never conflated: a credential (a secret stored locally), a password verifier (who checks a submitted password), and an identity (who the user is
according to some source). An account may hold any combination of the three, except that an
account whose password_verifier_source_id is not the local source may not acquire a local
credential at all — the schema refuses it, rather than an operator merely never setting one.
A source is described by its capabilities (interfaces it implements), not by an enum value.
Provisioning policy is decided in exactly one service, from a source-agnostic identity struct.
Multi-step browser sign-in is one explicit state machine, not a bag of session keys.
Gitea never stores a credential that belongs to another system. A remote source holds the
authoritative copy; a local copy is liability without benefit.
Schema
user — account attributes only; the login_* and password columns eventually go away.
auth_source (today login_source, table name preserved) — IdP configuration. The built-in local
source becomes a real row so it can be deactivated like any other source.
user_identity — replaces user.login_*, external_login_user and user_open_id: id, user_id (index), source_id, subject (unique with source_id, enforced only from the
contract release), login_name, is_primary, created_unix, updated_unix.
user_identity_profile / user_identity_token — the profile snapshot and the access/refresh token
material currently crammed into external_login_user, separated because they have different
lifetimes and different sensitivity.
user_credential — local secrets: user_id, kind, hash_algo, salt, secret, must_change, updated_unix. Enables password-less accounts and password history without further schema churn.
user.password_verifier_source_id — an explicit statement of who verifies a submitted password,
replacing the implicit meaning of login_source (P11). Pointing at the local source means "check user_credential", which is what OAuth2 accounts do today via the DB fallback; pointing at an LDAP,
SMTP or PAM source means "ask that source". Identity links and the local credential become
independent of it, so "local account with an OAuth2 identity" stops being a contradiction.
user_identity_unmapped — the parking table required by G5.
models/oauth2provider — the provider tables, explicitly separated from login sources.
Old → new mapping (the "no data loss" contract)
Legacy column / table
New home
Notes
user.login_source
user_identity.source_id with is_primary = true
local users point at the real local auth_source row
user.login_type
derived from auth_source.type
redundant by construction (P1); nothing is lost
user.login_name
user_identity.login_name, and subject for OAuth2/OpenID
copied verbatim for local and OAuth2 accounts (a live, locally chosen password — D3). The SMTP/PAM copies of remote passwords are deliberately purged in Step 1b instead of migrated (D3b)
user.login_source (as "who verifies my password")
user.password_verifier_source_id
OAuth2 rows map to the local source, matching today's DB fallback (P11)
External account management (provisioning, deprovisioning, re-linking after an instance rebuild) needs
to address an account by something other than an autoincrement row id. The design commits to the
following, so downstream work does not have to keep a parallel mapping table of its own.
subject is immutable and never reused, per source that can promise it. When an upstream
identifier and an upstream login name are different values, a rename upstream updates user_identity.login_name only; subject is written once at link time and never rewritten. This is
a real guarantee for OAuth2/OpenID Connect (sub) and for OpenID URIs.
It is not a global guarantee. For LDAP, SMTP and PAM the migrated subject is the same string
as login_name (a uid, a DN or an e-mail), which upstream may legitimately change. Stability is
therefore a source capability, declared by the source alongside its other capabilities
(Principle 3), not a property of the column. Sources that can offer a stable identifier
(e.g. LDAP entryUUID / objectGUID) may later opt in without a schema change; that is not part of
this proposal.
The stable external handle for a source is its name, not its id.login_source.name is already UNIQUE (models/auth/source.go:122) and is already the OIDC provider name. Any external addressing
is therefore (source name, subject); source_id stays an internal foreign key. A source recreated
on a rebuilt instance under the same name keeps addressing the same humans, which is the manual UPDATE user SET login_source = ... remap operators do today.
user_identity.user_id may be zero, meaning an identity that is not bound to an account yet.
Backfill never produces such a row — every migrated identity comes from an existing account — and no
sign-in path accepts one; it exists so that "create the identity, bind the account later" is
representable. If a later discussion decides against it, the column simply stays non-zero everywhere
and nothing else in the design changes.
Interfaces
// A source-agnostic result produced by every authentication path.typeExternalIdentitystruct {
SourceIDint64Subjectstring// stable upstream identifierLoginNamestring// upstream login/bind name, for display and re-bindEmailstringDisplayNamestringGroups []stringSSHKeys []stringProfile*IdentityProfileTokens*IdentityTokensRawmap[string]any
}
typeCredentialVerifierinterface { // db, ldap, dldap, smtp, pamVerifyPassword(ctx context.Context, subject, passwordstring) (*ExternalIdentity, error)
}
typeRedirectFlowProviderinterface { // oauth2 / oidc, openidAuthURL(ctx context.Context, statestring) (string, error)
Callback(ctx context.Context, r*http.Request) (*ExternalIdentity, error)
}
typeRequestAuthenticatorinterface { // sspi, reverseproxy, httpsign — no longer auth sourcesVerifyRequest(ctx context.Context, r*http.Request) (*ExternalIdentity, error)
}
typeUserSynchronizerinterface {
Sync(ctx context.Context, updateExistingbool) error
}
A single provisioning service consumes *ExternalIdentity and decides create / update / link /
reject, replacing the per-source CreateUser calls (P7).
Clearing the flow becomes a single delete, and adding a step (mandatory password change, device
confirmation) becomes a new stage instead of another ad-hoc key.
Migration roadmap
Each step is a self-contained PR, ordered so the tree is releasable after every one of them, per docs/guidelines-refactoring.md. Every step states its DB change, its compatibility behaviour, its
rollback story and its exit criterion.
Step 0 — free login_name from its non-auth use (D9)
Move the Actions task ID out of LoginName into a dedicated field on the in-memory user
(models/user/user_system.go:58-71), keeping GetActionsUserTaskID behaviour identical.
DB: none. Compatibility: none affected, these rows are never persisted.
Rollback: trivial, code-only.
Done when: LoginName has no meaning outside authentication.
Step 1 — guard rails and pre-flight diagnostics
New doctor check gitea doctor check --run auth-consistency, runnable before any schema work,
reporting per class: login_type != login_source.type, duplicate (login_source, login_name),
dangling login_source (D7), OAuth2 users without an external_login_user row (D5), and — most
importantly — accounts that would end up with neither a credential nor an identity, i.e. the exact
lockout candidates. --fix handles the mechanically repairable classes.
Migration that repairs user.login_type != login_source.type and normalises login_type for login_source = 0 (D1).
Test fixtures for one user per row of the lockout matrix below; every later step reuses them.
DB: data repair only, no schema change. Rollback: safe.
Done when: the invariant is asserted by tests on every write path and the doctor check is green on
the fixture set.
Step 1a — make the credential state visible, especially to administrators
Tracked separately in #38906. Visibility before mutation: today
nothing in the UI says whether an account holds a local password. The admin detail page shows only the
auth source (templates/admin/user/view_details.tmpl:17-19) and the user list has no such column or
filter, yet "auth source = Local" and "has a local password" are different things (P11, D3, D3b).
New predicates HasLocalPassword() and PasswordVerifiedLocally() in models/user, replacing
inference from IsLocal().
Admin detail rows for "Local password" and "Password verified by"; for an OAuth2 account the latter
correctly reads "Local".
A has_local_password status filter in the admin user list
(models/user/search.go:53-57, routers/web/admin/users.go:75), following the is_2fa_enabled
precedent, so affected accounts can be enumerated before Step 1b runs.
The user's own account page names the source that manages their password instead of the generic settings.password_change_disabled (templates/user/settings/account.tmpl:31).
Once user_credential and password_verifier_source_id exist (Steps 4/12), the same predicates
gate acquisition: every path that can create or reset a local secret refuses when the verifier is
not the local source, so "this account has no local secret and may not get one" becomes a property
the schema enforces instead of a convention an operator audits.
No schema change, no behaviour change, and it is a prerequisite for Step 1b so the purge is auditable
rather than a silent flip of IsPasswordSet().
Step 1b — stop storing remote credentials locally, and purge the existing copies
Tracked separately in #38905, because it stands on its own:
it needs no schema redesign and does not depend on any other step here (D3b, principle 6).
Order matters, because makeTimeLimitCodeHashData includes u.Passwd (D10):
Replace u.Passwd in the activation/reset signature with a credential version marker
(updated_unix of the credential, or a dedicated counter). This preserves the property the
current code wants — a reset link stops working once the password changes — without coupling the
signature to the secret itself. One-time cost: links already in flight at upgrade time become
invalid, which is acceptable for links whose lifetime is measured in minutes.
Stop setting Passwd in SMTP and PAM auto-registration
(services/auth/source/smtp/source_authenticate.go:73, services/auth/source/pam/source_authenticate.go:57), matching what LDAP already does.
Migration that clears passwd, salt and passwd_hash_algo for accounts whose password is
verified by a remote source (LDAP/SMTP/PAM), leaving OAuth2 and local accounts untouched.
No lockout (G1): these accounts are authenticated by their source, never by the local hash
(services/auth/signin.go:74), and they are already excluded from the reset and change-password
paths (routers/web/auth/password.go:75, templates/user/settings/account.tmpl:7). Nothing that
works today stops working.
One intentional behaviour change, to be called out in the release notes: after this step,
switching such an account to the local source no longer silently re-enables the user's old remote
password; an administrator has to set a password explicitly.
Not a G5 violation: the purged value is a copy of a secret owned by another system, which Gitea
has no legitimate use for. The rows themselves are untouched.
Done when: no source writes a remote credential into user, and the doctor check from Step 1
reports zero remote-verified accounts with a local password hash.
Step 2 — funnel all field access through accessors
Add user.AuthSourceID(), user.SetAuthSource(*auth.Source), user.SubjectName(), user.IsLocalAccount() in models/user; replace the ~135 direct reads/writes so the struct fields
become effectively package-private.
DB: none. Behaviour: none. Rollback: code-only.
Done when: no package outside models/user touches LoginType, LoginSource or LoginName
directly.
Step 3 — make the local source a real row (P2)
Migration inserts an auth_source row for built-in password authentication and repoints local
users at it; the old login_source = 0 value keeps working through the accessors during the
transition.
Delete the id == 0 special case (models/auth/source.go:290); NoType is retired from the user
side. IsActive = false on that row now disables built-in password login, resolving the FIXME.
DB: one inserted row plus an ID rewrite. Rollback: the reverse update is mechanical and shipped as a
documented SQL snippet.
Done when: no code path constructs a Source value that is not backed by a row.
Step 4 — create user_identity and backfill (expand phase)
Backfill from three inputs in this order: external_login_user (richest), then non-local user.login_* where no identity exists yet (D5), then user_open_id under a synthetic OpenID
source (D6). Local users get an identity on the local source; a stale login_name on a local user is
copied to login_name but never promoted to subject (D2).
Deduplicate on (source_id, subject)without creating the unique index yet (D4); ambiguous rows
go to user_identity_unmapped with a reason code and are surfaced in the admin UI (G5).
Migration logs a per-class summary (migrated, deduplicated, parked) and is idempotent (G4).
Reads still come from the old locations; writes go to both (dual write).
Rollback: the new tables are additive and simply ignored by an older binary (G3).
Done when: a consistency test proves old and new reads agree for every fixture, and the doctor check
reports zero unexplained parked rows on the fixture set.
Move identity lookups (routers/web/auth/oauth.go:510 and :519, the OpenID lookup, services/externalaccount) to the new table.
Replace the LoginType > auth.Plain feature gate (models/user/user.go:1456) with a query on the
user's primary identity, which is what the existing comment asks for.
Dual write continues, so external_login_user and user_open_id stay correct for a downgrade.
Done when: every read path is on user_identity and the integration suite signs in successfully as
each fixture user.
Step 6 — stop writing user.login_type (keep the column)
Derive the type from the source everywhere; rewrite GetIndividualUserByLoginSource to key on (source_id, login_name).
Keep the API surface: login_name and source_id stay in modules/structs, the admin API keeps
accepting login_type-shaped input by resolving it to a source ID, and models/user/search.go:122
keeps filtering by login_name (D11).
Replace the "<type>-<id>" form encoding (routers/web/admin/users.go:141) with a plain source-ID
select.
DB: the column stays, unread. Rollback: still safe.
Done when: API responses are byte-identical for the Step 1 fixtures.
Step 7 — fix the layering of source configuration (P6)
Move the Config registry out of models/auth into services/auth/source; replace the BeforeSet
hook with an explicit LoadSourceCfg(*Source) error called by the finders, and MustSourceCfg
panics with typed errors.
Split auth.Type into a protocol enum plus capability interfaces; DLDAP becomes an LDAP config
flag while keeping its stored numeric value for compatibility (open question 3).
models/user no longer imports models/auth.
DB: none. Done when: the model package has no knowledge of concrete source implementations.
Step 8 — unify provisioning (P7)
Add services/auth/provision.go with ProvisionFromIdentity(ctx, *ExternalIdentity, policy)
handling create, update, link-to-existing (by verified e-mail or username, gated by configuration),
activation, group/team mapping and SSH key sync.
Rewrite ldap, smtp, pam and the OAuth2 callback to return *ExternalIdentity and delegate,
removing their local CreateUser calls; reuse the same entry point from SyncExternalUsers so the
login and sync paths cannot drift.
Behaviour must be pinned by tests first: current auto-registration semantics per source are
captured as tests in Step 1's fixture set, then re-asserted here.
Done when: CreateUser has one caller per user-facing flow (self sign-up, admin create,
provisioning).
Step 9 — make the all-sources fallback explicit (P8)
Add a setting to disable the fallback loop (services/auth/signin.go:104), defaulting to the
current behaviour so upgrades do not change who can log in (G1); restrict it to sources that opt
in, apply a per-source timeout and rate-limit by remote address.
Done when: both settings are covered by tests and the default is behaviour-preserving.
Table names preserved; this is a package move plus import rewrite.
Done when: each package has a single domain and no import cycle is introduced.
Step 11 — the sign-in state machine (P9)
Introduce LoginFlow under one session key, replacing the ten ad-hoc keys and the manual cleanup
list (services/auth/session.go:57); convert the six router files to stage transitions.
Existing signed-in sessions are unaffected; only sign-in flows in progress across the restart are,
which is already true today.
Done when: ClearSessionKeysForSignIn is a single delete and every transition, including abandoned
and expired flows, is tested.
Step 12 — contract phase (release N+2)
Drop user.login_type, user.login_source, user.login_name; retire external_login_user and user_open_id; add UNIQUE(source_id, subject) once the reported duplicates are resolved (D4).
Optionally extract user_credential, copying passwd/salt/passwd_hash_algo/ must_change_password verbatim and keeping makeTimeLimitCodeHashData fed with the same value
(D10). This also removes the oddity that password updates are gated on IsLocal() || IsOAuth2() (services/user/update.go:212) and makes password-less accounts a
first-class state.
Release notes state that downgrades past this point are not supported (G3).
Done when: the doctor check reports zero parked rows and the unique index exists.
Steps 0–3 (including 1a and 1b) plus the registry part of 7 need no destructive schema change and already remove most of the
ambiguity. They are a reasonable first milestone even if the rest is deferred.
Lockout matrix
The concrete answer to "can a user end up unable to log in". Every row gets a fixture and an
end-to-end sign-in assertion through the real services/auth.UserSignIn, not a mock.
Account class
Legacy representation
After migration
Verification
Local, password set
login_type 0 or 1, login_source 0
primary identity on the local source; password columns untouched until Step 12
sign-in with the original password on migrated data
Local, no password
empty passwd
unchanged; still cannot password-sign-in, and we do not "repair" it
negative test
Local with stale login_name
D2
identity on the local source, login_name retained, subject not promoted
sign-in test
LDAP / DLDAP
login_type 2/5, login_name = uid or DN
identity with subject byte-identical
bind against a test LDAP using the migrated value
SMTP
login_type 3, login_name = e-mail
identity with the e-mail exactly as stored, no case folding
sign-in test
PAM
login_type 4
identity, verification path unchanged
sign-in test
OAuth2 with link row
user.login_*andexternal_login_user
identity built from the link row (tokens and profile preserved), credential kept, verifier = local source
callback test, token-refresh test, and password sign-in test
OAuth2 without link row
only user.login_*
identity synthesised from user.login_*, same verifier mapping
callback and password sign-in test on a pre-link fixture
SMTP / PAM with a copied remote password (D3b)
login_type 3/4 with non-empty passwd
local hash purged in Step 1b, no credential row, verifier = the source
upstream sign-in still succeeds; IsPasswordSet() becomes false; reset and change-password paths were already refused for these accounts
OpenID
user_open_id rows
identities under the synthetic OpenID source, Show preserved
sign-in test plus profile rendering test
SSPI
login_type 7 source rows
the source row is kept; SSPI stops being a password source and reads config from that row
request-auth test
Dangling source
login_source points nowhere
parked and reported; already unable to sign in today, so no regression
doctor output assertion
Duplicate (source, login_name)
D4
all rows kept, extras parked, unique index deferred
existing tests pass unchanged; sessions must not be invalidated
Test strategy
Fixture-based migration tests per step, using the existing pattern
(modelmigration/migrationtest.PrepareTestEnv, modelmigration/fixtures/Test_*), with Test_UnwrapLDAPSourceCfg as the closest precedent for source-config surgery.
One shared "legacy install" fixture containing exactly one user per row of the lockout matrix,
reused by every step so equivalence is proven against the same data set throughout.
A dual-write consistency test in release N comparing old and new reads for every fixture; it is
the gate for the Step 5 read switch.
Upgrade-and-login runs for the two riskiest transitions (Step 4 backfill, Step 5 read switch).
Compatibility and risk
API: login_name and source_id remain in modules/structs; login_type in the admin API is
resolved to a source ID rather than removed.
External consumers: third-party dashboards query the login_source table by name, so the table
name is kept even after the struct and package are renamed.
Irreversible migrations: only the Step 12 contract phase is irreversible, and it is a separate
release with explicit release notes.
Behaviour: Step 9 is the only step that can change who is able to log in, and it ships
behaviour-preserving by default.
Timing: schema-affecting steps should land early in a milestone.
Non-goals
No change to the OAuth2 provider protocol implementation, only its package location.
No change to the supported source types or their configuration semantics.
No change to how sessions or personal access tokens are validated, beyond package moves.
Federated / UserTypeRemoteUser accounts (models/user/user.go:67) are out of scope, though user_identity should be a better fit for them than user.login_*.
SCIM and external account management ([Thread] SSO Auth Tracking #23794) are out of scope. The addressing contract above is
written so that they stay implementable on top of this schema, nothing more.
Whether provisioning may accept an identity that arrives without an e-mail address
(CreateUserOption.Email is Required, modules/structs/admin_user.go:23; OIDC self-registration
refuses the same way, routers/web/auth/oauth.go:165) is an API policy question, not a schema one.
Step 8 collapses that decision into a single service, so it can be revisited afterwards in one
place instead of five.
Open questions
Duplicate (source, login_name) — recommendation is to keep every row, park the extras, defer
the unique index and require an administrator to resolve. The alternative (pick one
deterministically) silently changes who owns an upstream identity.
UserOpenID.Show — carry it on user_identity, or keep a thin profile-preferences table?
DLDAP — keep the stored type value forever, or migrate it into an LDAP config flag with a
one-way migration?
Contract window — one minor release or two before the old columns and tables are dropped?
Auto-created SSPI / reverse-proxy accounts — should they get an identity row for their source,
or remain credential-less local accounts as they are today?
SMTP/PAM password capture (D3b) — resolved: no. Gitea should not hold a copy of a credential
owned by another system, so Step 1b stops the capture and purges the existing copies. What is still
open is the replacement input for the activation/reset signature (credential updated_unix versus a
dedicated counter).
Deactivating the local source (P2/P11) — once built-in password login can be switched off,
does that also disable password verification for OAuth2 accounts, which currently delegate to the
DB authenticator? My preference is yes, with the setting documented as "local password
verification" rather than "local accounts". The stronger form is now a design point rather than a
question: an account whose verifier is not the local source cannot acquire a local credential
either (Principle 2, Step 1a).
Unbound identities — should user_identity.user_id = 0 be accepted, as the addressing contract
proposes, or should an identity always be a property of an existing account? This only affects
future provisioning work; migration and sign-in behave identically either way.
Stable subjects for LDAP/SMTP/PAM — worth declaring a per-source "subject is stable" capability
now, even though every source migrated from login_name starts out without it?
Feedback is most useful on the target schema, the old → new mapping table, and the ordering of
Steps 4–6, which is where the points of no return live.
Summary
The user and authentication code has grown into several overlapping representations of the same
concept. Identity data lives in
user,login_source,external_login_useranduser_open_idat the same time,
usercarries both a "which source authenticates me" pointer and a duplicatedcopy of that source's type, and the OAuth2 provider tables sit in the same package as the
OAuth2 consumer login source. As a result, simple questions ("is this user local?", "what is this
user's external identity?", "may this user change their password?") are answered differently in
different places.
This proposal covers the current state, the concrete problems with code references, a target design,
and — as a first-class part of the design rather than an afterthought — the legacy-data migration
contract that guarantees no user is ever locked out and no data is ever lost.
Baseline for all code references:
c186cc4b8d.Non-negotiable guarantees
Any step of this refactor that does not satisfy all five is not acceptable.
same credential or the same provider, without any user-visible action.
the read path. Every risky step spans at least two releases: release N writes both the old and the
new location, release N+1 reads the new one, release N+2 drops the old one.
within the same minor line still works. Rollback stops being possible only at the contract release,
which is called out explicitly in the release notes.
(
INSERT ... WHERE NOT EXISTSsemantics), so an interrupted upgrade can simply be retried.table and reported to the administrator; they are never deleted and never silently rewritten.
Current state
Persistence
login_sourceauth.Source,models/auth/source.go:109cfgTEXT columnuseruser.User,models/user/user.go:82login_type/login_source/login_nameand the local password columns (models/user/user.go:92)external_login_usermodels/user/external_login_user.go:46(external_id, login_source_id), plus profile snapshot and OAuth tokensuser_open_idmodels/user/openid.go:18oauth2_application/oauth2_grant/oauth2_authorization_codemodels/auth/oauth2.goaccess_token,auth_token,two_factor,webauthn_credential,sessionmodels/auth/*.goRuntime abstractions
Two unrelated notions of "authentication" live in
services/auth:PasswordAuthenticator(services/auth/interface.go:34) — credential verification, implemented bythe
db,ldap,smtpandpamsource configs.Method(services/auth/interface.go:26) — per-request authentication, implemented bysession,basic,oauth2(provider tokens),reverseproxy,httpsign,sspi.Source configs are registered from the service layer into a registry owned by the model layer
(
models/auth/source.go:99,RegisterTypeConfig) and deserialised through an XORMBeforeSethook(
models/auth/source.go:132).Interactive sign-in
services/auth/signin.go:26(UserSignIn) resolves the account by name or e-mail, then:userrow exists, authenticates only againstuser.login_source(
services/auth/signin.go:74);PasswordAuthenticatorand tries thesubmitted credentials against each (
services/auth/signin.go:104).Browser flows (OAuth2, OpenID, 2FA, WebAuthn, account linking) are orchestrated across
routers/web/auth/{auth,oauth,linkaccount,openid,2fa,webauthn}.goby passing state throughindividual session keys, cleared in one place at
services/auth/session.go:57.Problems
P1 —
user.login_typeduplicateslogin_source.type.Both must be written together (
services/user/update.go:198), and lookups use the redundant triple(login_type, login_source, login_name)(models/user/user.go:1309). If the two diverge, thesign-in path's behaviour is undefined, and no DB constraint keeps them consistent.
P2 —
login_source = 0is a ghost source.auth.GetSourceByID(ctx, 0)fabricates an in-memorySourcewithIsActive = true(
models/auth/source.go:290), with a FIXME stating that disabling built-in password authenticationis not possible. Both
NoTypeandPlainare registered to the same DB source(
services/auth/source/db/source.go:35), so "local" is expressible in two different ways andIsLocal()has to be written asLoginType <= auth.Plain(models/user/user.go:238).P3 —
user.login_nameis semantically polymorphic.It is an LDAP uid/DN, an SMTP e-mail address, or an OAuth2 subject
(
routers/web/auth/oauth.go:510), yet the admin UI renders one generic input for all of them(
templates/admin/user/edit.tmpl:56). For OAuth2 users the same identity is stored twice: inuser.login_nameand inexternal_login_user.external_id.P4 — "one primary source" and "many linked identities" coexist as two models.
A user has exactly one
login_source, but may also have any number ofexternal_login_useranduser_open_idrows. Feature gating therefore falls back to the coarseLoginType > auth.Plaincheck, whose own comment says the external-login table should be consulted instead
(
models/user/user.go:1456).P5 —
auth.Typeconflates three dimensions.Protocol (LDAP/SMTP/PAM/OAuth2), binding mode (
LDAPvsDLDAPare the same protocol withdifferent bind strategies) and trigger style (SSPI is request-header authentication and does not
implement
PasswordAuthenticatorat all, yet it is stored as a login source) share one enum(
models/auth/source.go:29).P6 — inverted layering around source configuration.
models/authowns theConfiginterface and the type registry while every implementation lives inservices/auth/source/*. The consequences are the XORMBeforeSetreflection hook(
models/auth/source.go:132), aMustSourceCfghelper that panics on type mismatch(
models/auth/source.go:183), andmodels/userimportingmodels/authsolely for one enum(
models/user/user.go:99).P7 — auto-provisioning logic is duplicated per source.
Each source creates users itself:
services/auth/source/ldap/source_authenticate.go:87,services/auth/source/pam/source_authenticate.go:58,services/auth/source/smtp/source_authenticate.go:74, plusservices/auth/source/ldap/source_sync.go:120for the sync path. There is no single place to expressactivation, e-mail conflict, visibility or group-mapping policy.
P8 — the all-sources fallback loop is unconditional.
For any unknown username the submitted password is replayed against every active password source in
order (
services/auth/signin.go:104). This enables account enumeration against upstream systems, cantrigger upstream lockouts, and adds latency proportional to the number of configured sources. It
cannot be turned off.
P9 — sign-in orchestration is an implicit state machine.
twofaUid,twofaRemember,webauthnAssertion,linkAccount,linkAccountData,openidPendingURI,openid_verified_uri,openid_signin_remember,openid_determined_email,openid_determined_usernameare individual session keys shared between six router files and cleanedup in one hand-maintained list (
services/auth/session.go:57). Forgetting one is a security bugrather than a compile error. The admin form even encodes the source as the string
"<type>-<id>"andparses it back by hand (
routers/web/admin/users.go:141).P10 —
models/authmixes unrelated domains.Login sources, personal access tokens, remember-me tokens, TOTP, WebAuthn, sessions and the OAuth2
provider implementation all live in one package, which makes the dependency graph and the naming
(
Source⇄login_source,models/auth⇄services/auth) hard to reason about.P11 — the local/external dichotomy that the code is built on does not exist.
user.passwdis the local password of a local account, andlogin_sourceonly says who verifies asubmitted password — it does not make the account "non-local". The code contradicts itself on this:
Authenticatedelegates straight back to the DB authenticator(
services/auth/source/oauth2/source_authenticate.go:15), so an OAuth2 user's password is verifiedlocally. An OAuth2 account is simultaneously a local password account and an externally linked
account — which is exactly why
UpdateAuthpermits password changes forIsLocal() || IsOAuth2()(
services/user/update.go:212).IsLocal()isLoginType <= auth.Plain(models/user/user.go:238), so it returnsfalseforthose same OAuth2 users, and
IsFeatureDisabledWithLoginTypegates onLoginType > auth.Plain(
models/user/user.go:1460). WithEXTERNAL_USER_DISABLE_FEATURES = manage_credentialsan OAuth2user is therefore denied management of the very password they sign in with
(
routers/web/user/setting/account.go:54).(
services/auth/source/smtp/source_authenticate.go:73,services/auth/source/pam/source_authenticate.go:57), hashed byCreateUser(
models/user/user.go:757). Those users do have a local password hash — one that is never usedfor verification and never refreshed. LDAP, by contrast, stores none
(
services/auth/source/ldap/source_authenticate.go:82-90). This is a separate defect from theOAuth2 case and is addressed by Step 1b below.
To be precise about OAuth2, since the two cases are easy to conflate: OAuth2 has no upstream
password, so nothing is copied. A password on an OAuth2 account is a genuinely local one, chosen on
the link-register form (
routers/web/auth/linkaccount.go:234) and deliberately left empty whenexternal-only registration is configured (
routers/web/auth/linkaccount.go:213-219, whose commentalready asks for a DB-level marker for "second-factor-only" accounts — which is what
user_credentialprovides).
So
IsLocal()means neither "is a local account" (all of them are), nor "has a local password"(SMTP/PAM users have one), nor "password is verified locally" (OAuth2 is, and returns
false). Every"is this user local" branch in the codebase is built on this one overloaded predicate.
Scale of the coupling: ~135 non-test references to
LoginType/.LoginSource/LoginNameacross~40 files, including templates, API structs (
modules/structs/user.go:22,modules/structs/admin_user.go:12) and conversion code (services/convert/user.go:79).Legacy data shapes the migration must handle
Audited against the current code; every one of these is reachable today and is the reason the
migration plan below looks the way it does.
login_typeis0(NoType) on old rows and1(Plain) onnewer ones, which is exactly why
IsLocal()isLoginType <= auth.Plain(
models/user/user.go:238). Both must map to the same local source.login_name.UpdateAuthwritesLoginNamewhenever theform provides it (
services/user/update.go:207) and the admin form keeps the field around(
templates/admin/user/new.tmpl:51). A local user with a leftoverlogin_namemust not producean external identity row.
actually used. The OAuth2 source verifies passwords through the DB authenticator
(
services/auth/source/oauth2/source_authenticate.go:15) and password updates are permitted forIsLocal() || IsOAuth2()(services/user/update.go:212). Credential backfill therefore cannot berestricted to
login_source = 0, or OAuth2 users lose the password they sign in with.legacy shape. Auto-registration stores the submitted upstream password
(
services/auth/source/smtp/source_authenticate.go:73,services/auth/source/pam/source_authenticate.go:57), hashed byCreateUser(
models/user/user.go:757); it is then never used for verification and never refreshed. Threeconsequences: a hash of a third-party credential sits in Gitea's database for the lifetime of the
account, so a DB leak exposes the user's corporate SMTP/PAM password rather than a Gitea-only one;
IsPasswordSet()reportstrue, which drives UI branching (templates/user/settings/account.tmpl:10);and if an administrator later moves such an account to the local source, the user's old remote
password silently becomes a valid Gitea password. LDAP already does the right thing and stores
nothing. These copies are therefore purged rather than migrated — see Step 1b.
(login_type, login_source, login_name). Duplicates and emptylogin_namevalues are possible in the wild, and a naiveUNIQUE(source_id, subject)index wouldeither fail to build or force row deletion. Precedent for handling this:
modelmigration/fixtures/Test_AddUniqueIndexForProjectIssue.user.login_name(subject) andexternal_login_user.external_id(routers/web/auth/oauth.go:510and:519). Installationsupgraded from before external linking may have only the former. Backfill must deduplicate on
(source_id, subject)and prefer theexternal_login_userrow, which additionally carries theprofile snapshot and tokens.
user_open_id(models/user/openid.go:18) is keyedonly by a globally unique URI and has a
Showflag controlling profile visibility. Consolidatingit requires a synthetic OpenID source row and a home for
Show, which is user-visible data.login_sourcereferences.DeleteSourcerefuses to remove a source in use(
services/auth/source.go:16), but hand-edited databases and pre-guard installations can still haveusers pointing at a missing row. Today those users cannot sign in; the migration must park them
rather than fabricate a source.
login_nameis collation-sensitive. For SMTP it is an e-mail address, for LDAP a uid or aDN. Deduplication must be collation-aware and the value preserved byte-for-byte, because it is the
upstream bind identifier — silently lowercasing it can break LDAP binds.
login_nameis not only an auth field. The Actions bot encodes a task ID into it(
models/user/user_system.go:59-61, parsed back at:69) withLoginSource = -1. These arein-memory rows, not DB rows, but the struct field cannot simply disappear; this needs its own small
preparatory PR moving the task ID to a dedicated field.
user.Passwdparticipates in the activation/reset signature.makeTimeLimitCodeHashDataincludesu.Passwd(models/user/user.go:919). Moving the password touser_credentialmust keep feeding the identical value into that hash, or every outstandingactivation and password-reset link breaks.
login_nameis a public API and admin-search surface.models/user/search.go:122filtersby it and
modules/structs/user.go:22/modules/structs/admin_user.go:12exposelogin_nameandsource_id. These must keep working after identities move.Target design
Principles
userrow is a local account. A source never classifies the account; it only states whoverifies a submitted password and which external identity the account is linked to.
password verifier (who checks a submitted password), and an identity (who the user is
according to some source). An account may hold any combination of the three, except that an
account whose
password_verifier_source_idis not the local source may not acquire a localcredential at all — the schema refuses it, rather than an operator merely never setting one.
authoritative copy; a local copy is liability without benefit.
Schema
user— account attributes only; thelogin_*and password columns eventually go away.auth_source(todaylogin_source, table name preserved) — IdP configuration. The built-in localsource becomes a real row so it can be deactivated like any other source.
user_identity— replacesuser.login_*,external_login_useranduser_open_id:id,user_id(index),source_id,subject(unique withsource_id, enforced only from thecontract release),
login_name,is_primary,created_unix,updated_unix.user_identity_profile/user_identity_token— the profile snapshot and the access/refresh tokenmaterial currently crammed into
external_login_user, separated because they have differentlifetimes and different sensitivity.
user_credential— local secrets:user_id,kind,hash_algo,salt,secret,must_change,updated_unix. Enables password-less accounts and password history without further schema churn.user.password_verifier_source_id— an explicit statement of who verifies a submitted password,replacing the implicit meaning of
login_source(P11). Pointing at the local source means "checkuser_credential", which is what OAuth2 accounts do today via the DB fallback; pointing at an LDAP,SMTP or PAM source means "ask that source". Identity links and the local credential become
independent of it, so "local account with an OAuth2 identity" stops being a contradiction.
user_identity_unmapped— the parking table required by G5.models/oauth2provider— the provider tables, explicitly separated from login sources.Old → new mapping (the "no data loss" contract)
user.login_sourceuser_identity.source_idwithis_primary = trueauth_sourcerowuser.login_typeauth_source.typeuser.login_nameuser_identity.login_name, andsubjectfor OAuth2/OpenIDuser.passwd,salt,passwd_hash_algo,must_change_passworduser_credentialuser.login_source(as "who verifies my password")user.password_verifier_source_idexternal_login_user.external_iduser_identity.subjectuser.login_name(D5)external_login_user.{email,name,first_name,last_name,nick_name,description,avatar_url,location,raw_data}user_identity_profileexternal_login_user.{access_token,access_token_secret,refresh_token,expires_at}user_identity_tokenservices/auth/source/oauth2/source_sync.go)external_login_user.provideruser_open_id.uriuser_identity.subjectunder a synthetic OpenID sourceuser_open_id.showuser_identity.show_on_profilelogin_source.*oauth2_*access_token,auth_token,two_factor,webauthn_credential,sessionExternal addressing contract
External account management (provisioning, deprovisioning, re-linking after an instance rebuild) needs
to address an account by something other than an autoincrement row id. The design commits to the
following, so downstream work does not have to keep a parallel mapping table of its own.
subjectis immutable and never reused, per source that can promise it. When an upstreamidentifier and an upstream login name are different values, a rename upstream updates
user_identity.login_nameonly;subjectis written once at link time and never rewritten. This isa real guarantee for OAuth2/OpenID Connect (
sub) and for OpenID URIs.subjectis the same stringas
login_name(a uid, a DN or an e-mail), which upstream may legitimately change. Stability istherefore a source capability, declared by the source alongside its other capabilities
(Principle 3), not a property of the column. Sources that can offer a stable identifier
(e.g. LDAP
entryUUID/objectGUID) may later opt in without a schema change; that is not part ofthis proposal.
login_source.nameis alreadyUNIQUE(models/auth/source.go:122) and is already the OIDC provider name. Any external addressingis therefore
(source name, subject);source_idstays an internal foreign key. A source recreatedon a rebuilt instance under the same name keeps addressing the same humans, which is the manual
UPDATE user SET login_source = ...remap operators do today.user_identity.user_idmay be zero, meaning an identity that is not bound to an account yet.Backfill never produces such a row — every migrated identity comes from an existing account — and no
sign-in path accepts one; it exists so that "create the identity, bind the account later" is
representable. If a later discussion decides against it, the column simply stays non-zero everywhere
and nothing else in the design changes.
Interfaces
A single provisioning service consumes
*ExternalIdentityand decides create / update / link /reject, replacing the per-source
CreateUsercalls (P7).Flow orchestration
One state object, one session key:
Clearing the flow becomes a single delete, and adding a step (mandatory password change, device
confirmation) becomes a new stage instead of another ad-hoc key.
Migration roadmap
Each step is a self-contained PR, ordered so the tree is releasable after every one of them, per
docs/guidelines-refactoring.md. Every step states its DB change, its compatibility behaviour, itsrollback story and its exit criterion.
Step 0 — free
login_namefrom its non-auth use (D9)LoginNameinto a dedicated field on the in-memory user(
models/user/user_system.go:58-71), keepingGetActionsUserTaskIDbehaviour identical.LoginNamehas no meaning outside authentication.Step 1 — guard rails and pre-flight diagnostics
gitea doctor check --run auth-consistency, runnable before any schema work,reporting per class:
login_type != login_source.type, duplicate(login_source, login_name),dangling
login_source(D7), OAuth2 users without anexternal_login_userrow (D5), and — mostimportantly — accounts that would end up with neither a credential nor an identity, i.e. the exact
lockout candidates.
--fixhandles the mechanically repairable classes.user.login_type != login_source.typeand normaliseslogin_typeforlogin_source = 0(D1).the fixture set.
Step 1a — make the credential state visible, especially to administrators
Tracked separately in #38906. Visibility before mutation: today
nothing in the UI says whether an account holds a local password. The admin detail page shows only the
auth source (
templates/admin/user/view_details.tmpl:17-19) and the user list has no such column orfilter, yet "auth source = Local" and "has a local password" are different things (P11, D3, D3b).
HasLocalPassword()andPasswordVerifiedLocally()inmodels/user, replacinginference from
IsLocal().correctly reads "Local".
has_local_passwordstatus filter in the admin user list(
models/user/search.go:53-57,routers/web/admin/users.go:75), following theis_2fa_enabledprecedent, so affected accounts can be enumerated before Step 1b runs.
settings.password_change_disabled(templates/user/settings/account.tmpl:31).user_credentialandpassword_verifier_source_idexist (Steps 4/12), the same predicatesgate acquisition: every path that can create or reset a local secret refuses when the verifier is
not the local source, so "this account has no local secret and may not get one" becomes a property
the schema enforces instead of a convention an operator audits.
rather than a silent flip of
IsPasswordSet().Step 1b — stop storing remote credentials locally, and purge the existing copies
Tracked separately in #38905, because it stands on its own:
it needs no schema redesign and does not depend on any other step here (D3b, principle 6).
makeTimeLimitCodeHashDataincludesu.Passwd(D10):u.Passwdin the activation/reset signature with a credential version marker(
updated_unixof the credential, or a dedicated counter). This preserves the property thecurrent code wants — a reset link stops working once the password changes — without coupling the
signature to the secret itself. One-time cost: links already in flight at upgrade time become
invalid, which is acceptable for links whose lifetime is measured in minutes.
Passwdin SMTP and PAM auto-registration(
services/auth/source/smtp/source_authenticate.go:73,services/auth/source/pam/source_authenticate.go:57), matching what LDAP already does.passwd,saltandpasswd_hash_algofor accounts whose password isverified by a remote source (LDAP/SMTP/PAM), leaving OAuth2 and local accounts untouched.
(
services/auth/signin.go:74), and they are already excluded from the reset and change-passwordpaths (
routers/web/auth/password.go:75,templates/user/settings/account.tmpl:7). Nothing thatworks today stops working.
switching such an account to the local source no longer silently re-enables the user's old remote
password; an administrator has to set a password explicitly.
has no legitimate use for. The rows themselves are untouched.
user, and the doctor check from Step 1reports zero remote-verified accounts with a local password hash.
Step 2 — funnel all field access through accessors
user.AuthSourceID(),user.SetAuthSource(*auth.Source),user.SubjectName(),user.IsLocalAccount()inmodels/user; replace the ~135 direct reads/writes so the struct fieldsbecome effectively package-private.
models/usertouchesLoginType,LoginSourceorLoginNamedirectly.
Step 3 — make the local source a real row (P2)
auth_sourcerow for built-in password authentication and repoints localusers at it; the old
login_source = 0value keeps working through the accessors during thetransition.
id == 0special case (models/auth/source.go:290);NoTypeis retired from the userside.
IsActive = falseon that row now disables built-in password login, resolving the FIXME.documented SQL snippet.
Sourcevalue that is not backed by a row.Step 4 — create
user_identityand backfill (expand phase)user_identity,user_identity_profile,user_identity_token,user_identity_unmapped.external_login_user(richest), then non-localuser.login_*where no identity exists yet (D5), thenuser_open_idunder a synthetic OpenIDsource (D6). Local users get an identity on the local source; a stale
login_nameon a local user iscopied to
login_namebut never promoted tosubject(D2).(source_id, subject)without creating the unique index yet (D4); ambiguous rowsgo to
user_identity_unmappedwith a reason code and are surfaced in the admin UI (G5).migrated,deduplicated,parked) and is idempotent (G4).reports zero unexplained parked rows on the fixture set.
Step 5 — switch reads to
user_identity(migrate phase, release N+1)routers/web/auth/oauth.go:510and:519, the OpenID lookup,services/externalaccount) to the new table.LoginType > auth.Plainfeature gate (models/user/user.go:1456) with a query on theuser's primary identity, which is what the existing comment asks for.
external_login_useranduser_open_idstay correct for a downgrade.user_identityand the integration suite signs in successfully aseach fixture user.
Step 6 — stop writing
user.login_type(keep the column)GetIndividualUserByLoginSourceto key on(source_id, login_name).login_nameandsource_idstay inmodules/structs, the admin API keepsaccepting
login_type-shaped input by resolving it to a source ID, andmodels/user/search.go:122keeps filtering by
login_name(D11)."<type>-<id>"form encoding (routers/web/admin/users.go:141) with a plain source-IDselect.
Step 7 — fix the layering of source configuration (P6)
Configregistry out ofmodels/authintoservices/auth/source; replace theBeforeSethook with an explicit
LoadSourceCfg(*Source) errorcalled by the finders, andMustSourceCfgpanics with typed errors.
auth.Typeinto a protocol enum plus capability interfaces;DLDAPbecomes an LDAP configflag while keeping its stored numeric value for compatibility (open question 3).
models/userno longer importsmodels/auth.Step 8 — unify provisioning (P7)
services/auth/provision.gowithProvisionFromIdentity(ctx, *ExternalIdentity, policy)handling create, update, link-to-existing (by verified e-mail or username, gated by configuration),
activation, group/team mapping and SSH key sync.
ldap,smtp,pamand the OAuth2 callback to return*ExternalIdentityand delegate,removing their local
CreateUsercalls; reuse the same entry point fromSyncExternalUsersso thelogin and sync paths cannot drift.
captured as tests in Step 1's fixture set, then re-asserted here.
CreateUserhas one caller per user-facing flow (self sign-up, admin create,provisioning).
Step 9 — make the all-sources fallback explicit (P8)
services/auth/signin.go:104), defaulting to thecurrent behaviour so upgrades do not change who can log in (G1); restrict it to sources that opt
in, apply a per-source timeout and rate-limit by remote address.
Step 10 — split
models/auth(P10)models/authsource(sources),models/token(access_token,auth_token,session),models/twofactor(TOTP + WebAuthn),models/oauth2provider(provider tables).Step 11 — the sign-in state machine (P9)
LoginFlowunder one session key, replacing the ten ad-hoc keys and the manual cleanuplist (
services/auth/session.go:57); convert the six router files to stage transitions.which is already true today.
ClearSessionKeysForSignInis a single delete and every transition, including abandonedand expired flows, is tested.
Step 12 — contract phase (release N+2)
user.login_type,user.login_source,user.login_name; retireexternal_login_useranduser_open_id; addUNIQUE(source_id, subject)once the reported duplicates are resolved (D4).user_credential, copyingpasswd/salt/passwd_hash_algo/must_change_passwordverbatim and keepingmakeTimeLimitCodeHashDatafed with the same value(D10). This also removes the oddity that password updates are gated on
IsLocal() || IsOAuth2()(services/user/update.go:212) and makes password-less accounts afirst-class state.
Release phasing
user_credential)Low-risk first slice
Steps 0–3 (including 1a and 1b) plus the registry part of 7 need no destructive schema change and already remove most of the
ambiguity. They are a reasonable first milestone even if the rest is deferred.
Lockout matrix
The concrete answer to "can a user end up unable to log in". Every row gets a fixture and an
end-to-end sign-in assertion through the real
services/auth.UserSignIn, not a mock.login_type0 or 1,login_source0passwdlogin_namelogin_nameretained,subjectnot promotedlogin_type2/5,login_name= uid or DNsubjectbyte-identicallogin_type3,login_name= e-maillogin_type4user.login_*andexternal_login_useruser.login_*user.login_*, same verifier mappinglogin_type3/4 with non-emptypasswdIsPasswordSet()becomesfalse; reset and change-password paths were already refused for these accountsuser_open_idrowsShowpreservedlogin_type7 source rowslogin_sourcepoints nowhere(source, login_name)type != 0two_factor,webauthn_credential,access_token,auth_token,sessionTest strategy
(
modelmigration/migrationtest.PrepareTestEnv,modelmigration/fixtures/Test_*), withTest_UnwrapLDAPSourceCfgas the closest precedent for source-config surgery.reused by every step so equivalence is proven against the same data set throughout.
the gate for the Step 5 read switch.
Compatibility and risk
login_nameandsource_idremain inmodules/structs;login_typein the admin API isresolved to a source ID rather than removed.
login_sourcetable by name, so the tablename is kept even after the struct and package are renamed.
release with explicit release notes.
behaviour-preserving by default.
Non-goals
UserTypeRemoteUseraccounts (models/user/user.go:67) are out of scope, thoughuser_identityshould be a better fit for them thanuser.login_*.written so that they stay implementable on top of this schema, nothing more.
(
CreateUserOption.EmailisRequired,modules/structs/admin_user.go:23; OIDC self-registrationrefuses the same way,
routers/web/auth/oauth.go:165) is an API policy question, not a schema one.Step 8 collapses that decision into a single service, so it can be revisited afterwards in one
place instead of five.
Open questions
(source, login_name)— recommendation is to keep every row, park the extras, deferthe unique index and require an administrator to resolve. The alternative (pick one
deterministically) silently changes who owns an upstream identity.
UserOpenID.Show— carry it onuser_identity, or keep a thin profile-preferences table?DLDAP— keep the stored type value forever, or migrate it into an LDAP config flag with aone-way migration?
or remain credential-less local accounts as they are today?
owned by another system, so Step 1b stops the capture and purges the existing copies. What is still
open is the replacement input for the activation/reset signature (credential
updated_unixversus adedicated counter).
does that also disable password verification for OAuth2 accounts, which currently delegate to the
DB authenticator? My preference is yes, with the setting documented as "local password
verification" rather than "local accounts". The stronger form is now a design point rather than a
question: an account whose verifier is not the local source cannot acquire a local credential
either (Principle 2, Step 1a).
user_identity.user_id = 0be accepted, as the addressing contractproposes, or should an identity always be a property of an existing account? This only affects
future provisioning work; migration and sign-in behave identically either way.
now, even though every source migrated from
login_namestarts out without it?Feedback is most useful on the target schema, the old → new mapping table, and the ordering of
Steps 4–6, which is where the points of no return live.
Assisted-by: Codet:claude-opus-4-6