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
13 changes: 13 additions & 0 deletions crates/common/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ pub enum AppError {
#[error("{0}")]
Forbidden(String),

/// The platform requires TOTP (`security.totp_required`) and the
/// signed-in user has not enrolled. The session stays valid but
/// only reaches the enrollment endpoints until they do; the
/// console switches on the `totp_enrollment_required` type to send
/// the user to enrollment.
#[error("Two-factor authentication must be set up before continuing")]
TotpEnrollmentRequired,

#[error("{0}")]
NotFound(String),

Expand Down Expand Up @@ -74,6 +82,11 @@ impl IntoResponse for AppError {
"Authentication required".to_string(),
),
AppError::Forbidden(reason) => (StatusCode::FORBIDDEN, "forbidden", reason.clone()),
AppError::TotpEnrollmentRequired => (
StatusCode::FORBIDDEN,
"totp_enrollment_required",
self.to_string(),
),
AppError::NotFound(m) => (StatusCode::NOT_FOUND, "not_found", m.clone()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m.clone()),
AppError::RateLimited => (
Expand Down
51 changes: 37 additions & 14 deletions crates/server/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1323,7 +1323,7 @@ pub async fn logout(
pub async fn me(
auth_user: AuthUser,
State(state): State<AppState>,
) -> Result<Json<UserResponse>, AppError> {
) -> Result<Json<MeResponse>, AppError> {
let user = repo::find_active(&state.db, auth_user.claims.sub)
.await?
.ok_or(AppError::NotFound("User not found".into()))?;
Expand Down Expand Up @@ -1354,21 +1354,38 @@ pub async fn me(
.map(|(id, name)| think_watch_common::dto::UserTeamSummary { id, name })
.collect();

Ok(Json(UserResponse {
id: user.id,
email: user.email,
display_name: user.display_name,
avatar_url: user.avatar_url,
is_active: user.is_active,
oidc_subject: user.oidc_subject,
role_assignments,
permissions,
denied_permissions,
teams,
created_at: user.created_at,
let totp_enrollment_required = !user.totp_enabled && state.dynamic_config.totp_required().await;

Ok(Json(MeResponse {
user: UserResponse {
id: user.id,
email: user.email,
display_name: user.display_name,
avatar_url: user.avatar_url,
is_active: user.is_active,
oidc_subject: user.oidc_subject,
role_assignments,
permissions,
denied_permissions,
teams,
created_at: user.created_at,
},
totp_enrollment_required,
}))
}

/// `GET /api/auth/me`: the profile, plus whether the session is held
/// at TOTP enrollment.
#[derive(Debug, Serialize)]
pub struct MeResponse {
#[serde(flatten)]
pub user: UserResponse,
/// The platform requires TOTP and this user has not enrolled: every
/// console endpoint other than enrollment answers 403
/// `totp_enrollment_required` until they do.
pub totp_enrollment_required: bool,
}

/// Helper: load every role assignment (system + custom) for a single
/// user. Pure read; never errors — returns an empty Vec on failure so
/// the caller can keep building a response.
Expand Down Expand Up @@ -1773,7 +1790,7 @@ pub async fn totp_verify_setup(
request_body = DisableTotpRequest,
responses(
(status = 200, description = "TOTP disabled"),
(status = 400, description = "TOTP not enabled or SSO account"),
(status = 400, description = "TOTP not enabled, required by the platform, or SSO account"),
(status = 401, description = "Unauthorized or wrong password"),
),
)]
Expand All @@ -1789,6 +1806,12 @@ pub async fn totp_disable(
if !user.totp_enabled {
return Err(AppError::BadRequest("TOTP is not enabled".into()));
}
// Disabling would only put the session straight back at enrollment.
if state.dynamic_config.totp_required().await {
return Err(AppError::BadRequest(
"TOTP is required on this platform and cannot be disabled".into(),
));
}

// Verify current password.
let hash = user.password_hash.as_ref().ok_or(AppError::BadRequest(
Expand Down
56 changes: 44 additions & 12 deletions crates/server/src/middleware/auth_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use axum::{
extract::{FromRequestParts, State},
http::{Request, StatusCode, header::AUTHORIZATION, request::Parts},
middleware::Next,
response::Response,
response::{IntoResponse, Response},
};

use think_watch_auth::{api_key, jwt::Claims, rbac};
Expand Down Expand Up @@ -896,18 +896,22 @@ pub async fn require_auth(
// would silently start authenticating again until expiry. One
// indexed PK lookup per request closes the gap — cost is sub-ms
// and only on the auth path.
let user_active: Option<bool> =
sqlx::query_scalar("SELECT is_active FROM users WHERE id = $1 AND deleted_at IS NULL")
.bind(claims.sub)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "DB check for users.is_active failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !matches!(user_active, Some(true)) {
//
// The same lookup reads `totp_enabled` for the TOTP-requirement
// gate further down, so enforcing it costs no extra query.
let user_row: Option<(bool, bool)> = sqlx::query_as(
"SELECT is_active, totp_enabled FROM users WHERE id = $1 AND deleted_at IS NULL",
)
.bind(claims.sub)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "DB check for users.is_active failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let Some((true, totp_enabled)) = user_row else {
return Err(StatusCode::UNAUTHORIZED);
}
};

let ip = extract_client_ip(&state, request.headers(), request.extensions()).await;
// Mirror the empty-string filter that `extract_client_ip` applies
Expand Down Expand Up @@ -939,6 +943,17 @@ pub async fn require_auth(
});
}

// `security.totp_required`: a session whose user has not enrolled
// reaches only what enrolling needs. Decided per request (not at
// login) so switching the setting on covers sessions that already
// exist, and enrolling lifts the limit on the same session.
if !totp_enabled
&& !reachable_before_totp_enrollment(request.uri().path())
&& state.dynamic_config.totp_required().await
{
return Ok(AppError::TotpEnrollmentRequired.into_response());
}

request.extensions_mut().insert(AuthUser {
claims,
ip,
Expand All @@ -953,6 +968,23 @@ pub async fn require_auth(
Ok(next.run(request).await)
}

/// Console paths a session can reach while the platform requires TOTP
/// and its user has not enrolled: who am I, the enrollment endpoints,
/// the signing-key registration every signed write depends on, and
/// logout.
const TOTP_ENROLLMENT_PATHS: &[&str] = &[
"/api/auth/me",
"/api/auth/register-key",
"/api/auth/logout",
"/api/auth/totp/status",
"/api/auth/totp/setup",
"/api/auth/totp/verify-setup",
];

fn reachable_before_totp_enrollment(path: &str) -> bool {
TOTP_ENROLLMENT_PATHS.contains(&path)
}

/// Authenticate a `tw-` API key against the `console` surface and build a
/// synthetic `AuthUser` from the key owner's current permissions. Inserts
/// `ApiKeyAuthenticated` so `verify_signature` knows to skip HMAC.
Expand Down
41 changes: 41 additions & 0 deletions crates/test-support/tests/admin_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -752,3 +752,44 @@ async fn requiring_totp_is_reported_to_users() {
let status = get(&admin, "/api/auth/totp/status").await;
assert_eq!(status["required"], true, "{status}");
}

/// With `security.totp_required` on, an SSO sign-in of a user who has
/// not enrolled gets a session held at enrollment, exactly like a
/// password sign-in (`totp_required.rs`), and enrolling releases it.
#[ignore = "integration test — run via `make test-it`"]
#[tokio::test]
async fn an_unenrolled_sso_user_is_held_at_totp_enrollment() {
let app = TestApp::spawn_reaching_loopback().await;
let admin = admin_session(&app).await;
let idp = mock_idp().await;
activate_sso(&app, &admin, &idp).await;
app.set_setting("auth.default_role", json!("developer"))
.await;
app.set_setting("security.totp_required", json!(true)).await;

let identity = json!({"sub": unique_name("sub"), "email": unique_email()});
let (con, status) = sso_login(&app, &idp, identity).await;
assert_eq!(status, 307);

let me = get(&con, "/api/auth/me").await;
assert_eq!(me["totp_enrollment_required"], true, "{me}");
let resp = con.get("/api/keys").await.unwrap();
resp.assert_status(403);
let body: Value = resp.json().unwrap();
assert_eq!(body["error"]["type"], "totp_enrollment_required", "{body}");

let setup: Value = con
.post_empty("/api/auth/totp/setup")
.await
.unwrap()
.json()
.unwrap();
let email = me["email"].as_str().unwrap();
let code =
think_watch_auth::totp::current_code(setup["secret"].as_str().unwrap(), email).unwrap();
con.post("/api/auth/totp/verify-setup", json!({"code": code}))
.await
.unwrap()
.assert_ok();
con.get("/api/keys").await.unwrap().assert_ok();
}
Loading
Loading