mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-11-15 13:16:56 +00:00
feat: Import images correctly from OIDC claims
This commit is contained in:
1 parent
6bbea25034
commit
2773ff3e16
6 files changed
+82
-35
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
Profile banners are now treated as images when imported from OIDC claims, like avatars already were.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
For deployments using OIDC with `profile_key_import_mode` set to `on_login`, users' avatars will no longer be clobbered if they didn't change their avatar since their last login.
|
||||||
@@ -2378,10 +2378,15 @@
|
|||||||
# Per-room overrides to the user's display name or avatar will be
|
# Per-room overrides to the user's display name or avatar will be
|
||||||
# preserved by the import process.
|
# preserved by the import process.
|
||||||
#
|
#
|
||||||
# SECURITY NOTE: If the `avatar_url` field is set, Continuwuity will
|
# SECURITY NOTE: For the following profile fields, Continuwuity will treat
|
||||||
# perform a HTTP GET to the URL in the mapped claim and use the returned
|
# the value of the mapped claim as an HTTP URL and upload the file located
|
||||||
# file as the user's profile picture. Make sure your users are not able
|
# at that URL to its internal media repository:
|
||||||
# to set the value of the mapped claim to an arbitrary URL.
|
#
|
||||||
|
# - `avatar_url`
|
||||||
|
# - `chat.commet.profile_banner`
|
||||||
|
#
|
||||||
|
# If you map these fields to claims, make sure your users cannot set those
|
||||||
|
# claims to arbitrary URLs.
|
||||||
#
|
#
|
||||||
#profile_key_map = { displayname = "name" }
|
#profile_key_map = { displayname = "name" }
|
||||||
|
|
||||||
|
|||||||
@@ -2864,10 +2864,15 @@ pub struct OidcConfig {
|
|||||||
/// Per-room overrides to the user's display name or avatar will be
|
/// Per-room overrides to the user's display name or avatar will be
|
||||||
/// preserved by the import process.
|
/// preserved by the import process.
|
||||||
///
|
///
|
||||||
/// SECURITY NOTE: If the `avatar_url` field is set, Continuwuity will
|
/// SECURITY NOTE: For the following profile fields, Continuwuity will treat
|
||||||
/// perform a HTTP GET to the URL in the mapped claim and use the returned
|
/// the value of the mapped claim as an HTTP URL and upload the file located
|
||||||
/// file as the user's profile picture. Make sure your users are not able
|
/// at that URL to its internal media repository:
|
||||||
/// to set the value of the mapped claim to an arbitrary URL.
|
///
|
||||||
|
/// - `avatar_url`
|
||||||
|
/// - `chat.commet.profile_banner`
|
||||||
|
///
|
||||||
|
/// If you map these fields to claims, make sure your users cannot set those
|
||||||
|
/// claims to arbitrary URLs.
|
||||||
///
|
///
|
||||||
/// default: { displayname = "name" }
|
/// default: { displayname = "name" }
|
||||||
#[serde(default = "default_profile_key_map")]
|
#[serde(default = "default_profile_key_map")]
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ pub(super) static MAPS: &[Descriptor] = &[
|
|||||||
},
|
},
|
||||||
Descriptor {
|
Descriptor {
|
||||||
name: "openidsubject_currentpictureurl",
|
name: "openidsubject_currentpictureurl",
|
||||||
|
..descriptor::DROPPED
|
||||||
|
},
|
||||||
|
Descriptor {
|
||||||
|
name: "openidsubjectprofilefield_url",
|
||||||
..descriptor::RANDOM_SMALL
|
..descriptor::RANDOM_SMALL
|
||||||
},
|
},
|
||||||
Descriptor {
|
Descriptor {
|
||||||
|
|||||||
+58
-27
@@ -47,7 +47,7 @@ pub struct Service {
|
|||||||
|
|
||||||
struct Data {
|
struct Data {
|
||||||
openidsubject_localpart: Arc<Map>,
|
openidsubject_localpart: Arc<Map>,
|
||||||
openidsubject_currentpictureurl: Arc<Map>,
|
openidsubjectprofilefield_url: Arc<Map>,
|
||||||
}
|
}
|
||||||
struct Services {
|
struct Services {
|
||||||
config: Dep<config::Service>,
|
config: Dep<config::Service>,
|
||||||
@@ -144,7 +144,7 @@ impl crate::Service for Service {
|
|||||||
runtime: args.server.runtime().clone(),
|
runtime: args.server.runtime().clone(),
|
||||||
db: Data {
|
db: Data {
|
||||||
openidsubject_localpart: args.db["openidsubject_localpart"].clone(),
|
openidsubject_localpart: args.db["openidsubject_localpart"].clone(),
|
||||||
openidsubject_currentpictureurl: args.db["openidsubject_currentpictureurl"].clone(),
|
openidsubjectprofilefield_url: args.db["openidsubjectprofilefield_url"].clone(),
|
||||||
},
|
},
|
||||||
client: args.server.config.oauth.oidc.as_ref().map(|config| -> Result<OidcClient> {
|
client: args.server.config.oauth.oidc.as_ref().map(|config| -> Result<OidcClient> {
|
||||||
Ok(OidcClient {
|
Ok(OidcClient {
|
||||||
@@ -214,6 +214,10 @@ impl crate::Service for Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Service {
|
impl Service {
|
||||||
|
// Profile keys that will be treated as image URLs and imported
|
||||||
|
// into the media repository. Keep this synced with the list in the
|
||||||
|
// doc comment for `profile_key_map` in the config.
|
||||||
|
const IMAGE_PROFILE_KEYS: &[&str] = &["avatar_url", "chat.commet.profile_banner"];
|
||||||
const SERVER_MISCONFIGURED: &str =
|
const SERVER_MISCONFIGURED: &str =
|
||||||
"Identity server is misconfigured. Contact your homeserver's administrator.";
|
"Identity server is misconfigured. Contact your homeserver's administrator.";
|
||||||
|
|
||||||
@@ -433,41 +437,66 @@ impl Service {
|
|||||||
let user_id = user_id.clone();
|
let user_id = user_id.clone();
|
||||||
let subject = claims.subject().to_string();
|
let subject = claims.subject().to_string();
|
||||||
let profile_key_map = config.profile_key_map.clone();
|
let profile_key_map = config.profile_key_map.clone();
|
||||||
let openidsubject_currentpictureurl = self.db.openidsubject_currentpictureurl.clone();
|
let openidsubjectprofilefield_url = self.db.openidsubjectprofilefield_url.clone();
|
||||||
let users = self.services.users.clone();
|
let users = self.services.users.clone();
|
||||||
let media = self.services.media.clone();
|
let media = self.services.media.clone();
|
||||||
|
|
||||||
let import_task = self.runtime.spawn(async move {
|
let import_task = self.runtime.spawn(async move {
|
||||||
|
// TODO: once we get support for full profile replacement, use
|
||||||
|
// that logic instead
|
||||||
for (field, claim) in &profile_key_map {
|
for (field, claim) in &profile_key_map {
|
||||||
let Some(value) = all_claims.get(claim).cloned() else {
|
let Some(value) = all_claims.get(claim).cloned() else {
|
||||||
warn!(?field, ?claim, "IDP provided no value for this mapped claim");
|
warn!(?field, ?claim, "IDP provided no value for this mapped claim");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let value = if let Some(picture_url) = value.as_str()
|
let value = if Self::IMAGE_PROFILE_KEYS.contains(&field.as_str()) {
|
||||||
&& field == ProfileFieldName::AvatarUrl.as_str()
|
if let Some(url) = value.as_str() {
|
||||||
&& openidsubject_currentpictureurl
|
// Check the last value the IDP gave us for this
|
||||||
.get(&subject)
|
// profile field. We have to track this in a
|
||||||
.await
|
// separate keyspace because, for fields in
|
||||||
.deserialized::<String>()
|
// `IMAGE_PROFILE_KEYS`, what gets saved in
|
||||||
.ok()
|
// the user's profile data is a MXC URI that has no
|
||||||
.is_none_or(|current_picture| current_picture != picture_url)
|
// relationship to the URL the IDP gave us.
|
||||||
{
|
if openidsubjectprofilefield_url
|
||||||
match media.download_media(picture_url).await {
|
.qry(&(&subject, field))
|
||||||
| Ok((mxc, size)) => {
|
.await
|
||||||
openidsubject_currentpictureurl.insert(&subject, picture_url);
|
.deserialized::<String>()
|
||||||
info!(?picture_url, ?mxc, ?size, "Downloaded profile picture");
|
.ok()
|
||||||
|
.is_none_or(|current_url| current_url != url)
|
||||||
|
{
|
||||||
|
match media.download_media(url).await {
|
||||||
|
| Ok((mxc, _)) => {
|
||||||
|
openidsubjectprofilefield_url.put((&subject, field), url);
|
||||||
|
info!(?url, ?field, ?mxc, "Downloaded profile image");
|
||||||
|
|
||||||
ProfileFieldValue::AvatarUrl(mxc)
|
ProfileFieldValue::new(
|
||||||
},
|
field,
|
||||||
| Err(err) => {
|
Value::String(mxc.to_string()),
|
||||||
warn!(
|
)
|
||||||
?claim,
|
.expect("MXC should be valid for this field")
|
||||||
?picture_url,
|
},
|
||||||
"Failed to download profile picture: {err}"
|
| Err(err) => {
|
||||||
);
|
warn!(
|
||||||
|
?field,
|
||||||
|
?url,
|
||||||
|
"Failed to download profile image: {err}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This claim hasn't changed from its previous
|
||||||
|
// value, don't update it.
|
||||||
continue;
|
continue;
|
||||||
},
|
}
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
?claim,
|
||||||
|
?value,
|
||||||
|
"Claim value for profile image was not a string"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match ProfileFieldValue::new(field, value.clone()) {
|
match ProfileFieldValue::new(field, value.clone()) {
|
||||||
@@ -499,8 +528,10 @@ impl Service {
|
|||||||
info!("Profile import complete");
|
info!("Profile import complete");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only wait for import to complete if this is a new account,
|
// Only wait for the import to complete if this is a new account,
|
||||||
// so they see the correct profile information in the account panel
|
// so they see the correct profile information in the account panel.
|
||||||
|
// Otherwise let it run in the background to avoid blocking the
|
||||||
|
// sign-in process.
|
||||||
if new_account_registered {
|
if new_account_registered {
|
||||||
let _ = import_task.await;
|
let _ = import_task.await;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user