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:
Rogee
2026-07-29 19:03:04 +08:00
parent 09f274e965
commit 851ca7e372
66 changed files with 154 additions and 10590 deletions
+9 -32
View File
@@ -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',