From 67cfe61c6e8b1d75a3fad9b46d00180caefd5243 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 18 Jun 2025 14:39:53 +0200 Subject: [PATCH] Refactor auth code Up until this point our "auth" code was scattered all over the place and mixed up authentication (who are you?) and authorization (what are you allowed to do?) in multiple places: - Whether a user account was locked was checked as part of authentication, although it is more of an authorization concern. - Whether a user has a verified email address was checked in the individual endpoints, if at all. - Whether a user is an admin was also checked in the individual endpoints that were adjusted to support admin users. - Whether a user is an owner (or team member) of a crate was also checked in the individual endpoints, in various different ways. - Whether an API token has the right scopes was also checked as part of authentication, but it is an authorization concern. This commit centralizes these checks with a three step approach: 1. In the first step the endpoint uses one of the `Credentials` structs as an axum request extractor to extract the raw credentials from the request metadata (user ID from session cookie, API token, Trusted Publishing token). 2. The credentials are used to look up a corresponding user (or Trusted Publishing token) from the database. 3. The authenticated entity is used to check for authorization of the requested operation. The second and third step are folded into one fn call (`.validate(permission)`) to make it harder to use an authenticated entity without knowing if they are authorized for the operation. While the auth code and the endpoints have changed quite a bit due to this refactoring, the test code is mostly the same except for a few minor details caused by using the request extractors now. --- crates/crates_io_trustpub/src/access_token.rs | 6 +- src/auth.rs | 420 ------------------ src/auth/authorization/entity.rs | 25 ++ src/auth/authorization/mod.rs | 9 + src/auth/authorization/permission.rs | 76 ++++ src/auth/authorization/trustpub.rs | 37 ++ src/auth/authorization/user.rs | 324 ++++++++++++++ src/auth/credentials/api_token.rs | 107 +++++ src/auth/credentials/cookie.rs | 83 ++++ src/auth/credentials/mod.rs | 11 + src/auth/credentials/publish.rs | 115 +++++ src/auth/credentials/trustpub.rs | 104 +++++ src/auth/credentials/user.rs | 92 ++++ src/auth/mod.rs | 5 + src/controllers/crate_owner_invitation.rs | 35 +- src/controllers/krate/delete.rs | 28 +- src/controllers/krate/follow.rs | 34 +- src/controllers/krate/owners.rs | 54 +-- src/controllers/krate/publish.rs | 116 +---- src/controllers/krate/search.rs | 16 +- src/controllers/session.rs | 4 +- src/controllers/token.rs | 45 +- .../trustpub/github_configs/create/mod.rs | 20 +- .../trustpub/github_configs/delete/mod.rs | 17 +- .../trustpub/github_configs/list/mod.rs | 28 +- src/controllers/trustpub/tokens/revoke/mod.rs | 18 +- src/controllers/user/email_notifications.rs | 11 +- src/controllers/user/email_verification.rs | 7 +- src/controllers/user/me.rs | 20 +- src/controllers/user/update.rs | 6 +- src/controllers/version/docs.rs | 22 +- src/controllers/version/update.rs | 45 +- src/controllers/version/yank.rs | 25 +- src/tests/krate/publish/trustpub.rs | 2 +- src/tests/routes/me/tokens/create.rs | 4 +- src/tests/routes/me/tokens/delete_current.rs | 4 +- 36 files changed, 1216 insertions(+), 759 deletions(-) delete mode 100644 src/auth.rs create mode 100644 src/auth/authorization/entity.rs create mode 100644 src/auth/authorization/mod.rs create mode 100644 src/auth/authorization/permission.rs create mode 100644 src/auth/authorization/trustpub.rs create mode 100644 src/auth/authorization/user.rs create mode 100644 src/auth/credentials/api_token.rs create mode 100644 src/auth/credentials/cookie.rs create mode 100644 src/auth/credentials/mod.rs create mode 100644 src/auth/credentials/publish.rs create mode 100644 src/auth/credentials/trustpub.rs create mode 100644 src/auth/credentials/user.rs create mode 100644 src/auth/mod.rs diff --git a/crates/crates_io_trustpub/src/access_token.rs b/crates/crates_io_trustpub/src/access_token.rs index 46b2877e348..ba33fbf3574 100644 --- a/crates/crates_io_trustpub/src/access_token.rs +++ b/crates/crates_io_trustpub/src/access_token.rs @@ -80,11 +80,15 @@ impl FromStr for AccessToken { } /// The error type for parsing access tokens. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum AccessTokenError { + #[error("Missing prefix `{}`", AccessToken::PREFIX)] MissingPrefix, + #[error("Invalid token length")] InvalidLength, + #[error("Invalid character in token")] InvalidCharacter, + #[error("Invalid checksum: claimed `{claimed}`, actual `{actual}`")] InvalidChecksum { claimed: char, actual: char }, } diff --git a/src/auth.rs b/src/auth.rs deleted file mode 100644 index 53c60d5c191..00000000000 --- a/src/auth.rs +++ /dev/null @@ -1,420 +0,0 @@ -use crate::controllers; -use crate::controllers::util::RequestPartsExt; -use crate::middleware::log_request::RequestLogExt; -use crate::models::token::{CrateScope, EndpointScope}; -use crate::models::{ApiToken, User}; -use crate::util::errors::{ - AppResult, BoxedAppError, InsecurelyGeneratedTokenRevoked, account_locked, custom, forbidden, - internal, -}; -use crate::util::token::HashedToken; -use axum::extract::FromRequestParts; -use chrono::Utc; -use crates_io_session::SessionExtension; -use diesel_async::AsyncPgConnection; -use http::request::Parts; -use http::{StatusCode, header}; -use secrecy::{ExposeSecret, SecretString}; - -pub struct AuthHeader(SecretString); - -impl AuthHeader { - pub async fn optional_from_request_parts(parts: &Parts) -> Result, BoxedAppError> { - let Some(auth_header) = parts.headers.get(header::AUTHORIZATION) else { - return Ok(None); - }; - - let auth_header = auth_header.to_str().map_err(|_| { - let message = "Invalid `Authorization` header: Found unexpected non-ASCII characters"; - custom(StatusCode::UNAUTHORIZED, message) - })?; - - let (scheme, token) = auth_header.split_once(' ').unwrap_or(("", auth_header)); - if !(scheme.eq_ignore_ascii_case("Bearer") || scheme.is_empty()) { - let message = format!( - "Invalid `Authorization` header: Found unexpected authentication scheme `{scheme}`" - ); - return Err(custom(StatusCode::UNAUTHORIZED, message)); - } - - let token = SecretString::from(token.trim_ascii()); - Ok(Some(AuthHeader(token))) - } - - pub async fn from_request_parts(parts: &Parts) -> Result { - let auth = Self::optional_from_request_parts(parts).await?; - auth.ok_or_else(|| { - let message = "Missing `Authorization` header"; - custom(StatusCode::UNAUTHORIZED, message) - }) - } - - pub fn token(&self) -> &SecretString { - &self.0 - } -} - -impl FromRequestParts for AuthHeader { - type Rejection = BoxedAppError; - - async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { - Self::from_request_parts(parts).await - } -} - -#[derive(Debug, Clone)] -pub struct AuthCheck { - allow_token: bool, - endpoint_scope: Option, - crate_name: Option, -} - -impl AuthCheck { - #[must_use] - // #[must_use] can't be applied in the `Default` trait impl - #[allow(clippy::should_implement_trait)] - pub fn default() -> Self { - Self { - allow_token: true, - endpoint_scope: None, - crate_name: None, - } - } - - #[must_use] - pub fn only_cookie() -> Self { - Self { - allow_token: false, - endpoint_scope: None, - crate_name: None, - } - } - - pub fn with_endpoint_scope(&self, endpoint_scope: EndpointScope) -> Self { - Self { - allow_token: self.allow_token, - endpoint_scope: Some(endpoint_scope), - crate_name: self.crate_name.clone(), - } - } - - pub fn for_crate(&self, crate_name: &str) -> Self { - Self { - allow_token: self.allow_token, - endpoint_scope: self.endpoint_scope, - crate_name: Some(crate_name.to_string()), - } - } - - #[instrument(name = "auth.check", skip_all)] - pub async fn check( - &self, - parts: &Parts, - conn: &mut AsyncPgConnection, - ) -> AppResult { - let auth = authenticate(parts, conn).await?; - - if let Some(token) = auth.api_token() { - if !self.allow_token { - let error_message = - "API Token authentication was explicitly disallowed for this API"; - parts.request_log().add("cause", error_message); - - return Err(forbidden( - "this action can only be performed on the crates.io website", - )); - } - - if !self.endpoint_scope_matches(token.endpoint_scopes.as_ref()) { - let error_message = "Endpoint scope mismatch"; - parts.request_log().add("cause", error_message); - - return Err(forbidden( - "this token does not have the required permissions to perform this action", - )); - } - - if !self.crate_scope_matches(token.crate_scopes.as_ref()) { - let error_message = "Crate scope mismatch"; - parts.request_log().add("cause", error_message); - - return Err(forbidden( - "this token does not have the required permissions to perform this action", - )); - } - } - - Ok(auth) - } - - fn endpoint_scope_matches(&self, token_scopes: Option<&Vec>) -> bool { - match (&token_scopes, &self.endpoint_scope) { - // The token is a legacy token. - (None, _) => true, - - // The token is NOT a legacy token, and the endpoint only allows legacy tokens. - (Some(_), None) => false, - - // The token is NOT a legacy token, and the endpoint allows a certain endpoint scope or a legacy token. - (Some(token_scopes), Some(endpoint_scope)) => token_scopes.contains(endpoint_scope), - } - } - - fn crate_scope_matches(&self, token_scopes: Option<&Vec>) -> bool { - match (&token_scopes, &self.crate_name) { - // The token is a legacy token. - (None, _) => true, - - // The token does not have any crate scopes. - (Some(token_scopes), _) if token_scopes.is_empty() => true, - - // The token has crate scopes, but the endpoint does not deal with crates. - (Some(_), None) => false, - - // The token is NOT a legacy token, and the endpoint allows a certain endpoint scope or a legacy token. - (Some(token_scopes), Some(crate_name)) => token_scopes - .iter() - .any(|token_scope| token_scope.matches(crate_name)), - } - } -} - -#[derive(Debug)] -pub enum Authentication { - Cookie(CookieAuthentication), - Token(TokenAuthentication), -} - -#[derive(Debug)] -pub struct CookieAuthentication { - user: User, -} - -#[derive(Debug)] -pub struct TokenAuthentication { - token: ApiToken, - user: User, -} - -impl Authentication { - pub fn user_id(&self) -> i32 { - self.user().id - } - - pub fn api_token_id(&self) -> Option { - self.api_token().map(|token| token.id) - } - - pub fn api_token(&self) -> Option<&ApiToken> { - match self { - Authentication::Token(token) => Some(&token.token), - _ => None, - } - } - - pub fn user(&self) -> &User { - match self { - Authentication::Cookie(cookie) => &cookie.user, - Authentication::Token(token) => &token.user, - } - } -} - -#[instrument(skip_all)] -async fn authenticate_via_cookie( - parts: &Parts, - conn: &mut AsyncPgConnection, -) -> AppResult> { - let session = parts - .extensions() - .get::() - .expect("missing cookie session"); - - let user_id_from_session = session.get("user_id").and_then(|s| s.parse::().ok()); - let Some(id) = user_id_from_session else { - return Ok(None); - }; - - let user = User::find(conn, id).await.map_err(|err| { - parts.request_log().add("cause", err); - internal("user_id from cookie not found in database") - })?; - - ensure_not_locked(&user)?; - - parts.request_log().add("uid", id); - - Ok(Some(CookieAuthentication { user })) -} - -#[instrument(skip_all)] -async fn authenticate_via_token( - parts: &Parts, - conn: &mut AsyncPgConnection, -) -> AppResult> { - let Some(auth_header) = AuthHeader::optional_from_request_parts(parts).await? else { - return Ok(None); - }; - - let token = auth_header.token().expose_secret(); - let token = HashedToken::parse(token).map_err(|_| InsecurelyGeneratedTokenRevoked::boxed())?; - - let token = ApiToken::find_by_api_token(conn, &token) - .await - .map_err(|e| { - let cause = format!("invalid token caused by {e}"); - parts.request_log().add("cause", cause); - - forbidden("authentication failed") - })?; - - let user = User::find(conn, token.user_id).await.map_err(|err| { - parts.request_log().add("cause", err); - internal("user_id from token not found in database") - })?; - - ensure_not_locked(&user)?; - - parts.request_log().add("uid", token.user_id); - parts.request_log().add("tokenid", token.id); - - Ok(Some(TokenAuthentication { user, token })) -} - -#[instrument(skip_all)] -async fn authenticate(parts: &Parts, conn: &mut AsyncPgConnection) -> AppResult { - controllers::util::verify_origin(parts)?; - - match authenticate_via_cookie(parts, conn).await { - Ok(None) => {} - Ok(Some(auth)) => return Ok(Authentication::Cookie(auth)), - Err(err) => return Err(err), - } - - match authenticate_via_token(parts, conn).await { - Ok(None) => {} - Ok(Some(auth)) => return Ok(Authentication::Token(auth)), - Err(err) => return Err(err), - } - - // Unable to authenticate the user - let cause = "no cookie session or auth header found"; - parts.request_log().add("cause", cause); - - return Err(forbidden("this action requires authentication")); -} - -fn ensure_not_locked(user: &User) -> AppResult<()> { - if let Some(reason) = &user.account_lock_reason { - let still_locked = user - .account_lock_until - .map(|until| until > Utc::now()) - .unwrap_or(true); - - if still_locked { - return Err(account_locked(reason, user.account_lock_until)); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn cs(scope: &str) -> CrateScope { - CrateScope::try_from(scope).unwrap() - } - - #[test] - fn regular_endpoint() { - let auth_check = AuthCheck::default(); - - assert!(auth_check.endpoint_scope_matches(None)); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishNew]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishUpdate]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::Yank]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::ChangeOwners]))); - - assert!(auth_check.crate_scope_matches(None)); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("tokio-console")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("tokio-*")]))); - } - - #[test] - fn publish_new_endpoint() { - let auth_check = AuthCheck::default() - .with_endpoint_scope(EndpointScope::PublishNew) - .for_crate("tokio-console"); - - assert!(auth_check.endpoint_scope_matches(None)); - assert!(auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishNew]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishUpdate]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::Yank]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::ChangeOwners]))); - - assert!(auth_check.crate_scope_matches(None)); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-console")]))); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-*")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("anyhow")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("actix-*")]))); - } - - #[test] - fn publish_update_endpoint() { - let auth_check = AuthCheck::default() - .with_endpoint_scope(EndpointScope::PublishUpdate) - .for_crate("tokio-console"); - - assert!(auth_check.endpoint_scope_matches(None)); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishNew]))); - assert!(auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishUpdate]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::Yank]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::ChangeOwners]))); - - assert!(auth_check.crate_scope_matches(None)); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-console")]))); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-*")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("anyhow")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("actix-*")]))); - } - - #[test] - fn yank_endpoint() { - let auth_check = AuthCheck::default() - .with_endpoint_scope(EndpointScope::Yank) - .for_crate("tokio-console"); - - assert!(auth_check.endpoint_scope_matches(None)); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishNew]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishUpdate]))); - assert!(auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::Yank]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::ChangeOwners]))); - - assert!(auth_check.crate_scope_matches(None)); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-console")]))); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-*")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("anyhow")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("actix-*")]))); - } - - #[test] - fn owner_change_endpoint() { - let auth_check = AuthCheck::default() - .with_endpoint_scope(EndpointScope::ChangeOwners) - .for_crate("tokio-console"); - - assert!(auth_check.endpoint_scope_matches(None)); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishNew]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishUpdate]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::Yank]))); - assert!(auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::ChangeOwners]))); - - assert!(auth_check.crate_scope_matches(None)); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-console")]))); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-*")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("anyhow")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("actix-*")]))); - } -} diff --git a/src/auth/authorization/entity.rs b/src/auth/authorization/entity.rs new file mode 100644 index 00000000000..ea6848a95bc --- /dev/null +++ b/src/auth/authorization/entity.rs @@ -0,0 +1,25 @@ +use crate::auth::authorization::trustpub::AuthorizedTrustPub; +use crate::auth::authorization::user::AuthorizedUser; +use crates_io_database::models::{ApiToken, User}; + +pub enum AuthorizedEntity { + User(Box>>), + TrustPub(AuthorizedTrustPub), +} + +impl AuthorizedEntity { + pub fn user_auth(&self) -> Option<&AuthorizedUser>> { + match self { + AuthorizedEntity::User(auth) => Some(auth), + AuthorizedEntity::TrustPub(_) => None, + } + } + + pub fn user(&self) -> Option<&User> { + self.user_auth().map(|auth| auth.user()) + } + + pub fn user_id(&self) -> Option { + self.user_auth().map(|auth| auth.user_id()) + } +} diff --git a/src/auth/authorization/mod.rs b/src/auth/authorization/mod.rs new file mode 100644 index 00000000000..811ea5f2671 --- /dev/null +++ b/src/auth/authorization/mod.rs @@ -0,0 +1,9 @@ +mod entity; +mod permission; +mod trustpub; +mod user; + +pub use self::entity::*; +pub use self::permission::*; +pub use self::trustpub::*; +pub use self::user::*; diff --git a/src/auth/authorization/permission.rs b/src/auth/authorization/permission.rs new file mode 100644 index 00000000000..6815152f371 --- /dev/null +++ b/src/auth/authorization/permission.rs @@ -0,0 +1,76 @@ +use crates_io_database::models::{Crate, Owner}; + +pub enum Permission<'a> { + ListApiTokens, + CreateApiToken, + ReadApiToken, + RevokeApiToken, + RevokeCurrentApiToken, + + PublishNew { + name: &'a str, + }, + PublishUpdate { + krate: &'a Crate, + }, + DeleteCrate { + krate: &'a Crate, + owners: &'a [Owner], + }, + + ModifyOwners { + krate: &'a Crate, + owners: &'a [Owner], + }, + + ListCrateOwnerInvitations, + ListOwnCrateOwnerInvitations, + HandleCrateOwnerInvitation, + + ListFollowedCrates, + ReadFollowState, + FollowCrate, + UnfollowCrate, + + ListTrustPubGitHubConfigs { + krate: &'a Crate, + }, + CreateTrustPubGitHubConfig { + user_owner_ids: Vec, + }, + DeleteTrustPubGitHubConfig { + user_owner_ids: Vec, + }, + + ReadUser, + UpdateUser, + + UpdateVersion { + krate: &'a Crate, + }, + YankVersion { + krate: &'a Crate, + }, + UnyankVersion { + krate: &'a Crate, + }, + + ResendEmailVerification, + UpdateEmailNotifications, + ListUpdates, + + RebuildDocs { + krate: &'a Crate, + }, +} + +impl Permission<'_> { + #[allow(clippy::match_like_matches_macro)] + pub(in crate::auth) fn allowed_for_admin(&self) -> bool { + match self { + Permission::YankVersion { .. } => true, + Permission::UnyankVersion { .. } => true, + _ => false, + } + } +} diff --git a/src/auth/authorization/trustpub.rs b/src/auth/authorization/trustpub.rs new file mode 100644 index 00000000000..0f5268118a2 --- /dev/null +++ b/src/auth/authorization/trustpub.rs @@ -0,0 +1,37 @@ +use crate::auth::Permission; +use crate::util::errors::{BoxedAppError, forbidden}; + +pub struct AuthorizedTrustPub { + crate_ids: Vec, +} + +impl AuthorizedTrustPub { + pub fn new(crate_ids: Vec) -> Self { + Self { crate_ids } + } + + pub(in crate::auth) async fn validate( + self, + permission: Permission<'_>, + ) -> Result { + let existing_crate = match permission { + Permission::PublishUpdate { krate } => krate, + Permission::PublishNew { .. } => { + let message = "Trusted Publishing tokens do not support creating new crates"; + return Err(forbidden(message)); + } + _ => { + let message = "Trusted Publishing tokens can only be used for publishing crates"; + return Err(forbidden(message)); + } + }; + + if !self.crate_ids.contains(&existing_crate.id) { + let name = &existing_crate.name; + let error = format!("The provided access token is not valid for crate `{name}`"); + return Err(forbidden(error)); + } + + Ok(self) + } +} diff --git a/src/auth/authorization/user.rs b/src/auth/authorization/user.rs new file mode 100644 index 00000000000..dbb899d1d79 --- /dev/null +++ b/src/auth/authorization/user.rs @@ -0,0 +1,324 @@ +use crate::auth::Permission; +use crate::controllers::helpers::authorization::Rights; +use crate::middleware::app::RequestApp; +use crate::middleware::log_request::RequestLogExt; +use crate::util::errors::{BoxedAppError, account_locked, bad_request, forbidden}; +use chrono::Utc; +use crates_io_database::models::token::EndpointScope; +use crates_io_database::models::{ApiToken, OwnerKind, User}; +use crates_io_database::schema::crate_owners; +use diesel::dsl::exists; +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use http::request::Parts; + +pub struct AuthorizedUser { + user: User, + api_token: T, +} + +impl AuthorizedUser { + pub fn new(user: User, api_token: T) -> Self { + AuthorizedUser { user, api_token } + } + + pub fn user(&self) -> &User { + &self.user + } + + pub fn user_id(&self) -> i32 { + self.user.id + } + + fn check_user_locked(&self) -> Result<(), BoxedAppError> { + ensure_not_locked(&self.user) + } + + async fn check_email_verification( + &self, + conn: &mut AsyncPgConnection, + permission: &Permission<'_>, + ) -> Result<(), BoxedAppError> { + if self.user.verified_email(conn).await?.is_some() { + return Ok(()); + } + + match permission { + Permission::PublishNew { .. } | Permission::PublishUpdate { .. } => Err(bad_request( + "A verified email address is required to publish crates to crates.io. Visit https://crates.io/settings/profile to set and verify your email address.", + )), + Permission::CreateTrustPubGitHubConfig { .. } => Err(forbidden( + "You must verify your email address to create a Trusted Publishing config", + )), + _ => Ok(()), + } + } + + async fn check_crate_rights( + &self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: &Permission<'_>, + ) -> Result<(), BoxedAppError> { + match permission { + Permission::PublishUpdate { krate } => { + const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem to be an owner. \ + If you believe this is a mistake, perhaps you need \ + to accept an invitation to be an owner before publishing."; + + let owners = krate.owners(conn).await?; + if Rights::get(&self.user, &*parts.app().github, &owners).await? < Rights::Publish { + return Err(forbidden(MISSING_RIGHTS_ERROR_MESSAGE)); + } + } + Permission::DeleteCrate { owners, .. } => { + match Rights::get(&self.user, &*parts.app().github, owners).await? { + Rights::Full => {} + Rights::Publish => { + return Err(forbidden( + "team members don't have permission to delete crates", + )); + } + Rights::None => { + return Err(forbidden("only owners have permission to delete crates")); + } + } + } + Permission::ModifyOwners { owners, .. } => { + match Rights::get(&self.user, &*parts.app().github, owners).await? { + Rights::Full => {} + Rights::Publish => { + return Err(forbidden( + "team members don't have permission to modify owners", + )); + } + Rights::None => { + return Err(forbidden("only owners have permission to modify owners")); + } + } + } + Permission::ListTrustPubGitHubConfigs { krate } => { + let is_owner = diesel::select(exists( + crate_owners::table + .filter(crate_owners::crate_id.eq(krate.id)) + .filter(crate_owners::deleted.eq(false)) + .filter(crate_owners::owner_kind.eq(OwnerKind::User)) + .filter(crate_owners::owner_id.eq(self.user.id)), + )) + .get_result::(conn) + .await?; + + if !is_owner { + return Err(bad_request("You are not an owner of this crate")); + } + } + Permission::CreateTrustPubGitHubConfig { user_owner_ids } => { + if user_owner_ids.iter().all(|id| *id != self.user.id) { + return Err(bad_request("You are not an owner of this crate")); + } + } + Permission::DeleteTrustPubGitHubConfig { user_owner_ids } => { + if user_owner_ids.iter().all(|id| *id != self.user.id) { + return Err(bad_request("You are not an owner of this crate")); + } + } + Permission::UpdateVersion { krate } => { + let owners = krate.owners(conn).await?; + if Rights::get(&self.user, &*parts.app().github, &owners).await? < Rights::Publish { + return Err(forbidden("must already be an owner to yank or unyank")); + } + } + Permission::YankVersion { krate } => { + let owners = krate.owners(conn).await?; + if Rights::get(&self.user, &*parts.app().github, &owners).await? < Rights::Publish { + return Err(forbidden("must already be an owner to yank or unyank")); + } + } + Permission::UnyankVersion { krate } => { + let owners = krate.owners(conn).await?; + if Rights::get(&self.user, &*parts.app().github, &owners).await? < Rights::Publish { + return Err(forbidden("must already be an owner to yank or unyank")); + } + } + Permission::RebuildDocs { krate } => { + let owners = krate.owners(conn).await?; + if Rights::get(&self.user, &*parts.app().github, &owners).await? < Rights::Publish { + return Err(forbidden( + "user doesn't have permission to trigger a docs rebuild", + )); + } + } + _ => {} + } + + Ok(()) + } +} + +impl AuthorizedUser<()> { + pub(in crate::auth) async fn validate( + self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result { + if self.user.is_admin && permission.allowed_for_admin() { + return Ok(self); + } + + self.check_user_locked()?; + self.check_email_verification(conn, &permission).await?; + self.check_crate_rights(conn, parts, &permission).await?; + + Ok(self) + } +} + +impl AuthorizedUser { + pub fn api_token(&self) -> &ApiToken { + &self.api_token + } + + pub fn api_token_id(&self) -> i32 { + self.api_token.id + } + + pub(in crate::auth) async fn validate( + self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result { + if self.user.is_admin && permission.allowed_for_admin() { + return Ok(self); + } + + self.check_user_locked()?; + self.check_email_verification(conn, &permission).await?; + self.check_crate_rights(conn, parts, &permission).await?; + self.check_token_scopes(parts, &permission).await?; + + Ok(self) + } + + async fn check_token_scopes( + &self, + parts: &Parts, + permission: &Permission<'_>, + ) -> Result<(), BoxedAppError> { + let (endpoint_scope, crate_name) = match permission { + Permission::PublishNew { name } => (Some(EndpointScope::PublishNew), Some(*name)), + Permission::PublishUpdate { krate } => ( + Some(EndpointScope::PublishUpdate), + Some(krate.name.as_str()), + ), + Permission::ModifyOwners { krate, .. } => { + (Some(EndpointScope::ChangeOwners), Some(krate.name.as_str())) + } + Permission::UpdateVersion { krate } => { + (Some(EndpointScope::Yank), Some(krate.name.as_str())) + } + Permission::YankVersion { krate } => { + (Some(EndpointScope::Yank), Some(krate.name.as_str())) + } + Permission::UnyankVersion { krate } => { + (Some(EndpointScope::Yank), Some(krate.name.as_str())) + } + _ => (None, None), + }; + + if !endpoint_scope_matches(endpoint_scope, &self.api_token) { + let error_message = "Endpoint scope mismatch"; + parts.request_log().add("cause", error_message); + + return Err(forbidden( + "this token does not have the required permissions to perform this action", + )); + } + + if !crate_scope_matches(crate_name, &self.api_token) { + let error_message = "Crate scope mismatch"; + parts.request_log().add("cause", error_message); + + return Err(forbidden( + "this token does not have the required permissions to perform this action", + )); + } + + Ok(()) + } +} + +impl AuthorizedUser> { + pub fn api_token(&self) -> Option<&ApiToken> { + self.api_token.as_ref() + } + + pub fn api_token_id(&self) -> Option { + self.api_token.as_ref().map(|token| token.id) + } +} + +impl From> for AuthorizedUser> { + fn from(auth: AuthorizedUser<()>) -> Self { + AuthorizedUser { + user: auth.user, + api_token: None, + } + } +} + +impl From> for AuthorizedUser> { + fn from(auth: AuthorizedUser) -> Self { + AuthorizedUser { + user: auth.user, + api_token: Some(auth.api_token), + } + } +} + +fn ensure_not_locked(user: &User) -> Result<(), BoxedAppError> { + if let Some(reason) = &user.account_lock_reason { + let still_locked = user + .account_lock_until + .map(|until| until > Utc::now()) + .unwrap_or(true); + + if still_locked { + return Err(account_locked(reason, user.account_lock_until)); + } + } + + Ok(()) +} + +fn endpoint_scope_matches(endpoint_scope: Option, token: &ApiToken) -> bool { + match (&token.endpoint_scopes, endpoint_scope) { + // The token is a legacy token. + (None, _) => true, + + // The token is NOT a legacy token, and the endpoint only allows legacy tokens. + (Some(_), None) => false, + + // The token is NOT a legacy token, and the endpoint allows a certain endpoint scope or a legacy token. + (Some(token_scopes), Some(endpoint_scope)) => token_scopes.contains(&endpoint_scope), + } +} + +fn crate_scope_matches(crate_name: Option<&str>, token: &ApiToken) -> bool { + match (&token.crate_scopes, &crate_name) { + // The token is a legacy token. + (None, _) => true, + + // The token does not have any crate scopes. + (Some(token_scopes), _) if token_scopes.is_empty() => true, + + // The token has crate scopes, but the endpoint does not deal with crates. + (Some(_), None) => false, + + // The token is NOT a legacy token, and the endpoint allows a certain endpoint scope or a legacy token. + (Some(token_scopes), Some(crate_name)) => token_scopes + .iter() + .any(|token_scope| token_scope.matches(crate_name)), + } +} diff --git a/src/auth/credentials/api_token.rs b/src/auth/credentials/api_token.rs new file mode 100644 index 00000000000..be9b441cfc7 --- /dev/null +++ b/src/auth/credentials/api_token.rs @@ -0,0 +1,107 @@ +use crate::auth::{AuthorizedUser, Permission}; +use crate::controllers; +use crate::middleware::log_request::RequestLogExt; +use crate::util::errors::{ + BoxedAppError, InsecurelyGeneratedTokenRevoked, bad_request, custom, forbidden, internal, +}; +use axum::extract::FromRequestParts; +use crates_io_database::models::{ApiToken, User}; +use crates_io_database::utils::token::{HashedToken, InvalidTokenError}; +use diesel_async::AsyncPgConnection; +use http::header::ToStrError; +use http::request::Parts; +use http::{HeaderValue, StatusCode}; + +#[derive(Debug)] +pub struct ApiTokenCredentials { + hashed_token: HashedToken, +} + +impl ApiTokenCredentials { + pub fn from_request_parts(parts: &Parts) -> Result { + use ApiTokenCredentialsError::*; + use http::header; + + let header = parts.headers.get(header::AUTHORIZATION); + let header = header.ok_or(MissingAuthorizationHeader)?; + + Self::from_header(header) + } + + pub fn from_header(header: &HeaderValue) -> Result { + let header = header.to_str()?; + + let (scheme, token) = header.split_once(' ').unwrap_or(("", header)); + if !(scheme.is_empty() || scheme.eq_ignore_ascii_case("Bearer")) { + return Err(ApiTokenCredentialsError::InvalidAuthScheme); + } + + Self::from_raw_token(token.trim_ascii()) + } + + pub fn from_raw_token(token: &str) -> Result { + let hashed_token = HashedToken::parse(token)?; + Ok(Self { hashed_token }) + } + + pub async fn validate( + &self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result, BoxedAppError> { + let api_token = ApiToken::find_by_api_token(conn, &self.hashed_token) + .await + .map_err(|e| { + let cause = format!("invalid token caused by {e}"); + parts.request_log().add("cause", cause); + forbidden("authentication failed") + })?; + + let user = User::find(conn, api_token.user_id).await.map_err(|err| { + parts.request_log().add("cause", err); + internal("user_id from token not found in database") + })?; + + parts.request_log().add("uid", api_token.user_id); + parts.request_log().add("tokenid", api_token.id); + + AuthorizedUser::new(user, api_token) + .validate(conn, parts, permission) + .await + } +} + +impl FromRequestParts for ApiTokenCredentials { + type Rejection = BoxedAppError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + controllers::util::verify_origin(parts)?; + Ok(Self::from_request_parts(parts)?) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ApiTokenCredentialsError { + #[error("Missing `Authorization` header")] + MissingAuthorizationHeader, + #[error("Unexpected non-ASCII characters in `Authorization` header: {0}")] + InvalidCharacters(#[from] ToStrError), + #[error("Unexpected `Authorization` header scheme")] + InvalidAuthScheme, + #[error("Invalid API token: {0}")] + InvalidAccessToken(#[from] InvalidTokenError), +} + +impl From for BoxedAppError { + fn from(err: ApiTokenCredentialsError) -> Self { + if matches!(err, ApiTokenCredentialsError::MissingAuthorizationHeader) { + bad_request("token not provided") + } else if matches!(err, ApiTokenCredentialsError::InvalidAccessToken(_)) { + InsecurelyGeneratedTokenRevoked::boxed() + } else { + let message = format!("Authentication failed: {err}"); + custom(StatusCode::UNAUTHORIZED, message) + } + } +} diff --git a/src/auth/credentials/cookie.rs b/src/auth/credentials/cookie.rs new file mode 100644 index 00000000000..1e6fb2dd64b --- /dev/null +++ b/src/auth/credentials/cookie.rs @@ -0,0 +1,83 @@ +use crate::auth::{AuthorizedUser, Permission}; +use crate::controllers; +use crate::middleware::log_request::RequestLogExt; +use crate::util::errors::{BoxedAppError, custom, forbidden, internal}; +use axum::extract::FromRequestParts; +use crates_io_database::models::User; +use crates_io_session::SessionExtension; +use diesel_async::AsyncPgConnection; +use http::request::Parts; +use http::{StatusCode, header}; +use std::num::ParseIntError; + +#[derive(Debug, Clone, Copy)] +pub struct CookieCredentials { + user_id: i32, +} + +impl CookieCredentials { + pub fn new(user_id: i32) -> Self { + Self { user_id } + } + + pub fn from_request_parts(parts: &Parts) -> Result, CookieCredentialsError> { + let Some(session) = parts.extensions.get::() else { + error!("No `SessionExtension` found in request parts!"); + return Ok(None); + }; + + let Some(user_id) = session.get("user_id") else { + return Ok(None); + }; + + let user_id = user_id.parse()?; + + Ok(Some(Self { user_id })) + } + + pub async fn validate( + &self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result, BoxedAppError> { + let user = User::find(conn, self.user_id).await.map_err(|err| { + parts.request_log().add("cause", err); + internal("user_id from cookie not found in database") + })?; + + parts.request_log().add("uid", self.user_id); + + AuthorizedUser::new(user, ()) + .validate(conn, parts, permission) + .await + } +} + +impl FromRequestParts for CookieCredentials { + type Rejection = BoxedAppError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + controllers::util::verify_origin(parts)?; + + Self::from_request_parts(parts)?.ok_or_else(|| { + if parts.headers.get(header::AUTHORIZATION).is_some() { + forbidden("this action can only be performed on the crates.io website") + } else { + forbidden("this action requires authentication") + } + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CookieCredentialsError { + #[error("Authentication failed: Unexpected characters in `user_id` session value: {0}")] + InvalidCharacters(#[from] ParseIntError), +} + +impl From for BoxedAppError { + fn from(err: CookieCredentialsError) -> Self { + custom(StatusCode::UNAUTHORIZED, err.to_string()) + } +} diff --git a/src/auth/credentials/mod.rs b/src/auth/credentials/mod.rs new file mode 100644 index 00000000000..9cb8d8cc41f --- /dev/null +++ b/src/auth/credentials/mod.rs @@ -0,0 +1,11 @@ +mod api_token; +mod cookie; +mod publish; +mod trustpub; +mod user; + +pub use self::api_token::*; +pub use self::cookie::*; +pub use self::publish::*; +pub use self::trustpub::*; +pub use self::user::*; diff --git a/src/auth/credentials/publish.rs b/src/auth/credentials/publish.rs new file mode 100644 index 00000000000..a4f21edaf7d --- /dev/null +++ b/src/auth/credentials/publish.rs @@ -0,0 +1,115 @@ +use crate::auth::{ + ApiTokenCredentials, ApiTokenCredentialsError, AuthorizedEntity, CookieCredentials, + CookieCredentialsError, Permission, TrustPubCredentials, TrustPubCredentialsError, + UserCredentials, +}; +use crate::controllers; +use crate::util::errors::{BoxedAppError, forbidden}; +use axum::extract::FromRequestParts; +use crates_io_trustpub::access_token::AccessToken; +use diesel_async::AsyncPgConnection; +use http::header; +use http::request::Parts; + +pub enum PublishCredentials { + User(UserCredentials), + TrustPub(TrustPubCredentials), +} + +impl PublishCredentials { + pub fn from_request_parts(parts: &Parts) -> Result { + if let Some(credentials) = CookieCredentials::from_request_parts(parts)? { + return Ok(credentials.into()); + } + + let Some(header) = parts.headers.get(header::AUTHORIZATION) else { + return Err(PublishCredentialsError::AuthenticationRequired); + }; + + let header = header.to_str().map_err(ApiTokenCredentialsError::from)?; + + let (scheme, token) = header.split_once(' ').unwrap_or(("", header)); + if !(scheme.is_empty() || scheme.eq_ignore_ascii_case("Bearer")) { + return Err(PublishCredentialsError::InvalidApiTokenCredentials( + ApiTokenCredentialsError::InvalidAuthScheme, + )); + } + + let token = token.trim_ascii(); + if token.starts_with(AccessToken::PREFIX) { + Ok(TrustPubCredentials::from_raw_token(token)?.into()) + } else { + Ok(ApiTokenCredentials::from_raw_token(token)?.into()) + } + } + + pub async fn validate( + &self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result { + match self { + PublishCredentials::User(credentials) => { + let auth = credentials.validate(conn, parts, permission).await?; + Ok(AuthorizedEntity::User(Box::new(auth))) + } + PublishCredentials::TrustPub(credentials) => { + let auth = credentials.validate(conn, parts, permission).await?; + Ok(AuthorizedEntity::TrustPub(auth)) + } + } + } +} + +impl From for PublishCredentials { + fn from(credentials: CookieCredentials) -> Self { + PublishCredentials::User(credentials.into()) + } +} + +impl From for PublishCredentials { + fn from(credentials: ApiTokenCredentials) -> Self { + PublishCredentials::User(credentials.into()) + } +} + +impl From for PublishCredentials { + fn from(credentials: TrustPubCredentials) -> Self { + PublishCredentials::TrustPub(credentials) + } +} + +impl FromRequestParts for PublishCredentials { + type Rejection = BoxedAppError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + controllers::util::verify_origin(parts)?; + Ok(Self::from_request_parts(parts)?) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PublishCredentialsError { + #[error(transparent)] + InvalidCookieCredentials(#[from] CookieCredentialsError), + #[error(transparent)] + InvalidApiTokenCredentials(#[from] ApiTokenCredentialsError), + #[error(transparent)] + InvalidTrustPubCredentials(#[from] TrustPubCredentialsError), + #[error("Authentication required")] + AuthenticationRequired, +} + +impl From for BoxedAppError { + fn from(err: PublishCredentialsError) -> Self { + match err { + PublishCredentialsError::InvalidCookieCredentials(err) => err.into(), + PublishCredentialsError::InvalidApiTokenCredentials(err) => err.into(), + PublishCredentialsError::InvalidTrustPubCredentials(err) => err.into(), + PublishCredentialsError::AuthenticationRequired => { + forbidden("this action requires authentication") + } + } + } +} diff --git a/src/auth/credentials/trustpub.rs b/src/auth/credentials/trustpub.rs new file mode 100644 index 00000000000..b9107486da1 --- /dev/null +++ b/src/auth/credentials/trustpub.rs @@ -0,0 +1,104 @@ +use crate::auth::{AuthorizedTrustPub, Permission}; +use crate::controllers; +use crate::util::errors::{BoxedAppError, custom, forbidden}; +use axum::extract::FromRequestParts; +use crates_io_database::schema::trustpub_tokens; +use crates_io_trustpub::access_token::{AccessToken, AccessTokenError}; +use diesel::dsl::now; +use diesel::{ExpressionMethods, OptionalExtension, QueryDsl}; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use http::StatusCode; +use http::header::ToStrError; +use http::request::Parts; + +#[derive(Debug)] +pub struct TrustPubCredentials { + token: AccessToken, +} + +impl TrustPubCredentials { + pub fn from_request_parts(parts: &Parts) -> Result { + use TrustPubCredentialsError::*; + use http::header; + + let header = parts.headers.get(header::AUTHORIZATION); + let header = header.ok_or(MissingAuthorizationHeader)?; + let header = header.to_str()?; + + let (scheme, token) = header.split_once(' ').unwrap_or(("", header)); + if !(scheme.is_empty() || scheme.eq_ignore_ascii_case("Bearer")) { + return Err(InvalidAuthScheme); + } + + Self::from_raw_token(token.trim_ascii()) + } + + pub fn from_raw_token(token: &str) -> Result { + let token = token.parse::()?; + Ok(Self { token }) + } + + pub async fn validate( + &self, + conn: &mut AsyncPgConnection, + _parts: &Parts, + permission: Permission<'_>, + ) -> Result { + let hashed_token = self.token.sha256(); + + let crate_ids = trustpub_tokens::table + .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) + .filter(trustpub_tokens::expires_at.gt(now)) + .select(trustpub_tokens::crate_ids) + .get_result::>>(conn) + .await + .optional()? + .ok_or_else(|| forbidden("Invalid authentication token"))?; + + let crate_ids = crate_ids.into_iter().flatten().collect(); + + AuthorizedTrustPub::new(crate_ids) + .validate(permission) + .await + } + + pub fn unvalidated_token(&self) -> &AccessToken { + &self.token + } +} + +impl FromRequestParts for TrustPubCredentials { + type Rejection = BoxedAppError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + controllers::util::verify_origin(parts)?; + Ok(Self::from_request_parts(parts)?) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum TrustPubCredentialsError { + #[error("Missing `Authorization` header")] + MissingAuthorizationHeader, + #[error("Unexpected non-ASCII characters in `Authorization` header: {0}")] + InvalidCharacters(#[from] ToStrError), + #[error("Unexpected `Authorization` header scheme")] + InvalidAuthScheme, + #[error("Invalid access token: {0}")] + InvalidAccessToken(#[from] AccessTokenError), +} + +impl From for BoxedAppError { + fn from(err: TrustPubCredentialsError) -> Self { + if matches!(err, TrustPubCredentialsError::InvalidAccessToken(_)) { + let message = "Invalid `Authorization` header: Failed to parse token"; + custom(StatusCode::UNAUTHORIZED, message) + } else if matches!(err, TrustPubCredentialsError::MissingAuthorizationHeader) { + let message = "Missing `Authorization` header"; + custom(StatusCode::UNAUTHORIZED, message) + } else { + let message = format!("Authentication failed: {err}"); + custom(StatusCode::UNAUTHORIZED, message) + } + } +} diff --git a/src/auth/credentials/user.rs b/src/auth/credentials/user.rs new file mode 100644 index 00000000000..bfa6295d7be --- /dev/null +++ b/src/auth/credentials/user.rs @@ -0,0 +1,92 @@ +use crate::auth::{ + ApiTokenCredentials, ApiTokenCredentialsError, AuthorizedUser, CookieCredentials, + CookieCredentialsError, Permission, +}; +use crate::controllers; +use crate::util::errors::{BoxedAppError, forbidden}; +use axum::extract::FromRequestParts; +use crates_io_database::models::ApiToken; +use diesel_async::AsyncPgConnection; +use http::header; +use http::request::Parts; + +#[derive(Debug)] +pub enum UserCredentials { + Cookie(CookieCredentials), + ApiToken(ApiTokenCredentials), +} + +impl UserCredentials { + pub fn from_request_parts(parts: &Parts) -> Result { + if let Some(credentials) = CookieCredentials::from_request_parts(parts)? { + return Ok(credentials.into()); + } + + let Some(header) = parts.headers.get(header::AUTHORIZATION) else { + return Err(UserCredentialsError::AuthenticationRequired); + }; + + let credentials = ApiTokenCredentials::from_header(header)?; + + Ok(credentials.into()) + } + + pub async fn validate( + &self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result>, BoxedAppError> { + match self { + UserCredentials::Cookie(credentials) => { + Ok(credentials.validate(conn, parts, permission).await?.into()) + } + UserCredentials::ApiToken(credentials) => { + Ok(credentials.validate(conn, parts, permission).await?.into()) + } + } + } +} + +impl From for UserCredentials { + fn from(credentials: CookieCredentials) -> Self { + UserCredentials::Cookie(credentials) + } +} + +impl From for UserCredentials { + fn from(credentials: ApiTokenCredentials) -> Self { + UserCredentials::ApiToken(credentials) + } +} + +impl FromRequestParts for UserCredentials { + type Rejection = BoxedAppError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + controllers::util::verify_origin(parts)?; + Ok(Self::from_request_parts(parts)?) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum UserCredentialsError { + #[error(transparent)] + InvalidCookieCredentials(#[from] CookieCredentialsError), + #[error(transparent)] + InvalidApiTokenCredentials(#[from] ApiTokenCredentialsError), + #[error("Authentication required")] + AuthenticationRequired, +} + +impl From for BoxedAppError { + fn from(err: UserCredentialsError) -> Self { + match err { + UserCredentialsError::InvalidCookieCredentials(err) => err.into(), + UserCredentialsError::InvalidApiTokenCredentials(err) => err.into(), + UserCredentialsError::AuthenticationRequired => { + forbidden("this action requires authentication") + } + } + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 00000000000..38db8836173 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,5 @@ +mod authorization; +mod credentials; + +pub use authorization::*; +pub use credentials::*; diff --git a/src/controllers/crate_owner_invitation.rs b/src/controllers/crate_owner_invitation.rs index 24547c0e650..e3e6786d2e6 100644 --- a/src/controllers/crate_owner_invitation.rs +++ b/src/controllers/crate_owner_invitation.rs @@ -1,6 +1,5 @@ use crate::app::AppState; -use crate::auth::AuthCheck; -use crate::auth::Authentication; +use crate::auth::{CookieCredentials, Permission, UserCredentials}; use crate::controllers::helpers::authorization::Rights; use crate::controllers::helpers::pagination::{Page, PaginationOptions, PaginationQueryParams}; use crate::models::crate_owner_invitation::AcceptError; @@ -43,16 +42,18 @@ pub struct LegacyListResponse { )] pub async fn list_crate_owner_invitations_for_user( app: AppState, + creds: CookieCredentials, req: Parts, ) -> AppResult> { let mut conn = app.db_read().await?; - let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; - let user_id = auth.user_id(); + let permission = Permission::ListOwnCrateOwnerInvitations; + let auth = creds.validate(&mut conn, &req, permission).await?; + let user = auth.user(); let PrivateListResponse { invitations, users, .. - } = prepare_list(&app, &req, auth, ListFilter::InviteeId(user_id), &mut conn).await?; + } = prepare_list(&app, &req, user, ListFilter::InviteeId(user.id), &mut conn).await?; // The schema for the private endpoints is converted to the schema used by v1 endpoints. let crate_owner_invitations = invitations @@ -108,13 +109,17 @@ pub struct ListQueryParams { pub async fn list_crate_owner_invitations( app: AppState, params: ListQueryParams, + creds: CookieCredentials, req: Parts, ) -> AppResult> { let mut conn = app.db_read().await?; - let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; + + let permission = Permission::ListCrateOwnerInvitations; + let auth = creds.validate(&mut conn, &req, permission).await?; + let user = auth.user(); let filter = params.try_into()?; - let list = prepare_list(&app, &req, auth, filter, &mut conn).await?; + let list = prepare_list(&app, &req, user, filter, &mut conn).await?; Ok(Json(list)) } @@ -142,7 +147,7 @@ impl TryFrom for ListFilter { async fn prepare_list( state: &AppState, req: &Parts, - auth: Authentication, + user: &User, filter: ListFilter, conn: &mut AsyncPgConnection, ) -> AppResult { @@ -151,8 +156,6 @@ async fn prepare_list( .enable_seek(true) .gather(req)?; - let user = auth.user(); - let config = &state.config; let mut crate_names = HashMap::new(); @@ -350,17 +353,19 @@ pub struct HandleResponse { pub async fn handle_crate_owner_invitation( state: AppState, parts: Parts, + creds: UserCredentials, Json(crate_invite): Json, ) -> AppResult> { let crate_invite = crate_invite.crate_owner_invite; let mut conn = state.db_write().await?; - let user_id = AuthCheck::default() - .check(&parts, &mut conn) - .await? - .user_id(); + + let permission = Permission::HandleCrateOwnerInvitation; + let auth = creds.validate(&mut conn, &parts, permission).await?; + let user = auth.user(); + let invitation = - CrateOwnerInvitation::find_by_id(user_id, crate_invite.crate_id, &mut conn).await?; + CrateOwnerInvitation::find_by_id(user.id, crate_invite.crate_id, &mut conn).await?; if crate_invite.accepted { invitation.accept(&mut conn).await?; diff --git a/src/controllers/krate/delete.rs b/src/controllers/krate/delete.rs index bc23449b15d..9b68f938155 100644 --- a/src/controllers/krate/delete.rs +++ b/src/controllers/krate/delete.rs @@ -1,6 +1,5 @@ use crate::app::AppState; -use crate::auth::AuthCheck; -use crate::controllers::helpers::authorization::Rights; +use crate::auth::{CookieCredentials, Permission}; use crate::controllers::krate::CratePath; use crate::email::Email; use crate::models::NewDeletedCrate; @@ -55,31 +54,24 @@ impl DeleteQueryParams { pub async fn delete_crate( path: CratePath, params: DeleteQueryParams, + creds: CookieCredentials, parts: Parts, app: AppState, ) -> AppResult { let mut conn = app.db_write().await?; - // Check that the user is authenticated - let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; - // Check that the crate exists let krate = path.load_crate(&mut conn).await?; + let owners = krate.owners(&mut conn).await?; - // Check that the user is an owner of the crate (team owners are not allowed to delete crates) + // Check that the user is authenticated and an owner of the crate + // (team owners are not allowed to delete crates) + let permission = Permission::DeleteCrate { + krate: &krate, + owners: &owners, + }; + let auth = creds.validate(&mut conn, &parts, permission).await?; let user = auth.user(); - let owners = krate.owners(&mut conn).await?; - match Rights::get(user, &*app.github, &owners).await? { - Rights::Full => {} - Rights::Publish => { - let msg = "team members don't have permission to delete crates"; - return Err(custom(StatusCode::FORBIDDEN, msg)); - } - Rights::None => { - let msg = "only owners have permission to delete crates"; - return Err(custom(StatusCode::FORBIDDEN, msg)); - } - } let created_at = krate.created_at; diff --git a/src/controllers/krate/follow.rs b/src/controllers/krate/follow.rs index 168bc8e807f..fe9df034c22 100644 --- a/src/controllers/krate/follow.rs +++ b/src/controllers/krate/follow.rs @@ -1,7 +1,7 @@ //! Endpoints for managing a per user list of followed crates use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{CookieCredentials, Permission, UserCredentials}; use crate::controllers::helpers::OkResponse; use crate::controllers::krate::CratePath; use crate::models::{Crate, Follow}; @@ -39,9 +39,17 @@ async fn follow_target( tag = "crates", responses((status = 200, description = "Successful Response", body = inline(OkResponse))), )] -pub async fn follow_crate(app: AppState, path: CratePath, req: Parts) -> AppResult { +pub async fn follow_crate( + app: AppState, + path: CratePath, + creds: UserCredentials, + req: Parts, +) -> AppResult { let mut conn = app.db_write().await?; - let user_id = AuthCheck::default().check(&req, &mut conn).await?.user_id(); + + let permission = Permission::FollowCrate; + let user_id = creds.validate(&mut conn, &req, permission).await?.user_id(); + let follow = follow_target(&path.name, &mut conn, user_id).await?; diesel::insert_into(follows::table) .values(&follow) @@ -64,9 +72,17 @@ pub async fn follow_crate(app: AppState, path: CratePath, req: Parts) -> AppResu tag = "crates", responses((status = 200, description = "Successful Response", body = inline(OkResponse))), )] -pub async fn unfollow_crate(app: AppState, path: CratePath, req: Parts) -> AppResult { +pub async fn unfollow_crate( + app: AppState, + path: CratePath, + creds: UserCredentials, + req: Parts, +) -> AppResult { let mut conn = app.db_write().await?; - let user_id = AuthCheck::default().check(&req, &mut conn).await?.user_id(); + + let permission = Permission::UnfollowCrate; + let user_id = creds.validate(&mut conn, &req, permission).await?.user_id(); + let follow = follow_target(&path.name, &mut conn, user_id).await?; diesel::delete(&follow).execute(&mut conn).await?; @@ -91,15 +107,15 @@ pub struct FollowingResponse { pub async fn get_following_crate( app: AppState, path: CratePath, + creds: CookieCredentials, req: Parts, ) -> AppResult> { use diesel::dsl::exists; let mut conn = app.db_read_prefer_primary().await?; - let user_id = AuthCheck::only_cookie() - .check(&req, &mut conn) - .await? - .user_id(); + + let permission = Permission::ReadFollowState; + let user_id = creds.validate(&mut conn, &req, permission).await?.user_id(); let follow = follow_target(&path.name, &mut conn, user_id).await?; let following = diesel::select(exists(follows::table.find(follow.id()))) diff --git a/src/controllers/krate/owners.rs b/src/controllers/krate/owners.rs index 1d1a0691538..8ba9e403369 100644 --- a/src/controllers/krate/owners.rs +++ b/src/controllers/krate/owners.rs @@ -1,18 +1,18 @@ //! All routes related to managing owners of a crate -use crate::controllers::helpers::authorization::Rights; +use crate::auth::{Permission, UserCredentials}; use crate::controllers::krate::CratePath; +use crate::email::Email; use crate::models::krate::OwnerRemoveError; use crate::models::{Crate, Owner, Team, User}; use crate::models::{ CrateOwner, NewCrateOwnerInvitation, NewCrateOwnerInvitationOutcome, NewTeam, - krate::NewOwnerInvite, token::EndpointScope, + krate::NewOwnerInvite, }; use crate::util::errors::{AppResult, BoxedAppError, bad_request, crate_not_found, custom}; use crate::views::EncodableOwner; use crate::{App, app::AppState}; -use crate::{auth::AuthCheck, email::Email}; -use axum::Json; +use axum::{Json, RequestPartsExt}; use chrono::Utc; use crates_io_github::{GitHubClient, GitHubError}; use diesel::prelude::*; @@ -161,7 +161,7 @@ pub struct ChangeOwnersRequest { async fn modify_owners( app: AppState, crate_name: String, - parts: Parts, + mut parts: Parts, body: ChangeOwnersRequest, add: bool, ) -> AppResult> { @@ -176,43 +176,27 @@ async fn modify_owners( } let mut conn = app.db_write().await?; - let auth = AuthCheck::default() - .with_endpoint_scope(EndpointScope::ChangeOwners) - .for_crate(&crate_name) - .check(&parts, &mut conn) - .await?; + let krate: Crate = Crate::by_name(&crate_name) + .first(&mut conn) + .await + .optional()? + .ok_or_else(|| crate_not_found(&crate_name))?; + + let owners = krate.owners(&mut conn).await?; + + let creds = parts.extract::().await?; + let permission = Permission::ModifyOwners { + krate: &krate, + owners: &owners, + }; + let auth = creds.validate(&mut conn, &parts, permission).await?; let user = auth.user(); let (msg, emails) = conn .transaction(|conn| { let app = app.clone(); async move { - let krate: Crate = Crate::by_name(&crate_name) - .first(conn) - .await - .optional()? - .ok_or_else(|| crate_not_found(&crate_name))?; - - let owners = krate.owners(conn).await?; - - match Rights::get(user, &*app.github, &owners).await? { - Rights::Full => {} - // Yes! - Rights::Publish => { - return Err(custom( - StatusCode::FORBIDDEN, - "team members don't have permission to modify owners", - )); - } - Rights::None => { - return Err(custom( - StatusCode::FORBIDDEN, - "only owners have permission to modify owners", - )); - } - } - // The set of emails to send out after invite processing is complete and // the database transaction has committed. let mut emails = Vec::with_capacity(logins.len()); diff --git a/src/controllers/krate/publish.rs b/src/controllers/krate/publish.rs index 1aa770be521..1ff63ebf258 100644 --- a/src/controllers/krate/publish.rs +++ b/src/controllers/krate/publish.rs @@ -1,7 +1,6 @@ //! Functionality related to publishing a new crate or version of a crate. use crate::app::AppState; -use crate::auth::{AuthCheck, AuthHeader, Authentication}; use crate::worker::jobs::{ self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion, }; @@ -11,7 +10,7 @@ use cargo_manifest::{Dependency, DepsSet, TargetDepsSet}; use chrono::{DateTime, SecondsFormat, Utc}; use crates_io_tarball::{TarballError, process_tarball}; use crates_io_worker::{BackgroundJob, EnqueueError}; -use diesel::dsl::{exists, now, select}; +use diesel::dsl::{exists, select}; use diesel::prelude::*; use diesel::sql_types::Timestamptz; use diesel_async::scoped_futures::ScopedFutureExt; @@ -21,7 +20,6 @@ use futures_util::TryStreamExt; use hex::ToHex; use http::StatusCode; use http::request::Parts; -use secrecy::ExposeSecret; use sha2::{Digest, Sha256}; use std::collections::HashMap; use tokio::io::{AsyncRead, AsyncReadExt}; @@ -33,45 +31,20 @@ use crate::models::{ VersionAction, default_versions::Version as DefaultVersion, }; -use crate::controllers::helpers::authorization::Rights; +use crate::auth::{Permission, PublishCredentials}; use crate::licenses::parse_license_expr; use crate::middleware::log_request::RequestLogExt; -use crate::models::token::EndpointScope; use crate::rate_limiter::LimitedAction; use crate::schema::*; -use crate::util::errors::{AppResult, BoxedAppError, bad_request, custom, forbidden, internal}; +use crate::util::errors::{AppResult, BoxedAppError, bad_request, custom, internal}; use crate::views::{ EncodableCrate, EncodableCrateDependency, GoodCrate, PublishMetadata, PublishWarnings, }; -use crates_io_database::models::{User, versions_published_by}; +use crates_io_database::models::versions_published_by; use crates_io_diesel_helpers::canon_crate_name; -use crates_io_trustpub::access_token::AccessToken; - -const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem to be an owner. \ - If you believe this is a mistake, perhaps you need \ - to accept an invitation to be an owner before \ - publishing."; const MAX_DESCRIPTION_LENGTH: usize = 1000; -enum AuthType { - Regular(Box), - TrustPub, -} - -impl AuthType { - fn user(&self) -> Option<&User> { - match self { - AuthType::Regular(auth) => Some(auth.user()), - AuthType::TrustPub => None, - } - } - - fn user_id(&self) -> Option { - self.user().map(|u| u.id) - } -} - /// Publish a new crate/version. /// /// Used by `cargo publish` to publish a new crate or to publish a new version of an @@ -87,7 +60,12 @@ impl AuthType { tag = "publish", responses((status = 200, description = "Successful Response", body = inline(GoodCrate))), )] -pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult> { +pub async fn publish( + app: AppState, + creds: PublishCredentials, + req: Parts, + body: Body, +) -> AppResult> { let stream = body.into_data_stream(); let stream = stream.map_err(std::io::Error::other); let mut reader = StreamReader::new(stream); @@ -147,60 +125,15 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult().map_err(|_| { - let message = "Invalid `Authorization` header: Failed to parse token"; - custom(StatusCode::UNAUTHORIZED, message) - })) - }) - .transpose()?; - - let auth = if let Some(trustpub_token) = trustpub_token { - let Some(existing_crate) = &existing_crate else { - let error = forbidden("Trusted Publishing tokens do not support creating new crates"); - return Err(error); - }; - - let hashed_token = trustpub_token.sha256(); - - let crate_ids: Vec> = trustpub_tokens::table - .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) - .filter(trustpub_tokens::expires_at.gt(now)) - .select(trustpub_tokens::crate_ids) - .get_result(&mut conn) - .await - .optional()? - .ok_or_else(|| forbidden("Invalid authentication token"))?; - - if !crate_ids.contains(&Some(existing_crate.id)) { - let name = &existing_crate.name; - let error = format!("The provided access token is not valid for crate `{name}`"); - return Err(forbidden(error)); - } - - AuthType::TrustPub - } else { - let endpoint_scope = match existing_crate { - Some(_) => EndpointScope::PublishUpdate, - None => EndpointScope::PublishNew, - }; - - let auth = AuthCheck::default() - .with_endpoint_scope(endpoint_scope) - .for_crate(&metadata.name) - .check(&req, &mut conn) - .await?; - - AuthType::Regular(Box::new(auth)) + let permission = match &existing_crate { + Some(krate) => Permission::PublishUpdate { krate }, + None => Permission::PublishNew { + name: &metadata.name, + }, }; + let auth = creds.validate(&mut conn, &req, permission).await?; + let verified_email_address = if let Some(user) = auth.user() { let verified_email_address = user.verified_email(&mut conn).await?; Some(verified_email_address.ok_or_else(|| verified_email_error(&app.config.domain_name))?) @@ -431,19 +364,10 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult krate, None => persist.update(conn).await?, - }; - - let owners = krate.owners(conn).await?; - if Rights::get(user, &*app.github, &owners).await? < Rights::Publish { - return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE)); } - - krate } else { // Trusted Publishing does not support creating new crates persist.update(conn).await? @@ -514,10 +438,10 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult AppResult> { // Notes: // The different use cases this function covers is handled through passing @@ -93,7 +93,7 @@ pub async fn list_crates( use diesel::sql_types::Float; use seek::*; - let filter_params = FilterParams::from(params, &req, &mut conn).await?; + let filter_params = FilterParams::from(params, &mut req, &mut conn).await?; let sort = filter_params.sort.as_deref(); let selection = ( @@ -356,7 +356,7 @@ struct FilterParams { impl FilterParams { async fn from( search_params: ListQueryParams, - parts: &Parts, + parts: &mut Parts, conn: &mut AsyncPgConnection, ) -> AppResult { const LETTER_ERROR: &str = "letter value must contain 1 character"; @@ -366,7 +366,11 @@ impl FilterParams { }; let auth_user_id = match search_params.following { - Some(_) => Some(AuthCheck::default().check(parts, conn).await?.user_id()), + Some(_) => { + let creds = parts.extract::().await?; + let permission = Permission::ListFollowedCrates; + Some(creds.validate(conn, parts, permission).await?.user_id()) + } None => None, }; diff --git a/src/controllers/session.rs b/src/controllers/session.rs index ce5300e6ae5..715953bb757 100644 --- a/src/controllers/session.rs +++ b/src/controllers/session.rs @@ -7,6 +7,7 @@ use http::request::Parts; use oauth2::{AuthorizationCode, CsrfToken, Scope, TokenResponse}; use crate::app::AppState; +use crate::auth::CookieCredentials; use crate::controllers::user::update::UserConfirmEmail; use crate::email::Emails; use crate::middleware::log_request::RequestLogExt; @@ -120,7 +121,8 @@ pub async fn authorize_session( // Log in by setting a cookie and the middleware authentication session.insert("user_id".to_string(), user.id.to_string()); - super::user::me::get_authenticated_user(app, req).await + let credentials = CookieCredentials::new(user.id); + super::user::me::get_authenticated_user(app, credentials, req).await } pub async fn save_user_to_database( diff --git a/src/controllers/token.rs b/src/controllers/token.rs index bfcd889d3b5..44229d112c9 100644 --- a/src/controllers/token.rs +++ b/src/controllers/token.rs @@ -3,7 +3,7 @@ use crate::schema::api_tokens; use crate::views::EncodableApiTokenWithToken; use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{ApiTokenCredentials, CookieCredentials, Permission, UserCredentials}; use crate::models::token::{CrateScope, EndpointScope}; use crate::util::errors::{AppResult, bad_request}; use crate::util::token::PlainToken; @@ -53,10 +53,13 @@ pub struct ListResponse { pub async fn list_api_tokens( app: AppState, Query(params): Query, + creds: CookieCredentials, req: Parts, ) -> AppResult { let mut conn = app.db_read_prefer_primary().await?; - let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; + + let permission = Permission::ListApiTokens; + let auth = creds.validate(&mut conn, &req, permission).await?; let user = auth.user(); let tokens: Vec = ApiToken::belonging_to(user) @@ -104,6 +107,7 @@ pub struct CreateResponse { )] pub async fn create_api_token( app: AppState, + creds: CookieCredentials, parts: Parts, Json(new): Json, ) -> AppResult> { @@ -112,14 +116,9 @@ pub async fn create_api_token( } let mut conn = app.db_write().await?; - let auth = AuthCheck::default().check(&parts, &mut conn).await?; - - if auth.api_token_id().is_some() { - return Err(bad_request( - "cannot use an API token to create a new API token", - )); - } + let permission = Permission::CreateApiToken; + let auth = creds.validate(&mut conn, &parts, permission).await?; let user = auth.user(); let max_token_per_user = 500; @@ -216,11 +215,15 @@ pub struct GetResponse { pub async fn find_api_token( app: AppState, Path(id): Path, + creds: UserCredentials, req: Parts, ) -> AppResult> { let mut conn = app.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; + + let permission = Permission::ReadApiToken; + let auth = creds.validate(&mut conn, &req, permission).await?; let user = auth.user(); + let api_token = ApiToken::belonging_to(user) .find(id) .select(ApiToken::as_select()) @@ -247,11 +250,15 @@ pub async fn find_api_token( pub async fn revoke_api_token( app: AppState, Path(id): Path, + creds: UserCredentials, req: Parts, ) -> AppResult { let mut conn = app.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; + + let permission = Permission::RevokeApiToken; + let auth = creds.validate(&mut conn, &req, permission).await?; let user = auth.user(); + diesel::update(ApiToken::belonging_to(user).find(id)) .set(api_tokens::revoked.eq(true)) .execute(&mut conn) @@ -271,14 +278,18 @@ pub async fn revoke_api_token( tag = "api_tokens", responses((status = 204, description = "Successful Response")), )] -pub async fn revoke_current_api_token(app: AppState, req: Parts) -> AppResult { +pub async fn revoke_current_api_token( + app: AppState, + creds: ApiTokenCredentials, + req: Parts, +) -> AppResult { let mut conn = app.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; - let api_token_id = auth - .api_token_id() - .ok_or_else(|| bad_request("token not provided"))?; - diesel::update(api_tokens::table.filter(api_tokens::id.eq(api_token_id))) + let permission = Permission::RevokeCurrentApiToken; + let auth = creds.validate(&mut conn, &req, permission).await?; + let api_token = auth.api_token(); + + diesel::update(api_tokens::table.filter(api_tokens::id.eq(api_token.id))) .set(api_tokens::revoked.eq(true)) .execute(&mut conn) .await?; diff --git a/src/controllers/trustpub/github_configs/create/mod.rs b/src/controllers/trustpub/github_configs/create/mod.rs index d7fffb6c1c3..b15e5456e81 100644 --- a/src/controllers/trustpub/github_configs/create/mod.rs +++ b/src/controllers/trustpub/github_configs/create/mod.rs @@ -1,9 +1,9 @@ use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{CookieCredentials, Permission}; use crate::controllers::krate::load_crate; use crate::controllers::trustpub::github_configs::emails::ConfigCreatedEmail; use crate::controllers::trustpub::github_configs::json; -use crate::util::errors::{AppResult, bad_request, forbidden}; +use crate::util::errors::{AppResult, bad_request}; use axum::Json; use crates_io_database::models::OwnerKind; use crates_io_database::models::trustpub::NewGitHubConfig; @@ -32,6 +32,7 @@ mod tests; )] pub async fn create_trustpub_github_config( state: AppState, + creds: CookieCredentials, parts: Parts, json: json::CreateRequest, ) -> AppResult> { @@ -46,9 +47,6 @@ pub async fn create_trustpub_github_config( let mut conn = state.db_write().await?; - let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; - let auth_user = auth.user(); - let krate = load_crate(&mut conn, &json_config.krate).await?; let user_owners = crate_owners::table @@ -61,15 +59,11 @@ pub async fn create_trustpub_github_config( .load::<(i32, String, String, bool)>(&mut conn) .await?; - let (_, _, _, email_verified) = user_owners - .iter() - .find(|(id, _, _, _)| *id == auth_user.id) - .ok_or_else(|| bad_request("You are not an owner of this crate"))?; + let user_owner_ids = user_owners.iter().map(|(id, _, _, _)| *id).collect(); - if !email_verified { - let message = "You must verify your email address to create a Trusted Publishing config"; - return Err(forbidden(message)); - } + let permission = Permission::CreateTrustPubGitHubConfig { user_owner_ids }; + let auth = creds.validate(&mut conn, &parts, permission).await?; + let auth_user = auth.user(); // Lookup `repository_owner_id` via GitHub API diff --git a/src/controllers/trustpub/github_configs/delete/mod.rs b/src/controllers/trustpub/github_configs/delete/mod.rs index 51b0e533529..9ed53710780 100644 --- a/src/controllers/trustpub/github_configs/delete/mod.rs +++ b/src/controllers/trustpub/github_configs/delete/mod.rs @@ -1,7 +1,7 @@ use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{CookieCredentials, Permission}; use crate::controllers::trustpub::github_configs::emails::ConfigDeletedEmail; -use crate::util::errors::{AppResult, bad_request, not_found}; +use crate::util::errors::{AppResult, not_found}; use axum::extract::Path; use crates_io_database::models::OwnerKind; use crates_io_database::models::trustpub::GitHubConfig; @@ -28,13 +28,11 @@ mod tests; pub async fn delete_trustpub_github_config( state: AppState, Path(id): Path, + creds: CookieCredentials, parts: Parts, ) -> AppResult { let mut conn = state.db_write().await?; - let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; - let auth_user = auth.user(); - // Check that a trusted publishing config with the given ID exists, // and fetch the corresponding crate ID and name. let (config, crate_name) = trustpub_configs_github::table @@ -57,10 +55,11 @@ pub async fn delete_trustpub_github_config( .load::<(i32, String, String, bool)>(&mut conn) .await?; - // Check if the authenticated user is an owner of the crate - if !user_owners.iter().any(|owner| owner.0 == auth_user.id) { - return Err(bad_request("You are not an owner of this crate")); - } + let user_owner_ids = user_owners.iter().map(|(id, _, _, _)| *id).collect(); + + let permission = Permission::DeleteTrustPubGitHubConfig { user_owner_ids }; + let auth = creds.validate(&mut conn, &parts, permission).await?; + let auth_user = auth.user(); // Delete the configuration from the database diesel::delete(trustpub_configs_github::table.filter(trustpub_configs_github::id.eq(id))) diff --git a/src/controllers/trustpub/github_configs/list/mod.rs b/src/controllers/trustpub/github_configs/list/mod.rs index 59c08934fcd..bd4cd5f3852 100644 --- a/src/controllers/trustpub/github_configs/list/mod.rs +++ b/src/controllers/trustpub/github_configs/list/mod.rs @@ -1,14 +1,12 @@ use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{CookieCredentials, Permission}; use crate::controllers::krate::load_crate; use crate::controllers::trustpub::github_configs::json::{self, ListResponse}; -use crate::util::errors::{AppResult, bad_request}; +use crate::util::errors::AppResult; use axum::Json; use axum::extract::{FromRequestParts, Query}; -use crates_io_database::models::OwnerKind; use crates_io_database::models::trustpub::GitHubConfig; -use crates_io_database::schema::{crate_owners, trustpub_configs_github}; -use diesel::dsl::{exists, select}; +use crates_io_database::schema::trustpub_configs_github; use diesel::prelude::*; use diesel_async::RunQueryDsl; use http::request::Parts; @@ -37,29 +35,15 @@ pub struct ListQueryParams { pub async fn list_trustpub_github_configs( state: AppState, params: ListQueryParams, + creds: CookieCredentials, parts: Parts, ) -> AppResult> { let mut conn = state.db_read().await?; - let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; - let auth_user = auth.user(); - let krate = load_crate(&mut conn, ¶ms.krate).await?; - // Check if the authenticated user is an owner of the crate - let is_owner = select(exists( - crate_owners::table - .filter(crate_owners::crate_id.eq(krate.id)) - .filter(crate_owners::deleted.eq(false)) - .filter(crate_owners::owner_kind.eq(OwnerKind::User)) - .filter(crate_owners::owner_id.eq(auth_user.id)), - )) - .get_result::(&mut conn) - .await?; - - if !is_owner { - return Err(bad_request("You are not an owner of this crate")); - } + let permission = Permission::ListTrustPubGitHubConfigs { krate: &krate }; + creds.validate(&mut conn, &parts, permission).await?; let configs = trustpub_configs_github::table .filter(trustpub_configs_github::crate_id.eq(krate.id)) diff --git a/src/controllers/trustpub/tokens/revoke/mod.rs b/src/controllers/trustpub/tokens/revoke/mod.rs index b0d79b2eb45..03723060566 100644 --- a/src/controllers/trustpub/tokens/revoke/mod.rs +++ b/src/controllers/trustpub/tokens/revoke/mod.rs @@ -1,12 +1,10 @@ use crate::app::AppState; -use crate::auth::AuthHeader; -use crate::util::errors::{AppResult, custom}; +use crate::auth::TrustPubCredentials; +use crate::util::errors::AppResult; use crates_io_database::schema::trustpub_tokens; -use crates_io_trustpub::access_token::AccessToken; use diesel::prelude::*; use diesel_async::RunQueryDsl; use http::StatusCode; -use secrecy::ExposeSecret; #[cfg(test)] mod tests; @@ -22,13 +20,11 @@ mod tests; tag = "trusted_publishing", responses((status = 204, description = "Successful Response")), )] -pub async fn revoke_trustpub_token(app: AppState, auth: AuthHeader) -> AppResult { - let token = auth.token().expose_secret(); - let Ok(token) = token.parse::() else { - let message = "Invalid `Authorization` header: Failed to parse token"; - return Err(custom(StatusCode::UNAUTHORIZED, message)); - }; - +pub async fn revoke_trustpub_token( + app: AppState, + creds: TrustPubCredentials, +) -> AppResult { + let token = creds.unvalidated_token(); let hashed_token = token.sha256(); let mut conn = app.db_write().await?; diff --git a/src/controllers/user/email_notifications.rs b/src/controllers/user/email_notifications.rs index baf6e760c6e..7b910440651 100644 --- a/src/controllers/user/email_notifications.rs +++ b/src/controllers/user/email_notifications.rs @@ -1,5 +1,5 @@ use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{Permission, UserCredentials}; use crate::controllers::helpers::OkResponse; use crate::models::{CrateOwner, OwnerKind}; use crate::schema::crate_owners; @@ -33,6 +33,7 @@ pub struct CrateEmailNotifications { #[deprecated] pub async fn update_email_notifications( app: AppState, + creds: UserCredentials, parts: Parts, Json(updates): Json>, ) -> AppResult { @@ -44,10 +45,10 @@ pub async fn update_email_notifications( .collect(); let mut conn = app.db_write().await?; - let user_id = AuthCheck::default() - .check(&parts, &mut conn) - .await? - .user_id(); + + let permission = Permission::UpdateEmailNotifications; + let auth = creds.validate(&mut conn, &parts, permission).await?; + let user_id = auth.user_id(); // Build inserts from existing crates belonging to the current user let to_insert = CrateOwner::by_owner_kind(OwnerKind::User) diff --git a/src/controllers/user/email_verification.rs b/src/controllers/user/email_verification.rs index 293d557ed5f..d7366424364 100644 --- a/src/controllers/user/email_verification.rs +++ b/src/controllers/user/email_verification.rs @@ -1,6 +1,6 @@ use super::update::UserConfirmEmail; use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{Permission, UserCredentials}; use crate::controllers::helpers::OkResponse; use crate::models::Email; use crate::util::errors::AppResult; @@ -58,10 +58,13 @@ pub async fn confirm_user_email( pub async fn resend_email_verification( state: AppState, Path(param_user_id): Path, + creds: UserCredentials, req: Parts, ) -> AppResult { let mut conn = state.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; + + let permission = Permission::ResendEmailVerification; + let auth = creds.validate(&mut conn, &req, permission).await?; // need to check if current user matches user to be updated if auth.user_id() != param_user_id { diff --git a/src/controllers/user/me.rs b/src/controllers/user/me.rs index 1b5989e77b1..ab22364eddc 100644 --- a/src/controllers/user/me.rs +++ b/src/controllers/user/me.rs @@ -1,4 +1,3 @@ -use crate::auth::AuthCheck; use axum::Json; use diesel::prelude::*; use diesel_async::RunQueryDsl; @@ -6,6 +5,7 @@ use futures_util::FutureExt; use http::request::Parts; use crate::app::AppState; +use crate::auth::{CookieCredentials, Permission}; use crate::controllers::helpers::Paginate; use crate::controllers::helpers::pagination::{Paginated, PaginationOptions}; use crate::models::krate::CrateName; @@ -22,12 +22,16 @@ use crate::views::{EncodableMe, EncodablePrivateUser, EncodableVersion, OwnedCra tag = "users", responses((status = 200, description = "Successful Response", body = inline(EncodableMe))), )] -pub async fn get_authenticated_user(app: AppState, req: Parts) -> AppResult> { +pub async fn get_authenticated_user( + app: AppState, + creds: CookieCredentials, + req: Parts, +) -> AppResult> { let mut conn = app.db_read_prefer_primary().await?; - let user_id = AuthCheck::only_cookie() - .check(&req, &mut conn) - .await? - .user_id(); + + let permission = Permission::ReadUser; + let auth = creds.validate(&mut conn, &req, permission).await?; + let user_id = auth.user_id(); let ((user, verified, email, verification_sent), owned_crates) = tokio::try_join!( users::table @@ -92,11 +96,13 @@ pub struct UpdatesResponseMeta { )] pub async fn get_authenticated_user_updates( app: AppState, + creds: CookieCredentials, req: Parts, ) -> AppResult> { let mut conn = app.db_read_prefer_primary().await?; - let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; + let permission = Permission::ListUpdates; + let auth = creds.validate(&mut conn, &req, permission).await?; let user = auth.user(); let followed_crates = Follow::belonging_to(user).select(follows::crate_id); diff --git a/src/controllers/user/update.rs b/src/controllers/user/update.rs index eb956c6b556..7a0399f220d 100644 --- a/src/controllers/user/update.rs +++ b/src/controllers/user/update.rs @@ -1,5 +1,5 @@ use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{Permission, UserCredentials}; use crate::controllers::helpers::OkResponse; use crate::models::NewEmail; use crate::schema::users; @@ -44,12 +44,14 @@ pub struct User { pub async fn update_user( state: AppState, Path(param_user_id): Path, + creds: UserCredentials, req: Parts, Json(user_update): Json, ) -> AppResult { let mut conn = state.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; + let permission = Permission::UpdateUser; + let auth = creds.validate(&mut conn, &req, permission).await?; let user = auth.user(); // need to check if current user matches user to be updated diff --git a/src/controllers/version/docs.rs b/src/controllers/version/docs.rs index f6d907e6a05..dedf5acc3e0 100644 --- a/src/controllers/version/docs.rs +++ b/src/controllers/version/docs.rs @@ -2,9 +2,8 @@ use super::CrateVersionPath; use crate::app::AppState; -use crate::auth::AuthCheck; -use crate::controllers::helpers::authorization::Rights; -use crate::util::errors::{AppResult, custom, server_error}; +use crate::auth::{CookieCredentials, Permission}; +use crate::util::errors::{AppResult, server_error}; use crate::worker::jobs; use crates_io_worker::BackgroundJob as _; use http::StatusCode; @@ -24,23 +23,16 @@ use http::request::Parts; pub async fn rebuild_version_docs( app: AppState, path: CrateVersionPath, + creds: CookieCredentials, req: Parts, ) -> AppResult { let mut conn = app.db_write().await?; - let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; // validate if version & crate exist - let (_, krate) = path.load_version_and_crate(&mut conn).await?; - - // Check that the user is an owner of the crate, or a team member (= publish rights) - let user = auth.user(); - let owners = krate.owners(&mut conn).await?; - if Rights::get(user, &*app.github, &owners).await? < Rights::Publish { - return Err(custom( - StatusCode::FORBIDDEN, - "user doesn't have permission to trigger a docs rebuild", - )); - } + let (_, ref krate) = path.load_version_and_crate(&mut conn).await?; + + let permission = Permission::RebuildDocs { krate }; + creds.validate(&mut conn, &req, permission).await?; let job = jobs::DocsRsQueueRebuild::new(path.name, path.version); job.enqueue(&mut conn).await.map_err(|error| { diff --git a/src/controllers/version/update.rs b/src/controllers/version/update.rs index 272bb0517d2..e747c4e73ab 100644 --- a/src/controllers/version/update.rs +++ b/src/controllers/version/update.rs @@ -1,19 +1,17 @@ use super::CrateVersionPath; use crate::app::AppState; -use crate::auth::{AuthCheck, Authentication}; -use crate::controllers::helpers::authorization::Rights; -use crate::models::token::EndpointScope; +use crate::auth::{AuthorizedUser, Permission, UserCredentials}; use crate::models::{Crate, NewVersionOwnerAction, Version, VersionAction, VersionOwnerAction}; use crate::rate_limiter::LimitedAction; use crate::schema::versions; -use crate::util::errors::{AppResult, bad_request, custom}; +use crate::util::errors::{AppResult, bad_request}; use crate::views::EncodableVersion; use crate::worker::jobs::{SyncToGitIndex, SyncToSparseIndex, UpdateDefaultVersion}; use axum::Json; +use crates_io_database::models::ApiToken; use crates_io_worker::BackgroundJob; use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; -use http::StatusCode; use http::request::Parts; use serde::Deserialize; @@ -49,13 +47,16 @@ pub struct UpdateResponse { pub async fn update_version( state: AppState, path: CrateVersionPath, + creds: UserCredentials, req: Parts, Json(update_request): Json, ) -> AppResult> { let mut conn = state.db_write().await?; let (mut version, krate) = path.load_version_and_crate(&mut conn).await?; validate_yank_update(&update_request.version, &version)?; - let auth = authenticate(&req, &mut conn, &krate.name).await?; + + let permission = Permission::UpdateVersion { krate: &krate }; + let auth = creds.validate(&mut conn, &req, permission).await?; state .rate_limiter @@ -63,7 +64,6 @@ pub async fn update_version( .await?; perform_version_yank_update( - &state, &mut conn, &mut version, &krate, @@ -97,48 +97,19 @@ fn validate_yank_update(update_data: &VersionUpdate, version: &Version) -> AppRe Ok(()) } -pub async fn authenticate( - req: &Parts, - conn: &mut AsyncPgConnection, - name: &str, -) -> AppResult { - AuthCheck::default() - .with_endpoint_scope(EndpointScope::Yank) - .for_crate(name) - .check(req, conn) - .await -} - pub async fn perform_version_yank_update( - state: &AppState, conn: &mut AsyncPgConnection, version: &mut Version, krate: &Crate, - auth: &Authentication, + auth: &AuthorizedUser>, yanked: Option, yank_message: Option, ) -> AppResult<()> { let api_token_id = auth.api_token_id(); let user = auth.user(); - let owners = krate.owners(conn).await?; let yanked = yanked.unwrap_or(version.yanked); - if Rights::get(user, &*state.github, &owners).await? < Rights::Publish { - if user.is_admin { - let action = if yanked { "yanking" } else { "unyanking" }; - warn!( - "Admin {} is {action} {}@{}", - user.gh_login, krate.name, version.num - ); - } else { - return Err(custom( - StatusCode::FORBIDDEN, - "must already be an owner to yank or unyank", - )); - } - } - // Check if the yanked state or yank message has changed and update if necessary let updated_cnt = diesel::update( versions::table.find(version.id).filter( diff --git a/src/controllers/version/yank.rs b/src/controllers/version/yank.rs index c18531606ee..ad0146b3010 100644 --- a/src/controllers/version/yank.rs +++ b/src/controllers/version/yank.rs @@ -1,11 +1,13 @@ //! Endpoints for yanking and unyanking specific versions of crates use super::CrateVersionPath; -use super::update::{authenticate, perform_version_yank_update}; +use super::update::perform_version_yank_update; use crate::app::AppState; +use crate::auth::{Permission, UserCredentials}; use crate::controllers::helpers::OkResponse; use crate::rate_limiter::LimitedAction; use crate::util::errors::AppResult; +use axum::RequestPartsExt; use http::request::Parts; /// Yank a crate version. @@ -62,7 +64,7 @@ pub async fn unyank_version( async fn modify_yank( path: CrateVersionPath, state: AppState, - req: Parts, + mut req: Parts, yanked: bool, ) -> AppResult { // FIXME: Should reject bad requests before authentication, but can't due to @@ -70,23 +72,20 @@ async fn modify_yank( let mut conn = state.db_write().await?; let (mut version, krate) = path.load_version_and_crate(&mut conn).await?; - let auth = authenticate(&req, &mut conn, &krate.name).await?; + + let creds = req.extract::().await?; + let permission = match yanked { + true => Permission::YankVersion { krate: &krate }, + false => Permission::UnyankVersion { krate: &krate }, + }; + let auth = creds.validate(&mut conn, &req, permission).await?; state .rate_limiter .check_rate_limit(auth.user_id(), LimitedAction::YankUnyank, &mut conn) .await?; - perform_version_yank_update( - &state, - &mut conn, - &mut version, - &krate, - &auth, - Some(yanked), - None, - ) - .await?; + perform_version_yank_update(&mut conn, &mut version, &krate, &auth, Some(yanked), None).await?; Ok(OkResponse::new()) } diff --git a/src/tests/krate/publish/trustpub.rs b/src/tests/krate/publish/trustpub.rs index 1f180f69dcc..0d9c06041d5 100644 --- a/src/tests/krate/publish/trustpub.rs +++ b/src/tests/krate/publish/trustpub.rs @@ -297,7 +297,7 @@ async fn test_non_existent_token_with_new_crate() -> anyhow::Result<()> { let pb = PublishBuilder::new("foo", "1.0.0"); let response = oidc_token_client.publish_crate(pb).await; assert_snapshot!(response.status(), @"403 Forbidden"); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Trusted Publishing tokens do not support creating new crates"}]}"#); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid authentication token"}]}"#); Ok(()) } diff --git a/src/tests/routes/me/tokens/create.rs b/src/tests/routes/me/tokens/create.rs index 856c8f18e5d..b7a2039e750 100644 --- a/src/tests/routes/me/tokens/create.rs +++ b/src/tests/routes/me/tokens/create.rs @@ -116,8 +116,8 @@ async fn cannot_create_token_with_token() { br#"{ "api_token": { "name": "baz" } }"# as &[u8], ) .await; - assert_snapshot!(response.status(), @"400 Bad Request"); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"cannot use an API token to create a new API token"}]}"#); + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action can only be performed on the crates.io website"}]}"#); assert!(app.emails().await.is_empty()); } diff --git a/src/tests/routes/me/tokens/delete_current.rs b/src/tests/routes/me/tokens/delete_current.rs index 6db821fb85a..34fc7347ec2 100644 --- a/src/tests/routes/me/tokens/delete_current.rs +++ b/src/tests/routes/me/tokens/delete_current.rs @@ -43,8 +43,8 @@ async fn revoke_current_token_without_auth() { let (_, anon) = TestApp::init().empty().await; let response = anon.delete::<()>("/api/v1/tokens/current").await; - assert_snapshot!(response.status(), @"403 Forbidden"); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"token not provided"}]}"#); } #[tokio::test(flavor = "multi_thread")]