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
37 changes: 37 additions & 0 deletions crates/db/src/invitation_dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,43 @@ pub async fn accept(
Ok(user)
}

/// Accept the invitation of the account `user_id` on its user's behalf, with
/// a password an administrator sets: its password becomes `password_hash`
/// and it is no longer invited, and its invitation, live or expired, is
/// marked accepted, so its link stops working. Whether an invitation waits
/// or not does not matter. `None` when the account is not an active invited
/// local one (its user chose a password already, an account of another
/// realm, a service account, a deactivated or a deleted account).
pub async fn accept_for_user(
pool: &PgPool,
user_id: i64,
password_hash: &str,
) -> sqlx::Result<Option<AppUser>> {
let mut tx = crate::begin(pool).await?;
let user: Option<AppUser> = sqlx::query_as(
"UPDATE app_user SET password_hash = $2, invited = false, updated_at = now() \
WHERE id = $1 AND invited AND active AND auth_source = 'local' AND kind = 'user' \
RETURNING id, username, password_hash, active, auth_source, external_subject, \
kind, created_at, updated_at",
)
.bind(user_id)
.bind(password_hash)
.fetch_optional(&mut *tx)
.await?;
if user.is_none() {
return Ok(None);
}
sqlx::query(
"UPDATE user_invitation SET accepted_at = now() \
WHERE user_id = $1 AND accepted_at IS NULL",
)
.bind(user_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(user)
}

/// Revoke the invitation of `user_id` (unless accepted). Returns whether
/// there was one.
pub async fn revoke<'e, E>(executor: E, user_id: i64) -> sqlx::Result<bool>
Expand Down
9 changes: 7 additions & 2 deletions crates/server/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
//! * [`images`] — deleting a tag, a manifest or an image of a hosted
//! repository of images.
//! * [`users`] — user CRUD: local accounts are invited to choose their
//! password ([`crate::invitations`]), never given one here; service
//! accounts (#508) have none at all.
//! password ([`crate::invitations`]), or an administrator accepts the
//! invitation for them with a password they set; service accounts (#508)
//! have none at all.
//! * [`tokens`] — token issue (plaintext returned **once**, only the hash is
//! stored) + revoke + list.
//! * [`members`] — group membership management.
Expand Down Expand Up @@ -272,6 +273,10 @@ pub fn admin_router(state: AppState) -> Router<AppState> {
"/users/{id}/invitation",
axum::routing::post(users::invite).delete(users::revoke_invitation),
)
.route(
"/users/{id}/invitation/accept",
axum::routing::post(users::accept_invitation),
)
.route(
"/users/{id}/convert-to-local",
axum::routing::post(users::convert_to_local),
Expand Down
79 changes: 79 additions & 0 deletions crates/server/src/admin/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
//! password. `POST /admin/users/{id}/invitation` makes a new invitation
//! (sent, or as a link to copy), replacing the previous one, and `DELETE`
//! revokes it; deactivating or deleting the account revokes it too.
//! `POST /admin/users/{id}/invitation/accept` accepts it on the user's
//! behalf with a password the administrator sets (#653), for an account
//! handed over ready to use: the only way an administrator sets another
//! person's password.
//!
//! A credential-forwarding account whose realm keeps password hashes
//! (`capture_password_hash`) can be switched to a local account with the last
Expand Down Expand Up @@ -812,6 +816,81 @@ pub async fn revoke_invitation(State(state): State<AppState>, Path(id): Path<i64
}
}

/// `POST /admin/users/{id}/invitation/accept` body.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AcceptInvitation {
/// The account's password from now on.
pub password: String,
}

