feat(auth): refresh tokens + remember me
Backend: add refresh JWT (30d, token_type claim), POST /auth/refresh endpoint (rotates token pair), remember_me on login, JWT_REFRESH_EXPIRY_DAYS env var. Extractors now reject refresh tokens on protected routes. Frontend: sessionStorage for non-remembered sessions, localStorage + refresh token for remembered sessions. Transparent 401 recovery in api.ts (retry once after refresh). Remember me checkbox on login page with security note when checked.
This commit is contained in:
@@ -6,13 +6,13 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::{LoginRequest, RegisterRequest, TokenResponse, UserResponse},
|
||||
dto::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse},
|
||||
error::ApiError,
|
||||
extractors::CurrentUser,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::create_jwt;
|
||||
use super::{create_jwt, create_refresh_jwt};
|
||||
|
||||
/// Login with email + password → JWT token
|
||||
pub(super) async fn login(
|
||||
@@ -35,6 +35,11 @@ pub(super) async fn login(
|
||||
}
|
||||
|
||||
let token = create_jwt(&user, &state)?;
|
||||
let refresh_token = if payload.remember_me {
|
||||
Some(create_refresh_jwt(&user, &state)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = state.activity_log_repo.log("user_login", user.email.as_ref(), None).await;
|
||||
|
||||
Ok((
|
||||
@@ -43,6 +48,7 @@ pub(super) async fn login(
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
refresh_token,
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -71,6 +77,7 @@ pub(super) async fn register(
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
refresh_token: None,
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -90,6 +97,46 @@ pub(super) async fn me(CurrentUser(user): CurrentUser) -> Result<impl IntoRespon
|
||||
}))
|
||||
}
|
||||
|
||||
/// Exchange a valid refresh token for a new access + refresh token pair
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub(super) async fn refresh_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RefreshRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let validator = state
|
||||
.jwt_validator
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
|
||||
|
||||
let claims = validator
|
||||
.validate_refresh_token(&payload.refresh_token)
|
||||
.map_err(|e| {
|
||||
tracing::debug!("Refresh token validation failed: {:?}", e);
|
||||
ApiError::Unauthorized("Invalid or expired refresh token".to_string())
|
||||
})?;
|
||||
|
||||
let user_id: uuid::Uuid = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?;
|
||||
|
||||
let user = state
|
||||
.user_service
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?;
|
||||
|
||||
let access_token = create_jwt(&user, &state)?;
|
||||
let new_refresh_token = create_refresh_jwt(&user, &state)?;
|
||||
|
||||
Ok(Json(TokenResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
refresh_token: Some(new_refresh_token),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Issue a new JWT for the currently authenticated user (OIDC→JWT exchange or token refresh)
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub(super) async fn get_token(
|
||||
@@ -102,5 +149,6 @@ pub(super) async fn get_token(
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
refresh_token: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/me", get(local::me));
|
||||
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
let r = r.route("/token", post(local::get_token));
|
||||
let r = r
|
||||
.route("/token", post(local::get_token))
|
||||
.route("/refresh", post(local::refresh_token));
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
let r = r
|
||||
@@ -28,7 +30,7 @@ pub fn router() -> Router<AppState> {
|
||||
r
|
||||
}
|
||||
|
||||
/// Helper: create JWT for a user
|
||||
/// Helper: create access JWT for a user
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub(super) fn create_jwt(user: &domain::User, state: &AppState) -> Result<String, ApiError> {
|
||||
let validator = state
|
||||
@@ -45,3 +47,21 @@ pub(super) fn create_jwt(user: &domain::User, state: &AppState) -> Result<String
|
||||
pub(super) fn create_jwt(_user: &domain::User, _state: &AppState) -> Result<String, ApiError> {
|
||||
Err(ApiError::Internal("JWT feature not enabled".to_string()))
|
||||
}
|
||||
|
||||
/// Helper: create refresh JWT for a user
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub(super) fn create_refresh_jwt(user: &domain::User, state: &AppState) -> Result<String, ApiError> {
|
||||
let validator = state
|
||||
.jwt_validator
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
|
||||
|
||||
validator
|
||||
.create_refresh_token(user)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create refresh token: {}", e)))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "auth-jwt"))]
|
||||
pub(super) fn create_refresh_jwt(_user: &domain::User, _state: &AppState) -> Result<String, ApiError> {
|
||||
Err(ApiError::Internal("JWT feature not enabled".to_string()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user