Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/scripts/npm-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,11 @@ done
[ "$(npm whoami --registry "$(reg npm-hosted)")" = "$admin_user" ] || fail "npm whoami (hosted)"

step "legacy login with Yarn, and logout"
# Yarn sends the admin's password: the local realm takes passwords from
# clients only once it accepts them.
local_realm=$(admin GET /admin/realms | jq -r '.[] | select(.kind == "local") | .id')
[ -n "$local_realm" ] || fail "no local realm"
admin PATCH "/admin/realms/$local_realm" '{"config":{"accept_client_passwords":true}}' >/dev/null
yarn_js="${YARN_BERRY:-}"
if [ -z "$yarn_js" ]; then
mkdir -p "$work/yarn"
Expand Down
23 changes: 14 additions & 9 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ With the server running, in a second terminal:
cargo xtask seed
```

This turns anonymous reads and password Basic on (**Admin → Settings**),
creates an anonymous-readable hosted repo `nuget`, mints a push token, and
This turns anonymous reads on (**Admin → Settings**), has the local realm
accept passwords over HTTP Basic (**Admin → Authentication**), creates an anonymous-readable hosted repo `nuget`, mints a push token, and
pushes the bundled sample package. It prints the feed URL and the available
versions. The feed is then browsable without auth at
`http://localhost:8421/repository/nuget/index.json`.
Expand Down Expand Up @@ -309,8 +309,8 @@ connections, drains in-flight requests, then stops the background workers.

### Access and sign-in settings

