Compare commits
2 Commits
ffd93c5435
...
9a599fdde2
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a599fdde2 | |||
| 764ad3d5a1 |
18
README.md
18
README.md
@@ -67,10 +67,11 @@ You can add as much upstream provider as you want, using the following syntax in
|
|||||||
```yaml
|
```yaml
|
||||||
- id: gitlab
|
- id: gitlab
|
||||||
name: GitLab
|
name: GitLab
|
||||||
logo: gitlab # Can be either gitea, gitlab, github, microsoft, google or a full URL
|
logo: gitlab # Can be either openid, gitea, gitlab, github, microsoft, google or a full URL
|
||||||
client_id: CLIENT_ID_GIVEN_BY_PROVIDER
|
client_id: CLIENT_ID_GIVEN_BY_PROVIDER
|
||||||
client_secret: CLIENT_SECRET_GIVEN_BY_PROVIDER
|
client_secret: CLIENT_SECRET_GIVEN_BY_PROVIDER
|
||||||
configuration_url: https://gitlab.com/.well-known/openid-configuration
|
configuration_url: https://gitlab.com/.well-known/openid-configuration
|
||||||
|
allow_auto_account_creation: true
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -108,5 +109,20 @@ Corresponding client configuration:
|
|||||||
|
|
||||||
OAuth proxy can then be access on this URL: http://192.168.2.103:4180/
|
OAuth proxy can then be access on this URL: http://192.168.2.103:4180/
|
||||||
|
|
||||||
|
## Testing with upstream identity provider
|
||||||
|
The folder [sample_upstream_provider](sample_upstream_provider) contains a working scenario of authentication with an upstream provider.
|
||||||
|
|
||||||
|
Run the following command to run the scenario:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sample_upstream_provider
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
- Upstream provider (not to be directly used): http://localhost:9001
|
||||||
|
- BasicOIDC: http://localhost:8000
|
||||||
|
- Client 2: http://localhost:8012
|
||||||
|
- Client 1: http://localhost:8011
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
If you wish to contribute to this software, feel free to send an email to contact@communiquons.org to get an account on my system, managed by BasicOIDC :)
|
If you wish to contribute to this software, feel free to send an email to contact@communiquons.org to get an account on my system, managed by BasicOIDC :)
|
||||||
|
|||||||
1
assets/img/brands/openid.svg
Normal file
1
assets/img/brands/openid.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenID</title><path d="M14.54.889l-3.63 1.773v18.17c-4.15-.52-7.27-2.78-7.27-5.5 0-2.58 2.8-4.75 6.63-5.41v-2.31C4.42 8.322 0 11.502 0 15.332c0 3.96 4.74 7.24 10.91 7.78l3.63-1.71V.888m.64 6.724v2.31c1.43.25 2.71.7 3.76 1.31l-1.97 1.11 7.03 1.53-.5-5.21-1.87 1.06c-1.74-1.06-3.96-1.81-6.45-2.11z"/></svg>
|
||||||
|
After Width: | Height: | Size: 382 B |
26
sample_upstream_provider/dex-provider/dex.config.yaml
Normal file
26
sample_upstream_provider/dex-provider/dex.config.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
issuer: http://127.0.0.1:9001/dex
|
||||||
|
|
||||||
|
storage:
|
||||||
|
type: memory
|
||||||
|
|
||||||
|
web:
|
||||||
|
http: 0.0.0.0:9001
|
||||||
|
|
||||||
|
oauth2:
|
||||||
|
# Automate some clicking
|
||||||
|
# Note: this might actually make some tests pass that otherwise wouldn't.
|
||||||
|
skipApprovalScreen: false
|
||||||
|
|
||||||
|
connectors:
|
||||||
|
# Note: this might actually make some tests pass that otherwise wouldn't.
|
||||||
|
- type: mockCallback
|
||||||
|
id: mock
|
||||||
|
name: Example
|
||||||
|
|
||||||
|
# Basic OP test suite requires two clients.
|
||||||
|
staticClients:
|
||||||
|
- id: foo
|
||||||
|
secret: bar
|
||||||
|
redirectURIs:
|
||||||
|
- http://localhost:8000/prov_cb
|
||||||
|
name: Auth
|
||||||
46
sample_upstream_provider/docker-compose.yaml
Normal file
46
sample_upstream_provider/docker-compose.yaml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
services:
|
||||||
|
upstream:
|
||||||
|
image: dexidp/dex
|
||||||
|
user: "1000"
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- ./dex-provider:/conf:ro
|
||||||
|
command: [ "dex", "serve", "/conf/dex.config.yaml" ]
|
||||||
|
|
||||||
|
client1:
|
||||||
|
image: pierre42100/oidc_test_client
|
||||||
|
user: "1000"
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- LISTEN_ADDR=0.0.0.0:8011
|
||||||
|
- PUBLIC_URL=http://127.0.0.1:8011
|
||||||
|
- CONFIGURATION_URL=http://localhost:8000/.well-known/openid-configuration
|
||||||
|
- CLIENT_ID=testclient1
|
||||||
|
- CLIENT_SECRET=secretone
|
||||||
|
|
||||||
|
client2:
|
||||||
|
image: pierre42100/oidc_test_client
|
||||||
|
user: "1000"
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- LISTEN_ADDR=0.0.0.0:8012
|
||||||
|
- PUBLIC_URL=http://127.0.0.1:8012
|
||||||
|
- CONFIGURATION_URL=http://localhost:8000/.well-known/openid-configuration
|
||||||
|
- CLIENT_ID=testclient2
|
||||||
|
- CLIENT_SECRET=secrettwo
|
||||||
|
|
||||||
|
basicoidc:
|
||||||
|
image: rust
|
||||||
|
user: "1000"
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- STORAGE_PATH=/storage
|
||||||
|
#- RUST_LOG=debug
|
||||||
|
volumes:
|
||||||
|
- ../:/app
|
||||||
|
- ./storage:/storage
|
||||||
|
- ~/.cargo/registry:/usr/local/cargo/registry
|
||||||
|
command:
|
||||||
|
- bash
|
||||||
|
- -c
|
||||||
|
- cd /app && cargo run
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
use crate::data::provider::{Provider, ProviderID};
|
use crate::data::provider::{Provider, ProviderID};
|
||||||
use actix::{Actor, Context, Handler, Message, MessageResult};
|
|
||||||
|
|
||||||
use crate::data::user::{FactorID, GeneralSettings, GrantedClients, TwoFactor, User, UserID};
|
use crate::data::user::{FactorID, GeneralSettings, GrantedClients, TwoFactor, User, UserID};
|
||||||
use crate::utils::err::Res;
|
use crate::utils::err::Res;
|
||||||
|
use crate::utils::string_utils::is_acceptable_login;
|
||||||
|
use actix::{Actor, Context, Handler, Message, MessageResult};
|
||||||
|
use light_openid::primitives::OpenIDUserInfo;
|
||||||
|
|
||||||
/// User storage interface
|
/// User storage interface
|
||||||
pub trait UsersSyncBackend {
|
pub trait UsersSyncBackend {
|
||||||
@@ -38,6 +39,8 @@ pub enum LoginResult {
|
|||||||
LocalAuthForbidden,
|
LocalAuthForbidden,
|
||||||
AuthFromProviderForbidden,
|
AuthFromProviderForbidden,
|
||||||
Success(Box<User>),
|
Success(Box<User>),
|
||||||
|
AccountAutoCreated(Box<User>),
|
||||||
|
CannotAutoCreateAccount(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Message)]
|
#[derive(Message)]
|
||||||
@@ -51,6 +54,7 @@ pub struct LocalLoginRequest {
|
|||||||
#[rtype(LoginResult)]
|
#[rtype(LoginResult)]
|
||||||
pub struct ProviderLoginRequest {
|
pub struct ProviderLoginRequest {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
pub user_info: OpenIDUserInfo,
|
||||||
pub provider: Provider,
|
pub provider: Provider,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +191,86 @@ impl Handler<ProviderLoginRequest> for UsersActor {
|
|||||||
log::error!("Failed to find user! {e}");
|
log::error!("Failed to find user! {e}");
|
||||||
MessageResult(LoginResult::Error)
|
MessageResult(LoginResult::Error)
|
||||||
}
|
}
|
||||||
Ok(None) => MessageResult(LoginResult::AccountNotFound),
|
Ok(None) => {
|
||||||
|
// Check if automatic account creation is enabled for this provider
|
||||||
|
if !msg.provider.allow_auto_account_creation {
|
||||||
|
return MessageResult(LoginResult::AccountNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract username for account creation
|
||||||
|
let mut username = msg
|
||||||
|
.user_info
|
||||||
|
.preferred_username
|
||||||
|
.unwrap_or(msg.email.to_string());
|
||||||
|
|
||||||
|
// Determine username from email, if necessary
|
||||||
|
if !is_acceptable_login(&username)
|
||||||
|
|| matches!(
|
||||||
|
self.manager.find_by_username_or_email(&username),
|
||||||
|
Ok(Some(_))
|
||||||
|
)
|
||||||
|
{
|
||||||
|
username = msg.email.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username is already taken
|
||||||
|
if matches!(
|
||||||
|
self.manager.find_by_username_or_email(&username),
|
||||||
|
Ok(Some(_))
|
||||||
|
) {
|
||||||
|
return MessageResult(LoginResult::CannotAutoCreateAccount(format!(
|
||||||
|
"username {username} is already taken!"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_acceptable_login(&username) {
|
||||||
|
return MessageResult(LoginResult::CannotAutoCreateAccount(
|
||||||
|
"could not determine acceptable login for user!".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatic account creation
|
||||||
|
let user_id = match self.manager.create_user_account(GeneralSettings {
|
||||||
|
uid: UserID::random(),
|
||||||
|
username,
|
||||||
|
first_name: msg.user_info.given_name.unwrap_or_default(),
|
||||||
|
last_name: msg.user_info.family_name.unwrap_or_default(),
|
||||||
|
email: msg.email.to_string(),
|
||||||
|
enabled: true,
|
||||||
|
two_factor_exemption_after_successful_login: false,
|
||||||
|
is_admin: false,
|
||||||
|
}) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to create user account! {e}");
|
||||||
|
return MessageResult(LoginResult::CannotAutoCreateAccount(
|
||||||
|
"missing some user information".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mark the provider as the only authorized source
|
||||||
|
if let Err(e) = self.manager.set_authorized_authentication_sources(
|
||||||
|
&user_id,
|
||||||
|
AuthorizedAuthenticationSources {
|
||||||
|
local: false,
|
||||||
|
upstream: vec![msg.provider.id],
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
log::error!(
|
||||||
|
"Failed to set authorized authentication sources for newly created account! {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user information to return them
|
||||||
|
let Ok(Some(user)) = self.manager.find_by_user_id(&user_id) else {
|
||||||
|
return MessageResult(LoginResult::CannotAutoCreateAccount(
|
||||||
|
"failed to get created user information".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageResult(LoginResult::AccountAutoCreated(Box::new(user)))
|
||||||
|
}
|
||||||
Ok(Some(user)) => {
|
Ok(Some(user)) => {
|
||||||
if !user.can_login_from_provider(&msg.provider) {
|
if !user.can_login_from_provider(&msg.provider) {
|
||||||
return MessageResult(LoginResult::AuthFromProviderForbidden);
|
return MessageResult(LoginResult::AuthFromProviderForbidden);
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use actix::Addr;
|
|
||||||
use actix_identity::Identity;
|
|
||||||
use actix_remote_ip::RemoteIP;
|
|
||||||
use actix_web::{HttpRequest, HttpResponse, Responder, web};
|
|
||||||
use askama::Template;
|
|
||||||
|
|
||||||
use crate::actors::bruteforce_actor::BruteForceActor;
|
use crate::actors::bruteforce_actor::BruteForceActor;
|
||||||
use crate::actors::providers_states_actor::{ProviderLoginState, ProvidersStatesActor};
|
use crate::actors::providers_states_actor::{ProviderLoginState, ProvidersStatesActor};
|
||||||
use crate::actors::users_actor::{LoginResult, UsersActor};
|
use crate::actors::users_actor::{LoginResult, UsersActor};
|
||||||
@@ -18,6 +12,11 @@ use crate::data::login_redirect::LoginRedirect;
|
|||||||
use crate::data::provider::{ProviderID, ProvidersManager};
|
use crate::data::provider::{ProviderID, ProvidersManager};
|
||||||
use crate::data::provider_configuration::ProviderConfigurationHelper;
|
use crate::data::provider_configuration::ProviderConfigurationHelper;
|
||||||
use crate::data::session_identity::{SessionIdentity, SessionStatus};
|
use crate::data::session_identity::{SessionIdentity, SessionStatus};
|
||||||
|
use actix::Addr;
|
||||||
|
use actix_identity::Identity;
|
||||||
|
use actix_remote_ip::RemoteIP;
|
||||||
|
use actix_web::{HttpRequest, HttpResponse, Responder, web};
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
#[derive(askama::Template)]
|
#[derive(askama::Template)]
|
||||||
#[template(path = "login/prov_login_error.html")]
|
#[template(path = "login/prov_login_error.html")]
|
||||||
@@ -273,7 +272,7 @@ pub async fn finish_login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if email was provided by the userinfo endpoint
|
// Check if email was provided by the userinfo endpoint
|
||||||
let email = match user_info.email {
|
let email = match &user_info.email {
|
||||||
Some(e) => e,
|
Some(e) => e,
|
||||||
None => {
|
None => {
|
||||||
logger.log(Action::ProviderMissingEmailInResponse {
|
logger.log(Action::ProviderMissingEmailInResponse {
|
||||||
@@ -293,6 +292,7 @@ pub async fn finish_login(
|
|||||||
let result: LoginResult = users
|
let result: LoginResult = users
|
||||||
.send(users_actor::ProviderLoginRequest {
|
.send(users_actor::ProviderLoginRequest {
|
||||||
email: email.clone(),
|
email: email.clone(),
|
||||||
|
user_info: user_info.clone(),
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -300,6 +300,13 @@ pub async fn finish_login(
|
|||||||
|
|
||||||
let user = match result {
|
let user = match result {
|
||||||
LoginResult::Success(u) => u,
|
LoginResult::Success(u) => u,
|
||||||
|
LoginResult::AccountAutoCreated(u) => {
|
||||||
|
logger.log(Action::ProviderAccountAutoCreated {
|
||||||
|
provider: &provider,
|
||||||
|
user: u.loggable(),
|
||||||
|
});
|
||||||
|
u
|
||||||
|
}
|
||||||
LoginResult::AccountNotFound => {
|
LoginResult::AccountNotFound => {
|
||||||
logger.log(Action::ProviderAccountNotFound {
|
logger.log(Action::ProviderAccountNotFound {
|
||||||
provider: &provider,
|
provider: &provider,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use actix::Addr;
|
use actix::Addr;
|
||||||
use actix_web::{HttpResponse, Responder, web};
|
use actix_web::{HttpResponse, Responder, web};
|
||||||
use uuid::Uuid;
|
|
||||||
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||||
|
|
||||||
use crate::actors::users_actor;
|
use crate::actors::users_actor;
|
||||||
@@ -53,7 +52,7 @@ pub async fn save_totp_factor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let factor = TwoFactor {
|
let factor = TwoFactor {
|
||||||
id: FactorID(Uuid::new_v4().to_string()),
|
id: FactorID::random(),
|
||||||
name: factor_name,
|
name: factor_name,
|
||||||
kind: TwoFactorType::TOTP(key),
|
kind: TwoFactorType::TOTP(key),
|
||||||
};
|
};
|
||||||
@@ -102,7 +101,7 @@ pub async fn save_webauthn_factor(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let factor = TwoFactor {
|
let factor = TwoFactor {
|
||||||
id: FactorID(Uuid::new_v4().to_string()),
|
id: FactorID::random(),
|
||||||
name: factor_name,
|
name: factor_name,
|
||||||
kind: TwoFactorType::WEBAUTHN(Box::new(key)),
|
kind: TwoFactorType::WEBAUTHN(Box::new(key)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ pub enum Action<'a> {
|
|||||||
provider: &'a Provider,
|
provider: &'a Provider,
|
||||||
email: &'a str,
|
email: &'a str,
|
||||||
},
|
},
|
||||||
|
ProviderAccountAutoCreated {
|
||||||
|
provider: &'a Provider,
|
||||||
|
user: LoggableUser,
|
||||||
|
},
|
||||||
ProviderAccountDisabled {
|
ProviderAccountDisabled {
|
||||||
provider: &'a Provider,
|
provider: &'a Provider,
|
||||||
email: &'a str,
|
email: &'a str,
|
||||||
@@ -282,6 +286,11 @@ impl Action<'_> {
|
|||||||
"could not login using provider {} because the email {email} could not be associated to any account!",
|
"could not login using provider {} because the email {email} could not be associated to any account!",
|
||||||
&provider.id.0
|
&provider.id.0
|
||||||
),
|
),
|
||||||
|
Action::ProviderAccountAutoCreated { provider, user } => format!(
|
||||||
|
"triggered automatic account creation for {} from provider {} because it was not found in local accounts list!",
|
||||||
|
user.quick_identity(),
|
||||||
|
&provider.id.0
|
||||||
|
),
|
||||||
Action::ProviderAccountDisabled { provider, email } => format!(
|
Action::ProviderAccountDisabled { provider, email } => format!(
|
||||||
"could not login using provider {} because the account associated to the email {email} is disabled!",
|
"could not login using provider {} because the account associated to the email {email} is disabled!",
|
||||||
&provider.id.0
|
&provider.id.0
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ pub struct Provider {
|
|||||||
///
|
///
|
||||||
/// (.well-known/openid-configuration endpoint)
|
/// (.well-known/openid-configuration endpoint)
|
||||||
pub configuration_url: String,
|
pub configuration_url: String,
|
||||||
|
|
||||||
|
/// Set to true if accounts on BasicOIDC should be automatically created from this provider
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_auto_account_creation: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Provider {
|
impl Provider {
|
||||||
@@ -42,6 +46,7 @@ impl Provider {
|
|||||||
"github" => "/assets/img/brands/github.svg",
|
"github" => "/assets/img/brands/github.svg",
|
||||||
"microsoft" => "/assets/img/brands/microsoft.svg",
|
"microsoft" => "/assets/img/brands/microsoft.svg",
|
||||||
"google" => "/assets/img/brands/google.svg",
|
"google" => "/assets/img/brands/google.svg",
|
||||||
|
"openid" => "/assets/img/brands/openid.svg",
|
||||||
s => s,
|
s => s,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ use crate::utils::time::{fmt_time, time};
|
|||||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, Encode, Decode)]
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, Encode, Decode)]
|
||||||
pub struct UserID(pub String);
|
pub struct UserID(pub String);
|
||||||
|
|
||||||
|
impl UserID {
|
||||||
|
pub fn random() -> Self {
|
||||||
|
Self(uuid::Uuid::new_v4().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GeneralSettings {
|
pub struct GeneralSettings {
|
||||||
pub uid: UserID,
|
pub uid: UserID,
|
||||||
@@ -46,6 +52,12 @@ impl GrantedClients {
|
|||||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct FactorID(pub String);
|
pub struct FactorID(pub String);
|
||||||
|
|
||||||
|
impl FactorID {
|
||||||
|
pub fn random() -> Self {
|
||||||
|
Self(uuid::Uuid::new_v4().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
pub enum TwoFactorType {
|
pub enum TwoFactorType {
|
||||||
TOTP(TotpKey),
|
TOTP(TotpKey),
|
||||||
@@ -295,7 +307,7 @@ impl Eq for User {}
|
|||||||
impl Default for User {
|
impl Default for User {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
uid: UserID(uuid::Uuid::new_v4().to_string()),
|
uid: UserID::random(),
|
||||||
first_name: "".to_string(),
|
first_name: "".to_string(),
|
||||||
last_name: "".to_string(),
|
last_name: "".to_string(),
|
||||||
username: "".to_string(),
|
username: "".to_string(),
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ impl UsersSyncBackend for EntityManager<User> {
|
|||||||
|
|
||||||
fn create_user_account(&mut self, settings: GeneralSettings) -> Res<UserID> {
|
fn create_user_account(&mut self, settings: GeneralSettings) -> Res<UserID> {
|
||||||
let mut user = User {
|
let mut user = User {
|
||||||
uid: UserID(uuid::Uuid::new_v4().to_string()),
|
uid: UserID::random(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
user.update_general_settings(settings);
|
user.update_general_settings(settings);
|
||||||
|
|||||||
Reference in New Issue
Block a user