refactor: 移除 SAML/LDAP/MFA 登录方式,仅保留本地账号密码和 OIDC
后端移除: - SAML: auth/saml.go, handler/saml_handler.go, account_saml_settings_handler.go, model/account_saml_settings.go, model/saml_idp_config.go, repo/*.go - LDAP: auth/ldap.go, handler/ldap_handler.go, model/account_ldap_settings.go, repo/account_ldap_settings_repo.go - MFA: auth/mfa.go, handler/mfa_handler.go - auth_service: 移除 mfaService 依赖、MFARequired 字段、LoginWithMFA 方法 - auth_handler: 移除 LoginMFA handler、MFA 分支逻辑 - bootstrap: 移除 SAML/LDAP/MFA service 初始化和 handler 注册 - sso_middleware: 精简为仅支持 OIDC provider - router: 移除 SAML/LDAP/MFA 路由注册 - config: 移除 SAMLConfig/LDAPConfig struct 和 defaults 前端移除: - v3/login: 移除 MFA 验证流程和 SAML 登录入口 - v3/api/auth: 移除 MFA 响应处理 - v3/routes: 移除 SSO login 路由 - dashboard: 移除 MFA 设置页面、SAML 安全设置页面 - i18n: 移除 mfa.json - featureFlags: 移除 SAML feature flag .env.example / .env: 移除 SAML/LDAP 配置段
This commit is contained in:
@@ -12,38 +12,15 @@ export const login = async ({
|
||||
ssoConversationId,
|
||||
...credentials
|
||||
}) => {
|
||||
try {
|
||||
const response = await wootAPI.post('auth/sign_in', credentials);
|
||||
|
||||
// Check if MFA is required
|
||||
if (response.status === 206 && response.data.mfa_required) {
|
||||
// Return MFA data instead of throwing error
|
||||
return {
|
||||
mfaRequired: true,
|
||||
mfaToken: response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
|
||||
setAuthCredentials(response);
|
||||
clearLocalStorageOnLogout();
|
||||
window.location = getLoginRedirectURL({
|
||||
ssoAccountId,
|
||||
ssoConversationId,
|
||||
user: response.data.data,
|
||||
});
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Check if it's an MFA required response
|
||||
if (error.response?.status === 206 && error.response?.data?.mfa_required) {
|
||||
return {
|
||||
mfaRequired: true,
|
||||
mfaToken: error.response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
const loginError = new Error(parseAPIErrorResponse(error));
|
||||
loginError.errorCode = error.response?.data?.error_code;
|
||||
throw loginError;
|
||||
}
|
||||
const response = await wootAPI.post('auth/sign_in', credentials);
|
||||
setAuthCredentials(response);
|
||||
clearLocalStorageOnLogout();
|
||||
window.location = getLoginRedirectURL({
|
||||
ssoAccountId,
|
||||
ssoConversationId,
|
||||
user: response.data.data,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resendConfirmation = async ({ email, hCaptchaClientResponse }) => {
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('#validateRouteAccess', () => {
|
||||
|
||||
it('allows routes that were previously restricted to enterprise installs', () => {
|
||||
validateRouteAccess(
|
||||
{ name: 'saml_login', meta: { requireEnterprise: true } },
|
||||
{ name: 'reset_password', meta: { requireEnterprise: true } },
|
||||
next
|
||||
);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
|
||||
@@ -10,17 +10,13 @@ import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
// components
|
||||
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
|
||||
import FormInput from '../../components/Form/Input.vue';
|
||||
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
||||
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
|
||||
'saml-authentication-failed': 'LOGIN.SAML.API.ERROR_MESSAGE',
|
||||
'saml-not-enabled': 'LOGIN.SAML.API.ERROR_MESSAGE',
|
||||
};
|
||||
|
||||
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
|
||||
@@ -29,11 +25,9 @@ const USER_NOT_CONFIRMED_ERROR_CODE = 'user_not_confirmed';
|
||||
export default {
|
||||
components: {
|
||||
FormInput,
|
||||
GoogleOAuthButton,
|
||||
Spinner,
|
||||
NextButton,
|
||||
SimpleDivider,
|
||||
MfaVerification,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
@@ -50,8 +44,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// We need to initialize the component with any
|
||||
// properties that will be used in it
|
||||
credentials: {
|
||||
email: '',
|
||||
password: '',
|
||||
@@ -62,8 +54,6 @@ export default {
|
||||
hasErrored: false,
|
||||
},
|
||||
error: '',
|
||||
mfaRequired: false,
|
||||
mfaToken: null,
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
@@ -79,71 +69,32 @@ export default {
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
allowedLoginMethods() {
|
||||
return window.chatwootConfig.allowedLoginMethods || ['email'];
|
||||
},
|
||||
showGoogleOAuth() {
|
||||
return (
|
||||
this.allowedLoginMethods.includes('google_oauth') &&
|
||||
Boolean(window.chatwootConfig.googleOAuthClientId)
|
||||
);
|
||||
},
|
||||
showSamlLogin() {
|
||||
return this.allowedLoginMethods.includes('saml');
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (this.ssoAuthToken) {
|
||||
this.submitLogin();
|
||||
}
|
||||
if (this.authError) {
|
||||
const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
|
||||
// Use a method to get the translated text to avoid dynamic key warning
|
||||
const translatedMessage = this.getTranslatedMessage(messageKey);
|
||||
useAlert(translatedMessage);
|
||||
// wait for idle state
|
||||
this.showAlertMessage(this.$t('LOGIN.API.UNAUTH'));
|
||||
this.requestIdleCallbackPolyfill(() => {
|
||||
// Remove the error query param from the url
|
||||
const { query } = this.$route;
|
||||
this.$router.replace({ query: { ...query, error: undefined } });
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getTranslatedMessage(key) {
|
||||
// Avoid dynamic key warning by handling each case explicitly
|
||||
switch (key) {
|
||||
case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
|
||||
return this.$t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
|
||||
case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
|
||||
return this.$t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
|
||||
case 'LOGIN.API.UNAUTH':
|
||||
default:
|
||||
return this.$t('LOGIN.API.UNAUTH');
|
||||
}
|
||||
},
|
||||
// TODO: Remove this when Safari gets wider support
|
||||
// Ref: https://caniuse.com/requestidlecallback
|
||||
//
|
||||
requestIdleCallbackPolyfill(callback) {
|
||||
if (window.requestIdleCallback) {
|
||||
window.requestIdleCallback(callback);
|
||||
} else {
|
||||
// Fallback for safari
|
||||
// Using a delay of 0 allows the callback to be executed asynchronously
|
||||
// in the next available event loop iteration, similar to requestIdleCallback
|
||||
setTimeout(callback, 0);
|
||||
}
|
||||
},
|
||||
showAlertMessage(message) {
|
||||
// Reset loading, current selected agent
|
||||
this.loginApi.showLoading = false;
|
||||
this.loginApi.message = message;
|
||||
useAlert(this.loginApi.message);
|
||||
},
|
||||
handleImpersonation() {
|
||||
// Detects impersonation mode via URL and sets a session flag to prevent user settings changes during impersonation.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const impersonation = urlParams.get(IMPERSONATION_URL_SEARCH_KEY);
|
||||
if (impersonation) {
|
||||
@@ -165,15 +116,7 @@ export default {
|
||||
};
|
||||
|
||||
login(credentials)
|
||||
.then(result => {
|
||||
// Check if MFA is required
|
||||
if (result?.mfaRequired) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.mfaRequired = true;
|
||||
this.mfaToken = result.mfaToken;
|
||||
return;
|
||||
}
|
||||
|
||||
.then(() => {
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
@@ -187,7 +130,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset URL Params if the authentication is invalid
|
||||
if (this.email) {
|
||||
window.location = '/app/login';
|
||||
}
|
||||
@@ -205,17 +147,6 @@ export default {
|
||||
|
||||
this.submitLogin();
|
||||
},
|
||||
handleMfaVerified() {
|
||||
// MFA verification successful, continue with login
|
||||
this.handleImpersonation();
|
||||
window.location = '/app';
|
||||
},
|
||||
handleMfaCancel() {
|
||||
// User cancelled MFA, reset state
|
||||
this.mfaRequired = false;
|
||||
this.mfaToken = null;
|
||||
this.credentials.password = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -224,47 +155,15 @@ export default {
|
||||
<main
|
||||
class="flex flex-col items-center justify-center w-full min-h-screen bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8"
|
||||
>
|
||||
<!-- MFA Verification Section -->
|
||||
<section v-if="mfaRequired" class="mt-11">
|
||||
<MfaVerification
|
||||
:mfa-token="mfaToken"
|
||||
@verified="handleMfaVerified"
|
||||
@cancel="handleMfaCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Regular Login Section -->
|
||||
<section
|
||||
v-else
|
||||
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
:class="{
|
||||
'mb-8 mt-15': !showGoogleOAuth,
|
||||
'mb-8 mt-15': true,
|
||||
'animate-wiggle': loginApi.hasErrored,
|
||||
}"
|
||||
>
|
||||
<div v-if="!email">
|
||||
<div class="flex flex-col gap-4">
|
||||
<GoogleOAuthButton v-if="showGoogleOAuth" />
|
||||
<div v-if="showSamlLogin" class="text-center">
|
||||
<router-link
|
||||
to="/app/login/sso"
|
||||
class="inline-flex justify-center w-full px-4 py-3 items-center bg-n-background dark:bg-n-solid-3 rounded-md shadow-sm ring-1 ring-inset ring-n-container dark:ring-n-container focus:outline-offset-0 hover:bg-n-alpha-2 dark:hover:bg-n-alpha-2"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
<span class="ml-2 text-base font-medium text-n-slate-12">
|
||||
{{ $t('LOGIN.SAML.LABEL') }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<SimpleDivider
|
||||
v-if="showGoogleOAuth || showSamlLogin"
|
||||
:label="$t('COMMON.OR')"
|
||||
class="uppercase"
|
||||
/>
|
||||
</div>
|
||||
<form class="space-y-5" @submit.prevent="submitFormLogin">
|
||||
<FormInput
|
||||
v-model="credentials.email"
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, nextTick, computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
// components
|
||||
import FormInput from '../../components/Form/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
authError: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
target: {
|
||||
type: String,
|
||||
default: 'web',
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const credentials = ref({
|
||||
email: '',
|
||||
});
|
||||
|
||||
const loginApi = ref({
|
||||
showLoading: false,
|
||||
hasErrored: false,
|
||||
});
|
||||
|
||||
const handleAuthError = () => {
|
||||
if (!props.authError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const translatedMessage = t('LOGIN.SAML.API.ERROR_MESSAGE');
|
||||
useAlert(translatedMessage);
|
||||
loginApi.value.hasErrored = true;
|
||||
};
|
||||
|
||||
const validations = {
|
||||
credentials: {
|
||||
email: {
|
||||
required,
|
||||
email,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validations, { credentials });
|
||||
|
||||
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||
const csrfToken = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
csrfToken.value =
|
||||
document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content') || '';
|
||||
|
||||
await nextTick(handleAuthError);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="flex flex-col w-full min-h-screen py-20 bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8"
|
||||
>
|
||||
<section class="max-w-5xl mx-auto">
|
||||
<img
|
||||
:src="globalConfig.logo"
|
||||
:alt="globalConfig.installationName"
|
||||
class="block w-auto h-8 mx-auto dark:hidden"
|
||||
/>
|
||||
<img
|
||||
v-if="globalConfig.logoDark"
|
||||
:src="globalConfig.logoDark"
|
||||
:alt="globalConfig.installationName"
|
||||
class="hidden w-auto h-8 mx-auto dark:block"
|
||||
/>
|
||||
<h2 class="mt-6 text-3xl font-medium text-center text-n-slate-12">
|
||||
{{ t('LOGIN.SAML.TITLE') }}
|
||||
</h2>
|
||||
</section>
|
||||
<section
|
||||
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
:class="{
|
||||
'animate-wiggle': loginApi.hasErrored,
|
||||
}"
|
||||
>
|
||||
<form class="space-y-5" method="POST" action="/api/v1/auth/saml_login">
|
||||
<FormInput
|
||||
v-model="credentials.email"
|
||||
name="email"
|
||||
type="text"
|
||||
:tabindex="1"
|
||||
required
|
||||
:label="t('LOGIN.SAML.WORK_EMAIL.LABEL')"
|
||||
:placeholder="t('LOGIN.SAML.WORK_EMAIL.PLACEHOLDER')"
|
||||
:has-error="v$.credentials.email.$error"
|
||||
@input="v$.credentials.email.$touch"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
class="h-0"
|
||||
name="authenticity_token"
|
||||
:value="csrfToken"
|
||||
/>
|
||||
<input type="hidden" class="h-0" name="target" :value="target" />
|
||||
<NextButton
|
||||
lg
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:tabindex="2"
|
||||
:label="t('LOGIN.SAML.SUBMIT')"
|
||||
:disabled="loginApi.showLoading"
|
||||
:is-loading="loginApi.showLoading"
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
<p class="mt-6 text-sm text-center text-n-slate-11">
|
||||
<router-link to="/app/login" class="text-link text-n-brand">
|
||||
{{ t('LOGIN.SAML.BACK_TO_LOGIN') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Login from './login/Index.vue';
|
||||
import SamlLogin from './login/Saml.vue';
|
||||
import ResetPassword from './auth/reset/password/Index.vue';
|
||||
import Confirmation from './auth/confirmation/Index.vue';
|
||||
import VerifyEmail from './auth/verify-email/Index.vue';
|
||||
@@ -21,16 +20,6 @@ export default [
|
||||
authError: route.query.error,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('login/sso'),
|
||||
name: 'sso_login',
|
||||
component: SamlLogin,
|
||||
meta: { requireEnterprise: true },
|
||||
props: route => ({
|
||||
authError: route.query.error,
|
||||
target: route.query.target,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('auth/confirmation'),
|
||||
name: 'auth_confirmation',
|
||||
|
||||
Reference in New Issue
Block a user