preview stack, refresh token, design

This commit is contained in:
2025-10-11 13:12:05 +02:00
parent 6850918e3d
commit a21853c874
9 changed files with 320 additions and 82 deletions
@@ -0,0 +1 @@
DROP TABLE refresh_tokens;
@@ -0,0 +1,13 @@
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
+17
View File
@@ -11,6 +11,10 @@ pub struct AppConfig {
pub jwt_issuer: String,
pub jwt_audience: String,
pub jwt_expiry_minutes: i64,
pub refresh_token_expiry_days: i64,
pub refresh_cookie_secure: bool,
pub refresh_cookie_domain: Option<String>,
pub cors_allowed_origin: Option<String>,
pub aws_endpoint_url: Option<String>,
pub aws_access_key_id: Option<String>,
pub aws_secret_access_key: Option<String>,
@@ -34,6 +38,15 @@ impl AppConfig {
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("JWT_EXPIRY_MINUTES must be an integer")?;
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
@@ -48,6 +61,10 @@ impl AppConfig {
jwt_issuer,
jwt_audience,
jwt_expiry_minutes,
refresh_token_expiry_days,
refresh_cookie_secure,
refresh_cookie_domain,
cors_allowed_origin,
aws_endpoint_url,
aws_access_key_id,
aws_secret_access_key,
+24
View File
@@ -191,3 +191,27 @@ pub struct NewDocumentTag {
pub tag_id: Uuid,
pub assigned_by: Option<Uuid>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = refresh_tokens)]
#[diesel(belongs_to(User))]
pub struct RefreshToken {
pub id: Uuid,
pub user_id: Uuid,
pub token_hash: String,
pub issued_at: NaiveDateTime,
pub expires_at: NaiveDateTime,
pub revoked_at: Option<NaiveDateTime>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = refresh_tokens)]
pub struct NewRefreshToken {
pub id: Uuid,
pub user_id: Uuid,
pub token_hash: String,
pub issued_at: NaiveDateTime,
pub expires_at: NaiveDateTime,
}
+202 -12
View File
@@ -1,15 +1,28 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use axum::{
extract::State,
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
Json,
};
use axum_extra::{headers::Cookie, typed_header::TypedHeader};
use chrono::{Duration as ChronoDuration, Utc};
use diesel::prelude::*;
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{
auth::{password, AuthenticatedUser},
error::{AppError, AppResult},
models::User,
schema::users::dsl,
models::{NewRefreshToken, RefreshToken, User},
schema::{refresh_tokens, users::dsl},
state::AppState,
};
use crate::schema::refresh_tokens::dsl as refresh_dsl;
const REFRESH_COOKIE_NAME: &str = "refresh_token";
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
@@ -26,7 +39,7 @@ pub struct LoginResponse {
pub async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> AppResult<Json<LoginResponse>> {
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
let mut conn = state.db()?;
let user: User = dsl::users
@@ -40,22 +53,199 @@ pub async fn login(
return Err(AppError::unauthorized());
}
let token = state
let access_token = state
.jwt
.generate_token(user.id, &user.username, &user.role)
.map_err(AppError::from)?;
Ok(Json(LoginResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}))
let now = Utc::now();
let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: refresh_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &refresh_value, refresh_expires_at),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
}
pub async fn logout(_user: AuthenticatedUser) -> impl IntoResponse {
StatusCode::NO_CONTENT
pub async fn refresh(
State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
let cookies = jar.ok_or_else(AppError::unauthorized)?;
let refresh_value = cookies
.get(REFRESH_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?;
let hashed = hash_refresh_token(refresh_value);
let mut conn = state.db()?;
let now = Utc::now();
let now_naive = now.naive_utc();
let token = match refresh_dsl::refresh_tokens
.filter(refresh_dsl::token_hash.eq(&hashed))
.filter(refresh_dsl::revoked_at.is_null())
.filter(refresh_dsl::expires_at.gt(now_naive))
.first::<RefreshToken>(&mut conn)
{
Ok(token) => token,
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
Err(err) => return Err(AppError::from(err)),
};
diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id)))
.set((
refresh_dsl::revoked_at.eq(now_naive),
refresh_dsl::updated_at.eq(now_naive),
))
.execute(&mut conn)?;
let user: User = dsl::users
.find(token.user_id)
.first(&mut conn)
.map_err(AppError::from)?;
let access_token = state
.jwt
.generate_token(user.id, &user.username, &user.role)
.map_err(AppError::from)?;
let new_refresh_value = generate_refresh_token();
let new_refresh_hash = hash_refresh_token(&new_refresh_value);
let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: new_refresh_hash,
issued_at: now_naive,
expires_at: new_refresh_expires.naive_utc(),
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
}
pub async fn logout(
State(state): State<AppState>,
user: AuthenticatedUser,
jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> {
let mut conn = state.db()?;
if let Some(cookies) = jar {
if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) {
let hashed = hash_refresh_token(value);
let now = Utc::now().naive_utc();
let _ = diesel::update(
refresh_dsl::refresh_tokens
.filter(refresh_dsl::token_hash.eq(hashed))
.filter(refresh_dsl::user_id.eq(user.user_id))
.filter(refresh_dsl::revoked_at.is_null()),
)
.set((
refresh_dsl::revoked_at.eq(now),
refresh_dsl::updated_at.eq(now),
))
.execute(&mut conn);
}
}
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, build_clear_refresh_cookie(&state));
Ok((headers, StatusCode::NO_CONTENT))
}
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user)
}
fn hash_refresh_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn generate_refresh_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn build_refresh_cookie(
state: &AppState,
token: &str,
expires_at: chrono::DateTime<Utc>,
) -> HeaderValue {
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
let mut parts = vec![format!("{}={}", REFRESH_COOKIE_NAME, token)];
parts.push("Path=/".into());
parts.push("HttpOnly".into());
parts.push("SameSite=Strict".into());
parts.push(format!("Max-Age={}", max_age));
parts.push(format!("Expires={}", expires_at.to_rfc2822()));
if state.config.refresh_cookie_secure {
parts.push("Secure".into());
}
if let Some(domain) = &state.config.refresh_cookie_domain {
parts.push(format!("Domain={}", domain));
}
HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
}
fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue {
let mut parts = vec![format!("{}=", REFRESH_COOKIE_NAME)];
parts.push("Path=/".into());
parts.push("HttpOnly".into());
parts.push("SameSite=Strict".into());
parts.push("Max-Age=0".into());
parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into());
if state.config.refresh_cookie_secure {
parts.push("Secure".into());
}
if let Some(domain) = &state.config.refresh_cookie_domain {
parts.push(format!("Domain={}", domain));
}
HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
}
+30 -5
View File
@@ -1,10 +1,11 @@
use axum::http::HeaderValue;
use axum::{
extract::DefaultBodyLimit,
middleware,
routing::{delete, get, patch, post},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::{auth::AuthenticatedUser, state::AppState};
@@ -15,13 +16,37 @@ pub mod health;
pub mod tags;
pub fn create_router(state: AppState) -> Router<()> {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() {
let headers: Vec<HeaderValue> = origins
.split(',')
.filter_map(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| {
trimmed
.parse::<HeaderValue>()
.expect("invalid CORS allowed origin")
})
})
.collect();
let allow_origin = AllowOrigin::list(headers);
CorsLayer::new()
.allow_origin(allow_origin)
.allow_methods(tower_http::cors::AllowMethods::mirror_request())
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
.allow_credentials(true)
} else {
CorsLayer::new()
.allow_origin(AllowOrigin::mirror_request())
.allow_methods(tower_http::cors::AllowMethods::mirror_request())
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
.allow_credentials(true)
};
let auth_routes = Router::new()
.route("/login", post(auth::login))
.route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout))
.route("/me", get(auth::me));
+15
View File
@@ -86,6 +86,19 @@ diesel::table! {
}
}
diesel::table! {
refresh_tokens (id) {
id -> Uuid,
user_id -> Uuid,
token_hash -> Text,
issued_at -> Timestamptz,
expires_at -> Timestamptz,
revoked_at -> Nullable<Timestamptz>,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! {
tags (id) {
id -> Uuid,
@@ -117,6 +130,7 @@ diesel::joinable!(document_tags -> tags (tag_id));
diesel::joinable!(document_tags -> users (assigned_by));
diesel::joinable!(document_versions -> documents (document_id));
diesel::joinable!(documents -> folders (folder_id));
diesel::joinable!(refresh_tokens -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!(
document_assets,
@@ -125,6 +139,7 @@ diesel::allow_tables_to_appear_in_same_query!(
documents,
folders,
jobs,
refresh_tokens,
tags,
users,
);