Compare commits

..

2 Commits

Author SHA1 Message Date
9a599fdde2 Can create accounts automatically for a given upstream provider
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-29 11:30:45 +01:00
764ad3d5a1 Add sample upstream provider 2025-10-29 09:34:10 +01:00
11 changed files with 220 additions and 16 deletions

View File

@@ -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 :)

View 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

View 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

View 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

View File

@@ -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);

View File

@@ -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,

View File

@@ -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)),
}; };

View File

@@ -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

View File

@@ -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,
} }
} }

View File

@@ -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(),

View File

@@ -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);