diff --git a/src/controllers/token.rs b/src/controllers/token.rs index 8dccba98831..83d80b20bd8 100644 --- a/src/controllers/token.rs +++ b/src/controllers/token.rs @@ -7,6 +7,7 @@ use crate::app::AppState; use crate::auth::AuthCheck; use crate::models::token::{CrateScope, EndpointScope}; use crate::util::errors::{bad_request, AppResult}; +use crate::util::token::PlainToken; use axum::extract::{Path, Query}; use axum::response::{IntoResponse, Response}; use axum::Json; @@ -19,6 +20,7 @@ use diesel::prelude::*; use diesel_async::RunQueryDsl; use http::request::Parts; use http::StatusCode; +use secrecy::ExposeSecret; #[derive(Deserialize)] pub struct GetParams { @@ -148,15 +150,16 @@ pub async fn create_api_token( let recipient = user.email(&mut conn).await?; - let api_token = ApiToken::insert_with_scopes( - &mut conn, - user.id, - &new.api_token.name, - crate_scopes, - endpoint_scopes, - new.api_token.expired_at, - ) - .await?; + let plaintext = PlainToken::generate(); + + let new_token = crate::models::token::NewApiToken::builder() + .user_id(user.id) + .name(&new.api_token.name) + .token(plaintext.hashed()) + .maybe_crate_scopes(crate_scopes) + .maybe_endpoint_scopes(endpoint_scopes) + .maybe_expired_at(new.api_token.expired_at) + .build(); if let Some(recipient) = recipient { let email = NewTokenEmail { @@ -174,7 +177,10 @@ pub async fn create_api_token( } } - let api_token = EncodableApiTokenWithToken::from(api_token); + let api_token = EncodableApiTokenWithToken { + token: new_token.insert(&mut conn).await?, + plaintext: plaintext.expose_secret().to_string(), + }; Ok(json!({ "api_token": api_token })) } diff --git a/src/models.rs b/src/models.rs index 5e71967d866..6c44a052356 100644 --- a/src/models.rs +++ b/src/models.rs @@ -14,7 +14,7 @@ pub use self::krate::{Crate, CrateName, NewCrate, RecentCrateDownloads}; pub use self::owner::{CrateOwner, Owner, OwnerKind}; pub use self::rights::Rights; pub use self::team::{NewTeam, Team}; -pub use self::token::{ApiToken, CreatedApiToken}; +pub use self::token::ApiToken; pub use self::user::{NewUser, User}; pub use self::version::{NewVersion, TopVersions, Version}; diff --git a/src/models/token.rs b/src/models/token.rs index bc1d320a558..231a634982d 100644 --- a/src/models/token.rs +++ b/src/models/token.rs @@ -1,5 +1,6 @@ mod scopes; +use bon::Builder; use chrono::NaiveDateTime; use diesel::dsl::now; use diesel::prelude::*; @@ -12,6 +13,31 @@ use crate::schema::api_tokens; use crate::util::rfc3339; use crate::util::token::{HashedToken, PlainToken}; +#[derive(Debug, Insertable, Builder)] +#[diesel(table_name = api_tokens, check_for_backend(diesel::pg::Pg))] +pub struct NewApiToken { + pub user_id: i32, + #[builder(into)] + pub name: String, + #[builder(default = PlainToken::generate().hashed())] + pub token: HashedToken, + /// `None` or a list of crate scope patterns (see RFC #2947) + pub crate_scopes: Option>, + /// A list of endpoint scopes or `None` for the `legacy` endpoint scope (see RFC #2947) + pub endpoint_scopes: Option>, + pub expired_at: Option, +} + +impl NewApiToken { + pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult { + diesel::insert_into(api_tokens::table) + .values(self) + .returning(ApiToken::as_returning()) + .get_result(conn) + .await + } +} + /// The model representing a row in the `api_tokens` database table. #[derive(Debug, Identifiable, Queryable, Selectable, Associations, Serialize)] #[diesel(belongs_to(User))] @@ -35,44 +61,6 @@ pub struct ApiToken { } impl ApiToken { - /// Generates a new named API token for a user - pub async fn insert( - conn: &mut AsyncPgConnection, - user_id: i32, - name: &str, - ) -> QueryResult { - Self::insert_with_scopes(conn, user_id, name, None, None, None).await - } - - pub async fn insert_with_scopes( - conn: &mut AsyncPgConnection, - user_id: i32, - name: &str, - crate_scopes: Option>, - endpoint_scopes: Option>, - expired_at: Option, - ) -> QueryResult { - let token = PlainToken::generate(); - - let model: ApiToken = diesel::insert_into(api_tokens::table) - .values(( - api_tokens::user_id.eq(user_id), - api_tokens::name.eq(name), - api_tokens::token.eq(token.hashed()), - api_tokens::crate_scopes.eq(crate_scopes), - api_tokens::endpoint_scopes.eq(endpoint_scopes), - api_tokens::expired_at.eq(expired_at), - )) - .returning(ApiToken::as_returning()) - .get_result(conn) - .await?; - - Ok(CreatedApiToken { - plaintext: token, - model, - }) - } - pub async fn find_by_api_token( conn: &mut AsyncPgConnection, token: &HashedToken, @@ -107,12 +95,6 @@ impl ApiToken { } } -#[derive(Debug)] -pub struct CreatedApiToken { - pub model: ApiToken, - pub plaintext: PlainToken, -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/tests/routes/me/tokens/create.rs b/src/tests/routes/me/tokens/create.rs index 529295631f4..6ca675ed6d4 100644 --- a/src/tests/routes/me/tokens/create.rs +++ b/src/tests/routes/me/tokens/create.rs @@ -1,4 +1,4 @@ -use crate::models::token::{CrateScope, EndpointScope}; +use crate::models::token::{CrateScope, EndpointScope, NewApiToken}; use crate::models::ApiToken; use crate::tests::util::insta::{self, assert_json_snapshot}; use crate::tests::util::{RequestHelper, TestApp}; @@ -46,7 +46,9 @@ async fn create_token_exceeded_tokens_per_user() { let id = user.as_model().id; for i in 0..1000 { - assert_ok!(ApiToken::insert(&mut conn, id, &format!("token {i}")).await); + let name = format!("token {i}"); + let new_token = NewApiToken::builder().name(name).user_id(id).build(); + assert_ok!(new_token.insert(&mut conn).await); } let response = user.put::<()>("/api/v1/me/tokens", NEW_BAR).await; diff --git a/src/tests/routes/me/tokens/get.rs b/src/tests/routes/me/tokens/get.rs index 56d06be1466..110fcc02c61 100644 --- a/src/tests/routes/me/tokens/get.rs +++ b/src/tests/routes/me/tokens/get.rs @@ -1,5 +1,4 @@ -use crate::models::token::{CrateScope, EndpointScope}; -use crate::models::ApiToken; +use crate::models::token::{CrateScope, EndpointScope, NewApiToken}; use crate::tests::util::{RequestHelper, TestApp}; use chrono::{Duration, Utc}; use http::StatusCode; @@ -31,23 +30,22 @@ async fn show_token_with_scopes() { let user_model = user.as_model(); let id = user_model.id; - assert_ok!(ApiToken::insert(&mut conn, id, "bar").await); - let token = assert_ok!( - ApiToken::insert_with_scopes( - &mut conn, - id, - "baz", - Some(vec![ - CrateScope::try_from("serde").unwrap(), - CrateScope::try_from("serde-*").unwrap() - ]), - Some(vec![EndpointScope::PublishUpdate]), - Some((Utc::now() - Duration::days(31)).naive_utc()), - ) - .await - ); + let new_token = NewApiToken::builder().name("bar").user_id(id).build(); + assert_ok!(new_token.insert(&mut conn).await); - let url = format!("/api/v1/me/tokens/{}", token.model.id); + let new_token = NewApiToken::builder() + .name("baz") + .user_id(id) + .crate_scopes(vec![ + CrateScope::try_from("serde").unwrap(), + CrateScope::try_from("serde-*").unwrap(), + ]) + .endpoint_scopes(vec![EndpointScope::PublishUpdate]) + .expired_at((Utc::now() - Duration::days(31)).naive_utc()) + .build(); + let token = assert_ok!(new_token.insert(&mut conn).await); + + let url = format!("/api/v1/me/tokens/{}", token.id); let response = user.get::<()>(&url).await; assert_eq!(response.status(), StatusCode::OK); assert_json_snapshot!(response.json(), { @@ -70,9 +68,10 @@ async fn show_other_user_token() { let user2 = app.db_new_user("baz").await; let user2 = user2.as_model(); - let token = assert_ok!(ApiToken::insert(&mut conn, user2.id, "bar").await); + let new_token = NewApiToken::builder().name("bar").user_id(user2.id).build(); + let token = assert_ok!(new_token.insert(&mut conn).await); - let url = format!("/api/v1/me/tokens/{}", token.model.id); + let url = format!("/api/v1/me/tokens/{}", token.id); let response = user1.get::<()>(&url).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } diff --git a/src/tests/routes/me/tokens/list.rs b/src/tests/routes/me/tokens/list.rs index ec1eb6574c0..56f46e4cb2f 100644 --- a/src/tests/routes/me/tokens/list.rs +++ b/src/tests/routes/me/tokens/list.rs @@ -1,5 +1,4 @@ -use crate::models::token::{CrateScope, EndpointScope}; -use crate::models::ApiToken; +use crate::models::token::{CrateScope, EndpointScope, NewApiToken}; use crate::tests::util::insta::{self, assert_json_snapshot}; use crate::tests::util::{RequestHelper, TestApp}; use chrono::{Duration, Utc}; @@ -33,32 +32,26 @@ async fn list_tokens() { let mut conn = app.db_conn().await; let id = user.as_model().id; - assert_ok!(ApiToken::insert(&mut conn, id, "bar").await); - assert_ok!( - ApiToken::insert_with_scopes( - &mut conn, - id, - "baz", - Some(vec![ - CrateScope::try_from("serde").unwrap(), - CrateScope::try_from("serde-*").unwrap() - ]), - Some(vec![EndpointScope::PublishUpdate]), - None - ) - .await - ); - assert_ok!( - ApiToken::insert_with_scopes( - &mut conn, - id, - "qux", - None, - None, - Some((Utc::now() - Duration::days(1)).naive_utc()), - ) - .await - ); + let new_token = NewApiToken::builder().name("bar").user_id(id).build(); + assert_ok!(new_token.insert(&mut conn).await); + + let new_token = NewApiToken::builder() + .name("baz") + .user_id(id) + .crate_scopes(vec![ + CrateScope::try_from("serde").unwrap(), + CrateScope::try_from("serde-*").unwrap(), + ]) + .endpoint_scopes(vec![EndpointScope::PublishUpdate]) + .build(); + assert_ok!(new_token.insert(&mut conn).await); + + let new_token = NewApiToken::builder() + .name("qux") + .user_id(id) + .expired_at((Utc::now() - Duration::days(1)).naive_utc()) + .build(); + assert_ok!(new_token.insert(&mut conn).await); let response = user.get::<()>("/api/v1/me/tokens").await; assert_eq!(response.status(), StatusCode::OK); @@ -80,32 +73,27 @@ async fn list_recently_expired_tokens() { let mut conn = app.db_conn().await; let id = user.as_model().id; - assert_ok!(ApiToken::insert(&mut conn, id, "bar").await); - assert_ok!( - ApiToken::insert_with_scopes( - &mut conn, - id, - "ancient", - Some(vec![ - CrateScope::try_from("serde").unwrap(), - CrateScope::try_from("serde-*").unwrap() - ]), - Some(vec![EndpointScope::PublishUpdate]), - Some((Utc::now() - Duration::days(31)).naive_utc()), - ) - .await - ); - assert_ok!( - ApiToken::insert_with_scopes( - &mut conn, - id, - "recent", - None, - None, - Some((Utc::now() - Duration::days(1)).naive_utc()), - ) - .await - ); + let new_token = NewApiToken::builder().name("bar").user_id(id).build(); + assert_ok!(new_token.insert(&mut conn).await); + + let new_token = NewApiToken::builder() + .name("ancient") + .user_id(id) + .crate_scopes(vec![ + CrateScope::try_from("serde").unwrap(), + CrateScope::try_from("serde-*").unwrap(), + ]) + .endpoint_scopes(vec![EndpointScope::PublishUpdate]) + .expired_at((Utc::now() - Duration::days(31)).naive_utc()) + .build(); + assert_ok!(new_token.insert(&mut conn).await); + + let new_token = NewApiToken::builder() + .name("recent") + .user_id(id) + .expired_at((Utc::now() - Duration::days(1)).naive_utc()) + .build(); + assert_ok!(new_token.insert(&mut conn).await); let response = user.get::<()>("/api/v1/me/tokens?expired_days=30").await; assert_eq!(response.status(), StatusCode::OK); @@ -131,8 +119,11 @@ async fn list_tokens_exclude_revoked() { let mut conn = app.db_conn().await; let id = user.as_model().id; - let token1 = assert_ok!(ApiToken::insert(&mut conn, id, "bar").await); - assert_ok!(ApiToken::insert(&mut conn, id, "baz").await); + let new_token = NewApiToken::builder().name("bar").user_id(id).build(); + let token1 = assert_ok!(new_token.insert(&mut conn).await); + + let new_token = NewApiToken::builder().name("baz").user_id(id).build(); + assert_ok!(new_token.insert(&mut conn).await); // List tokens expecting them all to be there. let response = user.get::<()>("/api/v1/me/tokens").await; @@ -143,7 +134,7 @@ async fn list_tokens_exclude_revoked() { // Revoke the first token. let response = user - .delete::<()>(&format!("/api/v1/me/tokens/{}", token1.model.id)) + .delete::<()>(&format!("/api/v1/me/tokens/{}", token1.id)) .await; assert_eq!(response.status(), StatusCode::OK); diff --git a/src/tests/util.rs b/src/tests/util.rs index 7491e9f8578..dc7053c8d61 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -19,7 +19,7 @@ //! `MockCookieUser` and `MockTokenUser` provide an `as_model` function which returns a reference //! to the underlying database model value (`User` and `ApiToken` respectively). -use crate::models::{ApiToken, CreatedApiToken, User}; +use crate::models::{ApiToken, User}; use crate::tests::{ CategoryListResponse, CategoryResponse, CrateList, CrateResponse, GoodCrate, OwnerResp, OwnersResponse, VersionResponse, @@ -28,7 +28,7 @@ use std::future::Future; use http::{Method, Request}; -use crate::models::token::{CrateScope, EndpointScope}; +use crate::models::token::{CrateScope, EndpointScope, NewApiToken}; use crate::util::token::PlainToken; use axum::body::{Body, Bytes}; use axum::extract::connect_info::MockConnectInfo; @@ -320,20 +320,23 @@ impl MockCookieUser { ) -> MockTokenUser { let mut conn = self.app().db_conn().await; - let token = ApiToken::insert_with_scopes( - &mut conn, - self.user.id, - name, - crate_scopes, - endpoint_scopes, - expired_at, - ) - .await - .unwrap(); + let plaintext = PlainToken::generate(); + + let new_token = NewApiToken::builder() + .user_id(self.user.id) + .name(name) + .token(plaintext.hashed()) + .maybe_crate_scopes(crate_scopes) + .maybe_endpoint_scopes(endpoint_scopes) + .maybe_expired_at(expired_at) + .build(); + + let token = new_token.insert(&mut conn).await.unwrap(); MockTokenUser { app: self.app.clone(), token, + plaintext, } } } @@ -341,13 +344,14 @@ impl MockCookieUser { /// A type that can generate token authenticated requests pub struct MockTokenUser { app: TestApp, - token: CreatedApiToken, + token: ApiToken, + plaintext: PlainToken, } impl RequestHelper for MockTokenUser { fn request_builder(&self, method: Method, path: &str) -> MockRequest { let mut request = req(method, path); - request.header(header::AUTHORIZATION, self.token.plaintext.expose_secret()); + request.header(header::AUTHORIZATION, self.plaintext.expose_secret()); request } @@ -359,10 +363,10 @@ impl RequestHelper for MockTokenUser { impl MockTokenUser { /// Returns a reference to the database `ApiToken` model pub fn as_model(&self) -> &ApiToken { - &self.token.model + &self.token } pub fn plaintext(&self) -> &PlainToken { - &self.token.plaintext + &self.plaintext } } diff --git a/src/views.rs b/src/views.rs index 239699a9dce..72492d664d3 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1,11 +1,9 @@ use chrono::NaiveDateTime; -use secrecy::ExposeSecret; use crate::external_urls::remove_blocked_urls; use crate::models::{ - ApiToken, Category, Crate, CrateOwnerInvitation, CreatedApiToken, Dependency, DependencyKind, - Keyword, Owner, ReverseDependency, Team, TopVersions, User, Version, VersionDownload, - VersionOwnerAction, + ApiToken, Category, Crate, CrateOwnerInvitation, Dependency, DependencyKind, Keyword, Owner, + ReverseDependency, Team, TopVersions, User, Version, VersionDownload, VersionOwnerAction, }; use crate::util::rfc3339; use crates_io_github as github; @@ -445,15 +443,6 @@ pub struct EncodableApiTokenWithToken { pub plaintext: String, } -impl From for EncodableApiTokenWithToken { - fn from(token: CreatedApiToken) -> Self { - EncodableApiTokenWithToken { - token: token.model, - plaintext: token.plaintext.expose_secret().to_string(), - } - } -} - #[derive(Deserialize, Serialize, Debug)] pub struct OwnedCrate { pub id: i32,