feat(admin-auth): add POST /api/admin/auth/refresh for sliding session window
All checks were successful
build-and-release / build (gateway) (push) Successful in 1m33s
build-and-release / build (graphic-designers) (push) Successful in 2m13s
build-and-release / build (ugc-content-creators) (push) Successful in 2m12s
build-and-release / build (users) (push) Successful in 4m48s
build-and-release / build (catering-services) (push) Successful in 1m22s
build-and-release / build (job-seekers) (push) Successful in 3m20s
build-and-release / build (companies) (push) Successful in 2m36s
build-and-release / build (social-media-managers) (push) Successful in 2m18s
build-and-release / build (tutors) (push) Successful in 2m41s
build-and-release / build (customers) (push) Successful in 1m38s
build-and-release / build (employees) (push) Successful in 3m16s
build-and-release / build (makeup-artists) (push) Successful in 1m46s
build-and-release / build (photographers) (push) Successful in 2m39s
build-and-release / build (jobs) (push) Successful in 1m5s
build-and-release / build (cron) (push) Successful in 1m40s
build-and-release / build (leads) (push) Successful in 2m18s
build-and-release / build (payments) (push) Successful in 3m10s
build-and-release / build (fitness-trainers) (push) Successful in 1m25s
build-and-release / build (video-editors) (push) Successful in 2m11s
build-and-release / build (developers) (push) Successful in 1m43s
All checks were successful
build-and-release / build (gateway) (push) Successful in 1m33s
build-and-release / build (graphic-designers) (push) Successful in 2m13s
build-and-release / build (ugc-content-creators) (push) Successful in 2m12s
build-and-release / build (users) (push) Successful in 4m48s
build-and-release / build (catering-services) (push) Successful in 1m22s
build-and-release / build (job-seekers) (push) Successful in 3m20s
build-and-release / build (companies) (push) Successful in 2m36s
build-and-release / build (social-media-managers) (push) Successful in 2m18s
build-and-release / build (tutors) (push) Successful in 2m41s
build-and-release / build (customers) (push) Successful in 1m38s
build-and-release / build (employees) (push) Successful in 3m16s
build-and-release / build (makeup-artists) (push) Successful in 1m46s
build-and-release / build (photographers) (push) Successful in 2m39s
build-and-release / build (jobs) (push) Successful in 1m5s
build-and-release / build (cron) (push) Successful in 1m40s
build-and-release / build (leads) (push) Successful in 2m18s
build-and-release / build (payments) (push) Successful in 3m10s
build-and-release / build (fitness-trainers) (push) Successful in 1m25s
build-and-release / build (video-editors) (push) Successful in 2m11s
build-and-release / build (developers) (push) Successful in 1m43s
Admin access tokens expire after 15 minutes with no way to renew one, so active admins got logged out mid-work with no warning (silent 401s, now surfaced by admin-solid's session-expired dialog). Add a refresh endpoint that exchanges the HttpOnly nxtgauge_admin_token cookie for a new 15-minute access token, rotating the employee_sessions row (revoke old, store new) - mirrors the existing pattern in apps/users/src/handlers/auth.rs, but against the DB-backed employee_sessions table instead of Redis. Add EmployeeRepository::get_by_id / get_valid_session_by_token / revoke_session to support it. The admin-solid frontend calls this on a timer while the admin is active and skips it once idle for 15 minutes, so the session now extends while active and expires on inactivity as intended, instead of on a fixed wall-clock timer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f56251374a
commit
8a29b81c84
2 changed files with 102 additions and 0 deletions
|
|
@ -19,6 +19,7 @@ pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", post(login))
|
.route("/login", post(login))
|
||||||
.route("/logout", post(logout))
|
.route("/logout", post(logout))
|
||||||
|
.route("/refresh", post(refresh))
|
||||||
.route("/session", get(session))
|
.route("/session", get(session))
|
||||||
.route("/forgot-password", post(forgot_password))
|
.route("/forgot-password", post(forgot_password))
|
||||||
.route("/reset-password", post(reset_password))
|
.route("/reset-password", post(reset_password))
|
||||||
|
|
@ -122,6 +123,74 @@ async fn logout(
|
||||||
(StatusCode::OK, [(SET_COOKIE, clear)], Json(serde_json::json!({ "message": "Logged out" })))
|
(StatusCode::OK, [(SET_COOKIE, clear)], Json(serde_json::json!({ "message": "Logged out" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /api/admin/auth/refresh
|
||||||
|
///
|
||||||
|
/// Exchanges the HttpOnly refresh-token cookie for a new 15-minute access
|
||||||
|
/// token, rotating the underlying session row (revoke old, store new).
|
||||||
|
/// Called by the admin frontend on user activity to implement a sliding
|
||||||
|
/// session window: active admins never see a 15-minute timeout, idle ones do.
|
||||||
|
async fn refresh(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
req: axum::http::Request<axum::body::Body>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let cookie_header = req
|
||||||
|
.headers()
|
||||||
|
.get(axum::http::header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let token = cookie_header
|
||||||
|
.split(';')
|
||||||
|
.map(str::trim)
|
||||||
|
.find_map(|p| p.strip_prefix("nxtgauge_admin_token="))
|
||||||
|
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token missing", "REFRESH_TOKEN_INVALID"))?;
|
||||||
|
|
||||||
|
let session = EmployeeRepository::get_valid_session_by_token(&state.pool, token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?
|
||||||
|
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token expired", "REFRESH_TOKEN_INVALID"))?;
|
||||||
|
|
||||||
|
let employee = EmployeeRepository::get_by_id(&state.pool, session.employee_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Employee not found", "INVALID_CREDENTIALS"))?
|
||||||
|
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Employee not found", "INVALID_CREDENTIALS"))?;
|
||||||
|
|
||||||
|
if employee.status != "ACTIVE" {
|
||||||
|
return Err(err(StatusCode::FORBIDDEN, "Account not active", "ACCOUNT_INACTIVE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate: revoke old session, issue a new one.
|
||||||
|
EmployeeRepository::revoke_session(&state.pool, session.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
||||||
|
|
||||||
|
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
|
let roles = vec![employee.role_code.clone()];
|
||||||
|
|
||||||
|
let tokens = generate_tokens(
|
||||||
|
employee.id.to_string(),
|
||||||
|
employee.email.clone(),
|
||||||
|
roles.clone(),
|
||||||
|
roles.first().cloned(),
|
||||||
|
&jwt_secret,
|
||||||
|
)
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
|
||||||
|
|
||||||
|
EmployeeRepository::store_session(&state.pool, employee.id, &tokens.refresh_token, chrono::Utc::now() + chrono::Duration::days(30))
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
||||||
|
|
||||||
|
let new_cookie = format!(
|
||||||
|
"nxtgauge_admin_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000",
|
||||||
|
tokens.refresh_token
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, [(SET_COOKIE, new_cookie)], Json(serde_json::json!({
|
||||||
|
"access_token": tokens.access_token,
|
||||||
|
"expires_in": 900
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
async fn session(
|
async fn session(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
_state: State<AppState>,
|
_state: State<AppState>,
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,15 @@ impl EmployeeRepository {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Employee>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Employee>(
|
||||||
|
"SELECT id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<Option<Employee>, sqlx::Error> {
|
pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<Option<Employee>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Employee>(
|
sqlx::query_as::<_, Employee>(
|
||||||
"SELECT id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE email = $1"
|
"SELECT id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE email = $1"
|
||||||
|
|
@ -180,6 +189,30 @@ impl EmployeeRepository {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_valid_session_by_token(
|
||||||
|
pool: &PgPool,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<Option<EmployeeSession>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, EmployeeSession>(
|
||||||
|
r#"
|
||||||
|
SELECT id, employee_id, token_hash, expires_at, revoked, created_at
|
||||||
|
FROM employee_sessions
|
||||||
|
WHERE token_hash = $1 AND revoked = false AND expires_at > NOW()
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_session(pool: &PgPool, session_id: Uuid) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE employee_sessions SET revoked = true WHERE id = $1")
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn change_password(
|
pub async fn change_password(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue