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
305 changes: 298 additions & 7 deletions services/core/src/identity/auth.rs

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions services/core/src/identity/browser.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Browser-session compatibility without importing legacy sessions or funds.
mod password;
use super::*;
use hmac::{Hmac, Mac};
use subtle::ConstantTimeEq;
Expand Down Expand Up @@ -91,6 +92,11 @@ impl BrowserAuth {
{
return Err(IdentityError::Invalid.into());
}
// Email verification is not implemented. Do not report successful
// registration while silently discarding a requested email address.
if !email.is_empty() {
return Err(IdentityError::Forbidden.into());
}
let hash = self.store.hash_password(input.password.to_owned()).await?;
let mut tx = self.store.pool.begin().await?;
let user = auth::create_user(&mut tx).await?;
Expand Down
179 changes: 179 additions & 0 deletions services/core/src/identity/browser/password.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//! Change a browser user's password and rotate that same login atomically.
use super::*;

impl BrowserAuth {
/// Requires both a current browser bearer and the existing password.
/// Advancing the user generation invalidates other sessions and API keys.
/// The current family retains its original absolute and weekly deadlines.
pub async fn change_password(
&self,
access: &str,
original_password: &str,
password: &str,
display_name: Option<&str>,
expected_sid: Option<&str>,
) -> BrowserResult<BrowserBundle> {
let actor = self.store.authorize(access).await?;
if actor.credential_kind != CredentialKind::Session {
return Err(IdentityError::Forbidden.into());
}
if !(8..=20).contains(&password.chars().count())
|| password.len() > 1024
|| original_password.is_empty()
|| original_password.len() > 1024
|| display_name.is_some_and(|v| v.len() > 256 || v.chars().any(char::is_control))
{
return Err(IdentityError::Invalid.into());
}
let (sid, login): (String, String) = sqlx::query_as(
"SELECT s.sid,p.login_name FROM core_identity.web_sessions s
JOIN core_identity.password_logins p ON p.user_id=s.user_id
WHERE s.current_credential_id=$1 AND s.user_id=$2",
)
.bind(actor.credential_id)
.bind(actor.user_id)
.fetch_optional(&self.store.pool)
.await?
.ok_or(IdentityError::Forbidden)?;
if expected_sid.is_some_and(|expected| expected != sid) {
return Err(BrowserAuthError::SessionMismatch);
}
// Do expensive work without holding transaction locks. Recheck the
// exact password, policy, generation and browser family after locking.
let verified = self
.store
.verified_password(&login, original_password)
.await
.map_err(|error| match error {
// A valid session with a wrong password is not an expired
// session. A 401 would make the Web client refresh and replay
// this sensitive request, then discard the valid login.
IdentityError::Unauthorized => IdentityError::Forbidden,
other => other,
})?;
let hash = self.store.hash_password(password.to_owned()).await?;
let mut tx = self.store.pool.begin().await?;
// Refresh locks the family before the user; policy updates lock the
// profile first. Take all required row locks without waiting instead
// of upgrading shared locks and introducing a lock-order cycle.
let locked = sqlx::query(
"SELECT u.id FROM core_identity.users u
JOIN core_identity.accounts a ON a.id=u.personal_account_id
JOIN core_identity.credentials c ON c.user_id=u.id
JOIN core_identity.web_sessions s ON s.current_credential_id=c.id
JOIN core_identity.password_logins p ON p.user_id=u.id
JOIN core_identity.user_profiles pr ON pr.user_id=u.id
WHERE u.id=$1 AND c.id=$2 AND s.sid=$3
FOR UPDATE OF u,c,s,p,pr NOWAIT FOR SHARE OF a NOWAIT",
)
.bind(actor.user_id)
.bind(actor.credential_id)
.bind(&sid)
.fetch_optional(&mut *tx)
.await
.map_err(|error| {
if error.as_database_error().and_then(|e| e.code()).as_deref() == Some("55P03") {
IdentityError::Conflict
} else {
IdentityError::from(error)
}
})?;
if locked.is_none() {
return Err(IdentityError::Unauthorized.into());
}
// New statement snapshots are important after acquiring the locks.
let current = session(&mut tx, access).await?;
let family = lock_family(&mut tx, &sid).await?;
require_family_live(&family)?;
let user = self
.store
.lock_password_user(&mut tx, &login, &verified)
.await?;
if user != current.user_id || current.user_version != actor.user_version {
return Err(IdentityError::Unauthorized.into());
}
let version: i64 = sqlx::query_scalar(
"UPDATE core_identity.users SET auth_version=auth_version+1
WHERE id=$1 AND auth_version=$2 RETURNING auth_version",
)
.bind(user)
.bind(current.user_version)
.fetch_optional(&mut *tx)
.await?
.ok_or(IdentityError::Conflict)?;
sqlx::query("UPDATE core_identity.password_logins SET password_hash=$2 WHERE user_id=$1")
.bind(user)
.bind(hash)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE core_identity.user_profiles
SET legacy_bcrypt_allowed=FALSE,display_name=COALESCE($2,display_name)
WHERE user_id=$1",
)
.bind(user)
.bind(display_name)
.execute(&mut *tx)
.await?;
let refresh = new_secret("lmmr_")?;
let next = self.derived_secret(b"browser-access-v1", &sid, &refresh, "lmms_")?;
let issued =
auth::issue_session_with_secret(&mut tx, user, "session.password_change", next.clone())
.await?;
sqlx::query(
"UPDATE core_identity.credentials
SET expires_at=LEAST(expires_at,clock_timestamp()+interval '15 minutes',
(SELECT expires_at FROM core_identity.web_sessions WHERE sid=$2))
WHERE id=$1",
)
.bind(issued.id)
.bind(&sid)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE core_identity.credentials SET revoked_at=clock_timestamp() WHERE id=$1",
)
.bind(current.credential_id)
.execute(&mut *tx)
.await?;
// Never grant a grace window to a refresh secret from before a
// password change, and never extend the existing family lifetime.
sqlx::query(
"UPDATE core_identity.web_sessions
SET user_version=$2,current_credential_id=$3,refresh_digest=$4,
previous_refresh_digest=NULL,previous_valid_until=NULL,
last_active_at=clock_timestamp()
WHERE sid=$1",
)
.bind(&sid)
.bind(version)
.bind(issued.id)
.bind(digest(&refresh)?)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO core_identity.web_session_credentials(credential_id,sid) VALUES ($1,$2)",
)
.bind(issued.id)
.bind(&sid)
.execute(&mut *tx)
.await?;
audit(
&mut tx,
&current,
"password.change",
json!({"user_id":user,"user_version":version,"session_id":issued.id}),
)
.await?;
let bundle = make_bundle(
&mut tx,
&sid,
&next,
format!("{sid}.{refresh}"),
self.store.financial_profile_enabled,
)
.await?;
tx.commit().await?;
Ok(bundle)
}
}
103 changes: 103 additions & 0 deletions services/core/src/identity/oauth/tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,109 @@
use super::*;
use jsonwebtoken::{EncodingKey, Header, encode};

