Skip to content

Proposal: refactor the user and authentication system #38904

Description

@lunny

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_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, plus login_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
user_open_id models/user/openid.go:18 Yet another external identity representation
oauth2_application / oauth2_grant / oauth2_authorization_code models/auth/oauth2.go Gitea as an OAuth2 provider — unrelated to login sources, but in the same package
access_token, auth_token, two_factor, webauthn_credential, session models/auth/*.go Tokens, remember-me, second factors, sessions

Runtime abstractions

Two unrelated notions of "authentication" live in services/auth:

  • PasswordAuthenticator (services/auth/interface.go:34) — credential verification, implemented by
    the db, ldap, smtp and pam source configs.
  • Method (services/auth/interface.go:26) — per-request authentication, implemented by
    session, 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 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:

  1. if a local user row exists, authenticates only against user.login_source
    (services/auth/signin.go:74);
  2. 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
(Sourcelogin_source, models/authservices/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

  1. 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.
  2. 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.
  3. A source is described by its capabilities (interfaces it implements), not by an enum value.
  4. Provisioning policy is decided in exactly one service, from a source-agnostic identity struct.
  5. Multi-step browser sign-in is one explicit state machine, not a bag of session keys.
  6. 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 preserved byte-for-byte (D8)
user.passwd, salt, passwd_hash_algo, must_change_password user_credential 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_login_user.external_id user_identity.subject deduplicated against user.login_name (D5)
external_login_user.{email,name,first_name,last_name,nick_name,description,avatar_url,location,raw_data} user_identity_profile full snapshot retained
external_login_user.{access_token,access_token_secret,refresh_token,expires_at} user_identity_token token refresh keeps working (services/auth/source/oauth2/source_sync.go)
external_login_user.provider derived from the source; retained during migration for lookups
user_open_id.uri user_identity.subject under a synthetic OpenID source D6
user_open_id.show user_identity.show_on_profile user-visible, must survive
login_source.* unchanged table, renamed struct/package third-party SQL keeps working
oauth2_* unchanged tables, moved package
access_token, auth_token, two_factor, webauthn_credential, session unchanged package move only; sessions are not invalidated

External 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.

  • 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.
type ExternalIdentity struct {
    SourceID    int64
    Subject     string   // stable upstream identifier
    LoginName   string   // upstream login/bind name, for display and re-bind
    Email       string
    DisplayName string
    Groups      []string
    SSHKeys     []string
    Profile     *IdentityProfile
    Tokens      *IdentityTokens
    Raw         map[string]any
}

type CredentialVerifier interface { // db, ldap, dldap, smtp, pam
    VerifyPassword(ctx context.Context, subject, password string) (*ExternalIdentity, error)
}

type RedirectFlowProvider interface { // oauth2 / oidc, openid
    AuthURL(ctx context.Context, state string) (string, error)
    Callback(ctx context.Context, r *http.Request) (*ExternalIdentity, error)
}

type RequestAuthenticator interface { // sspi, reverseproxy, httpsign — no longer auth sources
    VerifyRequest(ctx context.Context, r *http.Request) (*ExternalIdentity, error)
}

type UserSynchronizer interface {
    Sync(ctx context.Context, updateExisting bool) error
}

A single provisioning service consumes *ExternalIdentity and decides create / update / link /
reject, replacing the per-source CreateUser calls (P7).

Flow orchestration

One state object, one session key:

type LoginFlow struct {
    Stage     LoginStage // Identified -> SecondFactor -> LinkPending -> Completed
    UserID    int64
    SourceID  int64
    Remember  bool
    Method    string
    Pending   *ExternalIdentity // identity awaiting link/registration
    ReturnTo  string
    ExpiresAt int64
}

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):
    1. 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.
    2. 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.
    3. 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)

  • Create user_identity, user_identity_profile, user_identity_token, user_identity_unmapped.
  • 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.

Step 5 — switch reads to user_identity (migrate phase, release N+1)

  • 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.

Step 10 — split models/auth (P10)

  • models/authsource (sources), models/token (access_token, auth_token, session),
    models/twofactor (TOTP + WebAuthn), models/oauth2provider (provider tables).
  • 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.

Release phasing

Release Steps
N 0, 1, 1a, 1b, 2, 3, 4 (create + backfill + dual write), 7
N+1 5 (read switch), 6, 8, 9, 10, 11
N+2 12 (drops, unique index, optional 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.

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_* and external_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 doctor output assertion
Bot / remote / reserved / organization type != 0 untouched existing tests
2FA, WebAuthn, PATs, remember-me, sessions two_factor, webauthn_credential, access_token, auth_token, session untouched, package move only 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

  1. 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.
  2. UserOpenID.Show — carry it on user_identity, or keep a thin profile-preferences table?
  3. DLDAP — keep the stored type value forever, or migrate it into an LDAP config flag with a
    one-way migration?
  4. Contract window — one minor release or two before the old columns and tables are dropped?
  5. 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?
  6. 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).
  7. 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).
  8. 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.
  9. 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.


Assisted-by: Codet:claude-opus-4-6

Metadata

Metadata

Assignees

No one assigned

    Labels

    topic/authenticationtype/proposalThe new feature has not been accepted yet but needs to be discussed first.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions