From f78054fecda454cb75f65fee0dd91da095c89b15 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Sun, 3 May 2026 19:26:29 +0200 Subject: [PATCH] initial auth stuff --- http_client/environments/test.yml | 8 ++-- http_client/login.yml | 37 +++++++++++++++++++ http_client/logout.yml | 28 ++++++++++++++ http_client/logout_all.yml | 28 ++++++++++++++ http_client/refresh.yml | 30 +++++++++++++++ http_client/register.yml | 14 +++---- http_client/test protected routes.yml | 17 +++++++++ src/controller/middleware/auth_middleware.rs | 25 +++++++++++++ src/controller/middleware/mod.rs | 1 + src/controller/v1/auth_controller.rs | 5 --- src/controller/v1/mod.rs | 24 +++++++++--- .../v1/protected/auth_protected_controller.rs | 27 ++++++++++++++ src/controller/v1/protected/mod.rs | 11 ++++++ .../v1/protected/organization_controller.rs | 0 src/db/repository/refresh_token_repository.rs | 17 +++++++++ src/errors.rs | 3 ++ src/service/auth_service.rs | 25 +++++++++++-- src/utils/jwt.rs | 12 ++++++ src/utils/refresh_token.rs | 14 ++++--- 19 files changed, 295 insertions(+), 31 deletions(-) create mode 100644 http_client/login.yml create mode 100644 http_client/logout.yml create mode 100644 http_client/logout_all.yml create mode 100644 http_client/refresh.yml create mode 100644 http_client/test protected routes.yml create mode 100644 src/controller/middleware/auth_middleware.rs create mode 100644 src/controller/v1/protected/auth_protected_controller.rs create mode 100644 src/controller/v1/protected/mod.rs create mode 100644 src/controller/v1/protected/organization_controller.rs diff --git a/http_client/environments/test.yml b/http_client/environments/test.yml index f5ad812..97e28f7 100644 --- a/http_client/environments/test.yml +++ b/http_client/environments/test.yml @@ -1,8 +1,8 @@ name: test variables: - - name: access_token - value: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI5NWM0NWVlNC1iNmY4LTRjY2ItYTEzNi0wMDVlYzdmMmNjMTMiLCJpYXQiOjE3Nzc1NjY3MjYsImV4cCI6MTc3NzU2NzYyNiwianRpIjoiNDU1MTFkNzQtNmU0OS00NzA2LTljOGYtNWY2ODcxYjgzNTY5In0.0VGbyYuExdt8K-WuAILZJCbIzGxy3_MhexgcDUpyiCI - - name: refresh_token - value: refresh_token=3f9e5d21748cebd5113604c045ed485ec82ef6ea46f539bd86f0a7de1b03d025; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=604800 - name: base_url value: http://localhost:6969 + - name: access_token + value: "" + - name: refresh_token + value: "" diff --git a/http_client/login.yml b/http_client/login.yml new file mode 100644 index 0000000..fa8b7f6 --- /dev/null +++ b/http_client/login.yml @@ -0,0 +1,37 @@ +info: + name: login + type: http + seq: 3 + +http: + method: POST + url: "{{base_url}}/api/v1/auth/login" + body: + type: json + data: | + { + "email": "a@a.it", + "password": "Password1!6969_" + } + auth: inherit + +runtime: + scripts: + - type: after-response + code: |- + const response = res.getBody(); + const token = response.access_token; + bru.setEnvVar("access_token", token); + console.log("login - access_token:", token); + + const cookies = res.getHeaders()['set-cookie']; + if (cookies) { + bru.setEnvVar("refresh_token", cookies[0]); + console.log("login - refresh_token:", cookies[0]); + } + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/http_client/logout.yml b/http_client/logout.yml new file mode 100644 index 0000000..beee11a --- /dev/null +++ b/http_client/logout.yml @@ -0,0 +1,28 @@ +info: + name: logout + type: http + seq: 6 + +http: + method: POST + url: "{{base_url}}/api/v1/protected/auth/logout" + auth: + type: bearer + token: "{{access_token}}" + +runtime: + scripts: + - type: after-response + code: |- + const status = res.getStatus(); + if (status === 200 || status === 204) { + bru.setEnvVar("access_token", ""); + bru.setEnvVar("refresh_token", ""); + console.log("logout - tokens cleared"); + } + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/http_client/logout_all.yml b/http_client/logout_all.yml new file mode 100644 index 0000000..51a693d --- /dev/null +++ b/http_client/logout_all.yml @@ -0,0 +1,28 @@ +info: + name: logout all + type: http + seq: 7 + +http: + method: POST + url: "{{base_url}}/api/v1/protected/auth/logout-all" + auth: + type: bearer + token: "{{access_token}}" + +runtime: + scripts: + - type: after-response + code: |- + const status = res.getStatus(); + if (status === 200 || status === 204) { + bru.setEnvVar("access_token", ""); + bru.setEnvVar("refresh_token", ""); + console.log("logout_all - all sessions revoked, tokens cleared"); + } + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/http_client/refresh.yml b/http_client/refresh.yml new file mode 100644 index 0000000..ffbb20c --- /dev/null +++ b/http_client/refresh.yml @@ -0,0 +1,30 @@ +info: + name: refresh + type: http + seq: 5 + +http: + method: POST + url: "{{base_url}}/api/v1/auth/refresh" + auth: inherit + +runtime: + scripts: + - type: after-response + code: |- + const response = res.getBody(); + const token = response.access_token; + bru.setEnvVar("access_token", token); + console.log("refresh - access_token:", token); + + const cookies = res.getHeaders()['set-cookie']; + if (cookies) { + bru.setEnvVar("refresh_token", cookies[0]); + console.log("refresh - refresh_token:", cookies[0]); + } + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/http_client/register.yml b/http_client/register.yml index db637b1..5637dd6 100644 --- a/http_client/register.yml +++ b/http_client/register.yml @@ -10,8 +10,8 @@ http: type: json data: | { - "email": "d3@v.it", - "password": "password" + "email": "a@a.it", + "password": "Password1!6969_" } auth: inherit @@ -19,16 +19,16 @@ runtime: scripts: - type: after-response code: |- - // Post-response script on your login endpoint const response = res.getBody(); const token = response.access_token; - // Save to collection variables bru.setEnvVar("access_token", token); - console.log("access_token", token) + console.log("register - access_token:", token); const cookies = res.getHeaders()['set-cookie']; - bru.setEnvVar("refresh_token", cookies[0]); - console.log("refresh_token", cookies[0]) + if (cookies) { + bru.setEnvVar("refresh_token", cookies[0]); + console.log("register - refresh_token:", cookies[0]); + } settings: encodeUrl: true diff --git a/http_client/test protected routes.yml b/http_client/test protected routes.yml new file mode 100644 index 0000000..ed58c9c --- /dev/null +++ b/http_client/test protected routes.yml @@ -0,0 +1,17 @@ +info: + name: test protected routes + type: http + seq: 4 + +http: + method: GET + url: "{{base_url}}/api/v1/protected/ping" + auth: + type: bearer + token: "{{access_token}}" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/src/controller/middleware/auth_middleware.rs b/src/controller/middleware/auth_middleware.rs new file mode 100644 index 0000000..f71d520 --- /dev/null +++ b/src/controller/middleware/auth_middleware.rs @@ -0,0 +1,25 @@ +use crate::{errors::AppError, state::AppState, utils::jwt::verify_access_token}; +use axum::{ + extract::{Request, State}, + middleware::Next, + response::Response, +}; +pub async fn auth_middleware( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let auth_header = request + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .ok_or(AppError::Unauthorized)?; + if !auth_header.starts_with("Bearer ") { + return Err(AppError::Unauthorized); + } + let token = &auth_header[7..]; + let claims = verify_access_token(token, &state.jwt_secret)?; + // Inject the user ID into extensions for downstream handlers + request.extensions_mut().insert(claims.sub); + Ok(next.run(request).await) +} diff --git a/src/controller/middleware/mod.rs b/src/controller/middleware/mod.rs index a7523a9..79c0f20 100644 --- a/src/controller/middleware/mod.rs +++ b/src/controller/middleware/mod.rs @@ -1,2 +1,3 @@ pub mod anti_enumeration_middleware; +pub mod auth_middleware; pub mod rate_limiting_middleware; diff --git a/src/controller/v1/auth_controller.rs b/src/controller/v1/auth_controller.rs index ff950a2..6fe1bb1 100644 --- a/src/controller/v1/auth_controller.rs +++ b/src/controller/v1/auth_controller.rs @@ -17,7 +17,6 @@ pub fn auth_router(state: AppState) -> Router { .route("/login", post(login_handler)) .route("/register", post(register_handler)) .route("/refresh", post(refresh_handler)) - .route("/logout", post(logout_handler)) .layer(from_fn(random_delay_middleware)) .layer(from_fn_with_state(state.clone(), rate_limiting_middleware)) .layer(CookieManagerLayer::new()) @@ -44,7 +43,3 @@ async fn refresh_handler( ) -> Result, AppError> { refresh(&s, cookies).await } - -async fn logout_handler(State(s): State, cookies: Cookies) -> Result<(), AppError> { - crate::service::auth_service::logout(&s, cookies).await -} diff --git a/src/controller/v1/mod.rs b/src/controller/v1/mod.rs index 59b7964..32a52d4 100644 --- a/src/controller/v1/mod.rs +++ b/src/controller/v1/mod.rs @@ -1,10 +1,24 @@ -use axum::Router; +use axum::{ + Router, + middleware::{from_fn, from_fn_with_state}, +}; -use crate::state::AppState; +use crate::{ + controller::{middleware::auth_middleware::auth_middleware, v1::protected::protected_router}, + state::AppState, +}; -pub mod auth_controller; +mod auth_controller; +mod protected; pub fn router_v1(state: AppState) -> Router { - Router::new().nest("/auth", auth_controller::auth_router(state)) -} + let public_routes = Router::new().nest("/auth", auth_controller::auth_router(state.clone())); + let protected_routes = Router::new().nest( + "/protected", + protected::protected_router(state.clone()) + .layer(from_fn_with_state(state, auth_middleware)), + ); + + Router::new().merge(public_routes).merge(protected_routes) +} diff --git a/src/controller/v1/protected/auth_protected_controller.rs b/src/controller/v1/protected/auth_protected_controller.rs new file mode 100644 index 0000000..ca34630 --- /dev/null +++ b/src/controller/v1/protected/auth_protected_controller.rs @@ -0,0 +1,27 @@ +use axum::{Extension, Router, extract::State, routing::post}; +use tower_cookies::{CookieManagerLayer, Cookies}; + +use crate::{errors::AppError, service::auth_service, state::AppState}; + +pub fn router() -> Router { + Router::new() + .route("/logout", post(logout_handler)) + .route("/logout-all", post(logout_all_handler)) + .layer(CookieManagerLayer::new()) +} + +async fn logout_handler( + State(s): State, + cookies: Cookies, + Extension(user_id): Extension, +) -> Result<(), AppError> { + auth_service::logout(&s, cookies, user_id).await +} + +async fn logout_all_handler( + State(s): State, + cookies: Cookies, + Extension(user_id): Extension, +) -> Result<(), AppError> { + auth_service::logout_all(&s, cookies, user_id).await +} diff --git a/src/controller/v1/protected/mod.rs b/src/controller/v1/protected/mod.rs new file mode 100644 index 0000000..00920ac --- /dev/null +++ b/src/controller/v1/protected/mod.rs @@ -0,0 +1,11 @@ +use axum::Router; + +use crate::state::AppState; + +mod auth_protected_controller; + +pub fn protected_router(_state: AppState) -> Router { + Router::new() + .nest("/auth", auth_protected_controller::router()) + .route("/ping", axum::routing::get("pong")) +} diff --git a/src/controller/v1/protected/organization_controller.rs b/src/controller/v1/protected/organization_controller.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/db/repository/refresh_token_repository.rs b/src/db/repository/refresh_token_repository.rs index 42f3462..a1dcf83 100644 --- a/src/db/repository/refresh_token_repository.rs +++ b/src/db/repository/refresh_token_repository.rs @@ -57,3 +57,20 @@ where .map_err(AppError::from)?; Ok(()) } + +pub async fn revoke_all_for_user<'e, E>( + executor: E, + user_id: Uuid, +) -> Result<(), AppError> +where + E: Executor<'e, Database = Postgres>, +{ + sqlx::query!( + "update refresh_tokens set revoked_at = now() where user_id = $1 and revoked_at is null", + user_id + ) + .execute(executor) + .await + .map_err(AppError::from)?; + Ok(()) +} diff --git a/src/errors.rs b/src/errors.rs index 2a4ca54..b99589e 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -25,6 +25,8 @@ pub enum AppError { Validation(String), #[error("Internal server error")] Internal, + #[error("Request not authorized")] + Unauthorized, } #[derive(Debug, Error)] @@ -50,6 +52,7 @@ impl IntoResponse for AppError { StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string(), ), + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), }; (status, Json(serde_json::json!({ "error": message }))).into_response() } diff --git a/src/service/auth_service.rs b/src/service/auth_service.rs index dc2acf0..03cf1c0 100644 --- a/src/service/auth_service.rs +++ b/src/service/auth_service.rs @@ -10,7 +10,7 @@ use crate::errors::AppError; use crate::state::AppState; use crate::utils::hash; use crate::utils::jwt::generate_access_token; -use crate::utils::refresh_token::generate_refresh_token; +use crate::utils::refresh_token::{generate_refresh_token, hash_refresh_token}; pub async fn login( state: &AppState, @@ -86,8 +86,9 @@ pub async fn refresh(state: &AppState, cookies: Cookies) -> Result Result Result<(), AppError> { +use crate::db::repository::refresh_token_repository::revoke_all_for_user; + +pub async fn logout(state: &AppState, cookies: Cookies, _user_id: uuid::Uuid) -> Result<(), AppError> { let refresh_token = match get_refresh_cookie(&cookies) { Some(t) => t, None => return Ok(()), // Already logged out @@ -118,7 +121,9 @@ pub async fn logout(state: &AppState, cookies: Cookies) -> Result<(), AppError> let mut tx = state.db.begin().await?; - if let Some(token_data) = find_by_hash(&mut *tx, &refresh_token).await? { + let hash = hash_refresh_token(&refresh_token); + + if let Some(token_data) = find_by_hash(&mut *tx, &hash).await? { revoke(&mut *tx, token_data.id).await?; } @@ -129,6 +134,18 @@ pub async fn logout(state: &AppState, cookies: Cookies) -> Result<(), AppError> Ok(()) } +pub async fn logout_all(state: &AppState, cookies: Cookies, user_id: uuid::Uuid) -> Result<(), AppError> { + let mut tx = state.db.begin().await?; + + revoke_all_for_user(&mut *tx, user_id).await?; + + tx.commit().await?; + + remove_refresh_cookie(&cookies); + + Ok(()) +} + const REFRESH_COOKIE_NAME: &str = "refresh_token"; fn remove_refresh_cookie(cookies: &Cookies) { diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index 6a551dc..2432834 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -31,3 +31,15 @@ pub fn generate_access_token(user_id: Uuid, secret: &str) -> Result Result { + let mut validation = jsonwebtoken::Validation::default(); + validation.validate_exp = true; // Ensure expired tokens are rejected + jsonwebtoken::decode::( + token, + &jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .map(|data| data.claims) + .map_err(|_| AppError::Unauthorized) // Map any JWT error to 401 +} diff --git a/src/utils/refresh_token.rs b/src/utils/refresh_token.rs index d7e05d3..ce8fd5f 100644 --- a/src/utils/refresh_token.rs +++ b/src/utils/refresh_token.rs @@ -7,12 +7,14 @@ pub fn generate_refresh_token() -> (String, String) { thread_rng.fill_bytes(&mut bytes); let plain = hex::encode(bytes); // 64 hex chars for user - let hash = { - // SHA-256 for DB storage - let mut hasher = Sha256::new(); - hasher.update(&plain); - hex::encode(hasher.finalize()) - }; + let hash = hash_refresh_token(&plain); (plain, hash) } + +pub fn hash_refresh_token(plain: &str) -> String { + // SHA-256 for DB storage + let mut hasher = Sha256::new(); + hasher.update(&plain); + hex::encode(hasher.finalize()) +}