diff --git a/migrations/058_user_token_version.sql b/migrations/058_user_token_version.sql new file mode 100644 index 0000000..d7735e0 --- /dev/null +++ b/migrations/058_user_token_version.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0; diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index 0860f82..ae25aa5 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -16,6 +16,8 @@ pub struct Claims { pub is_banned: bool, #[serde(default)] pub email_verified: bool, + #[serde(default)] + pub ver: i32, } impl Claims { @@ -55,6 +57,7 @@ pub fn create_access_token( is_admin, is_banned, email_verified, + ver: 0, }; encode( &Header::default(), @@ -63,7 +66,10 @@ pub fn create_access_token( ) } -pub fn create_refresh_token(user_id: Uuid) -> Result { +pub fn create_refresh_token( + user_id: Uuid, + token_version: i32, +) -> Result { let now = Utc::now(); // Refresh tokens carry no tier — tier is re-read from DB on each refresh. let claims = Claims { @@ -77,6 +83,7 @@ pub fn create_refresh_token(user_id: Uuid) -> Result String { + let now = Utc::now(); + encode( + &Header::default(), + &LegacyClaims { + sub: user_id, + iat: now.timestamp(), + exp: (now + Duration::hours(1)).timestamp(), + kind: kind.to_string(), + tier: "pro".to_string(), + trial_ends_at: None, + trial_used: false, + is_admin: false, + is_banned: false, + }, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .unwrap() + } + #[test] fn access_token_includes_email_verified_claim() { // Serialize JWT_SECRET mutation against other env-touching tests (e.g. the @@ -136,31 +163,25 @@ mod tests { } #[test] - fn legacy_token_without_email_verified_decodes_as_unverified() { + fn legacy_token_decodes_with_defaults_for_later_claims() { let _guard = crate::test_support::env_lock(); std::env::set_var("JWT_SECRET", "test-secret"); - let now = Utc::now(); let user_id = Uuid::new_v4(); - let token = encode( - &Header::default(), - &LegacyClaims { - sub: user_id, - iat: now.timestamp(), - exp: (now + Duration::hours(1)).timestamp(), - kind: "access".to_string(), - tier: "pro".to_string(), - trial_ends_at: None, - trial_used: false, - is_admin: false, - is_banned: false, - }, - &EncodingKey::from_secret(b"test-secret"), - ) - .unwrap(); - let claims = validate_token(&token, "access").unwrap(); + let claims = validate_token(&legacy_token(user_id, "access", "test-secret"), "access").unwrap(); assert!(!claims.email_verified); + assert_eq!(claims.ver, 0); assert_eq!(claims.sub, user_id); } + + #[test] + fn refresh_token_carries_the_token_version() { + let _guard = crate::test_support::env_lock(); + std::env::set_var("JWT_SECRET", "test-secret"); + + let token = create_refresh_token(Uuid::new_v4(), 7).unwrap(); + + assert_eq!(validate_token(&token, "refresh").unwrap().ver, 7); + } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 86c9d41..ef45b0e 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -165,6 +165,7 @@ mod authz_tests { is_admin: false, is_banned, email_verified: true, + ver: 0, } } diff --git a/src/main.rs b/src/main.rs index 0fec9ef..b92699f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -286,7 +286,6 @@ async fn main() { let public = Router::new() .route("/v1/auth/challenge", get(routes::auth::challenge)) .route("/v1/auth/login", post(routes::auth::login)) - .route("/v1/auth/refresh", post(routes::auth::refresh)) .route("/v1/auth/verify-email", post(routes::auth::verify_email)) .route( "/v1/invitations/:token", @@ -295,6 +294,15 @@ async fn main() { .layer(middleware::from_fn(rate_limit::auth_rate_limit)) .layer(Extension(auth_limiter)); + // A revoked session retries refresh every few seconds; it must not spend the sign-in budget. + let refresh_route = Router::new() + .route("/v1/auth/refresh", post(routes::auth::refresh)) + .layer(middleware::from_fn(rate_limit::auth_rate_limit)) + .layer(Extension(RateLimiter::::new( + auth_per_minute * 3, + Duration::from_secs(60), + ))); + let waitlist = Router::new() .route( "/v1/mobile-waitlist", @@ -360,6 +368,7 @@ async fn main() { .route("/v1/auth/me", get(routes::auth::get_me)) .route("/v1/auth/email", put(routes::auth::update_email)) .route("/v1/auth/password", put(routes::auth::update_password)) + .route("/v1/auth/logout-all", post(routes::auth::logout_all)) .route( "/v1/auth/wrapped-user-secrets", put(routes::auth::upload_wrapped_user_secrets), @@ -760,6 +769,7 @@ async fn main() { let app = Router::new() .merge(public) + .merge(refresh_route) .merge(waitlist) .merge(register_route) .merge(webhooks) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index b276e67..67b3e28 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -30,11 +30,48 @@ struct TierInfo { is_admin: bool, is_banned: bool, email_verified: bool, + token_version: i32, +} + +fn access_token_for(user_id: Uuid, tier: &TierInfo) -> Result { + create_access_token( + user_id, + &tier.tier, + tier.trial_ends_at, + tier.trial_used, + tier.is_admin, + tier.is_banned, + tier.email_verified, + ) + .map_err(|err| { + error!(error = %err, user_id = %user_id, "Failed to create access token"); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + +fn issue_session(user_id: Uuid, tier: &TierInfo) -> Result<(String, String), StatusCode> { + let refresh_token = create_refresh_token(user_id, tier.token_version).map_err(|err| { + error!(error = %err, user_id = %user_id, "Failed to create refresh token"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok((access_token_for(user_id, tier)?, refresh_token)) +} + +async fn bump_token_version(pool: &PgPool, user_id: Uuid) -> Result<(), StatusCode> { + sqlx::query("UPDATE users SET token_version = token_version + 1 WHERE id = $1") + .bind(user_id) + .execute(pool) + .await + .map_err(|e| { + error!(error = %e, user_id = %user_id, "Failed to bump token version"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(()) } async fn fetch_tier(pool: &PgPool, user_id: Uuid) -> Result { - let row = sqlx::query_as::<_, (String, Option>, bool, bool, bool, bool, bool, Option, Option>)>( - "SELECT subscription_tier, trial_ends_at, trial_used, is_admin, is_banned, email_verified, admin_override, ls_subscription_id, deleted_at FROM users WHERE id = $1", + let row = sqlx::query_as::<_, (String, Option>, bool, bool, bool, bool, bool, Option, Option>, i32)>( + "SELECT subscription_tier, trial_ends_at, trial_used, is_admin, is_banned, email_verified, admin_override, ls_subscription_id, deleted_at, token_version FROM users WHERE id = $1", ) .bind(user_id) .fetch_one(pool) @@ -44,7 +81,7 @@ async fn fetch_tier(pool: &PgPool, user_id: Uuid) -> Result Result, axum::Extension(auth): axum::Extension, + axum::Extension(notifier): axum::Extension, Json(body): Json, ) -> Result, StatusCode> { let auth_hash = sqlx::query_scalar::<_, String>("SELECT auth_hash FROM users WHERE id = $1") @@ -821,7 +831,7 @@ pub async fn update_password( })?; sqlx::query( - "UPDATE users SET auth_hash = $1, wrapped_user_secrets = $2, updated_at = now() WHERE id = $3", + "UPDATE users SET auth_hash = $1, wrapped_user_secrets = $2, token_version = token_version + 1, updated_at = now() WHERE id = $3", ) .bind(&new_auth_hash) .bind(&body.new_wrapped_user_secrets) @@ -834,23 +844,8 @@ pub async fn update_password( })?; let tier = fetch_tier(&pool, auth.0).await?; - let jwt_token = create_access_token( - auth.0, - &tier.tier, - tier.trial_ends_at, - tier.trial_used, - tier.is_admin, - tier.is_banned, - tier.email_verified, - ) - .map_err(|e| { - error!(error = %e, user_id = %auth.0, "Failed to create access token after password update"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - let refresh_token = create_refresh_token(auth.0).map_err(|e| { - error!(error = %e, user_id = %auth.0, "Failed to create refresh token after password update"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let (jwt_token, refresh_token) = issue_session(auth.0, &tier)?; + notifier.notify(auth.0, "token_invalidated".to_string()); info!(user_id = %auth.0, "User password updated"); @@ -864,6 +859,29 @@ pub async fn update_password( })) } +// ─── Log out everywhere ─────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct SessionTokens { + pub jwt_token: String, + pub refresh_token: String, +} + +pub async fn logout_all( + State(pool): State, + axum::Extension(auth): axum::Extension, + axum::Extension(notifier): axum::Extension, +) -> Result, StatusCode> { + bump_token_version(&pool, auth.0).await?; + let tier = fetch_tier(&pool, auth.0).await?; + let (jwt_token, refresh_token) = issue_session(auth.0, &tier)?; + notifier.notify(auth.0, "token_invalidated".to_string()); + + info!(user_id = %auth.0, "User signed out every other session"); + + Ok(Json(SessionTokens { jwt_token, refresh_token })) +} + // ─── Upload wrapped user secrets (migration) ────────────────────────────────── #[derive(Deserialize)] @@ -1454,6 +1472,89 @@ mod handler_tests { } } + // ── revocation: a refresh token dies when token_version moves ──────────── + + async fn refresh_with(pool: &PgPool, refresh_token: &str) -> Result { + refresh( + State(pool.clone()), + Json(RefreshRequest { + refresh_token: refresh_token.to_string(), + }), + ) + .await + .map(|Json(r)| r.jwt_token) + } + + async fn signed_in_twice(pool: &PgPool) -> (Uuid, AuthResponse, AuthResponse) { + let account_id = Uuid::new_v4(); + let uid = seed_user_with_credentials(pool, account_id, "auth-key-secret").await; + let here = login_of(pool, account_id).await.expect("login ok"); + let elsewhere = login_of(pool, account_id).await.expect("login ok"); + (uid, here, elsewhere) + } + + #[tokio::test] + async fn password_change_revokes_earlier_refresh_tokens() { + let _env = EnvGuard::new(true); + let pool = test_pool_or_skip!(); + let (uid, _, elsewhere) = signed_in_twice(&pool).await; + + let Json(changed) = update_password( + State(pool.clone()), + Extension(AuthUser(uid)), + Extension(SyncNotifier::new()), + Json(UpdatePasswordRequest { + old_auth_key: "auth-key-secret".to_string(), + new_auth_key: "new-auth-key".to_string(), + new_wrapped_user_secrets: "wrapped".to_string(), + }), + ) + .await + .expect("password change ok"); + + assert_eq!( + refresh_with(&pool, &elsewhere.refresh_token).await, + Err(StatusCode::UNAUTHORIZED) + ); + assert!(refresh_with(&pool, &changed.refresh_token).await.is_ok()); + } + + #[tokio::test] + async fn logout_all_revokes_other_sessions_and_keeps_the_caller() { + let _env = EnvGuard::new(true); + let pool = test_pool_or_skip!(); + use crate::sync_notifier::SyncEvent; + let (uid, here, elsewhere) = signed_in_twice(&pool).await; + let notifier = SyncNotifier::new(); + let mut events = notifier.subscribe(); + + let Json(fresh) = logout_all(State(pool.clone()), Extension(AuthUser(uid)), Extension(notifier)) + .await + .expect("logout-all ok"); + + for revoked in [&here.refresh_token, &elsewhere.refresh_token] { + assert_eq!(refresh_with(&pool, revoked).await, Err(StatusCode::UNAUTHORIZED)); + } + assert!(refresh_with(&pool, &fresh.refresh_token).await.is_ok()); + assert!(validate_token(&fresh.jwt_token, "access").is_ok()); + assert!(matches!( + events.try_recv(), + Ok(SyncEvent::BlobPushed { user_id, device_id }) if user_id == uid && device_id == "token_invalidated" + )); + } + + #[tokio::test] + async fn refresh_token_from_before_the_version_claim_works_until_a_bump() { + let _env = EnvGuard::new(true); + let pool = test_pool_or_skip!(); + let uid = seed_user_with_credentials(&pool, Uuid::new_v4(), "auth-key-secret").await; + let legacy = crate::auth::jwt::tests::legacy_token(uid, "refresh", "ci-test-secret"); + + assert!(refresh_with(&pool, &legacy).await.is_ok()); + bump_token_version(&pool, uid).await.expect("bump"); + assert_eq!(refresh_with(&pool, &legacy).await, Err(StatusCode::UNAUTHORIZED)); + } + // ── soft delete: a deleted account is locked out of every auth path ─────── async fn soft_delete(pool: &PgPool, user_id: Uuid) { diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index fbaa468..678655b 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -1926,6 +1926,7 @@ mod authz_tests { is_admin: false, is_banned: false, email_verified: true, + ver: 0, }) }