Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/site-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:
steps:
- uses: actions/checkout@v6

- name: Install protoc
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

Expand Down
5 changes: 3 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

92 changes: 87 additions & 5 deletions crates/schema-forge-acton/src/routes/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ use std::fmt;
use std::sync::Arc;
use std::time::Duration;

use acton_service::audit::{AuditEventKind, AuditSeverity, AuditSource};
use acton_service::auth::tokens::paseto_generator::PasetoGenerator;
use acton_service::auth::tokens::{ClaimsBuilder, TokenGenerator};
use acton_service::middleware::Claims;
use acton_service::prelude::Error as ActonError;
use acton_service::state::AppState;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
Expand All @@ -27,6 +30,7 @@ use serde::{Deserialize, Serialize};

use crate::access::OptionalClaims;
use crate::authz::principal_claims::{PrincipalClaimMappings, PrincipalClaimsError};
use crate::config::SchemaForgeConfig;
use crate::state::DynAuthStore;

/// Default expiry for tokens minted by this endpoint (1 hour).
Expand Down Expand Up @@ -93,6 +97,7 @@ struct LoginErrorBody {
/// surfaced as 500 so they are easy to distinguish from the 401 "bad
/// credentials" case.
pub async fn login(
State(state): State<AppState<SchemaForgeConfig>>,
Extension(auth_store): Extension<Arc<dyn DynAuthStore>>,
Extension(generator): Extension<Arc<PasetoGenerator>>,
Extension(principal_claims): Extension<Arc<PrincipalClaimMappings>>,
Expand All @@ -103,14 +108,23 @@ pub async fn login(
.await
{
Ok(Some(u)) => u,
Ok(None) => return unauthorized_response(),
Err(e) => return internal_error_response(format!("auth store error: {e}")),
Ok(None) => {
emit_login_failed(&state, &req.username).await;
return unauthorized_response();
}
Err(e) => {
emit_login_failed(&state, &req.username).await;
return internal_error_response(format!("auth store error: {e}"));
}
};

let user_entity = if principal_claims.has_user_field_sources() {
match auth_store.get_user_entity(&user.username).await {
Ok(Some(e)) => Some(e),
Ok(None) => return unauthorized_response(),
Ok(None) => {
emit_login_failed(&state, &req.username).await;
return unauthorized_response();
}
Err(e) => return internal_error_response(format!("auth store error: {e}")),
}
} else {
Expand All @@ -120,7 +134,10 @@ pub async fn login(
let claims =
match build_login_claims(&user.username, &user.roles, user_entity.as_ref(), &principal_claims) {
Ok(c) => c,
Err(BuildLoginClaimsError::NullRequired(_)) => return unauthorized_response(),
Err(BuildLoginClaimsError::NullRequired(_)) => {
emit_login_failed(&state, &req.username).await;
return unauthorized_response();
}
Err(e) => return internal_error_response(format!("failed to build claims: {e}")),
};

Expand All @@ -129,7 +146,20 @@ pub async fn login(
Err(e) => return internal_error_response(format!("failed to generate token: {e}")),
};

let expires_at = Utc::now() + chrono::Duration::seconds(LOGIN_TOKEN_LIFETIME.as_secs() as i64);
let issued_at = Utc::now();

// Persist before returning the token. Fails closed: a DB write failure
// produces a 500 rather than handing the caller a token without an
// audit trail entry. The login handler already proved the row exists
// via validate_credentials, so the only realistic failure mode is a
// backend outage — in which case 500 is the right answer.
if let Err(e) = auth_store.record_login(&user.username, issued_at).await {
return internal_error_response(format!("failed to record last_login: {e}"));
}

emit_login_success(&state, &user.username).await;

let expires_at = issued_at + chrono::Duration::seconds(LOGIN_TOKEN_LIFETIME.as_secs() as i64);

let body = LoginResponse {
token,
Expand All @@ -148,6 +178,7 @@ pub async fn login(
/// expired or missing token get a clean 401 — the client can then show the
/// login screen without hitting a ginned-up internal error.
pub async fn refresh(
State(state): State<AppState<SchemaForgeConfig>>,
OptionalClaims(claims): OptionalClaims,
Extension(auth_store): Extension<Arc<dyn DynAuthStore>>,
Extension(generator): Extension<Arc<PasetoGenerator>>,
Expand Down Expand Up @@ -197,6 +228,8 @@ pub async fn refresh(

let expires_at = Utc::now() + chrono::Duration::seconds(LOGIN_TOKEN_LIFETIME.as_secs() as i64);

emit_token_refresh(&state, &user.username).await;

let body = LoginResponse {
token,
expires_at: expires_at.to_rfc3339(),
Expand All @@ -205,6 +238,55 @@ pub async fn refresh(
(StatusCode::OK, Json(body)).into_response()
}

// ---------------------------------------------------------------------------
// Audit emission helpers
// ---------------------------------------------------------------------------

async fn emit_login_success(state: &AppState<SchemaForgeConfig>, username: &str) {
if let Some(logger) = state.audit_logger() {
logger
.log_auth(
AuditEventKind::AuthLoginSuccess,
AuditSeverity::Informational,
AuditSource {
subject: Some(format!("user:{username}")),
..AuditSource::default()
},
)
.await;
}
}

async fn emit_login_failed(state: &AppState<SchemaForgeConfig>, attempted_username: &str) {
if let Some(logger) = state.audit_logger() {
logger
.log_auth(
AuditEventKind::AuthLoginFailed,
AuditSeverity::Warning,
AuditSource {
subject: Some(format!("user:{attempted_username}")),
..AuditSource::default()
},
)
.await;
}
}

async fn emit_token_refresh(state: &AppState<SchemaForgeConfig>, username: &str) {
if let Some(logger) = state.audit_logger() {
logger
.log_auth(
AuditEventKind::AuthTokenRefresh,
AuditSeverity::Informational,
AuditSource {
subject: Some(format!("user:{username}")),
..AuditSource::default()
},
)
.await;
}
}

// ---------------------------------------------------------------------------
// Pure helpers (unit-testable)
// ---------------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions crates/schema-forge-acton/src/shared_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,14 @@ mod tests {
) -> Result<(), BackendError> {
unimplemented!("not used by bootstrap path")
}

async fn record_login(
&self,
_username: &str,
_at: chrono::DateTime<chrono::Utc>,
) -> Result<(), BackendError> {
unimplemented!("not used by bootstrap path")
}
}

/// Helper: run an async block on a single-threaded current-thread runtime.
Expand Down
17 changes: 17 additions & 0 deletions crates/schema-forge-acton/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;

use chrono::{DateTime, Utc};
use schema_forge_backend::entity::{Entity, QueryResult};
use schema_forge_backend::error::BackendError;
use schema_forge_backend::traits::{EntityStore, SchemaBackend};
Expand Down Expand Up @@ -295,6 +296,14 @@ pub trait DynAuthStore: Send + Sync {
username: &'a str,
new_password: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), BackendError>> + Send + 'a>>;

/// Stamp the user's `last_login` field to `at`. See
/// [`schema_forge_backend::user_store::AuthStore::record_login`].
fn record_login<'a>(
&'a self,
username: &'a str,
at: DateTime<Utc>,
) -> Pin<Box<dyn Future<Output = Result<(), BackendError>> + Send + 'a>>;
}

/// Blanket impl: any concrete `AuthStore` automatically implements `DynAuthStore`.
Expand Down Expand Up @@ -379,6 +388,14 @@ impl<T: AuthStore + 'static> DynAuthStore for T {
) -> Pin<Box<dyn Future<Output = Result<(), BackendError>> + Send + 'a>> {
Box::pin(AuthStore::change_password(self, username, new_password))
}

fn record_login<'a>(
&'a self,
username: &'a str,
at: DateTime<Utc>,
) -> Pin<Box<dyn Future<Output = Result<(), BackendError>> + Send + 'a>> {
Box::pin(AuthStore::record_login(self, username, at))
}
}

// ---------------------------------------------------------------------------
Expand Down
68 changes: 61 additions & 7 deletions crates/schema-forge-acton/tests/auth_login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,20 +101,21 @@ fn build_test_generator() -> (Arc<PasetoGenerator>, NamedTempFile) {
}

/// Build a router that mounts only `/auth/login` with the Extensions the
/// login handler now depends on.
async fn login_app() -> (Router, NamedTempFile) {
/// login handler now depends on. The auth store is returned so tests can
/// read back the User row to verify side-effects (e.g. `last_login`).
async fn login_app() -> (Router, NamedTempFile, Arc<dyn DynAuthStore>) {
let auth_store = seeded_auth_store().await;
let (generator, key_tmp) = build_test_generator();
let principal_claims =
Arc::new(schema_forge_acton::authz::PrincipalClaimMappings::default());
let router = auth_routes()
.layer(Extension(auth_store))
.layer(Extension(auth_store.clone()))
.layer(Extension(generator))
.layer(Extension(principal_claims))
.with_state(acton_service::state::AppState::<
schema_forge_acton::SchemaForgeConfig,
>::default());
(router, key_tmp)
(router, key_tmp, auth_store)
}

async fn post_login(app: Router, body: &str) -> (StatusCode, serde_json::Value) {
Expand All @@ -133,7 +134,7 @@ async fn post_login(app: Router, body: &str) -> (StatusCode, serde_json::Value)

#[tokio::test]
async fn login_with_correct_credentials_returns_token() {
let (app, _key) = login_app().await;
let (app, _key, _store) = login_app().await;
let (status, body) = post_login(app, r#"{"username":"admin","password":"dev"}"#).await;

assert_eq!(status, StatusCode::OK, "body: {body}");
Expand All @@ -155,7 +156,7 @@ async fn login_with_correct_credentials_returns_token() {

#[tokio::test]
async fn login_with_wrong_password_returns_401_envelope() {
let (app, _key) = login_app().await;
let (app, _key, _store) = login_app().await;
let (status, body) = post_login(app, r#"{"username":"admin","password":"wrong"}"#).await;

assert_eq!(status, StatusCode::UNAUTHORIZED);
Expand All @@ -166,11 +167,64 @@ async fn login_with_wrong_password_returns_401_envelope() {

#[tokio::test]
async fn login_with_unknown_user_returns_401_envelope() {
let (app, _key) = login_app().await;
let (app, _key, _store) = login_app().await;
let (status, body) = post_login(app, r#"{"username":"ghost","password":"dev"}"#).await;

assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["error"], "invalid credentials");
assert_eq!(body["code"], "UNAUTHORIZED");
assert_eq!(body["status"], 401);
}

/// Regression for issue #59: a successful login must stamp `last_login` on
/// the User row. Prior to the fix the field was declared by the schema but
/// no code ever wrote it, so admins could not answer "who logged in
/// recently?" from `entity list User`.
#[tokio::test]
async fn login_success_stamps_last_login_on_user_row() {
use schema_forge_core::types::DynamicValue;

let (app, _key, store) = login_app().await;

let before = chrono::Utc::now();
let (status, _body) = post_login(app, r#"{"username":"admin","password":"dev"}"#).await;
assert_eq!(status, StatusCode::OK);

let entity = store
.get_user_entity("admin")
.await
.expect("get_user_entity ok")
.expect("admin row must exist");
let last_login = match entity.field("last_login") {
Some(DynamicValue::DateTime(dt)) => *dt,
other => panic!("expected DynamicValue::DateTime on last_login, got {other:?}"),
};
assert!(
last_login >= before,
"last_login {last_login} must be at-or-after the request start {before}"
);
assert!(
last_login <= chrono::Utc::now(),
"last_login {last_login} must not be in the future"
);
}

/// Failed credential validation must not stamp `last_login` — that field
/// records *successful* logins only.
#[tokio::test]
async fn login_failure_leaves_last_login_untouched() {
let (app, _key, store) = login_app().await;

let (status, _body) = post_login(app, r#"{"username":"admin","password":"wrong"}"#).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);

let entity = store
.get_user_entity("admin")
.await
.expect("get_user_entity ok")
.expect("admin row must exist");
assert!(
entity.field("last_login").is_none(),
"last_login must remain unset after a failed login"
);
}
1 change: 1 addition & 0 deletions crates/schema-forge-backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
acton-service = { version = "0.26.1", default-features = false, features = ["crypto-aws-lc-rs"] }
argon2 = { version = "0.5", features = ["std"] }
chrono = { version = "0.4.44", features = ["serde"] }
schema-forge-core = { path = "../schema-forge-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
Loading
Loading