From 0202985e22257cee035d50e342318dea08912782 Mon Sep 17 00:00:00 2001 From: DSeeLP <46624152+DSeeLP@users.noreply.github.com> Date: Sun, 6 Apr 2025 20:07:27 +0200 Subject: [PATCH] implement password changes --- bank_core/src/util.rs | 47 +++++++++++++++++++++------- openapi-def.yaml | 21 +++++++++++++ src/api/auth.rs | 4 +-- src/api/user.rs | 28 +++++++++++++++-- src/model/user.rs | 15 +++++++++ tests/integration/user-password.hurl | 24 ++++++++++++++ 6 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 tests/integration/user-password.hurl diff --git a/bank_core/src/util.rs b/bank_core/src/util.rs index 85a0f88..801c9b8 100644 --- a/bank_core/src/util.rs +++ b/bank_core/src/util.rs @@ -34,6 +34,31 @@ impl Name { } } +#[derive(Clone, PartialEq, Eq, Validate, Serialize, Deserialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[garde(transparent)] +pub struct Password(#[garde(length(min = 8, max = 96))] pub String); + +impl std::fmt::Debug for Password { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("Password").field(&"...").finish() + } +} + +impl Deref for Password { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.as_str() + } +} + +impl Password { + pub fn into_inner(self) -> String { + self.0 + } +} + #[derive(Debug, Clone, Deserialize, Serialize, Validate, PartialEq, Eq)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] #[serde(untagged)] @@ -42,22 +67,20 @@ pub enum NameOrUuid { Name(#[garde(dive)] Name), } -#[derive(Clone, Deserialize, Serialize, Validate, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, Serialize, Validate, PartialEq, Eq)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct ChangePassword { + #[garde(dive)] + pub password: Password, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Validate, PartialEq, Eq)] #[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] pub struct Credentials { #[garde(dive)] pub name: Name, - #[garde(length(min = 8, max = 96))] - pub password: String, -} - -impl std::fmt::Debug for Credentials { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Credentials") - .field("name", &self.name) - .field("password", &"...") - .finish() - } + #[garde(dive)] + pub password: Password, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] diff --git a/openapi-def.yaml b/openapi-def.yaml index 96abd5f..2ea2d05 100644 --- a/openapi-def.yaml +++ b/openapi-def.yaml @@ -125,6 +125,27 @@ paths: $ref: '#/components/responses/Unauthorized' default: $ref: '#/components/responses/Default' + /api/users/@me/password: + put: + operationId: self-change-password + summary: Change password + tags: + - Users + security: + - bearer: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + 401: + $ref: '#/components/responses/Unauthorized' + 422: + $ref: '#/components/responses/InvalidBody' + default: + $ref: '#/components/responses/Default' /api/users/@me/data: get: operationId: self-list-data diff --git a/src/api/auth.rs b/src/api/auth.rs index 85c0ead..6801fbc 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -52,7 +52,7 @@ async fn login( else { return Err(invalid_username_or_password().into()); }; - verify_password(credentials.password, &hash).map_err(|err| match err { + verify_password(&credentials.password.0, &hash).map_err(|err| match err { password_auth::VerifyError::Parse(parse_error) => { Error::new(InnerError::PHCParse(parse_error)) } @@ -63,7 +63,7 @@ async fn login( } #[derive(Serialize, Deserialize)] -struct Claims<'a> { +pub(crate) struct Claims<'a> { #[serde(rename = "iss")] issuer: Cow<'a, str>, #[serde(rename = "sub")] diff --git a/src/api/user.rs b/src/api/user.rs index a66b788..0c1894d 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -1,11 +1,17 @@ use std::sync::Arc; -use axum::{Router, extract::Path, routing::get}; +use axum::{ + Router, + extract::Path, + routing::{get, put}, +}; use bank_core::{ + ChangePassword, TokenResponse, pagination::Pagination, transaction::{FullTransaction, TransactionQuery}, user::{User, UserAccounts, UserBalance}, }; +use garde::Validate; use serde::{Deserialize, Serialize}; use tracing::instrument; use uuid::Uuid; @@ -15,7 +21,10 @@ use crate::{ model::{Accounts, Transactions, Users}, }; -use super::{AppState, EState, Error, Json, PaginationQuery, Query, State, auth::Auth}; +use super::{ + AppState, EState, Error, Json, PaginationQuery, Query, State, + auth::{Auth, Claims}, +}; pub(super) fn router() -> Router> { Router::new() @@ -25,6 +34,7 @@ pub(super) fn router() -> Router> { .route("/@me/data/{key}", get(get_user_data).put(set_user_data)) .route("/@me/accounts", get(user_accounts)) .route("/@me/transactions", get(me_transaction_history)) + .route("/@me/password", put(change_password)) .route("/", get(list_users)) } @@ -141,6 +151,20 @@ pub async fn set_user_data( Ok(()) } +#[instrument(skip(state))] +async fn change_password( + EState(state): State, + auth: Auth, + Json(body): Json, +) -> Result, Error> { + body.validate()?; + let mut conn = state.conn().await?; + let id = auth.user_id(); + Users::change_password(&mut conn, id, &body.password).await?; + let token = Claims::new(id).encode(&state.encoding_key).unwrap(); + Ok(Json(TokenResponse { token })) +} + #[cfg(test)] mod tests { use uuid::Uuid; diff --git a/src/model/user.rs b/src/model/user.rs index fa99a08..1d51087 100644 --- a/src/model/user.rs +++ b/src/model/user.rs @@ -42,6 +42,7 @@ impl Users { let result = client.query_one(&stmt, &[&id]).await?; Ok(result.get(0)) } + #[instrument(skip(client, password))] pub async fn create( client: &mut impl GenericClient, @@ -64,6 +65,20 @@ impl Users { Ok(id) } + #[instrument(skip(client, password))] + pub async fn change_password( + client: &mut impl GenericClient, + user: Uuid, + password: &str, + ) -> Result<(), tokio_postgres::Error> { + let hash = password_auth::generate_hash(password); + let stmt = client + .prepare_cached("update users set password = $2 where id = $1") + .await?; + client.execute(&stmt, &[&user, &hash]).await?; + Ok(()) + } + #[instrument(skip(client))] pub async fn get_password_and_info_by_username( client: &impl GenericClient, diff --git a/tests/integration/user-password.hurl b/tests/integration/user-password.hurl new file mode 100644 index 0000000..dc01222 --- /dev/null +++ b/tests/integration/user-password.hurl @@ -0,0 +1,24 @@ +POST {{host}}/api/login +{ + "name": "user6", + "password": "this-is-a-password" +} +HTTP 200 +[Captures] +token: jsonpath "$.token" + + +PUT {{host}}/api/users/@me/password +Authorization: Bearer {{token}} +{ + "password": "this-is-another-password" +} +HTTP 200 + + +POST {{host}}/api/login +{ + "name": "user6", + "password": "this-is-another-password" +} +HTTP 200