/// `POST /admin/users/{id}/invitation/accept`: accept an invited account's
/// invitation on its user's behalf, with a password the administrator sets,
/// for an account handed over ready to use (an install without email, a
/// user without a mailbox). The password becomes the account's, the account
/// is `active`, and its invitation, if there is one, is marked accepted, so
/// its link stops working: one transaction. It works with a live invitation
/// waiting or without one (`invited`, `invitation_expired`). This is the
/// only way an administrator sets another person's password.
///
/// **400** for a password the invitation page refuses too
/// ([`invitations::check_password`]); **404** for no account; **409** for an
/// account that is not an active invited one (its user chose a password
/// already, an account of another realm, a service account, a deactivated
/// account).
pub async fn accept_invitation(
State(state): State<AppState>,
Extension(principal): Extension<Principal>,
Path(id): Path<i64>,
Json(body): Json<AcceptInvitation>,
) -> Response {
const NOT_INVITED: &str = "only an active local account whose user has not chosen a \
password yet can have its invitation accepted";
if let Err(why) = invitations::check_password(&body.password) {
return (StatusCode::BAD_REQUEST, why).into_response();
}
let user = match user_dao::find_user_by_id(&state.pool, id).await {
Ok(Some(u)) => u,
Ok(None) => return (StatusCode::NOT_FOUND, "user not found").into_response(),
Err(e) => return db_error(e),
};
// Before the argon2 work; the update checks again, in its transaction.
let invited = match invitation_dao::onboarding(&state.pool, id).await {
Ok(onboarding) => onboarding.is_some_and(|o| o.invited),
Err(e) => return db_error(e),
};
if !invited || !user.active || user.auth_source != "local" || user.is_service_account() {
return (StatusCode::CONFLICT, NOT_INVITED).into_response();
}
let password_hash = match super::hash_password(body.password).await {
Ok(hash) => hash,
Err(resp) => return resp,
};
let accepted = match invitation_dao::accept_for_user(&state.pool, id, &password_hash).await {
Ok(Some(user)) => user,
// Accepted, deactivated or deleted meanwhile.
Ok(None) => {
return match user_dao::find_user_by_id(&state.pool, id).await {
Ok(Some(_)) => (StatusCode::CONFLICT, NOT_INVITED).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "user not found").into_response(),
Err(e) => db_error(e),
}
}
Err(e) => return db_error(e),
};
tracing::info!(
target: "forge::audit",
user_id = accepted.id,
username = %accepted.username,
principal = %principal.audit_label(),
"invitation accepted by an administrator: the password is set"
);
match UserView::load(&state, &accepted).await {
Ok(view) => Json(view).into_response(),
Err(resp) => resp,
}
}

/// `PATCH /admin/users/{id}` — deactivate (`{"active": false}`) or reactivate
/// (`{"active": true}`) a user. Returns the updated [`UserView`]; idempotent.
///
Expand Down
202 changes: 202 additions & 0 deletions crates/server/tests/invitations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,208 @@ async fn creating_a_user_takes_no_password() {
assert_eq!(status, 409, "only local accounts are invited");
}