#[sqlx::test(migrations = false)]
async fn google_login_cannot_issue_sessions_while_local_second_factor_is_required(pool: PgPool) {
let mock = MockProvider::default();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let app = Router::new()
.route("/token", post(exchange))
.route("/keys", get(public_keys))
.with_state(mock.clone());
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let mut config = GoogleOAuth::new(
"client-1".into(),
"test-only-client-secret".into(),
format!("https://example.com{CALLBACK_PATH}"),
)
.unwrap();
// Only this private test module can use the loopback provider.
config.client = Client::builder()
.redirect(Policy::none())
.timeout(Duration::from_secs(3))
.build()
.unwrap();
config.token_endpoint = Url::parse(&format!("http://{address}/token")).unwrap();
config.keys_endpoint = Url::parse(&format!("http://{address}/keys")).unwrap();
let store = IdentityStore::from_pool(pool.clone())
.with_google_oauth(config)
.with_registration_enabled(true);
store.init_database().await.unwrap();
let native = store
.register(&LoginRequest {
login_name: "mfa-account".into(),
password: "test-only long passphrase".into(),
})
.await
.unwrap();
let user_id = store.authorize(&native.secret).await.unwrap().user_id;
// Synthetic prelinked identity. This does not test an account-link API.
sqlx::query(
"INSERT INTO core_identity.oauth_identities(issuer,subject,user_id) VALUES ($1,$2,$3)",
)
.bind(ISSUER)
.bind("mfa-subject")
.bind(user_id)
.execute(&pool)
.await
.unwrap();
let binding_a = new_secret("").unwrap();
let binding_b = new_secret("").unwrap();
let url_a = store.begin_google_login(&binding_a).await.unwrap();
let url_b = store.begin_google_login(&binding_b).await.unwrap();
let (state_a, code_a) = prepare_code(&store, &mock, &url_a, "mfa-subject").await;
let (state_b, code_b) = prepare_code(&store, &mock, &url_b, "mfa-subject").await;
// The policy changes after the flows begin but before the callbacks.
sqlx::query(
"UPDATE core_identity.user_profiles SET second_factor_required=TRUE WHERE user_id=$1",
)
.bind(user_id)
.execute(&pool)
.await
.unwrap();
assert!(store.authorize(&native.secret).await.is_err());
let counts_sql = "SELECT (SELECT count(*) FROM core_identity.credentials), (SELECT count(*) FROM core_identity.audit WHERE action='session.google_login')";
let before: (i64, i64) = sqlx::query_as(counts_sql).fetch_one(&pool).await.unwrap();
let (first, second) = tokio::join!(
store.finish_google_login(&state_a, &binding_a, &code_a),
store.finish_google_login(&state_b, &binding_b, &code_b),
);
assert!(matches!(first, Err(IdentityError::Unauthorized)));
assert!(matches!(second, Err(IdentityError::Unauthorized)));
let after: (i64, i64) = sqlx::query_as(counts_sql).fetch_one(&pool).await.unwrap();
assert_eq!(before, after);
assert_eq!(mock.exchanges.load(Ordering::SeqCst), 2);
// A rejected callback still consumes its state. Replaying it does not
// make another provider exchange or recover an issued credential.
assert!(matches!(
store
.finish_google_login(&state_a, &binding_a, &code_a)
.await,
Err(IdentityError::Unauthorized)
));
assert_eq!(mock.exchanges.load(Ordering::SeqCst), 2);
sqlx::query(
"UPDATE core_identity.user_profiles SET second_factor_required=FALSE WHERE user_id=$1",
)
.bind(user_id)
.execute(&pool)
.await
.unwrap();
assert!(store.authorize(&native.secret).await.is_err());
let binding = new_secret("").unwrap();
let url = store.begin_google_login(&binding).await.unwrap();
let (state, code) = prepare_code(&store, &mock, &url, "mfa-subject").await;
let issued = store
.finish_google_login(&state, &binding, &code)
.await
.unwrap();
assert_eq!(
store.authorize(&issued.secret).await.unwrap().user_id,
user_id
);
server.abort();
}

