diff --git a/apps/employees/src/handlers/auth.rs b/apps/employees/src/handlers/auth.rs index 6eef70c..f186832 100644 --- a/apps/employees/src/handlers/auth.rs +++ b/apps/employees/src/handlers/auth.rs @@ -19,6 +19,7 @@ pub fn router() -> Router { Router::new() .route("/login", post(login)) .route("/logout", post(logout)) + .route("/refresh", post(refresh)) .route("/session", get(session)) .route("/forgot-password", post(forgot_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" }))) } +/// 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, + req: axum::http::Request, +) -> Result)> { + 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( auth: AuthUser, _state: State, diff --git a/crates/db/src/models/employee.rs b/crates/db/src/models/employee.rs index f831eeb..2cfa602 100644 --- a/crates/db/src/models/employee.rs +++ b/crates/db/src/models/employee.rs @@ -136,6 +136,15 @@ impl EmployeeRepository { Ok(()) } + pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, 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, 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 email = $1" @@ -180,6 +189,30 @@ impl EmployeeRepository { .await } + pub async fn get_valid_session_by_token( + pool: &PgPool, + token_hash: &str, + ) -> Result, 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( pool: &PgPool, id: Uuid,