initial auth stuff
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 10m55s

This commit is contained in:
Dmitri 2026-05-03 19:26:29 +02:00
parent eb436cf14c
commit f78054fecd
Signed by: kanopo
GPG Key ID: 759ADD40E3132AC7
19 changed files with 295 additions and 31 deletions

View File

@ -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: ""

37
http_client/login.yml Normal file
View File

@ -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

28
http_client/logout.yml Normal file
View File

@ -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

View File

@ -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

30
http_client/refresh.yml Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
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)
}

View File

@ -1,2 +1,3 @@
pub mod anti_enumeration_middleware;
pub mod auth_middleware;
pub mod rate_limiting_middleware;

View File

@ -17,7 +17,6 @@ pub fn auth_router(state: AppState) -> Router<AppState> {
.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<Json<AuthResponse>, AppError> {
refresh(&s, cookies).await
}
async fn logout_handler(State(s): State<AppState>, cookies: Cookies) -> Result<(), AppError> {
crate::service::auth_service::logout(&s, cookies).await
}

View File

@ -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<AppState> {
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)
}

View File

@ -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<AppState> {
Router::new()
.route("/logout", post(logout_handler))
.route("/logout-all", post(logout_all_handler))
.layer(CookieManagerLayer::new())
}
async fn logout_handler(
State(s): State<AppState>,
cookies: Cookies,
Extension(user_id): Extension<uuid::Uuid>,
) -> Result<(), AppError> {
auth_service::logout(&s, cookies, user_id).await
}
async fn logout_all_handler(
State(s): State<AppState>,
cookies: Cookies,
Extension(user_id): Extension<uuid::Uuid>,
) -> Result<(), AppError> {
auth_service::logout_all(&s, cookies, user_id).await
}

View File

@ -0,0 +1,11 @@
use axum::Router;
use crate::state::AppState;
mod auth_protected_controller;
pub fn protected_router(_state: AppState) -> Router<AppState> {
Router::new()
.nest("/auth", auth_protected_controller::router())
.route("/ping", axum::routing::get("pong"))
}

View File

@ -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(())
}

View File

@ -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()
}

View File

@ -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<Json<AuthResp
let refresh_token = get_refresh_cookie(&cookies).ok_or(AppError::InvalidCredentials)?;
let mut tx = state.db.begin().await?;
let hash = hash_refresh_token(&refresh_token);
let token_data = find_by_hash(&mut *tx, &refresh_token)
let token_data = find_by_hash(&mut *tx, &hash)
.await?
.ok_or(AppError::InvalidCredentials)?;
@ -110,7 +111,9 @@ pub async fn refresh(state: &AppState, cookies: Cookies) -> Result<Json<AuthResp
Ok(Json(AuthResponse { access_token }))
}
pub async fn logout(state: &AppState, cookies: Cookies) -> 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) {

View File

@ -31,3 +31,15 @@ pub fn generate_access_token(user_id: Uuid, secret: &str) -> Result<String, AppE
)
.map_err(|_| AppError::Internal)
}
pub fn verify_access_token(token: &str, secret: &str) -> Result<Claims, AppError> {
let mut validation = jsonwebtoken::Validation::default();
validation.validate_exp = true; // Ensure expired tokens are rejected
jsonwebtoken::decode::<Claims>(
token,
&jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.map(|data| data.claims)
.map_err(|_| AppError::Unauthorized) // Map any JWT error to 401
}

View File

@ -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())
}