fn fixture() -> Value {
serde_json::from_str(include_str!("../testdata/google-signing-key.json")).unwrap()
}
Expand Down
58 changes: 48 additions & 10 deletions services/core/src/identity_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,22 +250,60 @@ async fn models(State(state): State<AuthState>, headers: HeaderMap) -> Response
.await,
)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct PasswordUpdate {
original_password: String,
password: String,
display_name: Option<String>,
}

// Both variants reject unknown fields. Mixed password/preferences requests
// fail rather than silently dropping one part of a security-sensitive update.
#[derive(Deserialize)]
#[serde(untagged)]
pub(super) enum ProfileMutation {
Password(PasswordUpdate),
Preferences(ProfileUpdate),
}

pub(super) async fn profile(
State(state): State<AuthState>,
headers: HeaderMap,
input: Result<Json<ProfileUpdate>, JsonRejection>,
input: Result<Json<ProfileMutation>, JsonRejection>,
) -> Response {
let Ok(Json(body)) = input else {
return auth_failure(IdentityError::Invalid.into());
};
respond(
async {
state
.store
.update_dashboard_profile(access(&headers)?, &body)
.await?;
Ok(Value::Null)
match body {
ProfileMutation::Password(body) => {
let result = async {
state
.browser
.change_password(
access(&headers)?,
&body.original_password,
&body.password,
body.display_name.as_deref(),
expected_sid(&headers)?,
)
.await
}
.await;
match result {
Ok(bundle) => bundle_response(bundle, &state.config),
Err(error) => auth_failure(error),
}
}
.await,
)
ProfileMutation::Preferences(body) => respond(
async {
state
.store
.update_dashboard_profile(access(&headers)?, &body)
.await?;
Ok(Value::Null)
}
.await,
),
}
}
Loading
Loading