/// `POST /admin/users/{id}/invitation/accept` (#653): an administrator
/// accepts an invited account's invitation for its user, with a password
/// they set, whether a live invitation waits or not. The user signs in with
/// it, the account is active, and the old link answers as invalid. The
/// password follows the invitation page's rules (400); an account that is
/// not an active invited local one is refused (409), and no account is 404.
#[tokio::test]
async fn an_administrator_accepts_an_invitation_with_a_password_they_set() {
let upstream = MockServer::start().await;
let srv = server().await;
let n = common::unique();
let accept = |id: i64, password: &str| {
let srv = &srv;
let body = json!({ "password": password });
async move {
admin(
srv,
reqwest::Method::POST,
&format!("/admin/users/{id}/invitation/accept"),
Some(body),
)
.await
}
};
let create = |body: Value| {
let srv = &srv;
async move {
let (status, created) =
admin(srv, reqwest::Method::POST, "/admin/users", Some(body)).await;
assert_eq!(status, 201, "{created}");
created
}
};

// A live invitation waits.
let username = format!("gina{n}@example.com");
let created = create(json!({ "username": username })).await;
let id = created["id"].as_i64().unwrap();
let token = token_of(created["invitation"]["link"].as_str().unwrap());
assert_eq!(created["status"], "invited");

// The invitation page's rules.
let (status, body) = accept(id, "too-short").await;
assert_eq!(status, 400, "{body}");
assert!(body.as_str().unwrap().contains("12"), "{body}");
let (status, body) = accept(id, &"x".repeat(1025)).await;
assert_eq!(status, 400, "{body}");
assert!(body.as_str().unwrap().contains("1024"), "{body}");
assert_eq!(user_view(&srv, id).await["status"], "invited");
assert!(
live(&srv, &token).await,
"a refused password changes nothing"
);

let (status, view) = accept(id, "set-by-the-administrator").await;
assert_eq!(status, 200, "{view}");
assert_eq!(view["id"], json!(id));
assert_eq!(view["status"], "active");
assert_eq!(view["invitation"], Value::Null);
assert_eq!(user_view(&srv, id).await["status"], "active");
assert_eq!(
login(&srv, &username, "set-by-the-administrator").await,
200
);
let accepted_at: Option<chrono::DateTime<chrono::Utc>> =
sqlx::query_scalar("SELECT accepted_at FROM user_invitation WHERE user_id = $1")
.bind(id)
.fetch_one(&srv.pool)
.await
.unwrap();
assert!(accepted_at.is_some(), "the invitation is marked accepted");

// The old link answers as invalid.
assert!(!live(&srv, &token).await);
let resp = srv.accept_invitation(&token, "the-users-own-choice").await;
assert_eq!(resp.status(), 400);
assert_eq!(login(&srv, &username, "the-users-own-choice").await, 401);

// Its user has a password now: once only.
let (status, _) = accept(id, "another-long-password").await;
assert_eq!(status, 409);
assert_eq!(
login(&srv, &username, "set-by-the-administrator").await,
200
);

// The invitation expired.
let expired = format!("hank{n}@example.com");
let created = create(json!({ "username": expired })).await;
let expired_id = created["id"].as_i64().unwrap();
sqlx::query(
"UPDATE user_invitation SET expires_at = now() - interval '1 second' WHERE user_id = $1",
)
.bind(expired_id)
.execute(&srv.pool)
.await
.unwrap();
assert_eq!(
user_view(&srv, expired_id).await["status"],
"invitation_expired"
);
let (status, view) = accept(expired_id, "set-after-it-expired").await;
assert_eq!(status, 200, "{view}");
assert_eq!(view["status"], "active");
assert_eq!(login(&srv, &expired, "set-after-it-expired").await, 200);

// No invitation at all: revoked.
let revoked = format!("iris{n}@example.com");
let created = create(json!({ "username": revoked })).await;
let revoked_id = created["id"].as_i64().unwrap();
let (status, _) = admin(
&srv,
reqwest::Method::DELETE,
&format!("/admin/users/{revoked_id}/invitation"),
None,
)
.await;
assert_eq!(status, 204);
let (status, view) = accept(revoked_id, "set-without-a-link").await;
assert_eq!(status, 200, "{view}");
assert_eq!(view["status"], "active");
assert_eq!(login(&srv, &revoked, "set-without-a-link").await, 200);

// A deactivated invited account: refused, and still invited.
let inactive = format!("jack{n}@example.com");
let created = create(json!({ "username": inactive })).await;
let inactive_id = created["id"].as_i64().unwrap();
let (status, _) = admin(
&srv,
reqwest::Method::PATCH,
&format!("/admin/users/{inactive_id}"),
Some(json!({"active": false})),
)
.await;
assert_eq!(status, 200);
let (status, _) = accept(inactive_id, "set-while-deactivated").await;
assert_eq!(status, 409);
let (status, _) = admin(
&srv,
reqwest::Method::PATCH,
&format!("/admin/users/{inactive_id}"),
Some(json!({"active": true})),
)
.await;
assert_eq!(status, 200);
assert_eq!(
user_view(&srv, inactive_id).await["status"],
"invitation_expired"
);
assert_eq!(login(&srv, &inactive, "set-while-deactivated").await, 401);

// An account of another realm, a service account, the administrator.
let (status, _) = admin(
&srv,
reqwest::Method::POST,
"/admin/realms",
Some(json!({
"kind": "credential_forwarding",
"name": "directory",
"config": {"url": format!("{}/status", upstream.uri()), "key_pepper": "pepper"},
})),
)
.await;
assert_eq!(status, 201);
let forwarded = create(json!({
"username": format!("kate{n}@example.com"),
"realm": "directory",
}))
.await;
let service = create(json!({
"username": format!("ci{n}@example.com"),
"kind": "service",
"description": "The build agents",
}))
.await;
let (admin_name, _) = srv.admin_basic();
let admin_id = {
let (status, users) = admin(&srv, reqwest::Method::GET, "/admin/users", None).await;
assert_eq!(status, 200);
users
.as_array()
.unwrap()
.iter()
.find(|u| u["username"] == json!(admin_name))
.expect("the administrator is listed")["id"]
.as_i64()
.unwrap()
};
for id in [
forwarded["id"].as_i64().unwrap(),
service["id"].as_i64().unwrap(),
admin_id,
] {
let (status, body) = accept(id, "not-for-this-account").await;
assert_eq!(status, 409, "{id}: {body}");
}

// No account.
let (status, _) = accept(i64::MAX, "for-nobody-at-all").await;
assert_eq!(status, 404);
}

/// Failed lookups and acceptances are throttled per client network.
#[tokio::test]
async fn invitation_failures_are_throttled() {
Expand Down
10 changes: 10 additions & 0 deletions docs/changes/2.2.0/653.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
An administrator can accept an invited user's invitation for them, with a
password they set: **Accept invitation** on the user's page under
**Admin → Users**, or `POST /admin/users/{id}/invitation/accept`. It hands an
account over ready to use (an install without email, a user without a
mailbox) whether its invitation is still live or has expired: the password
becomes the account's, the account is active, and the invitation link stops
working. The password follows the invitation page's rules (at least 12
characters); an account whose user chose a password already, an account of
another realm, a service account and a deactivated account are refused. This
is the only way an administrator sets another person's password.
Loading
Loading