Anonymous access, the login screen, passwords over HTTP Basic, the session
lifetimes and the login lockout are **not** in `forge.toml`: they are auth
Anonymous access, the login screen, the session lifetimes and the login
lockout are **not** in `forge.toml`: they are auth
settings kept in the database, set under **Admin → Settings**
(`/manage/settings`) or through `GET`/`PUT /admin/settings/auth`, and every
instance applies a change without a restart (the others within
Expand All @@ -323,15 +323,19 @@ to set them. See
| `anonymous_mode` | `off` (default) / `all`. Makes unauthenticated requests an *anonymous principal*. |
| repo `anonymous_read` | **Also required** for anonymous reads — set it per repository. |
| `login_mode` | `identifier_first` (default) or `combined`. |
| `allow_password_basic` | Accept a user's password over HTTP Basic on the feeds (default off; the admin API never takes one). |
| `session_idle_ttl_seconds` / `session_absolute_ttl_seconds` | Browser-session lifetimes (default 8 h / 7 d). |
| `login_max_failures` / `login_failure_window_seconds` | Password-failure lockout per client IP + username for `/auth/login` and Basic password auth (default 10 per 300 s; `0` disables). |

> **Anonymous reads need both** the anonymous mode *and* the repo's
> `anonymous_read=true` (authz = authenticated user **or**
> anonymous-and-repo-allows). For zero-auth local browsing, `cargo xtask seed`
> sets the anonymous mode to `all` (and turns password Basic on) and creates
> an `anonymous_read` repository.
> sets the anonymous mode to `all` (and has the local realm accept passwords
> over HTTP Basic) and creates an `anonymous_read` repository.
>
> Whether a client may send a password over HTTP Basic is a realm's setting
> (`accept_client_passwords`, **Admin → Authentication**): off for the local
> realm, on for a credential-forwarding one, until set. See
> [docs/deployment.md](./docs/deployment.md#passwords-from-clients).

## Authentication realms

Expand Down Expand Up @@ -851,8 +855,9 @@ http://localhost:8421.
**Runtime configuration.** The baked `forge.toml` carries non-secret defaults
(`bind 0.0.0.0:8421`, SPA at `/app/web/dist`, no bootstrap admin — that
comes only from `FORGE_AUTH__BOOTSTRAP_ADMIN__*` env), and no S3 keys.
Anonymous access and password Basic start off and are set under **Admin →
Settings** (`cargo xtask seed` turns both on for a dev stack). Placeholder
Anonymous access and the local realm's passwords from clients start off and
are set under **Admin → Settings** and **Admin → Authentication** (`cargo
xtask seed` turns both on for a dev stack). Placeholder
credentials (`minioadmin` S3 keys, a `changeme` password) are **refused at
startup**; only the dev compose stack sets
`FORGE_SERVER__ALLOW_INSECURE_DEFAULTS=true` (plus admin@example.com/changeme).
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ identity (IRSA, EKS Pod Identity, STS web identity; see
bakes no bootstrap admin: `FORGE_AUTH__BOOTSTRAP_ADMIN__PASSWORD` creates one on the first start,
named by `FORGE_AUTH__BOOTSTRAP_ADMIN__USERNAME` (a UPN, default `admin@example.com`), and
once it is removed the image simply starts without one. A new install has
no anonymous access and accepts no passwords over HTTP Basic (clients use API
tokens); an administrator changes both in the web UI under **Admin →
Settings** (see
no anonymous access and its local realm accepts no passwords over HTTP Basic
(clients use API tokens); an administrator changes them in the web UI under
**Admin → Settings** and **Admin → Authentication** (see
[docs/deployment.md](docs/deployment.md#access-and-sign-in-settings)).

### Point a `dotnet` client at it
Expand Down
16 changes: 15 additions & 1 deletion crates/core/src/auth/local_password.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ pub struct LocalPasswordProvider {
/// A credential-forwarding provider follows this one in the chain; see
/// [`Self::forwarding_follows`].
forwarding_follows: bool,
/// Whether a password is checked at all; see [`Self::reject_only`].
verifies: bool,
}

impl LocalPasswordProvider {
Expand All @@ -222,6 +224,7 @@ impl LocalPasswordProvider {
pool,
limiter,
forwarding_follows: false,
verifies: true,
}
}

Expand All @@ -238,6 +241,17 @@ impl LocalPasswordProvider {
self
}

/// Check no password, but keep the local accounts' names: a name the
/// local store owns is refused (after a dummy verification, so it answers
/// like a wrong password), and only a foreign one goes on to the next
/// provider. The chain of a local realm that accepts no passwords from
/// clients holds the provider this way, so that such a password is never
/// forwarded to a credential-forwarding realm behind it.
pub fn reject_only(mut self) -> Self {
self.verifies = false;
self
}

/// `Skip` for a name that is not a local password account: at once when
/// forwarding follows, after a dummy verification otherwise.
async fn skip_foreign(&self, candidate: &str) -> AuthOutcome {
Expand Down Expand Up @@ -330,7 +344,7 @@ impl AuthProvider for LocalPasswordProvider {
// Skip: neither the owner's password nor a guess may reach a later
// (upstream) realm. It costs a (dummy) verification and answers like a
// wrong password, so it does not show that the account is deactivated.
if !user.active {
if !user.active || !self.verifies {
return self
.after_dummy_verify(cred.password, AuthOutcome::Rejected)
.await;
Expand Down
17 changes: 17 additions & 0 deletions crates/core/src/auth/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,23 @@ pub trait AuthProvider: Send + Sync {
async fn authenticate(&self, cred: &PasswordCredential<'_>) -> AuthOutcome;
}

/// A provider shared by several chains (a realm's, in the chain of the
/// browser login and in the one of the clients' passwords).
#[async_trait::async_trait]
impl<P: AuthProvider + ?Sized> AuthProvider for std::sync::Arc<P> {
fn id(&self) -> &str {
(**self).id()
}

fn source(&self) -> AuthSource {
(**self).source()
}

async fn authenticate(&self, cred: &PasswordCredential<'_>) -> AuthOutcome {
(**self).authenticate(cred).await
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
5 changes: 2 additions & 3 deletions crates/core/src/auth/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,8 @@ impl PrincipalSource for TokenHeaderSource {
/// username, run through the [`crate::auth::Chain`] of
/// [`crate::auth::AuthProvider`]s:
/// * token-as-basic always wins first;
/// * the password path is gated — when no chain is configured (neither
/// `allow_password_basic` nor an enabled credential-forwarding realm) the
/// password path is skipped entirely;
/// * the password path is gated — when no chain is configured (no realm
/// accepts passwords from clients) the password path is skipped entirely;
/// * a present-but-wrong / unknown password yields
/// [`SourceOutcome::Invalid`] (→ 401, never downgraded to anonymous).
///
Expand Down
44 changes: 40 additions & 4 deletions crates/core/tests/local_password.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
//!
//! The argon2id password check is the [`LocalPasswordProvider`], invoked
//! through the [`Chain`]. [`BasicAuthSource`] tries a token presented as the
//! basic secret first, then falls back to the password chain only when
//! password-basic is enabled; invalid credentials → 401. These tests assert
//! basic secret first, then falls back to the password chain only when a
//! realm accepts passwords from clients; invalid credentials → 401. These tests assert
//! both the provider's outcomes directly and the `BasicAuthSource` semantics
//! end-to-end.
//!
Expand Down Expand Up @@ -169,6 +169,41 @@ async fn local_provider_rejects_inactive_user() {
}
}

#[tokio::test]
async fn a_reject_only_provider_refuses_its_names_and_skips_the_others() {
// #647: a local realm that accepts no passwords from clients keeps its
// names in the clients' chain: the right password of an active local
// account is refused like a wrong one (never Skip, which would forward it
// upstream), and only a name the local store does not own goes on.
let pool = common::pool().await;
let username = insert_local(&pool, "pw", true).await;
let unknown = format!("nobody-{}@example.com", common::unique());
for forwarding_follows in [false, true] {
let provider = LocalPasswordProvider::new(pool.clone())
.forwarding_follows(forwarding_follows)
.reject_only();
for password in ["pw", "a-guess"] {
assert_eq!(
provider.authenticate(&cred(&username, password)).await,
AuthOutcome::Rejected,
"{password} (forwarding follows: {forwarding_follows})"
);
}
assert_eq!(
provider.authenticate(&cred(&unknown, "pw")).await,
AuthOutcome::Skip,
"forwarding follows: {forwarding_follows}"
);
}
// The same account signs in through a provider that checks passwords.
assert!(matches!(
LocalPasswordProvider::new(pool.clone())
.authenticate(&cred(&username, "pw"))
.await,
AuthOutcome::Authenticated(_)
));
}

#[tokio::test]
async fn local_provider_rejects_an_invited_account() {
// #322: an invited local account has no password until its user chooses
Expand Down Expand Up @@ -340,8 +375,9 @@ async fn basic_password_authenticates_when_enabled() {

#[tokio::test]
async fn basic_password_disabled_does_not_authenticate() {
// allow_password_basic = false → password path is entirely off; a valid
// password is treated as an invalid basic credential.
// No realm accepts passwords from clients → the password path is
// entirely off; a valid password is treated as an invalid basic
// credential.
let pool = common::pool().await;
let username = insert_local(&pool, "pw", true).await;
let src = basic_source(&pool, false);
Expand Down
17 changes: 13 additions & 4 deletions crates/e2e/src/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,15 @@ impl StackBuilder {
forge_server::app::ensure_auth_realms(&config, &pool, &envelope)
.await
.expect("seed auth realms");
// Tests sign in over Basic: the local realm accepts passwords from
// clients. A peer on the same database sets the same.
sqlx::query(
"UPDATE auth_realm SET config = config || '{\"accept_client_passwords\": true}' \
WHERE kind = 'local'",
)
.execute(&pool)
.await
.expect("seed the local realm's client passwords");

let cache = match &redis {
Some(url) => Cache::with_redis(config.cache.l1_max_capacity, url)
Expand All @@ -552,10 +561,10 @@ impl StackBuilder {
.expect("registry token key");
let state = AppState::new(pool.clone(), store, cache, config, envelope)
.with_registry_keys(registry_keys);
// The access policy is stored in the database: password Basic on
// (tests sign in over Basic), the stack's anonymous mode, and any
// further settings. A peer on the same database stores the same.
let mut update = serde_json::json!({"allow_password_basic": true});
// The access policy is stored in the database: the stack's anonymous
// mode, and any further settings. A peer on the same database stores
// the same.
let mut update = serde_json::json!({});
for extra in [Some(self.anon.settings()), self.auth_settings.clone()]
.into_iter()
.flatten()
Expand Down
4 changes: 2 additions & 2 deletions crates/server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ use crate::web_ui::spa_service;
///
/// The Basic source's password path asks [`crate::auth::RealmAuth`] for the
/// chain on every attempt, so admin realm edits apply without a restart
/// (and the wire path stays disabled unless the `allow_password_basic` auth
/// setting or an enabled credential-forwarding realm allows it). The
/// (the wire path holds only the realms that accept passwords from clients,
/// `accept_client_passwords`, and stays disabled without one). The
/// anonymous policy comes from the auth settings on every request too
/// ([`AnonSource::Settings`]). The session source is
/// always mounted — local password logins mint sessions too — and every
Expand Down
58 changes: 42 additions & 16 deletions crates/server/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1037,24 +1037,42 @@ impl IntoResponse for LoginRefusal {
}
}

/// Check a username and password as an interactive login does: through the
/// realm chain (local and credential-forwarding realms, in priority order),
/// Who sends the password [`verify_password_login`] checks, which picks the
/// realm chain that checks it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PasswordFrom {
/// The browser login: every password-capable realm
/// ([`RealmSnapshot::chain`]).
Browser,
/// A client (`npm login --auth-type=legacy`): only the realms that accept
/// passwords from clients ([`RealmSnapshot::client_chain`]).
Client,
}

/// Check a username and password as a login does: through the realm chain
/// `from` picks (local and credential-forwarding realms, in priority order),
/// under the login lockout, and never for a secret that is a token Forge
/// issued. Not gated by `allow_password_basic`, which governs passwords on
/// the feeds only. Returns the signed-in user and the canonical username.
/// `POST /auth/login` and `npm login` sign in through it.
/// issued. Returns the signed-in user and the canonical username.
/// `POST /auth/login` signs in through it as [`PasswordFrom::Browser`],
/// `npm login --auth-type=legacy` as [`PasswordFrom::Client`].
pub(crate) async fn verify_password_login(
state: &AppState,
client_ip: IpAddr,
username: &str,
password: &str,
from: PasswordFrom,
) -> Result<(forge_core::auth::principal::UserPrincipal, String), LoginRefusal> {
let snap = snapshot_or_resp(state)
.await
.map_err(|_| LoginRefusal::Unavailable)?;
let Some(chain) = snap.chain.clone() else {
// No enabled password-capable realm. Uniform denial (the UI learns
// whether to even offer the form from /auth/login-options).
let chain = match from {
PasswordFrom::Browser => snap.chain.clone(),
PasswordFrom::Client => snap.client_chain.clone(),
};
let Some(chain) = chain else {
// No enabled password-capable realm (for a client: none that accepts
// passwords from clients). Uniform denial (the UI learns whether to
// even offer the form from /auth/login-options).
return Err(LoginRefusal::Invalid);
};
if username.len() > MAX_USERNAME_BYTES || password.is_empty() {
Expand Down Expand Up @@ -1162,9 +1180,10 @@ pub(crate) async fn verify_password_login(
/// chain (local + credential-forwarding realms, in priority order) and mint a
/// browser session.
///
/// This is the form-login counterpart of the wire Basic path: it runs the SAME
/// chain with the same fail-closed semantics, but is **not** gated by
/// `auth.allow_password_basic` (that flag governs API Basic auth only). The
/// This is the form-login counterpart of the wire Basic path, with the same
/// fail-closed semantics, but it runs the chain of **every** password-capable
/// realm: a realm's `accept_client_passwords` governs only the passwords
/// clients send (it is how a local user gets a token). The
/// response mirrors `/auth/me` so the SPA can bootstrap immediately; failures
/// are a uniform 401 (no username-exists oracle). A username over
/// [`MAX_USERNAME_BYTES`], or one containing `:`, is refused the same way
Expand All @@ -1177,11 +1196,18 @@ async fn password_login(
Json(body): Json<LoginBody>,
) -> Response {
let client_ip = login_client_ip(&state, connect.map(|Extension(ci)| ci.0), &headers);
let (principal, username) =
match verify_password_login(&state, client_ip, &body.username, &body.password).await {
Ok(verified) => verified,
Err(refusal) => return refusal.into_response(),
};
let (principal, username) = match verify_password_login(
&state,
client_ip,
&body.username,
&body.password,
PasswordFrom::Browser,
)
.await
{
Ok(verified) => verified,
Err(refusal) => return refusal.into_response(),
};

// Fresh session per login (fixation-proof), same as the OIDC callback.
let issued = match state
Expand Down
Loading
Loading