From 26817a8c30230b8cf22065303509b74c64e56124 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Wed, 5 Jun 2024 12:17:47 +0200 Subject: [PATCH 01/73] wip: OIDC structures & migration --- Cargo.toml | 8 +----- migrations/20240603091113_oidc_login.down.sql | 1 + migrations/20240603091113_oidc_login.up.sql | 6 +++++ src/db/models/mod.rs | 3 +++ src/db/models/oauth2service.rs | 23 ++++++++++++++++ src/handlers/mod.rs | 1 + src/handlers/openid_login.rs | 26 +++++++++++++++++++ src/lib.rs | 5 +++- 8 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 migrations/20240603091113_oidc_login.down.sql create mode 100644 migrations/20240603091113_oidc_login.up.sql create mode 100644 src/db/models/oauth2service.rs create mode 100644 src/handlers/openid_login.rs diff --git a/Cargo.toml b/Cargo.toml index 810ad3d1b8..b37efbba13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,13 +58,7 @@ serde_cbor = { version = "0.12.0-dev", package = "serde_cbor_2" } serde_json = "1.0" serde_urlencoded = "0.7" sha-1 = "0.10" -sqlx = { version = "0.7", features = [ - "chrono", - "ipnetwork", - "runtime-tokio-native-tls", - "postgres", - "uuid", -] } +sqlx = { version = "0.7", features = ["chrono", "ipnetwork", "runtime-tokio-native-tls", "postgres", "uuid"] } ssh-key = "0.6" struct-patch = "0.4" tera = "1.19" diff --git a/migrations/20240603091113_oidc_login.down.sql b/migrations/20240603091113_oidc_login.down.sql new file mode 100644 index 0000000000..920b2c8467 --- /dev/null +++ b/migrations/20240603091113_oidc_login.down.sql @@ -0,0 +1 @@ +DROP TABLE oauth2service; diff --git a/migrations/20240603091113_oidc_login.up.sql b/migrations/20240603091113_oidc_login.up.sql new file mode 100644 index 0000000000..910d954fbf --- /dev/null +++ b/migrations/20240603091113_oidc_login.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE oauth2service ( + id bigserial PRIMARY KEY, + "client_id" text NOT NULL, + "client_secret" text NOT NULL, + "auth_url" text NOT NULL +); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index e60dfdfe99..dcbd90e200 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -10,6 +10,9 @@ pub mod group; pub mod oauth2authorizedapp; #[cfg(feature = "openid")] pub mod oauth2client; +// TODO(jck): enterprise-only enabled +// TODO(jck): #[cfg(feature = "openid")] +pub mod oauth2service; #[cfg(feature = "openid")] pub mod oauth2token; pub mod session; diff --git a/src/db/models/oauth2service.rs b/src/db/models/oauth2service.rs new file mode 100644 index 0000000000..a180fd3c9c --- /dev/null +++ b/src/db/models/oauth2service.rs @@ -0,0 +1,23 @@ +use model_derive::Model; + +// TODO(jck): maybe rename OpenIdProvider +#[derive(Deserialize, Model, Serialize)] +pub struct OAuth2Service { + pub id: Option, + pub client_id: String, // unique + // TODO(jck): maybe remove since we get the id_token in the first reponse? + pub client_secret: String, + pub auth_url: String, + // TODO(jck): provider image? + + // // TODO(jck): do we need this? + // #[model(ref)] + // pub redirect_uri: Vec, + // // TODO(jck): can we assume constant scope ahead of time? + // #[model(ref)] + // pub scope: Vec, + // // TODO(jck): remove? + // // informational + // pub name: String, + // pub enabled: bool, +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 0ee11dc383..8fce7dc299 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -24,6 +24,7 @@ pub(crate) mod mail; pub(crate) mod openid_clients; #[cfg(feature = "openid")] pub mod openid_flow; +pub mod openid_login; pub(crate) mod settings; pub(crate) mod ssh_authorized_keys; pub(crate) mod support; diff --git a/src/handlers/openid_login.rs b/src/handlers/openid_login.rs new file mode 100644 index 0000000000..bb1f7697c4 --- /dev/null +++ b/src/handlers/openid_login.rs @@ -0,0 +1,26 @@ +use crate::{error::WebError, AppState}; +use axum::extract::State; + +// /// Authorization Endpoint +// /// See https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint +// pub async fn authorization( +// State(appstate): State, +// Query(data): Query, +// cookies: CookieJar, +// private_cookies: PrivateCookieJar, +// ) -> Result<(StatusCode, HeaderMap, PrivateCookieJar), WebError> { + +// Ok() +// } + +pub async fn auth_callback(State(state): State) -> Result<(), WebError> { + // TODO(jck): log all useful stuff + debug!("OIDC login callback"); + // TODO(jck): get user info from oidc provider + // TOOD(jck): create user based on user info + // TOOD(jck): log user in + + // TODO(jck): log all useful stuff + info!("OIDC login succeeded for user"); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 85155852d1..4bd7f1685f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,6 +59,7 @@ use self::{ remove_group_member, }, mail::{send_support_data, test_mail}, + openid_login::auth_callback, settings::{ get_settings, get_settings_essentials, patch_settings, set_default_branding, test_ldap_settings, update_settings, @@ -273,7 +274,9 @@ pub fn build_webapp( .route("/webhook/:id", delete(delete_webhook)) .route("/webhook/:id", post(change_enabled)) // ldap - .route("/ldap/test", get(test_ldap_settings)), + .route("/ldap/test", get(test_ldap_settings)) + // OIDC login + .route("/oidc/callback", get(auth_callback)), ); #[cfg(feature = "openid")] From 6702591575c96864b0c5a66494b3125b2d4371eb Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Tue, 11 Jun 2024 09:48:53 +0200 Subject: [PATCH 02/73] wip: callback handler TODO: * handler expects id_token as query parameter, should be after '#' * extract user info --- src/handlers/openid_login.rs | 45 ++++++++++++++++++++++++------ web/src/pages/auth/Login/Login.tsx | 16 +++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/handlers/openid_login.rs b/src/handlers/openid_login.rs index bb1f7697c4..63f7ab6026 100644 --- a/src/handlers/openid_login.rs +++ b/src/handlers/openid_login.rs @@ -1,5 +1,10 @@ +use std::str::FromStr; + use crate::{error::WebError, AppState}; -use axum::extract::State; +use axum::extract::{Query, State}; +use openidconnect::{ + core::CoreIdTokenVerifier, AdditionalClaims, GenderClaim, IdToken, IdTokenVerifier, JsonWebKeyType, JweContentEncryptionAlgorithm, JwsSigningAlgorithm +}; // /// Authorization Endpoint // /// See https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint @@ -13,14 +18,38 @@ use axum::extract::State; // Ok() // } -pub async fn auth_callback(State(state): State) -> Result<(), WebError> { - // TODO(jck): log all useful stuff - debug!("OIDC login callback"); - // TODO(jck): get user info from oidc provider - // TOOD(jck): create user based on user info - // TOOD(jck): log user in +/// Authentication response callback +/// See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest +#[derive(Deserialize, Serialize, Debug)] +pub struct AuthenticationResponse { + id_token: String, +} +pub async fn auth_callback( + State(state): State, + Query(response): Query, +) -> Result<(), WebError> { // TODO(jck): log all useful stuff - info!("OIDC login succeeded for user"); + debug!("OIDC login callback got response: {response:?}"); + let token = IdToken::< + openidconnect::EmptyAdditionalClaims, + openidconnect::core::CoreGenderClaim, + openidconnect::core::CoreJweContentEncryptionAlgorithm, + openidconnect::core::CoreJwsSigningAlgorithm, + _, + >::from_str(&response.id_token); + debug!("Decoded token: {token:?}"); + if let Ok(token) = token { + // TOOD(jck): create user based on user info + // TOOD(jck): log user in + // token.claims(verifier, nonce_verifier); + + // TODO(jck): log all useful stuff + info!("OIDC login succeeded for user"); + } else { + // TODO(jck): add context + warn!("OIDC login failed"); + } + Ok(()) } diff --git a/web/src/pages/auth/Login/Login.tsx b/web/src/pages/auth/Login/Login.tsx index 4a9123c336..e07a289d52 100644 --- a/web/src/pages/auth/Login/Login.tsx +++ b/web/src/pages/auth/Login/Login.tsx @@ -85,6 +85,14 @@ export const Login = () => { } }; + const msUrl = + 'https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/oauth2/' + + 'v2.0/authorize?client_id=f2cef8b3-5b09-4c3f-988b-51fc3c42ecbc' + + '&scope=openid%20profile%20email&response_type=id_token&nonce=ala'; + const redirect = () => { + window.location.replace(msUrl); + }; + return (

{LL.loginPage.pageTitle()}

@@ -112,6 +120,14 @@ export const Login = () => { text={LL.form.login()} data-testid="login-form-submit" /> +
); From 474a02e7464550b1ace2dbcffae6072181343c23 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Tue, 11 Jun 2024 10:52:06 +0200 Subject: [PATCH 03/73] wip: try and parse the claims ISSUE: no jwks keys --- src/handlers/openid_login.rs | 47 +++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/handlers/openid_login.rs b/src/handlers/openid_login.rs index 63f7ab6026..4099e96428 100644 --- a/src/handlers/openid_login.rs +++ b/src/handlers/openid_login.rs @@ -3,7 +3,10 @@ use std::str::FromStr; use crate::{error::WebError, AppState}; use axum::extract::{Query, State}; use openidconnect::{ - core::CoreIdTokenVerifier, AdditionalClaims, GenderClaim, IdToken, IdTokenVerifier, JsonWebKeyType, JweContentEncryptionAlgorithm, JwsSigningAlgorithm + core::{CoreClient, CoreIdTokenVerifier, CoreProviderMetadata}, + AdditionalClaims, AuthUrl, ClientId, GenderClaim, IdToken, IdTokenVerifier, IssuerUrl, + JsonWebKeySet, JsonWebKeyType, JweContentEncryptionAlgorithm, JwsSigningAlgorithm, + NonceVerifier, }; // /// Authorization Endpoint @@ -25,6 +28,14 @@ pub struct AuthenticationResponse { id_token: String, } +struct Verifier; + +impl NonceVerifier for Verifier { + fn verify(self, nonce: Option<&openidconnect::Nonce>) -> Result<(), String> { + Ok(()) + } +} + pub async fn auth_callback( State(state): State, Query(response): Query, @@ -43,6 +54,40 @@ pub async fn auth_callback( // TOOD(jck): create user based on user info // TOOD(jck): log user in // token.claims(verifier, nonce_verifier); + // let provider_metadata = CoreProviderMetadata::discover( + // &IssuerUrl::new("https://accounts.example.com".to_string())?, + // http_client, + // )?; + + let client_id = ClientId::new("f2cef8b3-5b09-4c3f-988b-51fc3c42ecbc".to_string()); + let client_secret = None; + let issuer_url = IssuerUrl::new( + "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/v2.0" + .to_string(), + ) + .unwrap(); + let auth_url = AuthUrl::new( + "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/oauth2/v2.0/authorize".to_string() + ).unwrap(); + let token_url = None; + let userinfo_url = None; + let jwks = JsonWebKeySet::default(); + let client = CoreClient::new( + client_id, + client_secret, + issuer_url, + auth_url, + token_url, + userinfo_url, + jwks, + ); + let claims = token.claims(&client.id_token_verifier(), Verifier); + info!("### Decoded claims: {claims:#?}"); + // let name = claims.name().unwrap(); + // let family_name = claims.family_name().unwrap(); + // let given_name = claims.given_name().unwrap(); + // let email = claims.email().unwrap(); + // info!("### name: {name:?}, family_name: {family_name:?}, given_name: {given_name:?}, email: {email:?}"); // TODO(jck): log all useful stuff info!("OIDC login succeeded for user"); From f4fe911925641ab145a234c16f50b41b29f328b8 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Tue, 11 Jun 2024 11:12:02 +0200 Subject: [PATCH 04/73] wip: Decoded the claims using openidconnect client TODO: * proper nonce --- Cargo.lock | 1 + Cargo.toml | 2 +- src/handlers/openid_login.rs | 64 ++++++++++++++++++++++-------------- 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2224fd62ae..f6c1f2d7c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2543,6 +2543,7 @@ dependencies = [ "getrandom", "http 0.2.12", "rand", + "reqwest", "serde", "serde_json", "serde_path_to_error", diff --git a/Cargo.toml b/Cargo.toml index b37efbba13..42df6be076 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ ldap3 = { version = "0.11", default-features = false, features = ["tls"] } lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls"] } md4 = "0.10" otpauth = "0.4" -openidconnect = { version = "3.4", default-features = false, optional = true } +openidconnect = { version = "3.4", default-features = false, optional = true, features = ["reqwest"] } pulldown-cmark = "0.9" prost = "0.12" rand = "0.8" diff --git a/src/handlers/openid_login.rs b/src/handlers/openid_login.rs index 4099e96428..c9f8206155 100644 --- a/src/handlers/openid_login.rs +++ b/src/handlers/openid_login.rs @@ -4,8 +4,9 @@ use crate::{error::WebError, AppState}; use axum::extract::{Query, State}; use openidconnect::{ core::{CoreClient, CoreIdTokenVerifier, CoreProviderMetadata}, + reqwest::async_http_client, AdditionalClaims, AuthUrl, ClientId, GenderClaim, IdToken, IdTokenVerifier, IssuerUrl, - JsonWebKeySet, JsonWebKeyType, JweContentEncryptionAlgorithm, JwsSigningAlgorithm, + JsonWebKeySet, JsonWebKeyType, JweContentEncryptionAlgorithm, JwsSigningAlgorithm, Nonce, NonceVerifier, }; @@ -60,35 +61,50 @@ pub async fn auth_callback( // )?; let client_id = ClientId::new("f2cef8b3-5b09-4c3f-988b-51fc3c42ecbc".to_string()); - let client_secret = None; - let issuer_url = IssuerUrl::new( - "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/v2.0" - .to_string(), - ) - .unwrap(); - let auth_url = AuthUrl::new( - "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/oauth2/v2.0/authorize".to_string() - ).unwrap(); - let token_url = None; - let userinfo_url = None; - let jwks = JsonWebKeySet::default(); - let client = CoreClient::new( - client_id, - client_secret, - issuer_url, - auth_url, - token_url, - userinfo_url, - jwks, - ); - let claims = token.claims(&client.id_token_verifier(), Verifier); - info!("### Decoded claims: {claims:#?}"); + // let client_secret = None; + // let issuer_url = IssuerUrl::new( + // "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/v2.0" + // .to_string(), + // ) + // .unwrap(); + // let auth_url = AuthUrl::new( + // "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/oauth2/v2.0/authorize".to_string() + // ).unwrap(); + // let token_url = None; + // let userinfo_url = None; + // let jwks = JsonWebKeySet::default(); + // let client = CoreClient::new( + // client_id, + // client_secret, + // issuer_url, + // auth_url, + // token_url, + // userinfo_url, + // jwks, + // ); + // let claims = token.claims(&client.id_token_verifier(), Verifier); + // info!("### Decoded claims: {claims:#?}"); // let name = claims.name().unwrap(); // let family_name = claims.family_name().unwrap(); // let given_name = claims.given_name().unwrap(); // let email = claims.email().unwrap(); // info!("### name: {name:?}, family_name: {family_name:?}, given_name: {given_name:?}, email: {email:?}"); + let provider_metadata = CoreProviderMetadata::discover_async( + IssuerUrl::new( + "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/v2.0" + .to_string(), + ) + .unwrap(), + async_http_client, + ) + .await + .unwrap(); + let client = CoreClient::from_provider_metadata(provider_metadata, client_id, None); + let claims = token.claims(&client.id_token_verifier(), &Nonce::new("ala".to_string())).unwrap(); + info!("Decoded claims: {claims:#?}"); + // // Set the URL the user will be redirected to after the authorization process. + // .set_redirect_uri(RedirectUrl::new("http://localhost:3000/api/v1/oidc/callback".to_string())?); // TODO(jck): log all useful stuff info!("OIDC login succeeded for user"); } else { From 7ae9775aed5f6ece56b2159a3a122ab976cffa39 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 13 Jun 2024 10:58:04 +0200 Subject: [PATCH 05/73] Simple OpenID settings tab --- web/src/i18n/en/index.ts | 10 ++ web/src/i18n/i18n-types.ts | 44 ++++++++ web/src/i18n/pl/index.ts | 10 ++ web/src/pages/settings/SettingsPage.tsx | 8 ++ .../OpenIdSettings/OpenIdSettings.tsx | 5 + .../components/OpenIdSettingsForm.tsx | 100 ++++++++++++++++++ .../components/OpenIdSettings/style.scss | 0 web/src/shared/types.ts | 9 +- 8 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx create mode 100644 web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx create mode 100644 web/src/pages/settings/components/OpenIdSettings/style.scss diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index b17b71b4e5..fb0b2f5221 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -864,6 +864,7 @@ const en: BaseTranslation = { smtp: 'SMTP', global: 'Global settings', ldap: 'LDAP', + openid: 'OpenID', }, messages: { editSuccess: 'Settings updated', @@ -895,6 +896,15 @@ const en: BaseTranslation = { }, }, }, + openIdSettings: { + title: 'OpenID Settings', + form: { + labels: { + name: 'Name', + documentUrl: 'OpenID document URL', + }, + }, + }, modulesVisibility: { header: 'Modules Visibility', helper: `

diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 40142d1f62..7c6f2ce44b 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -2124,6 +2124,10 @@ type RootTranslation = { * L​D​A​P */ ldap: string + /** + * O​p​e​n​I​D + */ + openid: string } messages: { /** @@ -2209,6 +2213,24 @@ type RootTranslation = { } } } + openIdSettings: { + /** + * O​p​e​n​I​D​ ​S​e​t​t​i​n​g​s + */ + title: string + form: { + labels: { + /** + * N​a​m​e + */ + name: string + /** + * O​p​e​n​I​D​ ​d​o​c​u​m​e​n​t​ ​U​R​L + */ + documentUrl: string + } + } + } modulesVisibility: { /** * M​o​d​u​l​e​s​ ​V​i​s​i​b​i​l​i​t​y @@ -5954,6 +5976,10 @@ export type TranslationFunctions = { * LDAP */ ldap: () => LocalizedString + /** + * OpenID + */ + openid: () => LocalizedString } messages: { /** @@ -6039,6 +6065,24 @@ export type TranslationFunctions = { } } } + openIdSettings: { + /** + * OpenID Settings + */ + title: () => LocalizedString + form: { + labels: { + /** + * Name + */ + name: () => LocalizedString + /** + * OpenID document URL + */ + documentUrl: () => LocalizedString + } + } + } modulesVisibility: { /** * Modules Visibility diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 698fa3f439..381f673304 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -850,6 +850,7 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe smtp: 'SMTP', global: 'Globalne', ldap: 'LDAP', + openid: 'OpenID', }, messages: { editSuccess: 'Ustawienia zaktualizowane.', @@ -881,6 +882,15 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe submit: 'Test', }, }, + openIdSettings: { + title: 'OpenID Settings', + form: { + labels: { + name: 'Nazwa', + documentUrl: 'OpenID document URL', + }, + }, + }, modulesVisibility: { header: 'Widoczność modułów', helper: `

diff --git a/web/src/pages/settings/SettingsPage.tsx b/web/src/pages/settings/SettingsPage.tsx index bf6b9dac9f..8c2b8e1afb 100644 --- a/web/src/pages/settings/SettingsPage.tsx +++ b/web/src/pages/settings/SettingsPage.tsx @@ -14,6 +14,7 @@ import useApi from '../../shared/hooks/useApi'; import { QueryKeys } from '../../shared/queries'; import { GlobalSettings } from './components/GlobalSettings/GlobalSettings'; import { LdapSettings } from './components/LdapSettings/LdapSettings'; +import { OpenIdSettings } from './components/OpenIdSettings/OpenIdSettings'; import { SmtpSettings } from './components/SmtpSettings/SmtpSettings'; import { useSettingsPage } from './hooks/useSettingsPage'; @@ -21,6 +22,7 @@ const tabsContent: ReactNode[] = [ , , , + , ]; export const SettingsPage = () => { @@ -65,6 +67,12 @@ export const SettingsPage = () => { active: activeCard === 2, onClick: () => setActiveCard(2), }, + { + key: 3, + content: LL.settingsPage.tabs.openid(), + active: activeCard === 3, + onClick: () => setActiveCard(3), + }, ], [LL.settingsPage.tabs, activeCard], ); diff --git a/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx new file mode 100644 index 0000000000..e4239f84e5 --- /dev/null +++ b/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx @@ -0,0 +1,5 @@ +import './style.scss'; + +import { OpenIdSettingsForm } from './components/OpenIdSettingsForm'; + +export const OpenIdSettings = () => ; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx new file mode 100644 index 0000000000..a5676d6ba2 --- /dev/null +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx @@ -0,0 +1,100 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useRef } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import IconCheckmarkWhite from '../../../../../shared/components/svg/IconCheckmarkWhite'; +import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; +import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../../../shared/defguard-ui/components/Layout/Button/types'; +import useApi from '../../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { QueryKeys } from '../../../../../shared/queries'; +import { SettingsOpenId } from '../../../../../shared/types'; +import { useSettingsPage } from '../../../hooks/useSettingsPage'; + +type FormFields = SettingsOpenId; + +export const OpenIdSettingsForm = () => { + const { LL } = useI18nContext(); + const localLL = LL.settingsPage.openIdSettings; + const submitRef = useRef(null); + const settings = useSettingsPage((state) => state.settings); + const { + settings: { patchSettings }, + } = useApi(); + + const queryClient = useQueryClient(); + + const toaster = useToaster(); + + const { isLoading, mutate } = useMutation({ + mutationFn: patchSettings, + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.FETCH_SETTINGS]); + toaster.success(LL.settingsPage.messages.editSuccess()); + }, + }); + + const schema = useMemo( + () => + z.object({ + name: z.string().min(1, LL.form.error.required()), + document_url: z + .string() + .url(LL.form.error.invalid()) + .min(1, LL.form.error.required()), + }), + [LL.form.error], + ); + + const defaultValues = useMemo( + (): FormFields => ({ + name: settings?.name ?? '', + document_url: settings?.document_url ?? '', + }), + [settings], + ); + + const { handleSubmit, control } = useForm({ + resolver: zodResolver(schema), + defaultValues, + mode: 'all', + }); + + const handleValidSubmit: SubmitHandler = (data) => { + mutate(data); + }; + return ( +

+
+

{localLL.title()}

+
+
+ + + + +
+ ); +}; diff --git a/web/src/pages/settings/components/OpenIdSettings/style.scss b/web/src/pages/settings/components/OpenIdSettings/style.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 6d91636d2d..5971eaff17 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -776,7 +776,8 @@ export type Settings = SettingsModules & SettingsSMTP & SettingsEnrollment & SettingsBranding & - SettingsLDAP; + SettingsLDAP & + SettingsOpenId; // essentials for core frontend, includes only those that are required for frontend operations export type SettingsEssentials = SettingsModules & SettingsBranding; @@ -825,6 +826,12 @@ export type SettingsLDAP = { ldap_username_attr: string; }; +export type SettingsOpenId = { + // TODO(jck): array + name?: string; + document_url?: string; +}; + export type SettingsWeb3 = { challenge_template: string; }; From c08b73695dd2174a6fff7cd89cb62d24c8ce97e9 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 13 Jun 2024 13:17:29 +0200 Subject: [PATCH 06/73] Fetch openid providers from API --- migrations/20240603091113_oidc_login.down.sql | 1 - migrations/20240603091113_oidc_login.up.sql | 6 -- .../20240603091113_openid_provider.down.sql | 1 + .../20240603091113_openid_provider.up.sql | 6 ++ src/db/models/mod.rs | 5 +- .../{oauth2service.rs => openid_provider.rs} | 11 +-- src/handlers/mod.rs | 1 + src/handlers/openid_providers.rs | 38 ++++++++++ src/lib.rs | 7 +- .../components/OpenIdSettingsForm.tsx | 75 +++++++++++-------- web/src/shared/hooks/useApi.tsx | 5 ++ web/src/shared/queries.ts | 1 + web/src/shared/types.ts | 16 ++-- 13 files changed, 114 insertions(+), 59 deletions(-) delete mode 100644 migrations/20240603091113_oidc_login.down.sql delete mode 100644 migrations/20240603091113_oidc_login.up.sql create mode 100644 migrations/20240603091113_openid_provider.down.sql create mode 100644 migrations/20240603091113_openid_provider.up.sql rename src/db/models/{oauth2service.rs => openid_provider.rs} (67%) create mode 100644 src/handlers/openid_providers.rs diff --git a/migrations/20240603091113_oidc_login.down.sql b/migrations/20240603091113_oidc_login.down.sql deleted file mode 100644 index 920b2c8467..0000000000 --- a/migrations/20240603091113_oidc_login.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE oauth2service; diff --git a/migrations/20240603091113_oidc_login.up.sql b/migrations/20240603091113_oidc_login.up.sql deleted file mode 100644 index 910d954fbf..0000000000 --- a/migrations/20240603091113_oidc_login.up.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE oauth2service ( - id bigserial PRIMARY KEY, - "client_id" text NOT NULL, - "client_secret" text NOT NULL, - "auth_url" text NOT NULL -); diff --git a/migrations/20240603091113_openid_provider.down.sql b/migrations/20240603091113_openid_provider.down.sql new file mode 100644 index 0000000000..6ecbdb1291 --- /dev/null +++ b/migrations/20240603091113_openid_provider.down.sql @@ -0,0 +1 @@ +DROP TABLE openidprovider; diff --git a/migrations/20240603091113_openid_provider.up.sql b/migrations/20240603091113_openid_provider.up.sql new file mode 100644 index 0000000000..d325085382 --- /dev/null +++ b/migrations/20240603091113_openid_provider.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE openidprovider ( + id bigserial PRIMARY KEY, + "name" text NOT NULL, + "document_url" text NOT NULL, + CONSTRAINT openidprovider_name_unique UNIQUE ("name") +); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index dcbd90e200..8402eb7734 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -10,11 +10,10 @@ pub mod group; pub mod oauth2authorizedapp; #[cfg(feature = "openid")] pub mod oauth2client; -// TODO(jck): enterprise-only enabled -// TODO(jck): #[cfg(feature = "openid")] -pub mod oauth2service; #[cfg(feature = "openid")] pub mod oauth2token; +// TODO(jck): enterprise-only enabled +pub mod openid_provider; pub mod session; pub mod settings; pub mod user; diff --git a/src/db/models/oauth2service.rs b/src/db/models/openid_provider.rs similarity index 67% rename from src/db/models/oauth2service.rs rename to src/db/models/openid_provider.rs index a180fd3c9c..fef5fd1136 100644 --- a/src/db/models/oauth2service.rs +++ b/src/db/models/openid_provider.rs @@ -2,12 +2,13 @@ use model_derive::Model; // TODO(jck): maybe rename OpenIdProvider #[derive(Deserialize, Model, Serialize)] -pub struct OAuth2Service { +pub struct OpenIdProvider { pub id: Option, - pub client_id: String, // unique - // TODO(jck): maybe remove since we get the id_token in the first reponse? - pub client_secret: String, - pub auth_url: String, + pub name: String, + // pub client_id: String, // unique + // // TODO(jck): maybe remove since we get the id_token in the first reponse? + // pub client_secret: String, + // pub auth_url: String, // TODO(jck): provider image? // // TODO(jck): do we need this? diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 8fce7dc299..82ef2ba79d 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -25,6 +25,7 @@ pub(crate) mod openid_clients; #[cfg(feature = "openid")] pub mod openid_flow; pub mod openid_login; +pub mod openid_providers; pub(crate) mod settings; pub(crate) mod ssh_authorized_keys; pub(crate) mod support; diff --git a/src/handlers/openid_providers.rs b/src/handlers/openid_providers.rs new file mode 100644 index 0000000000..3eb1175e49 --- /dev/null +++ b/src/handlers/openid_providers.rs @@ -0,0 +1,38 @@ +use axum::{extract::State, http::StatusCode, Json}; + +use crate::{appstate::AppState, auth::AdminRole, db::models::openid_provider::OpenIdProvider}; + +use super::{ApiResponse, ApiResult}; +use serde_json::json; + +// pub async fn add_openid_provider( +// _admin: AdminRole, +// session: SessionInfo, +// State(appstate): State, +// Json(data): Json, +// ) -> ApiResult { +// debug!( +// "User {} adding OpenID provider {}", +// session.user.username, client.name +// ); +// client.save(&appstate.pool).await?; +// info!( +// "User {} added OpenID client {}", +// session.user.username, client.name +// ); +// Ok(ApiResponse { +// json: json!(client), +// status: StatusCode::CREATED, +// }) +// } + +pub async fn list_openid_providers( + _admin: AdminRole, + State(appstate): State, +) -> ApiResult { + let providers = OpenIdProvider::all(&appstate.pool).await?; + Ok(ApiResponse { + json: json!(providers), + status: StatusCode::OK, + }) +} diff --git a/src/lib.rs b/src/lib.rs index 4bd7f1685f..b0cbe19fb8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,9 +12,9 @@ use axum::{ serve, Extension, Router, }; -use handlers::ssh_authorized_keys::{ +use handlers::{openid_providers::list_openid_providers, ssh_authorized_keys::{ add_authentication_key, delete_authentication_key, fetch_authentication_keys, -}; +}}; use handlers::{ group::{bulk_assign_to_groups, list_groups_info}, ssh_authorized_keys::rename_authentication_key, @@ -276,7 +276,8 @@ pub fn build_webapp( // ldap .route("/ldap/test", get(test_ldap_settings)) // OIDC login - .route("/oidc/callback", get(auth_callback)), + .route("/openid/callback", get(auth_callback)) + .route("/openid/provider", get(list_openid_providers)), ); #[cfg(feature = "openid")] diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx index a5676d6ba2..08f644635e 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo, useRef } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -15,11 +15,8 @@ import { import useApi from '../../../../../shared/hooks/useApi'; import { useToaster } from '../../../../../shared/hooks/useToaster'; import { QueryKeys } from '../../../../../shared/queries'; -import { SettingsOpenId } from '../../../../../shared/types'; import { useSettingsPage } from '../../../hooks/useSettingsPage'; -type FormFields = SettingsOpenId; - export const OpenIdSettingsForm = () => { const { LL } = useI18nContext(); const localLL = LL.settingsPage.openIdSettings; @@ -31,12 +28,24 @@ export const OpenIdSettingsForm = () => { const queryClient = useQueryClient(); + const { + settings: { fetchOpenIdProviders }, + } = useApi(); + const { data: providers, isLoading } = useQuery({ + queryFn: fetchOpenIdProviders, + queryKey: [QueryKeys.FETCH_OPENID_PROVIDERS], + refetchOnMount: true, + refetchOnWindowFocus: false, + }); + + console.log('providers:', providers); + const toaster = useToaster(); - const { isLoading, mutate } = useMutation({ + const { isSaving, mutate } = useMutation({ mutationFn: patchSettings, onSuccess: () => { - queryClient.invalidateQueries([QueryKeys.FETCH_SETTINGS]); + queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); toaster.success(LL.settingsPage.messages.editSuccess()); }, }); @@ -53,25 +62,25 @@ export const OpenIdSettingsForm = () => { [LL.form.error], ); - const defaultValues = useMemo( - (): FormFields => ({ - name: settings?.name ?? '', - document_url: settings?.document_url ?? '', - }), - [settings], - ); + // const defaultValues = useMemo( + // (): FormFields => ({ + // name: settings?.name ?? '', + // document_url: settings?.document_url ?? '', + // }), + // [settings], + // ); - const { handleSubmit, control } = useForm({ - resolver: zodResolver(schema), - defaultValues, - mode: 'all', - }); + // const { handleSubmit, control } = useForm({ + // resolver: zodResolver(schema), + // defaultValues, + // mode: 'all', + // }); - const handleValidSubmit: SubmitHandler = (data) => { - mutate(data); - }; + // const handleValidSubmit: SubmitHandler = (data) => { + // mutate(data); + // }; return ( -
+

{localLL.title()}

-
- - - - + {/*
*/} + {/* */} + {/* */} + {/* */} + {/* */}
); }; diff --git a/web/src/shared/hooks/useApi.tsx b/web/src/shared/hooks/useApi.tsx index 6eef585b0b..37cd0774a2 100644 --- a/web/src/shared/hooks/useApi.tsx +++ b/web/src/shared/hooks/useApi.tsx @@ -23,6 +23,7 @@ import { NetworkToken, NetworkUserStats, OpenidClient, + OpenIdProvider, RemoveUserClientRequest, ResetPasswordRequest, Settings, @@ -435,6 +436,9 @@ const useApi = (props?: HookProps): ApiHook => { const addUsersToGroups: ApiHook['groups']['addUsersToGroups'] = (data) => client.post('/groups-assign', data).then(unpackRequest); + const fetchOpenIdProviders: ApiHook['settings']['fetchOpenIdProviders'] = async () => + client.get(`/openid/provider`).then((res) => res.data); + useEffect(() => { client.interceptors.response.use( (res) => { @@ -592,6 +596,7 @@ const useApi = (props?: HookProps): ApiHook => { patchSettings, getEssentialSettings, testLdapSettings, + fetchOpenIdProviders, }, support: { downloadSupportData, diff --git a/web/src/shared/queries.ts b/web/src/shared/queries.ts index 5ea8b8e69b..a977d62ec6 100644 --- a/web/src/shared/queries.ts +++ b/web/src/shared/queries.ts @@ -26,4 +26,5 @@ export const QueryKeys = { FETCH_SUPPORT_DATA: 'FETCH_SUPPORT_DATA', FETCH_LOGS: 'FETCH_LOGS', FETCH_AUTHENTICATION_KEYS_INFO: 'FETCH_AUTHENTICATION_KEYS_INFO', + FETCH_OPENID_PROVIDERS: 'FETCH_OPENID_PROVIDERS', }; diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 5971eaff17..8cf2ffa2ee 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -563,6 +563,7 @@ export interface ApiHook { patchSettings: (data: Partial) => EmptyApiResponse; getEssentialSettings: () => Promise; testLdapSettings: () => Promise; + fetchOpenIdProviders: () => Promise; }; support: { downloadSupportData: () => Promise; @@ -776,8 +777,7 @@ export type Settings = SettingsModules & SettingsSMTP & SettingsEnrollment & SettingsBranding & - SettingsLDAP & - SettingsOpenId; + SettingsLDAP; // essentials for core frontend, includes only those that are required for frontend operations export type SettingsEssentials = SettingsModules & SettingsBranding; @@ -826,12 +826,6 @@ export type SettingsLDAP = { ldap_username_attr: string; }; -export type SettingsOpenId = { - // TODO(jck): array - name?: string; - document_url?: string; -}; - export type SettingsWeb3 = { challenge_template: string; }; @@ -858,6 +852,12 @@ export interface OpenidClient { enabled: boolean; } +export interface OpenIdProvider { + id: string; + name: string; + document_url: string; +} + export interface EditOpenidClientRequest { id: string; name: string; From e386c5ec07a046f6d4e4d86964ff73392f7dbbec Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 13 Jun 2024 18:44:47 +0200 Subject: [PATCH 07/73] wip: display providers --- src/db/models/openid_provider.rs | 1 + .../components/OpenIdSettingsForm.tsx | 67 ++++---- .../components/ProviderDetails.tsx | 160 ++++++++++++++++++ 3 files changed, 194 insertions(+), 34 deletions(-) create mode 100644 web/src/pages/settings/components/OpenIdSettings/components/ProviderDetails.tsx diff --git a/src/db/models/openid_provider.rs b/src/db/models/openid_provider.rs index fef5fd1136..cd6564125b 100644 --- a/src/db/models/openid_provider.rs +++ b/src/db/models/openid_provider.rs @@ -5,6 +5,7 @@ use model_derive::Model; pub struct OpenIdProvider { pub id: Option, pub name: String, + pub document_url: String, // pub client_id: String, // unique // // TODO(jck): maybe remove since we get the id_token in the first reponse? // pub client_secret: String, diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx index 08f644635e..df66454cfd 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx @@ -1,32 +1,34 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useMemo, useRef } from 'react'; -import { SubmitHandler, useForm } from 'react-hook-form'; -import { z } from 'zod'; +// import { zodResolver } from '@hookform/resolvers/zod'; +// import { useMemo, useRef } from 'react'; +// import { SubmitHandler, useForm } from 'react-hook-form'; +// import { z } from 'zod'; + +import { useQuery } from '@tanstack/react-query'; import { useI18nContext } from '../../../../../i18n/i18n-react'; import IconCheckmarkWhite from '../../../../../shared/components/svg/IconCheckmarkWhite'; -import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; +// import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; import { ButtonSize, ButtonStyleVariant, } from '../../../../../shared/defguard-ui/components/Layout/Button/types'; import useApi from '../../../../../shared/hooks/useApi'; -import { useToaster } from '../../../../../shared/hooks/useToaster'; +// import { useToaster } from '../../../../../shared/hooks/useToaster'; import { QueryKeys } from '../../../../../shared/queries'; -import { useSettingsPage } from '../../../hooks/useSettingsPage'; +// import { useSettingsPage } from '../../../hooks/useSettingsPage'; +import { ProviderDetails } from './ProviderDetails'; export const OpenIdSettingsForm = () => { const { LL } = useI18nContext(); const localLL = LL.settingsPage.openIdSettings; - const submitRef = useRef(null); - const settings = useSettingsPage((state) => state.settings); - const { - settings: { patchSettings }, - } = useApi(); + // const submitRef = useRef(null); + // const settings = useSettingsPage((state) => state.settings); + // const { + // settings: { patchSettings }, + // } = useApi(); - const queryClient = useQueryClient(); + // const queryClient = useQueryClient(); const { settings: { fetchOpenIdProviders }, @@ -40,27 +42,15 @@ export const OpenIdSettingsForm = () => { console.log('providers:', providers); - const toaster = useToaster(); + // const toaster = useToaster(); - const { isSaving, mutate } = useMutation({ - mutationFn: patchSettings, - onSuccess: () => { - queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); - toaster.success(LL.settingsPage.messages.editSuccess()); - }, - }); - - const schema = useMemo( - () => - z.object({ - name: z.string().min(1, LL.form.error.required()), - document_url: z - .string() - .url(LL.form.error.invalid()) - .min(1, LL.form.error.required()), - }), - [LL.form.error], - ); + // const { isSaving, mutate } = useMutation({ + // mutationFn: patchSettings, + // onSuccess: () => { + // queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); + // toaster.success(LL.settingsPage.messages.editSuccess()); + // }, + // }); // const defaultValues = useMemo( // (): FormFields => ({ @@ -93,6 +83,15 @@ export const OpenIdSettingsForm = () => { onClick={() => submitRef.current?.click()} /> + <> + {providers && providers.length > 0 && ( +
+ {providers.map((provider) => ( + + ))} +
+ )} + {/*
*/} {/* { +// return dayjs.utc(date).format(dateFormat); +// }; + +interface Props { + provider: OpenIdProvider; +} + +export const ProviderDetails = ({ provider }: Props) => { + // const [hovered, setHovered] = useState(false); + const { LL } = useI18nContext(); + // const setDeleteDeviceModal = useDeleteDeviceModal((state) => state.setState); + // const setEditDeviceModal = useEditDeviceModal((state) => state.setState); + // const openDeviceConfigModal = useDeviceConfigModal((state) => state.open); + + // const schema = useMemo( + // () => + // z.object({ + // name: z.string().min(1, LL.form.error.required()), + // document_url: z + // .string() + // .url(LL.form.error.invalid()) + // .min(1, LL.form.error.required()), + // }), + // [LL.form.error], + // ); + + // const getContainerAnimate = useMemo((): TargetAndTransition => { + // const res: TargetAndTransition = { + // borderColor: ColorsRGB.White, + // }; + // if (expanded || hovered) { + // res.borderColor = ColorsRGB.GrayBorder; + // } + // return res; + // }, [expanded, hovered]); + + // // first, order by last_connected_at then if not preset, by network_id + // const orderedLocations = useMemo((): DeviceNetworkInfo[] => { + // const connected = device.networks.filter( + // (network) => !isUndefined(network.last_connected_at), + // ); + + // const neverConnected = device.networks.filter((network) => + // isUndefined(network.last_connected_at), + // ); + + // const connectedSorted = sortByDate( + // connected, + // (n) => n.last_connected_at as string, + // true, + // ); + // const neverConnectedSorted = orderBy(neverConnected, ['network_id'], ['desc']); + + // return [...connectedSorted, ...neverConnectedSorted]; + // }, [device.networks]); + + // const latestLocation = orderedLocations.length ? orderedLocations[0] : undefined; + + // if (!user) return null; + + return ( + setHovered(true)} + // onMouseOut={() => setHovered(false)} + > +
+
+

{provider.name}

+
+
+
+ +

{provider.document_url}

+
+
+
+
+ {/* */} + {/* { */} + {/* setEditDeviceModal({ */} + {/* visible: true, */} + {/* device: device, */} + {/* }); */} + {/* }} */} + {/* /> */} + {/* { */} + {/* openDeviceConfigModal({ */} + {/* deviceName: device.name, */} + {/* publicKey: device.wireguard_pubkey, */} + {/* deviceId: device.id, */} + {/* userId: user.user.id, */} + {/* networks: device.networks.map((n) => ({ */} + {/* networkId: n.network_id, */} + {/* networkName: n.network_name, */} + {/* })), */} + {/* }); */} + {/* }} */} + {/* /> */} + {/* */} + {/* setDeleteDeviceModal({ */} + {/* visible: true, */} + {/* device: device, */} + {/* }) */} + {/* } */} + {/* /> */} + {/* */} + {/* setExpanded((state) => !state)} */} + {/* /> */} +
+
+ ); +}; From 619f764bfafbb14edfdda46bd916b57bcd8c9574 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 10 Jul 2024 17:40:28 +0200 Subject: [PATCH 08/73] rudimentary openid flow --- LICENSE | 2 + .../20240603091113_openid_provider.up.sql | 10 +- .../20240710095931_add_openid_login.down.sql | 1 + .../20240710095931_add_openid_login.up.sql | 1 + ...20240710100027_make_emails_unique.down.sql | 1 + .../20240710100027_make_emails_unique.up.sql | 2 + src/db/models/group.rs | 2 +- src/db/models/mod.rs | 4 +- src/db/models/openid_provider.rs | 25 -- src/db/models/user.rs | 13 +- src/enterprise/LICENSE | 1 + src/enterprise/db/mod.rs | 1 + src/enterprise/db/models/mod.rs | 1 + src/enterprise/db/models/openid_provider.rs | 78 ++++ src/enterprise/handlers/mod.rs | 2 + src/enterprise/handlers/openid_login.rs | 379 ++++++++++++++++++ src/enterprise/handlers/openid_providers.rs | 137 +++++++ src/enterprise/mod.rs | 2 + src/grpc/enrollment.rs | 2 +- src/handlers/auth.rs | 41 +- src/handlers/group.rs | 2 +- src/handlers/mod.rs | 4 +- src/handlers/openid_login.rs | 116 ------ src/handlers/openid_providers.rs | 38 -- src/lib.rs | 15 +- web/src/i18n/en/index.ts | 4 +- web/src/i18n/i18n-types.ts | 24 +- web/src/i18n/pl/index.ts | 4 +- web/src/pages/auth/Login/Login.tsx | 29 +- .../components/OpenIdSettingsForm.tsx | 228 ++++++++--- .../components/ProviderDetails.tsx | 158 +++++--- .../pages/users/UserProfile/UserProfile.tsx | 5 +- web/src/shared/hooks/useApi.tsx | 13 + web/src/shared/types.ts | 17 +- 34 files changed, 1033 insertions(+), 329 deletions(-) create mode 100644 migrations/20240710095931_add_openid_login.down.sql create mode 100644 migrations/20240710095931_add_openid_login.up.sql create mode 100644 migrations/20240710100027_make_emails_unique.down.sql create mode 100644 migrations/20240710100027_make_emails_unique.up.sql delete mode 100644 src/db/models/openid_provider.rs create mode 100644 src/enterprise/LICENSE create mode 100644 src/enterprise/db/mod.rs create mode 100644 src/enterprise/db/models/mod.rs create mode 100644 src/enterprise/db/models/openid_provider.rs create mode 100644 src/enterprise/handlers/mod.rs create mode 100644 src/enterprise/handlers/openid_login.rs create mode 100644 src/enterprise/handlers/openid_providers.rs create mode 100644 src/enterprise/mod.rs delete mode 100644 src/handlers/openid_login.rs delete mode 100644 src/handlers/openid_providers.rs diff --git a/LICENSE b/LICENSE index 8ddd140946..ec3d63af4f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,7 @@ Copyright 2023 teonite ventures sp. z o.o. (teonite) +Note: The following license applies to the entire repository except for the "enterprise" directory. + Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at diff --git a/migrations/20240603091113_openid_provider.up.sql b/migrations/20240603091113_openid_provider.up.sql index d325085382..8e47ecf749 100644 --- a/migrations/20240603091113_openid_provider.up.sql +++ b/migrations/20240603091113_openid_provider.up.sql @@ -1,6 +1,12 @@ CREATE TABLE openidprovider ( id bigserial PRIMARY KEY, "name" text NOT NULL, - "document_url" text NOT NULL, - CONSTRAINT openidprovider_name_unique UNIQUE ("name") + -- "document_url" text NOT NULL, + "provider_url" text NOT NULL, + "client_id" text NOT NULL, + "client_secret" text NOT NULL, + "enabled" boolean NOT NULL DEFAULT FALSE, + CONSTRAINT openidprovider_name_unique UNIQUE ("name"), + CONSTRAINT openidprovider_client_id_unique UNIQUE ("client_id"), + CONSTRAINT openidprovider_client_secret_unique UNIQUE ("client_secret") ); diff --git a/migrations/20240710095931_add_openid_login.down.sql b/migrations/20240710095931_add_openid_login.down.sql new file mode 100644 index 0000000000..d216b19dd0 --- /dev/null +++ b/migrations/20240710095931_add_openid_login.down.sql @@ -0,0 +1 @@ +ALTER TABLE "user" DROP COLUMN openid_login; diff --git a/migrations/20240710095931_add_openid_login.up.sql b/migrations/20240710095931_add_openid_login.up.sql new file mode 100644 index 0000000000..07f397654d --- /dev/null +++ b/migrations/20240710095931_add_openid_login.up.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN openid_login boolean NOT NULL DEFAULT false; diff --git a/migrations/20240710100027_make_emails_unique.down.sql b/migrations/20240710100027_make_emails_unique.down.sql new file mode 100644 index 0000000000..91a5a0a715 --- /dev/null +++ b/migrations/20240710100027_make_emails_unique.down.sql @@ -0,0 +1 @@ +ALTER TABLE "user" DROP CONSTRAINT "user_email_key"; diff --git a/migrations/20240710100027_make_emails_unique.up.sql b/migrations/20240710100027_make_emails_unique.up.sql new file mode 100644 index 0000000000..4c341861c6 --- /dev/null +++ b/migrations/20240710100027_make_emails_unique.up.sql @@ -0,0 +1,2 @@ +-- TODO(aleksander): Drop duplicate emails before adding the constraint. +ALTER TABLE "user" ADD CONSTRAINT "user_email_key" UNIQUE (email); diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 73f4476188..f5b5ea3ef2 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -60,7 +60,7 @@ impl Group { User, "SELECT \"user\".id \"id?\", username, password_hash, last_name, first_name, email, \ phone, mfa_enabled, totp_enabled, totp_secret, email_mfa_enabled, email_mfa_secret, \ - mfa_method \"mfa_method: _\", recovery_codes, is_active \ + mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_login \ FROM \"user\" \ JOIN group_user ON \"user\".id = group_user.user_id \ WHERE group_user.group_id = $1", diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index b79c6138f4..246bb2f4da 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -12,8 +12,6 @@ pub mod oauth2authorizedapp; pub mod oauth2client; #[cfg(feature = "openid")] pub mod oauth2token; -// TODO(jck): enterprise-only enabled -pub mod openid_provider; pub mod session; pub mod settings; pub mod user; @@ -100,7 +98,7 @@ impl UserInfo { mfa_method: user.mfa_method.clone(), authorized_apps, is_active: user.is_active, - enrolled: user.has_password(), + enrolled: user.has_password() || user.openid_login, }) } diff --git a/src/db/models/openid_provider.rs b/src/db/models/openid_provider.rs deleted file mode 100644 index cd6564125b..0000000000 --- a/src/db/models/openid_provider.rs +++ /dev/null @@ -1,25 +0,0 @@ -use model_derive::Model; - -// TODO(jck): maybe rename OpenIdProvider -#[derive(Deserialize, Model, Serialize)] -pub struct OpenIdProvider { - pub id: Option, - pub name: String, - pub document_url: String, - // pub client_id: String, // unique - // // TODO(jck): maybe remove since we get the id_token in the first reponse? - // pub client_secret: String, - // pub auth_url: String, - // TODO(jck): provider image? - - // // TODO(jck): do we need this? - // #[model(ref)] - // pub redirect_uri: Vec, - // // TODO(jck): can we assume constant scope ahead of time? - // #[model(ref)] - // pub scope: Vec, - // // TODO(jck): remove? - // // informational - // pub name: String, - // pub enabled: bool, -} diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 2ea623127a..68eabd80a1 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -74,6 +74,8 @@ pub struct User { pub phone: Option, pub mfa_enabled: bool, pub is_active: bool, + /// Whether user logs in through an OpenID provider + pub openid_login: bool, // secret has been verified and TOTP can be used pub(crate) totp_enabled: bool, pub(crate) email_mfa_enabled: bool, @@ -120,6 +122,7 @@ impl User { mfa_method: MFAMethod::None, recovery_codes: Vec::new(), is_active: true, + openid_login: false, } } @@ -451,7 +454,7 @@ impl User { ) -> Result, SqlxError> { let users = query!( "SELECT id, mfa_enabled, totp_enabled, email_mfa_enabled, \ - mfa_method as \"mfa_method: MFAMethod\", password_hash, is_active \ + mfa_method as \"mfa_method: MFAMethod\", password_hash, is_active, openid_login \ FROM \"user\"" ) .fetch_all(pool) @@ -465,7 +468,7 @@ impl User { mfa_enabled: u.mfa_enabled, id: u.id, is_active: u.is_active, - enrolled: u.password_hash.is_some(), + enrolled: u.password_hash.is_some() || u.openid_login, }) .collect(); Ok(res) @@ -481,7 +484,7 @@ impl User { "SELECT \"user\".id \"id?\", username, password_hash, last_name, first_name, email, \ phone, mfa_enabled, totp_enabled, totp_secret, \ email_mfa_enabled, email_mfa_secret, \ - mfa_method \"mfa_method: _\", recovery_codes, is_active \ + mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_login \ FROM \"user\" INNER JOIN \"group_user\" ON \"user\".id = \"group_user\".user_id INNER JOIN \"group\" ON \"group_user\".group_id = \"group\".id @@ -572,7 +575,7 @@ impl User { Self, "SELECT id \"id?\", username, password_hash, last_name, first_name, email, \ phone, mfa_enabled, totp_enabled, email_mfa_enabled, \ - totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active \ + totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_login \ FROM \"user\" WHERE username = $1", username ) @@ -588,7 +591,7 @@ impl User { Self, "SELECT id \"id?\", username, password_hash, last_name, first_name, email, \ phone, mfa_enabled, totp_enabled, email_mfa_enabled, \ - totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active \ + totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_login \ FROM \"user\" WHERE email = $1", email ) diff --git a/src/enterprise/LICENSE b/src/enterprise/LICENSE new file mode 100644 index 0000000000..0ba4000c5c --- /dev/null +++ b/src/enterprise/LICENSE @@ -0,0 +1 @@ +Files in this directory ("enterprise") are not under the default repository license. Contact salesdefguard.net for further information. \ No newline at end of file diff --git a/src/enterprise/db/mod.rs b/src/enterprise/db/mod.rs new file mode 100644 index 0000000000..c446ac8833 --- /dev/null +++ b/src/enterprise/db/mod.rs @@ -0,0 +1 @@ +pub mod models; diff --git a/src/enterprise/db/models/mod.rs b/src/enterprise/db/models/mod.rs new file mode 100644 index 0000000000..972680e374 --- /dev/null +++ b/src/enterprise/db/models/mod.rs @@ -0,0 +1 @@ +pub mod openid_provider; diff --git a/src/enterprise/db/models/openid_provider.rs b/src/enterprise/db/models/openid_provider.rs new file mode 100644 index 0000000000..5a5ee00533 --- /dev/null +++ b/src/enterprise/db/models/openid_provider.rs @@ -0,0 +1,78 @@ +use model_derive::Model; +use sqlx::{query, query_as, Error as SqlxError}; + +use crate::db::DbPool; + +// TODO(jck): maybe rename OpenIdProvider +#[derive(Deserialize, Model, Serialize)] +pub struct OpenIdProvider { + pub id: Option, + pub name: String, + pub provider_url: String, + pub client_id: String, + pub client_secret: String, + pub enabled: bool, + // pub client_id: String, // unique + // // TODO(jck): maybe remove since we get the id_token in the first reponse? + // pub client_secret: String, + // pub auth_url: String, + // TODO(jck): provider image? + + // // TODO(jck): do we need this? + // #[model(ref)] + // pub redirect_uri: Vec, + // // TODO(jck): can we assume constant scope ahead of time? + // #[model(ref)] + // pub scope: Vec, + // // TODO(jck): remove? + // // informational + // pub name: String, + // pub enabled: bool, +} + +impl OpenIdProvider { + #[must_use] + pub fn new>(name: S, provider_url: S, client_id: S, client_secret: S) -> Self { + Self { + id: None, + name: name.into(), + provider_url: provider_url.into(), + client_id: client_id.into(), + client_secret: client_secret.into(), + enabled: false, + } + } + + pub async fn find_by_name(pool: &DbPool, name: &str) -> Result, SqlxError> { + query_as!( + OpenIdProvider, + "SELECT id \"id?\", name, provider_url, client_id, client_secret, enabled FROM openidprovider WHERE name = $1", + name + ) + .fetch_optional(pool) + .await + } + + pub async fn exists(pool: &DbPool, provider: &OpenIdProvider) -> Result { + query!( + "SELECT EXISTS(SELECT 1 FROM openidprovider WHERE name = $1 OR client_id = $2 OR client_secret = $3)", + provider.name, + provider.client_id, + provider.client_secret + ) + .fetch_one(pool) + .await? + .exists + .ok_or_else(|| SqlxError::RowNotFound) + } + + // TODO(aleksander): there may be more than one active provider + pub async fn get_enabled(pool: &DbPool) -> Result { + query_as!( + OpenIdProvider, + "SELECT id \"id?\", name, provider_url, client_id, client_secret, enabled FROM openidprovider WHERE enabled = true limit 1" + ) + .fetch_one(pool) + .await + } +} diff --git a/src/enterprise/handlers/mod.rs b/src/enterprise/handlers/mod.rs new file mode 100644 index 0000000000..0ffbdd9e5f --- /dev/null +++ b/src/enterprise/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod openid_login; +pub mod openid_providers; diff --git a/src/enterprise/handlers/openid_login.rs b/src/enterprise/handlers/openid_login.rs new file mode 100644 index 0000000000..2636168f90 --- /dev/null +++ b/src/enterprise/handlers/openid_login.rs @@ -0,0 +1,379 @@ +// use std::str::FromStr; + +// use crate::{error::WebError, AppState}; +// use axum::extract::{Query, State}; +// use openidconnect::{ +// core::{CoreClient, CoreIdTokenVerifier, CoreProviderMetadata}, +// reqwest::async_http_client, +// AdditionalClaims, AuthUrl, ClientId, GenderClaim, IdToken, IdTokenVerifier, IssuerUrl, +// JsonWebKeySet, JsonWebKeyType, JweContentEncryptionAlgorithm, JwsSigningAlgorithm, Nonce, +// NonceVerifier, +// }; + +// // /// Authorization Endpoint +// // /// See https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint +// // pub async fn authorization( +// // State(appstate): State, +// // Query(data): Query, +// // cookies: CookieJar, +// // private_cookies: PrivateCookieJar, +// // ) -> Result<(StatusCode, HeaderMap, PrivateCookieJar), WebError> { + +// // Ok() +// // } + +// /// Authentication response callback +// /// See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest +// #[derive(Deserialize, Serialize, Debug)] +// pub struct AuthenticationResponse { +// // id_token: String, +// code: String, +// } + +// struct Verifier; + +// impl NonceVerifier for Verifier { +// fn verify(self, nonce: Option<&openidconnect::Nonce>) -> Result<(), String> { +// Ok(()) +// } +// } + +// pub async fn auth_callback( +// State(state): State, +// Query(response): Query, +// ) -> Result<(), WebError> { +// // TODO(jck): log all useful stuff +// debug!("OIDC login callback got response: {response:?}"); +// let code = response.code.clone(); + +// info!("OIDC login callback got code: {code:?}"); +// // let token = IdToken::< +// // openidconnect::EmptyAdditionalClaims, +// // openidconnect::core::CoreGenderClaim, +// // openidconnect::core::CoreJweContentEncryptionAlgorithm, +// // openidconnect::core::CoreJwsSigningAlgorithm, +// // _, +// // >::from_str(&response.code); +// // debug!("Decoded token: {token:?}"); +// // if let Ok(token) = token { +// // // TOOD(jck): create user based on user info +// // // TOOD(jck): log user in +// // // token.claims(verifier, nonce_verifier); +// // // let provider_metadata = CoreProviderMetadata::discover( +// // // &IssuerUrl::new("https://accounts.example.com".to_string())?, +// // // http_client, +// // // )?; + +// // // let client_id = ClientId::new("f2cef8b3-5b09-4c3f-988b-51fc3c42ecbc".to_string()); +// // // let client_secret = None; +// // // let issuer_url = IssuerUrl::new( +// // // "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/v2.0" +// // // .to_string(), +// // // ) +// // // .unwrap(); +// // // let auth_url = AuthUrl::new( +// // // "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/oauth2/v2.0/authorize".to_string() +// // // ).unwrap(); +// // // let token_url = None; +// // // let userinfo_url = None; +// // // let jwks = JsonWebKeySet::default(); +// // // let client = CoreClient::new( +// // // client_id, +// // // client_secret, +// // // issuer_url, +// // // auth_url, +// // // token_url, +// // // userinfo_url, +// // // jwks, +// // // ); +// // // let claims = token.claims(&client.id_token_verifier(), Verifier); +// // // info!("### Decoded claims: {claims:#?}"); +// // // let name = claims.name().unwrap(); +// // // let family_name = claims.family_name().unwrap(); +// // // let given_name = claims.given_name().unwrap(); +// // // let email = claims.email().unwrap(); +// // // info!("### name: {name:?}, family_name: {family_name:?}, given_name: {given_name:?}, email: {email:?}"); +// // let provider_metadata = CoreProviderMetadata::discover_async( +// // IssuerUrl::new( +// // // "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/v2.0" +// // "https://accounts.google.com/o/oauth2/v2/auth".to_string(), +// // ) +// // .unwrap(), +// // async_http_client, +// // ) +// // .await +// // .unwrap(); + +// // let client = CoreClient::from_provider_metadata(provider_metadata, client_id, None); +// // let claims = token +// // .claims(&client.id_token_verifier(), &Nonce::new("ala".to_string())) +// // .unwrap(); +// // info!("Decoded claims: {claims:#?}"); +// // // // Set the URL the user will be redirected to after the authorization process. +// // // .set_redirect_uri(RedirectUrl::new("http://localhost:3000/api/v1/oidc/callback".to_string())?); +// // // TODO(jck): log all useful stuff +// // info!("OIDC login succeeded for user"); +// // } else { +// // // TODO(jck): add context +// // warn!("OIDC login failed"); +// // } + +// Ok(()) +// } + +use axum::http::header::LOCATION; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum_extra::extract::cookie::{Cookie, SameSite}; +use serde_json::json; +use sqlx::PgPool; +use std::collections::HashMap; +use time::Duration; + +use axum::extract::{Query, State}; +use axum::Json; +use axum_client_ip::{InsecureClientIp, LeftmostXForwardedFor}; +use axum_extra::extract::{CookieJar, PrivateCookieJar}; +use axum_extra::headers::UserAgent; +use axum_extra::TypedHeader; +use openidconnect::core::{ + CoreAuthDisplay, CoreClaimName, CoreClaimType, CoreClient, CoreClientAuthMethod, CoreGrantType, + CoreIdTokenClaims, CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, + CoreJweKeyManagementAlgorithm, CoreResponseMode, CoreResponseType, CoreRevocableToken, + CoreSubjectIdentifierType, +}; +use openidconnect::{ + core::CoreProviderMetadata, reqwest::async_http_client, ClientId, ClientSecret, IssuerUrl, + ProviderMetadata, RedirectUrl, RevocationUrl, +}; +use openidconnect::{AuthenticationFlow, AuthorizationCode, CsrfToken, LanguageTag, Nonce, Scope}; + +use crate::appstate::AppState; +use crate::db::{AppEvent, DbPool, Session, SessionState, User, UserInfo}; +use crate::enterprise::db::models::openid_provider::OpenIdProvider; +use crate::error::WebError; +use crate::handlers::{ApiResponse, AuthResponse, SESSION_COOKIE_NAME, SIGN_IN_COOKIE_NAME}; +use crate::headers::{check_new_device_login, get_user_agent_device, parse_user_agent}; +use crate::server_config; + +type ProvMeta = ProviderMetadata< + openidconnect::EmptyAdditionalProviderMetadata, + openidconnect::core::CoreAuthDisplay, + openidconnect::core::CoreClientAuthMethod, + openidconnect::core::CoreClaimName, + openidconnect::core::CoreClaimType, + openidconnect::core::CoreGrantType, + openidconnect::core::CoreJweContentEncryptionAlgorithm, + openidconnect::core::CoreJweKeyManagementAlgorithm, + openidconnect::core::CoreJwsSigningAlgorithm, + openidconnect::core::CoreJsonWebKeyType, + openidconnect::core::CoreJsonWebKeyUse, + openidconnect::core::CoreJsonWebKey, + openidconnect::core::CoreResponseMode, + openidconnect::core::CoreResponseType, + openidconnect::core::CoreSubjectIdentifierType, +>; + +async fn get_provider_metadata(url: &str) -> Result { + let issuer_url = IssuerUrl::new(url.to_string()).unwrap(); + + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, async_http_client) + .await + .unwrap(); + + println!("{:?}", provider_metadata); + + Ok(provider_metadata) +} + +async fn make_oidc_client(pool: &DbPool) -> Result { + let provider = OpenIdProvider::get_enabled(pool).await?; + let provider_metadata = get_provider_metadata(&provider.provider_url).await?; + + let client_id = ClientId::new(provider.client_id); + + let client_secret = ClientSecret::new(provider.client_secret); + + let client = + CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) + .set_redirect_uri( + RedirectUrl::new("http://localhost:3000/api/v1/openid/callback".to_string()) + .unwrap(), + ); + + Ok(client) +} + +fn nonce_fn() -> Nonce { + Nonce::new("nonce".to_string()) +} + +fn csrf_fn() -> CsrfToken { + CsrfToken::new("csrf".to_string()) +} + +pub async fn make_auth_url(State(appstate): State) -> Result { + // TODO(aleksander): make sure that the user enables the oidc login first + let client = make_oidc_client(&appstate.pool).await?; + + let (authorize_url, csrf_state, nonce) = client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + csrf_fn, + nonce_fn, + ) + // This example is requesting access to the "calendar" features and the user's profile. + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .url(); + + info!("{:?}", authorize_url); + info!("{:?}", csrf_state); + info!("{:?}", nonce); + + Ok(authorize_url.to_string()) +} + +/// Helper function to return redirection with status code 302. +fn redirect_to>(uri: T, cookies: CookieJar) -> (StatusCode, HeaderMap, CookieJar) { + let mut headers = HeaderMap::new(); + headers.insert( + LOCATION, + HeaderValue::try_from(uri.as_ref()).expect("URI isn't a valid header value"), + ); + + (StatusCode::FOUND, headers, cookies) +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct AuthenticationResponse { + code: AuthorizationCode, + state: CsrfToken, +} + +pub async fn auth_callback( + cookies: CookieJar, + user_agent: Option>, + forwarded_for_ip: Option, + InsecureClientIp(insecure_ip): InsecureClientIp, + Query(params): Query, + State(appstate): State, +) -> Result<(StatusCode, HeaderMap, CookieJar), WebError> { + let client = make_oidc_client(&appstate.pool).await?; + + let token = client + .exchange_code(params.code) + .request_async(async_http_client) + .await + .unwrap(); + + let nonce = Nonce::new("nonce".to_string()); + + let token_verifier = client.id_token_verifier(); + let token_claims = token + .extra_fields() + .id_token() + .expect("Server did not return an ID token") + .claims(&token_verifier, &nonce) + .unwrap(); + // println!("Google returned ID token: {:?}", token_claims); + + let email = token_claims.email().unwrap(); + let name = token_claims.name().unwrap(); + // TODO: check whats up with localized claims + // println!("{:?}", token_claims); + let given_name = token_claims + .given_name() + .unwrap() + .clone() + .into_iter() + .next() + .unwrap() + .1; + let family_name = token_claims + .family_name() + .unwrap() + .clone() + .into_iter() + .next() + .unwrap() + .1; + println!("Email: {:?}", email); + println!("Name: {:?}", name); + println!("Given Name: {:?}", given_name); + println!("Family Name: {:?}", family_name); + + let username = email.split('@').next().unwrap(); + + let user = match User::find_by_username(&appstate.pool, username).await { + Ok(Some(user)) => user, + Ok(None) => { + let mut user = User::new( + username.to_string(), + None, + family_name.to_string(), + given_name.to_string(), + email.to_string(), + // TODO: Add phone + None, + ); + user.openid_login = true; + user.save(&appstate.pool).await?; + user + } + Err(e) => { + return Err(WebError::Authorization(e.to_string())); + } + }; + + let ip_address = forwarded_for_ip.map_or(insecure_ip, |v| v.0).to_string(); + let user_agent_string = match user_agent { + Some(value) => value.to_string(), + None => String::new(), + }; + let agent = parse_user_agent(&appstate.user_agent_parser, &user_agent_string); + let device_info = agent.clone().map(|v| get_user_agent_device(&v)); + + Session::delete_expired(&appstate.pool).await?; + let session = Session::new( + user.id.unwrap(), + SessionState::PasswordVerified, + ip_address.clone(), + device_info, + ); + session.save(&appstate.pool).await?; + + let max_age = Duration::seconds(server_config().auth_cookie_timeout.as_secs() as i64); + let config = server_config(); + let auth_cookie = Cookie::build((SESSION_COOKIE_NAME, session.id.clone())) + .domain( + config + .cookie_domain + .clone() + .expect("Cookie domain not found"), + ) + .path("/") + .http_only(true) + .secure(!config.cookie_insecure) + .same_site(SameSite::Lax) + .max_age(max_age); + let cookies = cookies.add(auth_cookie); + + let login_event_type = "AUTHENTICATION".to_string(); + + let user_info = UserInfo::from_user(&appstate.pool, &user).await?; + appstate.trigger_action(AppEvent::UserCreated(user_info.clone())); + + check_new_device_login( + &appstate.pool, + &appstate.mail_tx, + &session, + &user, + ip_address, + login_event_type, + agent, + ) + .await?; + + Ok(redirect_to("/", cookies)) +} diff --git a/src/enterprise/handlers/openid_providers.rs b/src/enterprise/handlers/openid_providers.rs new file mode 100644 index 0000000000..b72dff312a --- /dev/null +++ b/src/enterprise/handlers/openid_providers.rs @@ -0,0 +1,137 @@ +use axum::{extract::State, http::StatusCode, Json}; + +use crate::{ + appstate::AppState, + auth::{AdminRole, SessionInfo}, + enterprise::db::models::openid_provider::OpenIdProvider, + handlers::{ApiResponse, ApiResult}, +}; + +use serde_json::json; + +#[derive(Debug, Deserialize)] +pub struct AddProviderData { + name: String, + provider_url: String, + client_id: String, + client_secret: String, +} + +pub async fn add_openid_provider( + _admin: AdminRole, + session: SessionInfo, + State(appstate): State, + Json(provider_data): Json, +) -> ApiResult { + let mut new_provider = OpenIdProvider::new( + provider_data.name, + provider_data.provider_url, + provider_data.client_id, + provider_data.client_secret, + ); + // check if it already exists + if OpenIdProvider::exists(&appstate.pool, &new_provider).await? { + warn!( + "User {} failed to add OpenID client {}. Such client already exists.", + session.user.username, new_provider.name + ); + return Ok(ApiResponse { + json: json!({}), + status: StatusCode::CONFLICT, + }); + } + + debug!( + "User {} adding OpenID provider {}", + session.user.username, new_provider.name + ); + new_provider.save(&appstate.pool).await?; + info!( + "User {} added OpenID client {}", + session.user.username, new_provider.name + ); + Ok(ApiResponse { + json: json!({}), + status: StatusCode::CREATED, + }) +} + +pub async fn delete_openid_provider( + _admin: AdminRole, + session: SessionInfo, + State(appstate): State, + Json(provider_data): Json, +) -> ApiResult { + debug!( + "User {} deleting OpenID provider {}", + session.user.username, provider_data.name + ); + let provider = OpenIdProvider::find_by_name(&appstate.pool, &provider_data.name).await?; + if let Some(provider) = provider { + provider.delete(&appstate.pool).await?; + info!( + "User {} deleted OpenID client {}", + session.user.username, provider_data.name + ); + Ok(ApiResponse { + json: json!({}), + status: StatusCode::OK, + }) + } else { + warn!( + "User {} failed to delete OpenID client {}. Such client does not exist.", + session.user.username, provider_data.name + ); + Ok(ApiResponse { + json: json!({}), + status: StatusCode::NOT_FOUND, + }) + } +} + +pub async fn modify_openid_provider( + _admin: AdminRole, + session: SessionInfo, + State(appstate): State, + Json(provider_data): Json, +) -> ApiResult { + debug!( + "User {} modifying OpenID provider {}", + session.user.username, provider_data.name + ); + let provider = OpenIdProvider::find_by_name(&appstate.pool, &provider_data.name).await?; + if let Some(mut provider) = provider { + provider.provider_url = provider_data.provider_url; + provider.client_id = provider_data.client_id; + provider.client_secret = provider_data.client_secret; + provider.save(&appstate.pool).await?; + info!( + "User {} modified OpenID client {}", + session.user.username, provider.name + ); + Ok(ApiResponse { + json: json!({}), + status: StatusCode::OK, + }) + } else { + warn!( + "User {} failed to modify OpenID client {}. Such client does not exist.", + session.user.username, provider_data.name + ); + Ok(ApiResponse { + json: json!({}), + status: StatusCode::NOT_FOUND, + }) + } +} + +pub async fn list_openid_providers( + _admin: AdminRole, + State(appstate): State, +) -> ApiResult { + let providers = OpenIdProvider::all(&appstate.pool).await?; + Ok(ApiResponse { + json: json!(providers), + status: StatusCode::OK, + }) +} diff --git a/src/enterprise/mod.rs b/src/enterprise/mod.rs new file mode 100644 index 0000000000..212ebefc90 --- /dev/null +++ b/src/enterprise/mod.rs @@ -0,0 +1,2 @@ +pub mod db; +pub mod handlers; diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs index 16569d0d0b..9e299d501a 100644 --- a/src/grpc/enrollment.rs +++ b/src/grpc/enrollment.rs @@ -497,7 +497,7 @@ impl From for AdminInfo { impl InitialUserInfo { async fn from_user(pool: &DbPool, user: User) -> Result { - let is_enrolled = user.has_password(); + let is_enrolled = user.has_password() || user.openid_login; let devices = user.devices(pool).await?; let device_names = devices.into_iter().map(|dev| dev.device.name).collect(); Ok(Self { diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index d5c076aac1..b4fd350473 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -74,14 +74,39 @@ pub async fn authenticate( } }, Ok(None) => { - // create user from LDAP - debug!("User not found in DB, authenticating user {username} with LDAP"); - if let Ok(user) = user_from_ldap(&appstate.pool, &username, &data.password).await { - user - } else { - info!("Failed to authenticate user {username} with LDAP"); - log_failed_login_attempt(&appstate.failed_logins, &username); - return Err(WebError::Authorization("user not found".into())); + match User::find_by_email(&appstate.pool, &username).await { + Ok(Some(user)) => match user.verify_password(&data.password) { + Ok(()) => { + if user.is_active { + user + } else { + info!("Failed to authenticate user {username}: user is disabled"); + return Err(WebError::Authorization("user not found".into())); + } + } + Err(err) => { + info!("Failed to authenticate user {username}: {err}"); + log_failed_login_attempt(&appstate.failed_logins, &username); + return Err(WebError::Authorization(err.to_string())); + } + }, + Ok(None) => { + // create user from LDAP + debug!("User not found in DB, authenticating user {username} with LDAP"); + if let Ok(user) = + user_from_ldap(&appstate.pool, &username, &data.password).await + { + user + } else { + info!("Failed to authenticate user {username} with LDAP"); + log_failed_login_attempt(&appstate.failed_logins, &username); + return Err(WebError::Authorization("user not found".into())); + } + } + Err(err) => { + error!("DB error when authenticating user {username}: {err}"); + return Err(WebError::DbError(err.to_string())); + } } } Err(err) => { diff --git a/src/handlers/group.rs b/src/handlers/group.rs index 748bdc5388..5969cefbd9 100644 --- a/src/handlers/group.rs +++ b/src/handlers/group.rs @@ -47,7 +47,7 @@ pub(crate) async fn bulk_assign_to_groups( User, "SELECT id \"id?\", username, password_hash, last_name, first_name, email, \ phone, mfa_enabled, totp_enabled, email_mfa_enabled, \ - totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active \ + totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_login \ FROM \"user\" WHERE id = ANY($1)", &data.users ) diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 82ef2ba79d..dd3189df0a 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -24,8 +24,6 @@ pub(crate) mod mail; pub(crate) mod openid_clients; #[cfg(feature = "openid")] pub mod openid_flow; -pub mod openid_login; -pub mod openid_providers; pub(crate) mod settings; pub(crate) mod ssh_authorized_keys; pub(crate) mod support; @@ -38,7 +36,7 @@ pub mod worker; pub(crate) mod yubikey; pub(crate) static SESSION_COOKIE_NAME: &str = "defguard_session"; -static SIGN_IN_COOKIE_NAME: &str = "defguard_sign_in"; +pub(crate) static SIGN_IN_COOKIE_NAME: &str = "defguard_sign_in"; #[derive(Default)] pub struct ApiResponse { diff --git a/src/handlers/openid_login.rs b/src/handlers/openid_login.rs deleted file mode 100644 index c9f8206155..0000000000 --- a/src/handlers/openid_login.rs +++ /dev/null @@ -1,116 +0,0 @@ -use std::str::FromStr; - -use crate::{error::WebError, AppState}; -use axum::extract::{Query, State}; -use openidconnect::{ - core::{CoreClient, CoreIdTokenVerifier, CoreProviderMetadata}, - reqwest::async_http_client, - AdditionalClaims, AuthUrl, ClientId, GenderClaim, IdToken, IdTokenVerifier, IssuerUrl, - JsonWebKeySet, JsonWebKeyType, JweContentEncryptionAlgorithm, JwsSigningAlgorithm, Nonce, - NonceVerifier, -}; - -// /// Authorization Endpoint -// /// See https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint -// pub async fn authorization( -// State(appstate): State, -// Query(data): Query, -// cookies: CookieJar, -// private_cookies: PrivateCookieJar, -// ) -> Result<(StatusCode, HeaderMap, PrivateCookieJar), WebError> { - -// Ok() -// } - -/// Authentication response callback -/// See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest -#[derive(Deserialize, Serialize, Debug)] -pub struct AuthenticationResponse { - id_token: String, -} - -struct Verifier; - -impl NonceVerifier for Verifier { - fn verify(self, nonce: Option<&openidconnect::Nonce>) -> Result<(), String> { - Ok(()) - } -} - -pub async fn auth_callback( - State(state): State, - Query(response): Query, -) -> Result<(), WebError> { - // TODO(jck): log all useful stuff - debug!("OIDC login callback got response: {response:?}"); - let token = IdToken::< - openidconnect::EmptyAdditionalClaims, - openidconnect::core::CoreGenderClaim, - openidconnect::core::CoreJweContentEncryptionAlgorithm, - openidconnect::core::CoreJwsSigningAlgorithm, - _, - >::from_str(&response.id_token); - debug!("Decoded token: {token:?}"); - if let Ok(token) = token { - // TOOD(jck): create user based on user info - // TOOD(jck): log user in - // token.claims(verifier, nonce_verifier); - // let provider_metadata = CoreProviderMetadata::discover( - // &IssuerUrl::new("https://accounts.example.com".to_string())?, - // http_client, - // )?; - - let client_id = ClientId::new("f2cef8b3-5b09-4c3f-988b-51fc3c42ecbc".to_string()); - // let client_secret = None; - // let issuer_url = IssuerUrl::new( - // "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/v2.0" - // .to_string(), - // ) - // .unwrap(); - // let auth_url = AuthUrl::new( - // "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/oauth2/v2.0/authorize".to_string() - // ).unwrap(); - // let token_url = None; - // let userinfo_url = None; - // let jwks = JsonWebKeySet::default(); - // let client = CoreClient::new( - // client_id, - // client_secret, - // issuer_url, - // auth_url, - // token_url, - // userinfo_url, - // jwks, - // ); - // let claims = token.claims(&client.id_token_verifier(), Verifier); - // info!("### Decoded claims: {claims:#?}"); - // let name = claims.name().unwrap(); - // let family_name = claims.family_name().unwrap(); - // let given_name = claims.given_name().unwrap(); - // let email = claims.email().unwrap(); - // info!("### name: {name:?}, family_name: {family_name:?}, given_name: {given_name:?}, email: {email:?}"); - let provider_metadata = CoreProviderMetadata::discover_async( - IssuerUrl::new( - "https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/v2.0" - .to_string(), - ) - .unwrap(), - async_http_client, - ) - .await - .unwrap(); - - let client = CoreClient::from_provider_metadata(provider_metadata, client_id, None); - let claims = token.claims(&client.id_token_verifier(), &Nonce::new("ala".to_string())).unwrap(); - info!("Decoded claims: {claims:#?}"); - // // Set the URL the user will be redirected to after the authorization process. - // .set_redirect_uri(RedirectUrl::new("http://localhost:3000/api/v1/oidc/callback".to_string())?); - // TODO(jck): log all useful stuff - info!("OIDC login succeeded for user"); - } else { - // TODO(jck): add context - warn!("OIDC login failed"); - } - - Ok(()) -} diff --git a/src/handlers/openid_providers.rs b/src/handlers/openid_providers.rs deleted file mode 100644 index 3eb1175e49..0000000000 --- a/src/handlers/openid_providers.rs +++ /dev/null @@ -1,38 +0,0 @@ -use axum::{extract::State, http::StatusCode, Json}; - -use crate::{appstate::AppState, auth::AdminRole, db::models::openid_provider::OpenIdProvider}; - -use super::{ApiResponse, ApiResult}; -use serde_json::json; - -// pub async fn add_openid_provider( -// _admin: AdminRole, -// session: SessionInfo, -// State(appstate): State, -// Json(data): Json, -// ) -> ApiResult { -// debug!( -// "User {} adding OpenID provider {}", -// session.user.username, client.name -// ); -// client.save(&appstate.pool).await?; -// info!( -// "User {} added OpenID client {}", -// session.user.username, client.name -// ); -// Ok(ApiResponse { -// json: json!(client), -// status: StatusCode::CREATED, -// }) -// } - -pub async fn list_openid_providers( - _admin: AdminRole, - State(appstate): State, -) -> ApiResult { - let providers = OpenIdProvider::all(&appstate.pool).await?; - Ok(ApiResponse { - json: json!(providers), - status: StatusCode::OK, - }) -} diff --git a/src/lib.rs b/src/lib.rs index c5cff67923..15b657e5c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,12 +12,17 @@ use axum::{ }; use assets::{index, svg, web_asset}; +use enterprise::handlers::{ + openid_login::{auth_callback, make_auth_url}, + openid_providers::{ + add_openid_provider, delete_openid_provider, list_openid_providers, modify_openid_provider, + }, +}; use handlers::ssh_authorized_keys::{ add_authentication_key, delete_authentication_key, fetch_authentication_keys, }; use handlers::{ group::{bulk_assign_to_groups, list_groups_info}, - openid_providers::list_openid_providers, ssh_authorized_keys::rename_authentication_key, yubikey::{delete_yubikey, rename_yubikey}, }; @@ -57,7 +62,6 @@ use self::{ remove_group_member, }, mail::{send_support_data, test_mail}, - openid_login::auth_callback, settings::{ get_settings, get_settings_essentials, patch_settings, set_default_branding, test_ldap_settings, update_settings, @@ -111,6 +115,7 @@ pub mod assets; pub mod auth; pub mod config; pub mod db; +pub mod enterprise; mod error; pub mod grpc; pub mod handlers; @@ -280,8 +285,12 @@ pub fn build_webapp( // ldap .route("/ldap/test", get(test_ldap_settings)) // OIDC login + .route("/openid/provider", get(list_openid_providers)) + .route("/openid/provider", post(add_openid_provider)) + .route("/openid/provider/:name", put(modify_openid_provider)) + .route("/openid/provider/:name", delete(delete_openid_provider)) .route("/openid/callback", get(auth_callback)) - .route("/openid/provider", get(list_openid_providers)), + .route("/openid/get_auth_url", get(make_auth_url)), ); #[cfg(feature = "openid")] diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index c07485308d..b7173da4b0 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -926,7 +926,9 @@ const en: BaseTranslation = { form: { labels: { name: 'Name', - documentUrl: 'OpenID document URL', + provider_url: 'Provider URL', + client_id: 'Client ID', + client_secret: 'Client Secret', }, }, }, diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index aba715a0f7..7def1961af 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -2287,9 +2287,17 @@ type RootTranslation = { */ name: string /** - * O​p​e​n​I​D​ ​d​o​c​u​m​e​n​t​ ​U​R​L + * P​r​o​v​i​d​e​r​ ​U​R​L */ - documentUrl: string + provider_url: string + /** + * C​l​i​e​n​t​ ​I​D + */ + client_id: string + /** + * C​l​i​e​n​t​ ​S​e​c​r​e​t + */ + client_secret: string } } } @@ -6197,9 +6205,17 @@ export type TranslationFunctions = { */ name: () => LocalizedString /** - * OpenID document URL + * Provider URL + */ + provider_url: () => LocalizedString + /** + * Client ID + */ + client_id: () => LocalizedString + /** + * Client Secret */ - documentUrl: () => LocalizedString + client_secret: () => LocalizedString } } } diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 9d85acffa7..3b0ce59738 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -912,7 +912,9 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe form: { labels: { name: 'Nazwa', - documentUrl: 'OpenID document URL', + provider_url: 'URL dostawcy OpenID', + client_id: 'ID klienta', + client_secret: 'Sekret klienta', }, }, }, diff --git a/web/src/pages/auth/Login/Login.tsx b/web/src/pages/auth/Login/Login.tsx index e07a289d52..522671105a 100644 --- a/web/src/pages/auth/Login/Login.tsx +++ b/web/src/pages/auth/Login/Login.tsx @@ -2,7 +2,7 @@ import './style.scss'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; +import axios, { AxiosError } from 'axios'; import { useMemo } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -79,21 +79,36 @@ export const Login = () => { }, }); + const client = useMemo(() => { + const res = axios.create({ + baseURL: '/api/v1', + }); + + res.defaults.headers.common['Content-Type'] = 'application/json'; + return res; + }, []); + const onSubmit: SubmitHandler = (data) => { if (!loginMutation.isLoading) { loginMutation.mutate(trimObjectStrings(data)); } }; - const msUrl = - 'https://login.microsoftonline.com/2fc43015-5699-4d01-bd01-d6f2bd66818a/oauth2/' + - 'v2.0/authorize?client_id=f2cef8b3-5b09-4c3f-988b-51fc3c42ecbc' + - '&scope=openid%20profile%20email&response_type=id_token&nonce=ala'; + const getUrl = async () => { + const url = client.get('/openid/get_auth_url').then((res) => res.data); + return url; + }; + + getUrl().then((url) => { + console.log(url); + }); const redirect = () => { - window.location.replace(msUrl); + getUrl().then((url) => { + window.location.replace(url); + }); }; - return ( + https: return (

{LL.loginPage.pageTitle()}

diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx index df66454cfd..a0795fd59e 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx @@ -3,7 +3,7 @@ // import { SubmitHandler, useForm } from 'react-hook-form'; // import { z } from 'zod'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useI18nContext } from '../../../../../i18n/i18n-react'; import IconCheckmarkWhite from '../../../../../shared/components/svg/IconCheckmarkWhite'; @@ -18,20 +18,43 @@ import useApi from '../../../../../shared/hooks/useApi'; import { QueryKeys } from '../../../../../shared/queries'; // import { useSettingsPage } from '../../../hooks/useSettingsPage'; import { ProviderDetails } from './ProviderDetails'; +import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSettingsPage } from '../../../hooks/useSettingsPage'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { OpenIdProvider, SettingsOpenID } from '../../../../../shared/types'; +import { z } from 'zod'; +import { Select } from '../../../../../shared/defguard-ui/components/Layout/Select/Select'; +import { + SelectSelectedValue, + SelectSizeVariant, +} from '../../../../../shared/defguard-ui/components/Layout/Select/types'; + +type FormFields = SettingsOpenID; export const OpenIdSettingsForm = () => { const { LL } = useI18nContext(); const localLL = LL.settingsPage.openIdSettings; - // const submitRef = useRef(null); + const submitRef = useRef(null); // const settings = useSettingsPage((state) => state.settings); - // const { - // settings: { patchSettings }, - // } = useApi(); + // const setSettings = useSettingsPage((state) => state.setState); + const [currentProvider, setCurrentProvider] = useState(null); + + const { + settings: { patchSettings }, + } = useApi(); - // const queryClient = useQueryClient(); + const queryClient = useQueryClient(); const { - settings: { fetchOpenIdProviders }, + settings: { + fetchOpenIdProviders, + addOpenIdProvider, + deleteOpenIdProvider, + editOpenIdProvider, + }, } = useApi(); const { data: providers, isLoading } = useQuery({ queryFn: fetchOpenIdProviders, @@ -40,39 +63,150 @@ export const OpenIdSettingsForm = () => { refetchOnWindowFocus: false, }); - console.log('providers:', providers); - - // const toaster = useToaster(); - - // const { isSaving, mutate } = useMutation({ - // mutationFn: patchSettings, - // onSuccess: () => { - // queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); - // toaster.success(LL.settingsPage.messages.editSuccess()); - // }, - // }); - - // const defaultValues = useMemo( - // (): FormFields => ({ - // name: settings?.name ?? '', - // document_url: settings?.document_url ?? '', - // }), - // [settings], - // ); - - // const { handleSubmit, control } = useForm({ - // resolver: zodResolver(schema), - // defaultValues, - // mode: 'all', - // }); - - // const handleValidSubmit: SubmitHandler = (data) => { - // mutate(data); - // }; + const toaster = useToaster(); + + const { mutate } = useMutation({ + mutationFn: currentProvider ? editOpenIdProvider : addOpenIdProvider, + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); + toaster.success(LL.settingsPage.messages.editSuccess()); + }, + // TODO(aleksander): HANDLE ERROR + onError: (error) => { + toaster.error(error.message); + }, + }); + + useEffect(() => { + if (providers && providers.length > 0) { + setCurrentProvider(providers[0]); + } + }, [providers]); + + const schema = useMemo( + () => + z.object({ + name: z.string().min(1, LL.form.error.required()), + provider_url: z + .string() + .url(LL.form.error.invalid()) + .min(1, LL.form.error.required()), + client_id: z.string().min(1, LL.form.error.required()), + client_secret: z.string().min(1, LL.form.error.required()), + }), + [LL.form.error], + ); + + const defaultValues = useMemo( + (): FormFields => ({ + name: currentProvider?.name ?? '', + provider_url: currentProvider?.provider_url ?? '', + client_id: currentProvider?.client_id ?? '', + client_secret: currentProvider?.client_secret ?? '', + }), + [currentProvider], + ); + + const { handleSubmit, reset, control } = useForm({ + resolver: zodResolver(schema), + defaultValues, + mode: 'all', + }); + + useEffect(() => { + reset(defaultValues); + }, [defaultValues]); + + console.log(currentProvider); + + const handleValidSubmit: SubmitHandler = (data) => { + mutate(data); + }; + + const options = useMemo( + () => [ + { + key: 1, + value: 'https://accounts.google.com', + label: 'Google', + }, + { + key: 2, + value: 'https://accounts.google2.com', + label: 'Microsoft', + }, + ], + [], + ); + + const renderSelected = useCallback( + (selected: string): SelectSelectedValue => { + const option = options.find((o) => o.value === selected); + + if (!option) throw Error("Selected value doesn't exist"); + + return { + key: option.key, + displayValue: option.label, + }; + }, + [options], + ); + + const handleChange = async (val: string) => { + if (!isLoading && currentProvider) { + const newProvider: OpenIdProvider = { + id: currentProvider.id, + name: options.find((o) => o.value === val)?.label ?? '', + provider_url: val, + client_id: currentProvider.client_id, + client_secret: currentProvider.client_secret, + }; + setCurrentProvider(newProvider); + } + }; + return (

{localLL.title()}

+
+ + {/* TODO(aleksander): Make a select here */} + + + {/* handleChange(res)} + loading={isLoading} + /> + + +
); }; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/ProviderDetails.tsx b/web/src/pages/settings/components/OpenIdSettings/components/ProviderDetails.tsx index 408f71d871..3885671269 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/ProviderDetails.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/components/ProviderDetails.tsx @@ -7,13 +7,21 @@ // import { isUndefined, orderBy } from 'lodash-es'; // import { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useI18nContext } from '../../../../../i18n/i18n-react'; +import SvgIconUserList from '../../../../../shared/components/svg/IconUserList'; +import SvgIconUserListExpanded from '../../../../../shared/components/svg/IconUserListExpanded'; +import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { ButtonStyleVariant } from '../../../../../shared/defguard-ui/components/Layout/Button/types'; // import IconClip from '../../../../../shared/components/svg/IconClip'; // import SvgIconCollapse from '../../../../../shared/components/svg/IconCollapse'; // import SvgIconExpand from '../../../../../shared/components/svg/IconExpand'; // import { ColorsRGB } from '../../../../../shared/constants'; // import { Badge } from '../../../../../shared/defguard-ui/components/Layout/Badge/Badge'; import { Card } from '../../../../../shared/defguard-ui/components/Layout/Card/Card'; +import { EditButton } from '../../../../../shared/defguard-ui/components/Layout/EditButton/EditButton'; +import { EditButtonOption } from '../../../../../shared/defguard-ui/components/Layout/EditButton/EditButtonOption'; +import { EditButtonOptionStyleVariant } from '../../../../../shared/defguard-ui/components/Layout/EditButton/types'; // import { DeviceAvatar } from '../../../../../shared/defguard-ui/components/Layout/DeviceAvatar/DeviceAvatar'; // import { EditButton } from '../../../../../shared/defguard-ui/components/Layout/EditButton/EditButton'; // import { EditButtonOption } from '../../../../../shared/defguard-ui/components/Layout/EditButton/EditButtonOption'; @@ -26,7 +34,10 @@ import { OpenIdProvider } from '../../../../../shared/types'; // import { useDeleteDeviceModal } from '../hooks/useDeleteDeviceModal'; // import { useDeviceConfigModal } from '../hooks/useDeviceConfigModal'; // import { useEditDeviceModal } from '../hooks/useEditDeviceModal'; - +import { motion, TargetAndTransition } from 'framer-motion'; +import SvgIconCollapse from '../../../../../shared/components/svg/IconCollapse'; +import SvgIconExpand from '../../../../../shared/components/svg/IconExpand'; +import useApi from '../../../../../shared/hooks/useApi'; // dayjs.extend(utc); // const dateFormat = 'DD.MM.YYYY | HH:mm'; @@ -39,12 +50,40 @@ interface Props { provider: OpenIdProvider; } +type ExpandButtonProps = { + expanded: boolean; + onExpand: () => void; +}; + +const ExpandButton = ({ expanded, onExpand }: ExpandButtonProps) => { + return ( + + ); +}; + export const ProviderDetails = ({ provider }: Props) => { // const [hovered, setHovered] = useState(false); const { LL } = useI18nContext(); // const setDeleteDeviceModal = useDeleteDeviceModal((state) => state.setState); // const setEditDeviceModal = useEditDeviceModal((state) => state.setState); // const openDeviceConfigModal = useDeviceConfigModal((state) => state.open); + const [expanded, setExpanded] = useState(false); + + const getClassName = useMemo(() => { + const res = ['user-connection-list-item']; + if (expanded) { + res.push('expanded'); + } + return res.join(' '); + }, [expanded]); + + const { + settings: { deleteOpenIdProvider }, + } = useApi(); // const schema = useMemo( // () => @@ -104,57 +143,76 @@ export const ProviderDetails = ({ provider }: Props) => {

{provider.name}

-
-
- -

{provider.document_url}

-
+
+
+ + setExpanded((state) => !state)} + /> + {expanded && ( +
+
+ +

{provider.provider_url}

+
+
+ +

{provider.client_id}

+
+
+ +

{provider.client_secret}

+
+
+ )}
-
- {/* */} - {/* { */} - {/* setEditDeviceModal({ */} - {/* visible: true, */} - {/* device: device, */} - {/* }); */} - {/* }} */} - {/* /> */} - {/* { */} - {/* openDeviceConfigModal({ */} - {/* deviceName: device.name, */} - {/* publicKey: device.wireguard_pubkey, */} - {/* deviceId: device.id, */} - {/* userId: user.user.id, */} - {/* networks: device.networks.map((n) => ({ */} - {/* networkId: n.network_id, */} - {/* networkName: n.network_name, */} - {/* })), */} - {/* }); */} - {/* }} */} - {/* /> */} - {/* */} - {/* setDeleteDeviceModal({ */} - {/* visible: true, */} - {/* device: device, */} - {/* }) */} - {/* } */} - {/* /> */} - {/* */} - {/* setExpanded((state) => !state)} */} - {/* /> */} -
+ {/*
+ + { + // setEditDeviceModal({ + // visible: true, + // device: device, + // }); + }} + /> + { + // openDeviceConfigModal({ + // deviceName: device.name, + // publicKey: device.wireguard_pubkey, + // deviceId: device.id, + // userId: user.user.id, + // networks: device.networks.map((n) => ({ + // networkId: n.network_id, + // networkName: n.network_name, + // })), + // }); + }} + /> + {} + // setDeleteDeviceModal({ + // visible: true, + // device: device, + // }) + } + /> + + + +
*/} ); }; diff --git a/web/src/pages/users/UserProfile/UserProfile.tsx b/web/src/pages/users/UserProfile/UserProfile.tsx index fc44b44b03..5e01288894 100644 --- a/web/src/pages/users/UserProfile/UserProfile.tsx +++ b/web/src/pages/users/UserProfile/UserProfile.tsx @@ -182,7 +182,10 @@ const EditModeControls = () => { size={ButtonSize.SMALL} styleVariant={ButtonStyleVariant.SAVE} icon={} - onClick={() => submitSubject.next()} + onClick={() => { + submitSubject.next(); + console.log('submitSubject.next();'); + }} loading={loading} /> diff --git a/web/src/shared/hooks/useApi.tsx b/web/src/shared/hooks/useApi.tsx index 37cd0774a2..bacdabbb15 100644 --- a/web/src/shared/hooks/useApi.tsx +++ b/web/src/shared/hooks/useApi.tsx @@ -439,6 +439,16 @@ const useApi = (props?: HookProps): ApiHook => { const fetchOpenIdProviders: ApiHook['settings']['fetchOpenIdProviders'] = async () => client.get(`/openid/provider`).then((res) => res.data); + const addOpenIdProvider: ApiHook['settings']['addOpenIdProvider'] = async (data) => + client.post(`/openid/provider`, data).then(unpackRequest); + + const deleteOpenIdProvider: ApiHook['settings']['deleteOpenIdProvider'] = async ( + name, + ) => client.delete(`/openid/provider/${name}`).then(unpackRequest); + + const editOpenIdProvider: ApiHook['settings']['editOpenIdProvider'] = async (data) => + client.put(`/openid/provider/${data.name}`, data).then(unpackRequest); + useEffect(() => { client.interceptors.response.use( (res) => { @@ -597,6 +607,9 @@ const useApi = (props?: HookProps): ApiHook => { getEssentialSettings, testLdapSettings, fetchOpenIdProviders, + addOpenIdProvider, + deleteOpenIdProvider, + editOpenIdProvider, }, support: { downloadSupportData, diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 7801750261..6044d7df2a 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -570,6 +570,9 @@ export interface ApiHook { getEssentialSettings: () => Promise; testLdapSettings: () => Promise; fetchOpenIdProviders: () => Promise; + addOpenIdProvider: (data: OpenIdProvider) => Promise; + deleteOpenIdProvider: (id: string) => Promise; + editOpenIdProvider: (data: OpenIdProvider) => Promise; }; support: { downloadSupportData: () => Promise; @@ -787,7 +790,8 @@ export type Settings = SettingsModules & SettingsSMTP & SettingsEnrollment & SettingsBranding & - SettingsLDAP; + SettingsLDAP & + SettingsOpenID; // essentials for core frontend, includes only those that are required for frontend operations export type SettingsEssentials = SettingsModules & SettingsBranding; @@ -840,6 +844,13 @@ export type SettingsWeb3 = { challenge_template: string; }; +export type SettingsOpenID = { + name: string; + provider_url: string; + client_id: string; + client_secret: string; +}; + export interface Webhook { id: string; url: string; @@ -865,7 +876,9 @@ export interface OpenidClient { export interface OpenIdProvider { id: string; name: string; - document_url: string; + provider_url: string; + client_id: string; + client_secret: string; } export interface EditOpenidClientRequest { From 80afbe685a4b6cb014f0ca3a83bb37564384dc05 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Fri, 12 Jul 2024 16:59:37 +0200 Subject: [PATCH 09/73] openid flow 2 --- .../20240603091113_openid_provider.up.sql | 3 +- .../20240710095931_add_openid_login.down.sql | 1 - .../20240710095931_add_openid_login.up.sql | 1 - src/db/models/group.rs | 2 +- src/db/models/mod.rs | 2 +- src/db/models/user.rs | 13 +- src/enterprise/db/models/openid_provider.rs | 47 ++-- src/enterprise/handlers/openid_login.rs | 234 +++++++++++++----- src/enterprise/handlers/openid_providers.rs | 36 +-- src/grpc/enrollment.rs | 2 +- src/handlers/group.rs | 2 +- src/lib.rs | 11 +- web/src/i18n/en/index.ts | 4 +- web/src/i18n/i18n-types.ts | 24 +- web/src/i18n/pl/index.ts | 4 +- web/src/pages/auth/Login/Login.tsx | 39 ++- .../OpenIdSettings/OpenIdSettings.tsx | 8 +- .../components/OpenIdSettingsForm.tsx | 170 ++++++------- .../components/ProviderDetails.tsx | 218 ---------------- .../OpenIdSettings/components/style.scss | 22 ++ web/src/shared/hooks/useApi.tsx | 12 +- web/src/shared/types.ts | 17 +- 22 files changed, 401 insertions(+), 471 deletions(-) delete mode 100644 migrations/20240710095931_add_openid_login.down.sql delete mode 100644 migrations/20240710095931_add_openid_login.up.sql delete mode 100644 web/src/pages/settings/components/OpenIdSettings/components/ProviderDetails.tsx create mode 100644 web/src/pages/settings/components/OpenIdSettings/components/style.scss diff --git a/migrations/20240603091113_openid_provider.up.sql b/migrations/20240603091113_openid_provider.up.sql index 8e47ecf749..397dda83e9 100644 --- a/migrations/20240603091113_openid_provider.up.sql +++ b/migrations/20240603091113_openid_provider.up.sql @@ -1,8 +1,7 @@ CREATE TABLE openidprovider ( id bigserial PRIMARY KEY, "name" text NOT NULL, - -- "document_url" text NOT NULL, - "provider_url" text NOT NULL, + "base_url" text NOT NULL, "client_id" text NOT NULL, "client_secret" text NOT NULL, "enabled" boolean NOT NULL DEFAULT FALSE, diff --git a/migrations/20240710095931_add_openid_login.down.sql b/migrations/20240710095931_add_openid_login.down.sql deleted file mode 100644 index d216b19dd0..0000000000 --- a/migrations/20240710095931_add_openid_login.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "user" DROP COLUMN openid_login; diff --git a/migrations/20240710095931_add_openid_login.up.sql b/migrations/20240710095931_add_openid_login.up.sql deleted file mode 100644 index 07f397654d..0000000000 --- a/migrations/20240710095931_add_openid_login.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "user" ADD COLUMN openid_login boolean NOT NULL DEFAULT false; diff --git a/src/db/models/group.rs b/src/db/models/group.rs index f5b5ea3ef2..73f4476188 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -60,7 +60,7 @@ impl Group { User, "SELECT \"user\".id \"id?\", username, password_hash, last_name, first_name, email, \ phone, mfa_enabled, totp_enabled, totp_secret, email_mfa_enabled, email_mfa_secret, \ - mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_login \ + mfa_method \"mfa_method: _\", recovery_codes, is_active \ FROM \"user\" \ JOIN group_user ON \"user\".id = group_user.user_id \ WHERE group_user.group_id = $1", diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 246bb2f4da..fe7b8d4dd8 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -98,7 +98,7 @@ impl UserInfo { mfa_method: user.mfa_method.clone(), authorized_apps, is_active: user.is_active, - enrolled: user.has_password() || user.openid_login, + enrolled: user.has_password(), }) } diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 68eabd80a1..2ea623127a 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -74,8 +74,6 @@ pub struct User { pub phone: Option, pub mfa_enabled: bool, pub is_active: bool, - /// Whether user logs in through an OpenID provider - pub openid_login: bool, // secret has been verified and TOTP can be used pub(crate) totp_enabled: bool, pub(crate) email_mfa_enabled: bool, @@ -122,7 +120,6 @@ impl User { mfa_method: MFAMethod::None, recovery_codes: Vec::new(), is_active: true, - openid_login: false, } } @@ -454,7 +451,7 @@ impl User { ) -> Result, SqlxError> { let users = query!( "SELECT id, mfa_enabled, totp_enabled, email_mfa_enabled, \ - mfa_method as \"mfa_method: MFAMethod\", password_hash, is_active, openid_login \ + mfa_method as \"mfa_method: MFAMethod\", password_hash, is_active \ FROM \"user\"" ) .fetch_all(pool) @@ -468,7 +465,7 @@ impl User { mfa_enabled: u.mfa_enabled, id: u.id, is_active: u.is_active, - enrolled: u.password_hash.is_some() || u.openid_login, + enrolled: u.password_hash.is_some(), }) .collect(); Ok(res) @@ -484,7 +481,7 @@ impl User { "SELECT \"user\".id \"id?\", username, password_hash, last_name, first_name, email, \ phone, mfa_enabled, totp_enabled, totp_secret, \ email_mfa_enabled, email_mfa_secret, \ - mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_login \ + mfa_method \"mfa_method: _\", recovery_codes, is_active \ FROM \"user\" INNER JOIN \"group_user\" ON \"user\".id = \"group_user\".user_id INNER JOIN \"group\" ON \"group_user\".group_id = \"group\".id @@ -575,7 +572,7 @@ impl User { Self, "SELECT id \"id?\", username, password_hash, last_name, first_name, email, \ phone, mfa_enabled, totp_enabled, email_mfa_enabled, \ - totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_login \ + totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active \ FROM \"user\" WHERE username = $1", username ) @@ -591,7 +588,7 @@ impl User { Self, "SELECT id \"id?\", username, password_hash, last_name, first_name, email, \ phone, mfa_enabled, totp_enabled, email_mfa_enabled, \ - totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_login \ + totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active \ FROM \"user\" WHERE email = $1", email ) diff --git a/src/enterprise/db/models/openid_provider.rs b/src/enterprise/db/models/openid_provider.rs index 5a5ee00533..9841f5f329 100644 --- a/src/enterprise/db/models/openid_provider.rs +++ b/src/enterprise/db/models/openid_provider.rs @@ -8,7 +8,7 @@ use crate::db::DbPool; pub struct OpenIdProvider { pub id: Option, pub name: String, - pub provider_url: String, + pub base_url: String, pub client_id: String, pub client_secret: String, pub enabled: bool, @@ -32,11 +32,11 @@ pub struct OpenIdProvider { impl OpenIdProvider { #[must_use] - pub fn new>(name: S, provider_url: S, client_id: S, client_secret: S) -> Self { + pub fn new>(name: S, base_url: S, client_id: S, client_secret: S) -> Self { Self { id: None, name: name.into(), - provider_url: provider_url.into(), + base_url: base_url.into(), client_id: client_id.into(), client_secret: client_secret.into(), enabled: false, @@ -46,33 +46,42 @@ impl OpenIdProvider { pub async fn find_by_name(pool: &DbPool, name: &str) -> Result, SqlxError> { query_as!( OpenIdProvider, - "SELECT id \"id?\", name, provider_url, client_id, client_secret, enabled FROM openidprovider WHERE name = $1", + "SELECT id \"id?\", name, base_url, client_id, client_secret, enabled FROM openidprovider WHERE name = $1", name ) .fetch_optional(pool) .await } - pub async fn exists(pool: &DbPool, provider: &OpenIdProvider) -> Result { - query!( - "SELECT EXISTS(SELECT 1 FROM openidprovider WHERE name = $1 OR client_id = $2 OR client_secret = $3)", - provider.name, - provider.client_id, - provider.client_secret - ) - .fetch_one(pool) - .await? - .exists - .ok_or_else(|| SqlxError::RowNotFound) + // TODO: this is a temporary method. We currently support only one provider at a time + pub async fn upsert(&mut self, pool: &DbPool) -> Result<(), SqlxError> { + // TODO(aleksander): do it with only one query? + if let Some(provider) = OpenIdProvider::get_current(pool).await? { + query!( + "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, client_secret = $4, enabled = $5 WHERE id = $6", + self.name, + self.base_url, + self.client_id, + self.client_secret, + self.enabled, + provider.id + ) + .execute(pool) + .await?; + } else { + self.save(pool).await?; + } + + Ok(()) } - // TODO(aleksander): there may be more than one active provider - pub async fn get_enabled(pool: &DbPool) -> Result { + // TODO: this is a temporary method. We currently support only one provider at a time + pub async fn get_current(pool: &DbPool) -> Result, SqlxError> { query_as!( OpenIdProvider, - "SELECT id \"id?\", name, provider_url, client_id, client_secret, enabled FROM openidprovider WHERE enabled = true limit 1" + "SELECT id \"id?\", name, base_url, client_id, client_secret, enabled FROM openidprovider" ) - .fetch_one(pool) + .fetch_optional(pool) .await } } diff --git a/src/enterprise/handlers/openid_login.rs b/src/enterprise/handlers/openid_login.rs index 2636168f90..b809b9e049 100644 --- a/src/enterprise/handlers/openid_login.rs +++ b/src/enterprise/handlers/openid_login.rs @@ -146,7 +146,10 @@ use openidconnect::{ core::CoreProviderMetadata, reqwest::async_http_client, ClientId, ClientSecret, IssuerUrl, ProviderMetadata, RedirectUrl, RevocationUrl, }; -use openidconnect::{AuthenticationFlow, AuthorizationCode, CsrfToken, LanguageTag, Nonce, Scope}; +use openidconnect::{ + AuthenticationFlow, AuthorizationCode, CsrfToken, EndUserEmail, EndUserFamilyName, + EndUserGivenName, LanguageTag, LocalizedClaim, Nonce, Scope, +}; use crate::appstate::AppState; use crate::db::{AppEvent, DbPool, Session, SessionState, User, UserInfo}; @@ -177,9 +180,16 @@ type ProvMeta = ProviderMetadata< async fn get_provider_metadata(url: &str) -> Result { let issuer_url = IssuerUrl::new(url.to_string()).unwrap(); - let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, async_http_client) - .await - .unwrap(); + let provider_metadata = + match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await { + Ok(metadata) => metadata, + Err(_) => { + return Err(WebError::Authorization(format!( + "Failed to discover provider metadata, make sure the providers' url is correct: {}", + url + ))); + } + }; println!("{:?}", provider_metadata); @@ -187,33 +197,48 @@ async fn get_provider_metadata(url: &str) -> Result { } async fn make_oidc_client(pool: &DbPool) -> Result { - let provider = OpenIdProvider::get_enabled(pool).await?; - let provider_metadata = get_provider_metadata(&provider.provider_url).await?; + let provider = match OpenIdProvider::get_current(pool).await? { + Some(provider) => provider, + None => { + return Err(WebError::Authorization( + "OpenID provider not found".to_string(), + )); + } + }; + let provider_metadata = get_provider_metadata(&provider.base_url).await?; let client_id = ClientId::new(provider.client_id); - let client_secret = ClientSecret::new(provider.client_secret); + let config = server_config(); + let url = format!("{}api/v1/openid/callback", config.url); + let redirect_url = match RedirectUrl::new(url) { + Ok(url) => url, + Err(err) => { + return Err(WebError::Authorization(format!( + "Failed to create a redirect url from string: {}", + err + ))); + } + }; - let client = + Ok( CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) - .set_redirect_uri( - RedirectUrl::new("http://localhost:3000/api/v1/openid/callback".to_string()) - .unwrap(), - ); - - Ok(client) + .set_redirect_uri(redirect_url), + ) } fn nonce_fn() -> Nonce { - Nonce::new("nonce".to_string()) + Nonce::new("nonce123123".to_string()) } fn csrf_fn() -> CsrfToken { CsrfToken::new("csrf".to_string()) } -pub async fn make_auth_url(State(appstate): State) -> Result { - // TODO(aleksander): make sure that the user enables the oidc login first +pub async fn get_auth_info( + private_cookies: PrivateCookieJar, + State(appstate): State, +) -> Result<(PrivateCookieJar, ApiResponse), WebError> { let client = make_oidc_client(&appstate.pool).await?; let (authorize_url, csrf_state, nonce) = client @@ -222,16 +247,56 @@ pub async fn make_auth_url(State(appstate): State) -> Result>(uri: T, cookies: CookieJar) -> (StatusCode, Header pub struct AuthenticationResponse { code: AuthorizationCode, state: CsrfToken, + // nonce: Nonce, } pub async fn auth_callback( cookies: CookieJar, + private_cookies: PrivateCookieJar, user_agent: Option>, forwarded_for_ip: Option, InsecureClientIp(insecure_ip): InsecureClientIp, Query(params): Query, State(appstate): State, ) -> Result<(StatusCode, HeaderMap, CookieJar), WebError> { + let cookie_nonce = private_cookies + .get("nonce") + .ok_or(WebError::Authorization( + "Nonce cookie not found".to_string(), + ))? + .value_trimmed() + .to_string(); + + let cookie_csrf = private_cookies + .get("csrf") + .ok_or(WebError::Authorization("CSRF cookie not found".to_string()))? + .value_trimmed() + .to_string(); + + if params.state.secret() != &cookie_csrf { + return Err(WebError::Authorization("CSRF token mismatch".to_string())); + }; + let client = make_oidc_client(&appstate.pool).await?; let token = client .exchange_code(params.code) .request_async(async_http_client) .await - .unwrap(); + .map_err(|error| { + WebError::Authorization(format!( + "Failed to exchange code for token, error: {:?}", + error + )) + })?; - let nonce = Nonce::new("nonce".to_string()); + let nonce = Nonce::new(cookie_nonce); let token_verifier = client.id_token_verifier(); - let token_claims = token - .extra_fields() - .id_token() - .expect("Server did not return an ID token") - .claims(&token_verifier, &nonce) - .unwrap(); + let id_token = match token.extra_fields().id_token() { + Some(token) => token, + None => { + return Err(WebError::Authorization( + "Server did not return an ID token".to_string(), + )); + } + }; + + let token_claims = match id_token.claims(&token_verifier, &nonce) { + Ok(claims) => claims, + Err(error) => { + return Err(WebError::Authorization(format!( + "Failed to verify ID token, error: {:?}", + error + ))); + } + }; // println!("Google returned ID token: {:?}", token_claims); - let email = token_claims.email().unwrap(); - let name = token_claims.name().unwrap(); - // TODO: check whats up with localized claims - // println!("{:?}", token_claims); - let given_name = token_claims - .given_name() - .unwrap() - .clone() - .into_iter() - .next() - .unwrap() - .1; - let family_name = token_claims - .family_name() - .unwrap() - .clone() - .into_iter() - .next() - .unwrap() - .1; + let email = token_claims.email().ok_or(WebError::Authorization( + "Email not found in the information returned from provider.".to_string(), + ))?; + + // let name = token_claims.name().unwrap(); + // let given_name: Option<&EndUserGivenName> = match token_claims.given_name() { + // Some(given_name) => Ok(given_name.get(None)), + // None => Err(WebError::Authorization( + // "Given name not found in the information returned from provider.".to_string(), + // ))?, + // }?; + // let family_name: Option<&EndUserFamilyName> = match token_claims.family_name() { + // Some(family_name) => Ok(family_name.get(None)), + // None => Err(WebError::Authorization( + // "Family name not found in the information returned from provider.".to_string(), + // ))?, + // }?; + + let phone = token_claims.phone_number(); + println!("Email: {:?}", email); - println!("Name: {:?}", name); - println!("Given Name: {:?}", given_name); - println!("Family Name: {:?}", family_name); + // println!("Name: {:?}", name); + // println!("Given Name: {:?}", given_name); + // println!("Family Name: {:?}", family_name); let username = email.split('@').next().unwrap(); let user = match User::find_by_username(&appstate.pool, username).await { Ok(Some(user)) => user, Ok(None) => { - let mut user = User::new( - username.to_string(), - None, - family_name.to_string(), - given_name.to_string(), - email.to_string(), - // TODO: Add phone - None, - ); - user.openid_login = true; - user.save(&appstate.pool).await?; - user + // let mut user = User::new( + // username.to_string(), + // None, + // family_name.to_string(), + // given_name.to_string(), + // email.to_string(), + // None, + // ); + // user.save(&appstate.pool).await?; + // user + return Err(WebError::Authorization( + "User not found. The user needs to be created first in order to login using OIDC." + .to_string(), + )); } Err(e) => { return Err(WebError::Authorization(e.to_string())); diff --git a/src/enterprise/handlers/openid_providers.rs b/src/enterprise/handlers/openid_providers.rs index b72dff312a..85ea295a4d 100644 --- a/src/enterprise/handlers/openid_providers.rs +++ b/src/enterprise/handlers/openid_providers.rs @@ -12,7 +12,7 @@ use serde_json::json; #[derive(Debug, Deserialize)] pub struct AddProviderData { name: String, - provider_url: String, + base_url: String, client_id: String, client_secret: String, } @@ -25,27 +25,15 @@ pub async fn add_openid_provider( ) -> ApiResult { let mut new_provider = OpenIdProvider::new( provider_data.name, - provider_data.provider_url, + provider_data.base_url, provider_data.client_id, provider_data.client_secret, ); - // check if it already exists - if OpenIdProvider::exists(&appstate.pool, &new_provider).await? { - warn!( - "User {} failed to add OpenID client {}. Such client already exists.", - session.user.username, new_provider.name - ); - return Ok(ApiResponse { - json: json!({}), - status: StatusCode::CONFLICT, - }); - } - debug!( "User {} adding OpenID provider {}", session.user.username, new_provider.name ); - new_provider.save(&appstate.pool).await?; + new_provider.upsert(&appstate.pool).await?; info!( "User {} added OpenID client {}", session.user.username, new_provider.name @@ -56,6 +44,22 @@ pub async fn add_openid_provider( }) } +pub async fn get_current_openid_provider( + _admin: AdminRole, + State(appstate): State, +) -> ApiResult { + match OpenIdProvider::get_current(&appstate.pool).await? { + Some(provider) => Ok(ApiResponse { + json: json!(provider), + status: StatusCode::OK, + }), + None => Ok(ApiResponse { + json: json!({}), + status: StatusCode::NOT_FOUND, + }), + } +} + pub async fn delete_openid_provider( _admin: AdminRole, session: SessionInfo, @@ -101,7 +105,7 @@ pub async fn modify_openid_provider( ); let provider = OpenIdProvider::find_by_name(&appstate.pool, &provider_data.name).await?; if let Some(mut provider) = provider { - provider.provider_url = provider_data.provider_url; + provider.base_url = provider_data.base_url; provider.client_id = provider_data.client_id; provider.client_secret = provider_data.client_secret; provider.save(&appstate.pool).await?; diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs index 9e299d501a..16569d0d0b 100644 --- a/src/grpc/enrollment.rs +++ b/src/grpc/enrollment.rs @@ -497,7 +497,7 @@ impl From for AdminInfo { impl InitialUserInfo { async fn from_user(pool: &DbPool, user: User) -> Result { - let is_enrolled = user.has_password() || user.openid_login; + let is_enrolled = user.has_password(); let devices = user.devices(pool).await?; let device_names = devices.into_iter().map(|dev| dev.device.name).collect(); Ok(Self { diff --git a/src/handlers/group.rs b/src/handlers/group.rs index 5969cefbd9..748bdc5388 100644 --- a/src/handlers/group.rs +++ b/src/handlers/group.rs @@ -47,7 +47,7 @@ pub(crate) async fn bulk_assign_to_groups( User, "SELECT id \"id?\", username, password_hash, last_name, first_name, email, \ phone, mfa_enabled, totp_enabled, email_mfa_enabled, \ - totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_login \ + totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active \ FROM \"user\" WHERE id = ANY($1)", &data.users ) diff --git a/src/lib.rs b/src/lib.rs index 15b657e5c9..983c58715f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,9 +13,10 @@ use axum::{ use assets::{index, svg, web_asset}; use enterprise::handlers::{ - openid_login::{auth_callback, make_auth_url}, + openid_login::{auth_callback, get_auth_info}, openid_providers::{ - add_openid_provider, delete_openid_provider, list_openid_providers, modify_openid_provider, + add_openid_provider, delete_openid_provider, get_current_openid_provider, + list_openid_providers, modify_openid_provider, }, }; use handlers::ssh_authorized_keys::{ @@ -285,12 +286,10 @@ pub fn build_webapp( // ldap .route("/ldap/test", get(test_ldap_settings)) // OIDC login - .route("/openid/provider", get(list_openid_providers)) + .route("/openid/provider", get(get_current_openid_provider)) .route("/openid/provider", post(add_openid_provider)) - .route("/openid/provider/:name", put(modify_openid_provider)) - .route("/openid/provider/:name", delete(delete_openid_provider)) .route("/openid/callback", get(auth_callback)) - .route("/openid/get_auth_url", get(make_auth_url)), + .route("/openid/auth_info", get(get_auth_info)), ); #[cfg(feature = "openid")] diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index b7173da4b0..439cf622be 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -926,9 +926,11 @@ const en: BaseTranslation = { form: { labels: { name: 'Name', - provider_url: 'Provider URL', + provider: 'Provider', client_id: 'Client ID', client_secret: 'Client Secret', + base_url: 'Provider URL', + tenant_id: 'Tenant ID', }, }, }, diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 7def1961af..1ff6f18654 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -2287,9 +2287,9 @@ type RootTranslation = { */ name: string /** - * P​r​o​v​i​d​e​r​ ​U​R​L + * P​r​o​v​i​d​e​r */ - provider_url: string + provider: string /** * C​l​i​e​n​t​ ​I​D */ @@ -2298,6 +2298,14 @@ type RootTranslation = { * C​l​i​e​n​t​ ​S​e​c​r​e​t */ client_secret: string + /** + * P​r​o​v​i​d​e​r​ ​U​R​L + */ + base_url: string + /** + * T​e​n​a​n​t​ ​I​D + */ + tenant_id: string } } } @@ -6205,9 +6213,9 @@ export type TranslationFunctions = { */ name: () => LocalizedString /** - * Provider URL + * Provider */ - provider_url: () => LocalizedString + provider: () => LocalizedString /** * Client ID */ @@ -6216,6 +6224,14 @@ export type TranslationFunctions = { * Client Secret */ client_secret: () => LocalizedString + /** + * Provider URL + */ + base_url: () => LocalizedString + /** + * Tenant ID + */ + tenant_id: () => LocalizedString } } } diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 3b0ce59738..2c5d5ca2c0 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -912,9 +912,11 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe form: { labels: { name: 'Nazwa', - provider_url: 'URL dostawcy OpenID', + provider: 'Dostawca OpenID', client_id: 'ID klienta', client_secret: 'Sekret klienta', + base_url: 'URL dostawcy', + tenant_id: 'ID dzierżawy (Tenant ID)', }, }, }, diff --git a/web/src/pages/auth/Login/Login.tsx b/web/src/pages/auth/Login/Login.tsx index 522671105a..cd5d7947da 100644 --- a/web/src/pages/auth/Login/Login.tsx +++ b/web/src/pages/auth/Login/Login.tsx @@ -2,7 +2,7 @@ import './style.scss'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation } from '@tanstack/react-query'; -import axios, { AxiosError } from 'axios'; +import { AxiosError } from 'axios'; import { useMemo } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -47,7 +47,10 @@ export const Login = () => { ); const { - auth: { login }, + auth: { + login, + openid: { getOpenidInfo }, + }, } = useApi(); const { handleSubmit, control, setError } = useForm({ @@ -79,36 +82,26 @@ export const Login = () => { }, }); - const client = useMemo(() => { - const res = axios.create({ - baseURL: '/api/v1', - }); - - res.defaults.headers.common['Content-Type'] = 'application/json'; - return res; - }, []); - const onSubmit: SubmitHandler = (data) => { if (!loginMutation.isLoading) { loginMutation.mutate(trimObjectStrings(data)); } }; - const getUrl = async () => { - const url = client.get('/openid/get_auth_url').then((res) => res.data); - return url; - }; + // const redirect = () => { + // // getUrl().then((url) => { + // // window.location.replace(url); + // // }); + // }; - getUrl().then((url) => { - console.log(url); - }); - const redirect = () => { - getUrl().then((url) => { - window.location.replace(url); + const openIdLogin = () => { + getOpenidInfo().then((data) => { + // console.log(data); + window.location.replace(data.url); }); }; - https: return ( + return (

{LL.loginPage.pageTitle()}

@@ -141,7 +134,7 @@ export const Login = () => { styleVariant={ButtonStyleVariant.PRIMARY} text="Login with OIDC" data-testid="login-form-submit" - onClick={redirect} + onClick={openIdLogin} />
diff --git a/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx index e4239f84e5..bb4d08a60b 100644 --- a/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx @@ -2,4 +2,10 @@ import './style.scss'; import { OpenIdSettingsForm } from './components/OpenIdSettingsForm'; -export const OpenIdSettings = () => ; +export const OpenIdSettings = () => { + return ( +
+ +
+ ); +}; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx index a0795fd59e..c99576d2f8 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx @@ -1,62 +1,42 @@ -// import { zodResolver } from '@hookform/resolvers/zod'; -// import { useMemo, useRef } from 'react'; -// import { SubmitHandler, useForm } from 'react-hook-form'; -// import { z } from 'zod'; +import './style.scss'; +import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod'; import { useI18nContext } from '../../../../../i18n/i18n-react'; import IconCheckmarkWhite from '../../../../../shared/components/svg/IconCheckmarkWhite'; -// import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; +import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; import { ButtonSize, ButtonStyleVariant, } from '../../../../../shared/defguard-ui/components/Layout/Button/types'; -import useApi from '../../../../../shared/hooks/useApi'; -// import { useToaster } from '../../../../../shared/hooks/useToaster'; -import { QueryKeys } from '../../../../../shared/queries'; -// import { useSettingsPage } from '../../../hooks/useSettingsPage'; -import { ProviderDetails } from './ProviderDetails'; -import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useSettingsPage } from '../../../hooks/useSettingsPage'; -import { SubmitHandler, useForm } from 'react-hook-form'; -import { useToaster } from '../../../../../shared/hooks/useToaster'; -import { OpenIdProvider, SettingsOpenID } from '../../../../../shared/types'; -import { z } from 'zod'; import { Select } from '../../../../../shared/defguard-ui/components/Layout/Select/Select'; import { + SelectOption, SelectSelectedValue, SelectSizeVariant, } from '../../../../../shared/defguard-ui/components/Layout/Select/types'; +import useApi from '../../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { QueryKeys } from '../../../../../shared/queries'; +import { OpenIdProvider } from '../../../../../shared/types'; -type FormFields = SettingsOpenID; +type FormFields = OpenIdProvider; export const OpenIdSettingsForm = () => { const { LL } = useI18nContext(); const localLL = LL.settingsPage.openIdSettings; - const submitRef = useRef(null); - // const settings = useSettingsPage((state) => state.settings); - // const setSettings = useSettingsPage((state) => state.setState); const [currentProvider, setCurrentProvider] = useState(null); - - const { - settings: { patchSettings }, - } = useApi(); - const queryClient = useQueryClient(); const { - settings: { - fetchOpenIdProviders, - addOpenIdProvider, - deleteOpenIdProvider, - editOpenIdProvider, - }, + settings: { fetchOpenIdProviders, addOpenIdProvider }, } = useApi(); - const { data: providers, isLoading } = useQuery({ + const { data: provider, isLoading } = useQuery({ queryFn: fetchOpenIdProviders, queryKey: [QueryKeys.FETCH_OPENID_PROVIDERS], refetchOnMount: true, @@ -66,28 +46,28 @@ export const OpenIdSettingsForm = () => { const toaster = useToaster(); const { mutate } = useMutation({ - mutationFn: currentProvider ? editOpenIdProvider : addOpenIdProvider, + mutationFn: addOpenIdProvider, onSuccess: () => { queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); toaster.success(LL.settingsPage.messages.editSuccess()); }, - // TODO(aleksander): HANDLE ERROR onError: (error) => { - toaster.error(error.message); + toaster.error(LL.messages.error()); + console.error(error); }, }); useEffect(() => { - if (providers && providers.length > 0) { - setCurrentProvider(providers[0]); + if (provider) { + setCurrentProvider(provider); } - }, [providers]); + }, [provider]); const schema = useMemo( () => z.object({ name: z.string().min(1, LL.form.error.required()), - provider_url: z + base_url: z .string() .url(LL.form.error.invalid()) .min(1, LL.form.error.required()), @@ -99,8 +79,9 @@ export const OpenIdSettingsForm = () => { const defaultValues = useMemo( (): FormFields => ({ + id: currentProvider?.id ?? 0, name: currentProvider?.name ?? '', - provider_url: currentProvider?.provider_url ?? '', + base_url: currentProvider?.base_url ?? '', client_id: currentProvider?.client_id ?? '', client_secret: currentProvider?.client_secret ?? '', }), @@ -113,27 +94,31 @@ export const OpenIdSettingsForm = () => { mode: 'all', }); + // Make sure the form is refresh useEffect(() => { reset(defaultValues); - }, [defaultValues]); - - console.log(currentProvider); + }, [defaultValues, reset]); const handleValidSubmit: SubmitHandler = (data) => { mutate(data); }; - const options = useMemo( + const options: SelectOption[] = useMemo( () => [ { - key: 1, - value: 'https://accounts.google.com', + value: 'Google', label: 'Google', + key: 1, }, { - key: 2, - value: 'https://accounts.google2.com', + value: 'Microsoft', label: 'Microsoft', + key: 2, + }, + { + value: 'Custom', + label: 'Custom', + key: 3, }, ], [], @@ -153,50 +138,43 @@ export const OpenIdSettingsForm = () => { [options], ); - const handleChange = async (val: string) => { - if (!isLoading && currentProvider) { - const newProvider: OpenIdProvider = { - id: currentProvider.id, - name: options.find((o) => o.value === val)?.label ?? '', - provider_url: val, - client_id: currentProvider.client_id, - client_secret: currentProvider.client_secret, - }; - setCurrentProvider(newProvider); + const getProviderUrl = useCallback(({ name }: { name: string }): string | null => { + switch (name) { + case 'Google': + return 'https://accounts.google.com'; + case 'Microsoft': + return `https://login.microsoftonline.com//v2.0`; + default: + return null; } + }, []); + + const handleChange = async (val: string) => { + console.log(currentProvider?.base_url); + setCurrentProvider({ + id: currentProvider?.id ?? 0, + name: val, + base_url: getProviderUrl({ name: val }) ?? '', + client_id: currentProvider?.client_id ?? '', + client_secret: currentProvider?.client_secret ?? '', + }); }; return (

{localLL.title()}

-
-
- {/* TODO(aleksander): Make a select here */} - - - {/* handleChange(res)} +
); }; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/style.scss b/web/src/pages/settings/components/OpenIdSettings/components/style.scss index d0c0181827..017489e228 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/style.scss +++ b/web/src/pages/settings/components/OpenIdSettings/components/style.scss @@ -19,4 +19,9 @@ .select { padding-bottom: 25px; } + .checkbox-row { + display: flex; + align-items: center; + gap: 10px; + } } From c9d67bc68eed0993f71dc3201ac46f3801ac034c Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:37:33 +0200 Subject: [PATCH 19/73] cargo fix --- tests/openid_login.rs | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/openid_login.rs b/tests/openid_login.rs index 840d0c3b44..3f39c9e005 100644 --- a/tests/openid_login.rs +++ b/tests/openid_login.rs @@ -1,34 +1,19 @@ -use std::str::FromStr; -use axum::http::header::ToStrError; -use claims::assert_err; use defguard::{ config::DefGuardConfig, db::{ - models::{oauth2client::OAuth2Client, NewOpenIDClient}, DbPool, }, enterprise::handlers::openid_providers::AddProviderData, handlers::Auth, }; -use openidconnect::{ - core::{ - CoreClient, CoreGenderClaim, CoreProviderMetadata, CoreResponseType, CoreTokenResponse, - }, - http::Method, - AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, - EmptyAdditionalClaims, HttpRequest, HttpResponse, IssuerUrl, Nonce, OAuth2TokenResponse, - PkceCodeChallenge, RedirectUrl, Scope, UserInfoClaims, -}; use reqwest::{ - header::{HeaderName, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}, StatusCode, Url, }; -use rsa::RsaPrivateKey; use serde::Deserialize; mod common; -use self::common::{client::TestClient, init_test_db, make_base_client, make_test_client}; +use self::common::{client::TestClient, make_base_client, make_test_client}; async fn make_client() -> TestClient { let (client, _) = make_test_client().await; From 2970bba7fe0a74e2e46d20be2cc0ce50d88f2530 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:40:34 +0200 Subject: [PATCH 20/73] fix type --- web/src/shared/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 3df675374e..9afb13f866 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -309,8 +309,6 @@ export interface LoginResponse { export interface OpenIdInfoResponse { url: string; - nonce: string; - csrf: string; } export interface DeleteWebAuthNKeyRequest { From dace47a4df6bc8ccc0d98bff21f22b0bb62e71f8 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Tue, 16 Jul 2024 13:14:23 +0200 Subject: [PATCH 21/73] fmt --- tests/openid_login.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tests/openid_login.rs b/tests/openid_login.rs index 3f39c9e005..2a6af144b6 100644 --- a/tests/openid_login.rs +++ b/tests/openid_login.rs @@ -1,15 +1,8 @@ - use defguard::{ - config::DefGuardConfig, - db::{ - DbPool, - }, - enterprise::handlers::openid_providers::AddProviderData, + config::DefGuardConfig, db::DbPool, enterprise::handlers::openid_providers::AddProviderData, handlers::Auth, }; -use reqwest::{ - StatusCode, Url, -}; +use reqwest::{StatusCode, Url}; use serde::Deserialize; mod common; From d9e42917f9f094e7fc74dcf75ae4fde36b293db3 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:12:31 +0200 Subject: [PATCH 22/73] compress migrations --- .../20240603091113_openid_provider.down.sql | 1 - .../20240603091113_openid_provider.up.sql | 11 -------- ...20240710100027_make_emails_unique.down.sql | 1 - .../20240710100027_make_emails_unique.up.sql | 8 ------ ...0240715095940_add_oidc_login_flag.down.sql | 1 - .../20240715095940_add_oidc_login_flag.up.sql | 1 - ...5_add_oidc_create_account_setting.down.sql | 1 - ...955_add_oidc_create_account_setting.up.sql | 1 - ...6114732_add_external_openid_login.down.sql | 4 +++ ...716114732_add_external_openid_login.up.sql | 25 +++++++++++++++++++ 10 files changed, 29 insertions(+), 25 deletions(-) delete mode 100644 migrations/20240603091113_openid_provider.down.sql delete mode 100644 migrations/20240603091113_openid_provider.up.sql delete mode 100644 migrations/20240710100027_make_emails_unique.down.sql delete mode 100644 migrations/20240710100027_make_emails_unique.up.sql delete mode 100644 migrations/20240715095940_add_oidc_login_flag.down.sql delete mode 100644 migrations/20240715095940_add_oidc_login_flag.up.sql delete mode 100644 migrations/20240715095955_add_oidc_create_account_setting.down.sql delete mode 100644 migrations/20240715095955_add_oidc_create_account_setting.up.sql create mode 100644 migrations/20240716114732_add_external_openid_login.down.sql create mode 100644 migrations/20240716114732_add_external_openid_login.up.sql diff --git a/migrations/20240603091113_openid_provider.down.sql b/migrations/20240603091113_openid_provider.down.sql deleted file mode 100644 index 6ecbdb1291..0000000000 --- a/migrations/20240603091113_openid_provider.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE openidprovider; diff --git a/migrations/20240603091113_openid_provider.up.sql b/migrations/20240603091113_openid_provider.up.sql deleted file mode 100644 index 397dda83e9..0000000000 --- a/migrations/20240603091113_openid_provider.up.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE openidprovider ( - id bigserial PRIMARY KEY, - "name" text NOT NULL, - "base_url" text NOT NULL, - "client_id" text NOT NULL, - "client_secret" text NOT NULL, - "enabled" boolean NOT NULL DEFAULT FALSE, - CONSTRAINT openidprovider_name_unique UNIQUE ("name"), - CONSTRAINT openidprovider_client_id_unique UNIQUE ("client_id"), - CONSTRAINT openidprovider_client_secret_unique UNIQUE ("client_secret") -); diff --git a/migrations/20240710100027_make_emails_unique.down.sql b/migrations/20240710100027_make_emails_unique.down.sql deleted file mode 100644 index 91a5a0a715..0000000000 --- a/migrations/20240710100027_make_emails_unique.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "user" DROP CONSTRAINT "user_email_key"; diff --git a/migrations/20240710100027_make_emails_unique.up.sql b/migrations/20240710100027_make_emails_unique.up.sql deleted file mode 100644 index 2d79a9dff6..0000000000 --- a/migrations/20240710100027_make_emails_unique.up.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Deletes duplicated users based on their email. The first (by id) user with a given email is kept. -DELETE FROM - "user" u1 - USING "user" u2 -WHERE - u1.id > u2.id - AND u1.email = u2.email; -ALTER TABLE "user" ADD CONSTRAINT "user_email_key" UNIQUE (email); diff --git a/migrations/20240715095940_add_oidc_login_flag.down.sql b/migrations/20240715095940_add_oidc_login_flag.down.sql deleted file mode 100644 index 5eb7de3502..0000000000 --- a/migrations/20240715095940_add_oidc_login_flag.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "user" DROP COLUMN "openid_login"; diff --git a/migrations/20240715095940_add_oidc_login_flag.up.sql b/migrations/20240715095940_add_oidc_login_flag.up.sql deleted file mode 100644 index c89fb898f1..0000000000 --- a/migrations/20240715095940_add_oidc_login_flag.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "user" ADD COLUMN "openid_login" BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/20240715095955_add_oidc_create_account_setting.down.sql b/migrations/20240715095955_add_oidc_create_account_setting.down.sql deleted file mode 100644 index 7fc045f9ee..0000000000 --- a/migrations/20240715095955_add_oidc_create_account_setting.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE settings DROP COLUMN openid_create_account; diff --git a/migrations/20240715095955_add_oidc_create_account_setting.up.sql b/migrations/20240715095955_add_oidc_create_account_setting.up.sql deleted file mode 100644 index b82357c2d4..0000000000 --- a/migrations/20240715095955_add_oidc_create_account_setting.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE settings ADD COLUMN openid_create_account BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/migrations/20240716114732_add_external_openid_login.down.sql b/migrations/20240716114732_add_external_openid_login.down.sql new file mode 100644 index 0000000000..92196a2601 --- /dev/null +++ b/migrations/20240716114732_add_external_openid_login.down.sql @@ -0,0 +1,4 @@ +DROP TABLE openidprovider; +ALTER TABLE "user" DROP CONSTRAINT "user_email_key"; +ALTER TABLE "user" DROP COLUMN "openid_login"; +ALTER TABLE settings DROP COLUMN openid_create_account; diff --git a/migrations/20240716114732_add_external_openid_login.up.sql b/migrations/20240716114732_add_external_openid_login.up.sql new file mode 100644 index 0000000000..37fa18fbf0 --- /dev/null +++ b/migrations/20240716114732_add_external_openid_login.up.sql @@ -0,0 +1,25 @@ +-- External OpenID login +CREATE TABLE openidprovider ( + id bigserial PRIMARY KEY, + "name" text NOT NULL, + "base_url" text NOT NULL, + "client_id" text NOT NULL, + "client_secret" text NOT NULL, + "enabled" boolean NOT NULL DEFAULT FALSE, + CONSTRAINT openidprovider_name_unique UNIQUE ("name"), + CONSTRAINT openidprovider_client_id_unique UNIQUE ("client_id"), + CONSTRAINT openidprovider_client_secret_unique UNIQUE ("client_secret") +); + +ALTER TABLE "user" ADD COLUMN "openid_login" BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE settings ADD COLUMN openid_create_account BOOLEAN NOT NULL DEFAULT TRUE; + +-- Make emails unique +-- Deletes duplicated users based on their email. The first (by id) user with a given email is kept. +DELETE FROM + "user" u1 + USING "user" u2 +WHERE + u1.id > u2.id + AND u1.email = u2.email; +ALTER TABLE "user" ADD CONSTRAINT "user_email_key" UNIQUE (email); From ce075e0a49985376b0f0a6e9128048b467c20bf0 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:47:06 +0200 Subject: [PATCH 23/73] cleanup --- web/src/i18n/pl/index.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 65a9d8e723..69b847fb4a 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -907,23 +907,6 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe submit: 'Test', }, }, - // openIdSettings: { - // titleClient: 'Ustawienia klienta zewnętrznego OpenID', - // titleGeneral: 'Ustawienia zewnętrznego OpenID', - // general: { - // createAccount: 'Automatycznie twórz konta w momencie logowania przez zewnętrznego dostawcę OpenID', - // }, - // form: { - // labels: { - // name: 'Nazwa', - // provider: 'Dostawca OpenID', - // client_id: 'ID klienta', - // client_secret: 'Sekret klienta', - // base_url: 'URL dostawcy', - // tenant_id: 'ID dzierżawy (Tenant ID)', - // }, - // }, - // }, openIdSettings: { general: { title: 'Ustawienia zewnętrznego OpenID', @@ -935,7 +918,6 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe }, form: { title: 'Ustawienia klienta zewnętrznego OpenID', - // helper: 'Here you can configure the OpenID client settings with values provided by your external OpenID provider.', helper: 'Tutaj możesz skonfigurować ustawienia klienta OpenID z wartościami dostarczonymi przez zewnętrznego dostawcę OpenID.', custom: "Niestandardowy", documentation: 'Dokumentacja', From e81ef9370cfde25f057baae447fa7df46c123819 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 17 Jul 2024 09:42:39 +0200 Subject: [PATCH 24/73] fix frontend --- web/src/shared/hooks/store/useAuthStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/shared/hooks/store/useAuthStore.ts b/web/src/shared/hooks/store/useAuthStore.ts index 3b6c850841..1679f03134 100644 --- a/web/src/shared/hooks/store/useAuthStore.ts +++ b/web/src/shared/hooks/store/useAuthStore.ts @@ -3,7 +3,7 @@ import { Subject } from 'rxjs'; import { createJSONStorage, persist } from 'zustand/middleware'; import { createWithEqualityFn } from 'zustand/traditional'; -import { LoginSubjectData, OpenIdInfoResponse, OpenIdProvider, User } from '../../types'; +import { LoginSubjectData, OpenIdInfoResponse, User } from '../../types'; export const useAuthStore = createWithEqualityFn()( persist( From 8073db7571981dd115bf7f4919b0cbf5269e39f6 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 17 Jul 2024 09:55:44 +0200 Subject: [PATCH 25/73] enable e2e and dev deployment --- .github/workflows/current.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml index b2c6aabc92..8fcd706d0c 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -4,6 +4,7 @@ on: branches: - main - dev + - openid-login paths-ignore: - '*.md' - 'LICENSE' From 788df3500a3bc340d5d8d79102b04ef11797b3d0 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 17 Jul 2024 10:03:15 +0200 Subject: [PATCH 26/73] license update --- LICENSE | 3 ++- src/enterprise/LICENSE | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index ec3d63af4f..9d2476c053 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ Copyright 2023 teonite ventures sp. z o.o. (teonite) -Note: The following license applies to the entire repository except for the "enterprise" directory. +NOTE: The following license applies to the entire repository except for all the contents in the "enterprise" directory. +For the license of the enterprise directory, go to enterprise/LICENSE. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/enterprise/LICENSE b/src/enterprise/LICENSE index 0ba4000c5c..79e35089e6 100644 --- a/src/enterprise/LICENSE +++ b/src/enterprise/LICENSE @@ -1 +1 @@ -Files in this directory ("enterprise") are not under the default repository license. Contact salesdefguard.net for further information. \ No newline at end of file +For enterprise license and usage of this code and binary distribution, please contact us by email at: salesdefguard.net From 69048494dc592f3829c054ddcb94b5fc87325dbf Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 17 Jul 2024 11:20:38 +0200 Subject: [PATCH 27/73] fix test --- e2e/tests/vpn/wizard.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/tests/vpn/wizard.spec.ts b/e2e/tests/vpn/wizard.spec.ts index 4e9c597e32..8afb9f29ad 100644 --- a/e2e/tests/vpn/wizard.spec.ts +++ b/e2e/tests/vpn/wizard.spec.ts @@ -37,6 +37,7 @@ test.describe('Setup VPN (wizard) ', () => { ...testUserTemplate, firstName: `test${id}`, username: `test${id}`, + mail: `test${id}@test.com` })); await loginBasic(page, defaultUserAdmin); await apiCreateUsersBulk(page, users); From f3e5b1787ac41fd8980d653e6e4e0c39995dbfee Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 17 Jul 2024 13:55:18 +0200 Subject: [PATCH 28/73] make changes according to the review --- ...20240716114732_add_external_openid_login.up.sql | 8 +------- src/db/models/mod.rs | 2 +- src/db/models/user.rs | 7 +++++++ src/enterprise/handlers/openid_login.rs | 14 -------------- src/enterprise/handlers/openid_providers.rs | 10 +++++----- src/grpc/enrollment.rs | 4 ++-- web/src/pages/users/UserProfile/UserProfile.tsx | 5 +---- 7 files changed, 17 insertions(+), 33 deletions(-) diff --git a/migrations/20240716114732_add_external_openid_login.up.sql b/migrations/20240716114732_add_external_openid_login.up.sql index 37fa18fbf0..169801721f 100644 --- a/migrations/20240716114732_add_external_openid_login.up.sql +++ b/migrations/20240716114732_add_external_openid_login.up.sql @@ -15,11 +15,5 @@ ALTER TABLE "user" ADD COLUMN "openid_login" BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE settings ADD COLUMN openid_create_account BOOLEAN NOT NULL DEFAULT TRUE; -- Make emails unique --- Deletes duplicated users based on their email. The first (by id) user with a given email is kept. -DELETE FROM - "user" u1 - USING "user" u2 -WHERE - u1.id > u2.id - AND u1.email = u2.email; +-- This migration may fail if there are duplicate emails in the database already ALTER TABLE "user" ADD CONSTRAINT "user_email_key" UNIQUE (email); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 2b24d74ed5..73722c1026 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -99,7 +99,7 @@ impl UserInfo { mfa_method: user.mfa_method.clone(), authorized_apps, is_active: user.is_active, - enrolled: user.has_password() || user.openid_login, + enrolled: user.is_enrolled(), }) } diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 9a2a9f51ab..773c87b3af 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -157,6 +157,13 @@ impl User { format!("{} {}", self.first_name, self.last_name) } + /// Check if user is enrolled. + /// We assume the user is enrolled if they have a password set + /// or they have logged in using an external OIDC. + pub fn is_enrolled(&self) -> bool { + self.password_hash.is_some() || self.openid_login + } + /// Generate new TOTP secret, save it, then return it as RFC 4648 base32-encoded string. pub async fn new_totp_secret<'e, E>(&mut self, executor: E) -> Result where diff --git a/src/enterprise/handlers/openid_login.rs b/src/enterprise/handlers/openid_login.rs index 6b86d7d562..ebc368cbcb 100644 --- a/src/enterprise/handlers/openid_login.rs +++ b/src/enterprise/handlers/openid_login.rs @@ -373,17 +373,3 @@ pub async fn auth_callback( headers.insert(LOCATION, header_value); Ok((StatusCode::FOUND, headers, cookies, private_cookies)) } - -#[cfg(test)] -mod test { - use super::*; - - #[tokio::test] - async fn test_get_provider_metadata() { - let metadata = get_provider_metadata("https://accounts.google.com") - .await - .unwrap(); - - assert_eq!(metadata.issuer().to_string(), "https://accounts.google.com"); - } -} diff --git a/src/enterprise/handlers/openid_providers.rs b/src/enterprise/handlers/openid_providers.rs index c8ffd54e6d..58bef9a970 100644 --- a/src/enterprise/handlers/openid_providers.rs +++ b/src/enterprise/handlers/openid_providers.rs @@ -18,12 +18,12 @@ pub struct AddProviderData { } impl AddProviderData { - pub fn new(name: String, base_url: String, client_id: String, client_secret: String) -> Self { + pub fn new(name: &str, base_url: &str, client_id: &str, client_secret: &str) -> Self { Self { - name, - base_url, - client_id, - client_secret, + name: name.to_string(), + base_url: base_url.to_string(), + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), } } } diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs index 4e578e1e36..1e314f8f45 100644 --- a/src/grpc/enrollment.rs +++ b/src/grpc/enrollment.rs @@ -516,7 +516,7 @@ impl From for AdminInfo { impl InitialUserInfo { async fn from_user(pool: &DbPool, user: User) -> Result { - let is_enrolled = user.has_password() || user.openid_login; + let enrolled = user.is_enrolled(); let devices = user.devices(pool).await?; let device_names = devices.into_iter().map(|dev| dev.device.name).collect(); Ok(Self { @@ -527,7 +527,7 @@ impl InitialUserInfo { phone_number: user.phone, is_active: user.is_active, device_names, - enrolled: is_enrolled, + enrolled, }) } } diff --git a/web/src/pages/users/UserProfile/UserProfile.tsx b/web/src/pages/users/UserProfile/UserProfile.tsx index 5e01288894..fc44b44b03 100644 --- a/web/src/pages/users/UserProfile/UserProfile.tsx +++ b/web/src/pages/users/UserProfile/UserProfile.tsx @@ -182,10 +182,7 @@ const EditModeControls = () => { size={ButtonSize.SMALL} styleVariant={ButtonStyleVariant.SAVE} icon={} - onClick={() => { - submitSubject.next(); - console.log('submitSubject.next();'); - }} + onClick={() => submitSubject.next()} loading={loading} /> From 6c7a0d0813c810cf4bda2d84b50c01c0cf58490e Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Thu, 18 Jul 2024 11:49:23 +0200 Subject: [PATCH 29/73] flow rework and fixes --- src/enterprise/handlers/openid_login.rs | 75 ++++++++-------- src/enterprise/handlers/openid_providers.rs | 17 +++- src/handlers/auth.rs | 2 +- src/lib.rs | 5 +- web/src/components/App/App.tsx | 2 +- web/src/components/AppLoader.tsx | 19 +---- web/src/i18n/en/index.ts | 5 ++ web/src/i18n/i18n-types.ts | 28 ++++++ web/src/i18n/pl/index.ts | 5 ++ web/src/pages/auth/AuthPage.tsx | 2 + web/src/pages/auth/Callback/Callback.tsx | 85 +++++++++++++++++++ web/src/pages/auth/Callback/style.scss | 5 ++ web/src/pages/auth/Login/Login.tsx | 83 +++++++++++------- .../components/OpenIdSettingsForm.tsx | 54 +++++++++--- .../OpenIdSettings/components/style.scss | 5 ++ web/src/shared/hooks/store/useAuthStore.ts | 4 +- web/src/shared/hooks/useApi.tsx | 4 + web/src/shared/mutations.ts | 1 + web/src/shared/types.ts | 8 +- 19 files changed, 298 insertions(+), 111 deletions(-) create mode 100644 web/src/pages/auth/Callback/Callback.tsx create mode 100644 web/src/pages/auth/Callback/style.scss diff --git a/src/enterprise/handlers/openid_login.rs b/src/enterprise/handlers/openid_login.rs index ebc368cbcb..0ea72ad6fc 100644 --- a/src/enterprise/handlers/openid_login.rs +++ b/src/enterprise/handlers/openid_login.rs @@ -1,30 +1,37 @@ use axum::http::header::LOCATION; use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::Json; use axum_extra::extract::cookie::{Cookie, SameSite}; use serde_json::json; use time::Duration; -use axum::extract::{Query, State}; +use axum::extract::{path, Query, State}; use axum_client_ip::{InsecureClientIp, LeftmostXForwardedFor}; use axum_extra::extract::{CookieJar, PrivateCookieJar}; use axum_extra::headers::UserAgent; use axum_extra::TypedHeader; -use openidconnect::core::{CoreClient, CoreResponseType}; +use openidconnect::core::{ + CoreClient, CoreGenderClaim, CoreJsonWebKeyType, CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, CoreResponseType, +}; use openidconnect::{ core::CoreProviderMetadata, reqwest::async_http_client, ClientId, ClientSecret, IssuerUrl, ProviderMetadata, RedirectUrl, }; -use openidconnect::{AuthenticationFlow, AuthorizationCode, CsrfToken, Nonce, Scope}; +use openidconnect::{ + AccessToken, AuthenticationFlow, AuthorizationCode, CsrfToken, EmptyAdditionalClaims, IdToken, + Nonce, Scope, +}; use crate::appstate::AppState; use crate::db::{AppEvent, DbPool, Session, SessionState, Settings, User, UserInfo}; use crate::enterprise::db::models::openid_provider::OpenIdProvider; use crate::error::WebError; use crate::handlers::user::check_username; -use crate::handlers::{ApiResponse, SESSION_COOKIE_NAME}; +use crate::handlers::{ApiResponse, AuthResponse, SESSION_COOKIE_NAME}; use crate::headers::{check_new_device_login, get_user_agent_device, parse_user_agent}; use crate::server_config; @@ -71,7 +78,7 @@ async fn make_oidc_client(pool: &DbPool) -> Result { Some(provider) => provider, None => { return Err(WebError::ObjectNotFound( - "OpenID provider not found".to_string(), + "OpenID provider not set".to_string(), )); } }; @@ -80,7 +87,7 @@ async fn make_oidc_client(pool: &DbPool) -> Result { let client_id = ClientId::new(provider.client_id); let client_secret = ClientSecret::new(provider.client_secret); let config = server_config(); - let url = format!("{}api/v1/openid/callback", config.url); + let url = format!("{}/auth/callback", config.url); let redirect_url = match RedirectUrl::new(url) { Ok(url) => url, Err(err) => { @@ -106,7 +113,7 @@ pub async fn get_auth_info( // Generate the redirect URL and the values needed later for callback authenticity verification let (authorize_url, csrf_state, nonce) = client .authorize_url( - AuthenticationFlow::::AuthorizationCode, + AuthenticationFlow::::Implicit(false), CsrfToken::new_random, Nonce::new_random, ) @@ -124,7 +131,7 @@ pub async fn get_auth_info( ) .path("/api/v1/openid/callback") .http_only(true) - .same_site(SameSite::Lax) + .same_site(SameSite::Strict) .secure(true) .max_age(Duration::days(1)) .build(); @@ -137,10 +144,11 @@ pub async fn get_auth_info( ) .path("/api/v1/openid/callback") .http_only(true) - .same_site(SameSite::Lax) + .same_site(SameSite::Strict) .secure(true) .max_age(Duration::days(1)) .build(); + let private_cookies = private_cookies.add(nonce_cookie).add(csrf_cookie); Ok(( @@ -158,7 +166,13 @@ pub async fn get_auth_info( #[derive(Deserialize, Serialize, Debug)] pub struct AuthenticationResponse { - code: AuthorizationCode, + id_token: IdToken< + EmptyAdditionalClaims, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + CoreJsonWebKeyType, + >, state: CsrfToken, } @@ -168,9 +182,9 @@ pub async fn auth_callback( user_agent: Option>, forwarded_for_ip: Option, InsecureClientIp(insecure_ip): InsecureClientIp, - Query(params): Query, State(appstate): State, -) -> Result<(StatusCode, HeaderMap, CookieJar, PrivateCookieJar), WebError> { + Json(payload): Json, +) -> Result<(CookieJar, PrivateCookieJar, ApiResponse), WebError> { debug!("Auth callback received, logging in user..."); // Get the nonce and csrf cookies, we need them to verify the callback @@ -189,32 +203,15 @@ pub async fn auth_callback( .to_string(); // Verify the csrf token - if params.state.secret() != &cookie_csrf { + if *payload.state.secret() != cookie_csrf { return Err(WebError::Authorization("CSRF token mismatch".to_string())); }; // Get the ID token and verify it against the nonce value received in the callback let client = make_oidc_client(&appstate.pool).await?; - let token = client - .exchange_code(params.code) - .request_async(async_http_client) - .await - .map_err(|error| { - WebError::Authorization(format!( - "Failed to exchange code for token, error: {:?}", - error - )) - })?; let nonce = Nonce::new(cookie_nonce); let token_verifier = client.id_token_verifier(); - let id_token = match token.extra_fields().id_token() { - Some(token) => token, - None => { - return Err(WebError::Authorization( - "Server did not return an ID token".to_string(), - )); - } - }; + let id_token = payload.id_token; private_cookies = private_cookies .remove(Cookie::from("nonce")) @@ -367,9 +364,15 @@ pub async fn auth_callback( user.username ); - // Redirect to '/' on successful login - let mut headers = HeaderMap::new(); - let header_value = HeaderValue::from_str("/").or(Err(StatusCode::INTERNAL_SERVER_ERROR))?; - headers.insert(LOCATION, header_value); - Ok((StatusCode::FOUND, headers, cookies, private_cookies)) + Ok(( + cookies, + private_cookies, + ApiResponse { + json: json!(AuthResponse { + user: user_info, + url: None, + }), + status: StatusCode::OK, + }, + )) } diff --git a/src/enterprise/handlers/openid_providers.rs b/src/enterprise/handlers/openid_providers.rs index 58bef9a970..6c94149d1c 100644 --- a/src/enterprise/handlers/openid_providers.rs +++ b/src/enterprise/handlers/openid_providers.rs @@ -1,4 +1,8 @@ -use axum::{extract::State, http::StatusCode, Json}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; use crate::{ appstate::AppState, @@ -17,6 +21,11 @@ pub struct AddProviderData { client_secret: String, } +#[derive(Debug, Deserialize, Serialize)] +pub struct DeleteProviderData { + name: String, +} + impl AddProviderData { pub fn new(name: &str, base_url: &str, client_id: &str, client_secret: &str) -> Self { Self { @@ -76,7 +85,7 @@ pub async fn delete_openid_provider( _admin: AdminRole, session: SessionInfo, State(appstate): State, - Json(provider_data): Json, + Path(provider_data): Path, ) -> ApiResult { debug!( "User {} deleting OpenID provider {}", @@ -86,7 +95,7 @@ pub async fn delete_openid_provider( if let Some(provider) = provider { provider.delete(&appstate.pool).await?; info!( - "User {} deleted OpenID client {}", + "User {} deleted OpenID provider {}", session.user.username, provider_data.name ); Ok(ApiResponse { @@ -95,7 +104,7 @@ pub async fn delete_openid_provider( }) } else { warn!( - "User {} failed to delete OpenID client {}. Such client does not exist.", + "User {} failed to delete OpenID provider {}. Such provider does not exist.", session.user.username, provider_data.name ); Ok(ApiResponse { diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index b4fd350473..088ffdf453 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -57,7 +57,7 @@ pub async fn authenticate( // check if user can proceed with login check_username(&appstate.failed_logins, &username)?; - let user = match User::find_by_username(&appstate.pool, &username).await { + let user: User = match User::find_by_username(&appstate.pool, &username).await { Ok(Some(user)) => match user.verify_password(&data.password) { Ok(()) => { if user.is_active { diff --git a/src/lib.rs b/src/lib.rs index 6c7d91989d..78a38b1123 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,7 @@ use axum::{ use assets::{index, svg, web_asset}; use enterprise::handlers::{ openid_login::{auth_callback, get_auth_info}, - openid_providers::{add_openid_provider, get_current_openid_provider}, + openid_providers::{add_openid_provider, delete_openid_provider, get_current_openid_provider}, }; use handlers::ssh_authorized_keys::{ add_authentication_key, delete_authentication_key, fetch_authentication_keys, @@ -406,7 +406,8 @@ pub fn build_webapp( // OIDC login .route("/openid/provider", get(get_current_openid_provider)) .route("/openid/provider", post(add_openid_provider)) - .route("/openid/callback", get(auth_callback)) + .route("/openid/provider/:name", delete(delete_openid_provider)) + .route("/openid/callback", post(auth_callback)) .route("/openid/auth_info", get(get_auth_info)), ); diff --git a/web/src/components/App/App.tsx b/web/src/components/App/App.tsx index 6e94143410..56fa6e5eef 100644 --- a/web/src/components/App/App.tsx +++ b/web/src/components/App/App.tsx @@ -1,7 +1,7 @@ import 'react-loading-skeleton/dist/skeleton.css'; import './App.scss'; -import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom'; +import { Navigate, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; import { AddDevicePage } from '../../pages/addDevice/AddDevicePage'; import { OpenidAllowPage } from '../../pages/allow/OpenidAllowPage'; diff --git a/web/src/components/AppLoader.tsx b/web/src/components/AppLoader.tsx index 82fc8ee459..0e87e4a4d8 100644 --- a/web/src/components/AppLoader.tsx +++ b/web/src/components/AppLoader.tsx @@ -30,9 +30,6 @@ export const AppLoader = () => { getAppInfo, user: { getMe }, settings: { getEssentialSettings }, - auth: { - openid: { getOpenIdInfo: getOpenidInfo }, - }, } = useApi(); const [userLoading, setUserLoading] = useState(true); const { setLocale } = useI18nContext(); @@ -109,21 +106,7 @@ export const AppLoader = () => { } }, [essentialSettings, setAppStore]); - const { data: openIdInfo, isLoading: openIdLoading } = useQuery({ - queryKey: [QueryKeys.FETCH_OPENID_INFO], - queryFn: getOpenidInfo, - refetchOnMount: true, - refetchOnWindowFocus: false, - retry: false, - }); - - useEffect(() => { - if (openIdInfo) { - setAuthState({ openIdLoginInfo: openIdInfo }); - } - }, [openIdInfo, setAuthState]); - - if (userLoading || (settingsLoading && isUndefined(appSettings)) || openIdLoading) { + if (userLoading || (settingsLoading && isUndefined(appSettings))) { return ; } diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 00c07552c2..a5ed451099 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -935,6 +935,7 @@ const en: BaseTranslation = { helper: 'Here you can configure the OpenID client settings with values provided by your external OpenID provider.', custom: "Custom", documentation: 'Documentation', + delete: 'Delete provider', labels: { provider: { label: 'Provider', @@ -1485,6 +1486,10 @@ const en: BaseTranslation = { }, loginPage: { pageTitle: 'Enter your credentials', + callback: { + return: 'Go back to login', + error: 'An error occurred during external OpenID login', + }, mfa: { title: 'Two-factor authentication', controls: { diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 13f1da8b26..27fd686ad4 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -2313,6 +2313,10 @@ type RootTranslation = { * D​o​c​u​m​e​n​t​a​t​i​o​n */ documentation: string + /** + * D​e​l​e​t​e​ ​p​r​o​v​i​d​e​r + */ + 'delete': string labels: { provider: { /** @@ -3536,6 +3540,16 @@ type RootTranslation = { * E​n​t​e​r​ ​y​o​u​r​ ​c​r​e​d​e​n​t​i​a​l​s */ pageTitle: string + callback: { + /** + * G​o​ ​b​a​c​k​ ​t​o​ ​l​o​g​i​n + */ + 'return': string + /** + * A​n​ ​e​r​r​o​r​ ​o​c​c​u​r​r​e​d​ ​d​u​r​i​n​g​ ​e​x​t​e​r​n​a​l​ ​O​p​e​n​I​D​ ​l​o​g​i​n + */ + error: string + } mfa: { /** * T​w​o​-​f​a​c​t​o​r​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n @@ -6287,6 +6301,10 @@ export type TranslationFunctions = { * Documentation */ documentation: () => LocalizedString + /** + * Delete provider + */ + 'delete': () => LocalizedString labels: { provider: { /** @@ -7499,6 +7517,16 @@ export type TranslationFunctions = { * Enter your credentials */ pageTitle: () => LocalizedString + callback: { + /** + * Go back to login + */ + 'return': () => LocalizedString + /** + * An error occurred during external OpenID login + */ + error: () => LocalizedString + } mfa: { /** * Two-factor authentication diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 69b847fb4a..e557a292c7 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -921,6 +921,7 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe helper: 'Tutaj możesz skonfigurować ustawienia klienta OpenID z wartościami dostarczonymi przez zewnętrznego dostawcę OpenID.', custom: "Niestandardowy", documentation: 'Dokumentacja', + delete: 'Usuń dostawcę', labels: { provider: { label: 'Dostawca', @@ -1471,6 +1472,10 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe }, loginPage: { pageTitle: 'Wprowadź swoje dane logowania', + callback: { + return: 'Powrót do logowania', + error: 'Wystąpił błąd podczas logowania przez zewnętrznego dostawcę OpenID', + }, mfa: { title: 'Autoryzacja dwuetapowa.', controls: { diff --git a/web/src/pages/auth/AuthPage.tsx b/web/src/pages/auth/AuthPage.tsx index ea2c14633c..ff734aed3a 100644 --- a/web/src/pages/auth/AuthPage.tsx +++ b/web/src/pages/auth/AuthPage.tsx @@ -16,6 +16,7 @@ import { RedirectPage } from '../redirect/RedirectPage'; import { Login } from './Login/Login'; import { MFARoute } from './MFARoute/MFARoute'; import { useMFAStore } from './shared/hooks/useMFAStore'; +import { OpenIDCallback } from './Callback/Callback'; export const AuthPage = () => { const { @@ -152,6 +153,7 @@ export const AuthPage = () => { } /> } /> } /> + } /> } /> diff --git a/web/src/pages/auth/Callback/Callback.tsx b/web/src/pages/auth/Callback/Callback.tsx new file mode 100644 index 0000000000..64d85f3818 --- /dev/null +++ b/web/src/pages/auth/Callback/Callback.tsx @@ -0,0 +1,85 @@ +import './style.scss'; + +import { useMutation } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +import { useEffect, useState } from 'react'; +import useApi from '../../../shared/hooks/useApi'; +import { MutationKeys } from '../../../shared/mutations'; +import { CallbackData } from '../../../shared/types'; +import { useAuthStore } from '../../../shared/hooks/store/useAuthStore'; +import { LoaderSpinner } from '../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; +import { useToaster } from '../../../shared/hooks/useToaster'; +import { useI18nContext } from '../../../i18n/i18n-react'; +import { Button } from '../../../shared/defguard-ui/components/Layout/Button/Button'; +import { useNavigate } from 'react-router'; + +export const OpenIDCallback = () => { + const { + auth: { + openid: { callback }, + }, + } = useApi(); + const loginSubject = useAuthStore((state) => state.loginSubject); + const toaster = useToaster(); + const { LL } = useI18nContext(); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + const callbackMutation = useMutation((data: CallbackData) => callback(data), { + mutationKey: [MutationKeys.OPENID_CALLBACK], + onSuccess: (data) => { + loginSubject.next(data); + }, + onError: (error: AxiosError) => { + toaster.error(LL.messages.error()); + console.error(error); + }, + retry: false, + }); + + useEffect(() => { + if (window.location.hash && window.location.hash.length > 0) { + const hashFragment = window.location.hash.substring(1); + const params = new URLSearchParams(hashFragment); + + // check if error occured + const error = params.get('error'); + + if (error) { + setError(error); + toaster.error(LL.messages.error()); + return; + } + + const id_token = params.get('id_token'); + const state = params.get('state'); + + if (id_token && state) { + const data: CallbackData = { + id_token, + state, + }; + console.log('CALLING CALLBACK MUTATION'); + callbackMutation.mutate(data); + } + } + }, []); + + // TODO: Perhaphs make it a bit more user friendly + return error ? ( +
+

+ {LL.loginPage.callback.error()}: {error} +

+
+ ) : ( + + ); +}; diff --git a/web/src/pages/auth/Callback/style.scss b/web/src/pages/auth/Callback/style.scss new file mode 100644 index 0000000000..763c165186 --- /dev/null +++ b/web/src/pages/auth/Callback/style.scss @@ -0,0 +1,5 @@ +.error-info { + display: flex; + flex-direction: column; + gap: 10px; +} diff --git a/web/src/pages/auth/Login/Login.tsx b/web/src/pages/auth/Login/Login.tsx index 121599f793..6d6a46a152 100644 --- a/web/src/pages/auth/Login/Login.tsx +++ b/web/src/pages/auth/Login/Login.tsx @@ -1,7 +1,7 @@ import './style.scss'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { useMemo } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; @@ -14,10 +14,12 @@ import { ButtonSize, ButtonStyleVariant, } from '../../../shared/defguard-ui/components/Layout/Button/types'; +import { LoaderSpinner } from '../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; import { useAuthStore } from '../../../shared/hooks/store/useAuthStore'; import useApi from '../../../shared/hooks/useApi'; import { MutationKeys } from '../../../shared/mutations'; import { patternSafeUsernameCharacters } from '../../../shared/patterns'; +import { QueryKeys } from '../../../shared/queries'; import { LoginData } from '../../../shared/types'; import { trimObjectStrings } from '../../../shared/utils/trimObjectStrings'; import { OpenIdLoginButton } from './components/OidcButtons'; @@ -29,7 +31,20 @@ type Inputs = { export const Login = () => { const { LL } = useI18nContext(); - const openIdInfo = useAuthStore((state) => state.openIdLoginInfo); + const { + auth: { + login, + openid: { getOpenIdInfo: getOpenidInfo }, + }, + } = useApi(); + + const { data: openIdInfo, isLoading: openIdLoading } = useQuery({ + queryKey: [QueryKeys.FETCH_OPENID_INFO], + queryFn: getOpenidInfo, + refetchOnMount: true, + refetchOnWindowFocus: false, + retry: false, + }); const zodSchema = useMemo( () => @@ -48,10 +63,6 @@ export const Login = () => { [LL.form.error], ); - const { - auth: { login }, - } = useApi(); - const { handleSubmit, control, setError } = useForm({ resolver: zodResolver(zodSchema), mode: 'all', @@ -89,33 +100,39 @@ export const Login = () => { return (
-

{LL.loginPage.pageTitle()}

-
- - -
); }; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx index fff1539069..336d9c0fad 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx @@ -36,7 +36,7 @@ export const OpenIdSettingsForm = () => { const queryClient = useQueryClient(); const { - settings: { fetchOpenIdProviders, addOpenIdProvider }, + settings: { fetchOpenIdProviders, addOpenIdProvider, deleteOpenIdProvider }, } = useApi(); const { isLoading } = useQuery({ @@ -64,6 +64,18 @@ export const OpenIdSettingsForm = () => { }, }); + const { mutate: deleteProvider } = useMutation({ + mutationFn: deleteOpenIdProvider, + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); + toaster.success(LL.settingsPage.messages.editSuccess()); + }, + onError: (error) => { + toaster.error(LL.messages.error()); + console.error(error); + }, + }); + const schema = useMemo( () => z.object({ @@ -104,6 +116,13 @@ export const OpenIdSettingsForm = () => { mutate(data); }; + const handleDeleteProvider = useCallback(() => { + if (currentProvider) { + deleteProvider(currentProvider.name); + setCurrentProvider(null); + } + }, [currentProvider, deleteProvider]); + const options: SelectOption[] = useMemo( () => [ { @@ -168,15 +187,27 @@ export const OpenIdSettingsForm = () => {

{localLL.form.title()}

{parse(localLL.form.helper())} -