sub2api/frontend/src/views/auth/LoginView.vue
DaydreamCoding b19da9c7fe feat(dingtalk): 钉钉 OAuth 登录接入与 internal_only 用户属性同步
⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(DingTalk 开放平台
internal_app 类型)。第三方个人应用、第三方企业应用类型暂不支持——OAuth 流程
相同但 corp 校验、跨企业行为不同。backend 通过 DingTalkAppKind 校验对非
internal_app 类型 fail-closed(硬约束)。

钉钉 OAuth 登录主链
- 4 步 OAuth 链:ExchangeCodeForUserToken / GetUnionIdByUserToken /
  GetUserIdByUnionId / GetStaffInfoByUserId;app token 缓存
- pending session 机制持久化 OAuth 中间态;cookie-only token 持久化
- 三种分流:bind_login_required / email_completion / choose_account_action
- corp_restriction_policy 支持 none + internal_only;stale "whitelist" 在
  加载层与写入层均静默 coerce 为 none + slog.Warn
- bypass_registration 开关:企业内部模式豁免全局 REGISTRATION_DISABLED
- isReservedEmail / signup_source / canUnbindProvider / OAuth pending flow
  等横切点支持 dingtalk provider
- migration 136:4 表 CHECK 约束加入 'dingtalk' provider 值

internal_only 模式同步企业邮箱/姓名/部门到用户属性
- SyncCorpEmail / SyncDisplayName / SyncDept 三个独立开关 + 对应
  SyncXxxAttrKey 目标属性 key(默认 dingtalk_email / dingtalk_name /
  dingtalk_department);非 internal_only policy 在写入层与加载层均
  coerce 为 false,admin handler 与 setting_service 双层兜底
- 同步语义:首次注册写 users.username(昵称优先 → 企业姓名 fallback),
  之后每次登录刷新 3 个属性;空值也写入以覆盖旧值
- 邮箱三级 fallback:org_email > email > extension["企业邮箱"]
  (钉钉自定义字段 JSON)
- 部门路径递归向上拼接,跳过 dept_id=1 选首个真实子部门,剥离根组织名
- GetUnionIdByUserToken 同时返回 OIDC /contact/users/me 的 nick 字段;
  新增 GetDeptInfo 调用 OAPI /topapi/v2/department/get
- AuthHandler 注入 UserAttributeService;OAuth pending flow 在
  createPendingOAuthAccount / bindPendingOAuthLogin 分别派发到
  AfterRegistration(syncUsername=true)/ AfterLogin
- migration 137 seed dingtalk_email/name/department 三个用户属性定义

附带修复(同集成路径暴露的两个 OAuth 注册回归)
- LoginOrRegisterOAuthWithTokenPair 新建用户分支用 inferLegacySignupSource
  覆写 caller 显式传入的 signupSource,导致 dingtalk/linuxdo/oidc/wechat
  渠道授权按 email 渠道读取;改为只在 caller 未显式传入时回退邮箱推断
- mergeProviderDefaultGrantSettings 把 parse fallback 默认值
  (Concurrency=5 / Balance=0) 当作"未配置"哨兵,admin 显式设 5 时被误判
  退回全局默认(复现:全局默认 1 + 渠道默认并发 5 + grant_on_signup → 新
  用户实际 concurrency=1);去掉哨兵,admin 任何 >=0 值都覆盖 globalDefaults

前端
- DingTalk Login / Callback / EmailCompletion / ChoiceAccount / Error
  视图;router + auth API client
- admin SettingsView:corp policy radio(none / internal_only)+ bypass
  注册开关 + i18n;internal_only 下展示三同步开关 + 目标 attr key 下拉
  (拉取 user attribute definitions),展示 fieldEmail /
  qyapi_get_department_list 钉钉权限申请提示
- Profile:S1 主动绑定 / S5 解绑钉钉按钮 + 合成邮箱防自锁

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:27:47 +08:00

567 lines
18 KiB
Vue

<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.welcomeBack') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('auth.signInToAccount') }}
</p>
</div>
<!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-5">
<!-- Email Input -->
<div>
<label for="email" class="input-label">
{{ t('auth.emailLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="email"
v-model="formData.email"
type="email"
required
autofocus
autocomplete="email"
:disabled="authActionDisabled"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
</div>
<!-- Password Input -->
<div>
<label for="password" class="input-label">
{{ t('auth.passwordLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="password"
v-model="formData.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
:disabled="authActionDisabled"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.passwordPlaceholder')"
/>
<button
type="button"
@click="showPassword = !showPassword"
:disabled="authActionDisabled"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon v-if="showPassword" name="eyeOff" size="md" />
<Icon v-else name="eye" size="md" />
</button>
</div>
<div class="mt-1 flex items-center justify-between">
<span></span>
<router-link
v-if="passwordResetEnabled && !backendModeEnabled"
to="/forgot-password"
class="text-sm font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.forgotPassword') }}
</router-link>
</div>
</div>
<!-- Turnstile Widget -->
<div v-if="turnstileEnabled && turnstileSiteKey">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
</div>
<!-- Submit Button -->
<button
type="submit"
:disabled="authActionDisabled || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="login" size="md" class="mr-2" />
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
</button>
<LoginAgreementPrompt
v-if="loginAgreementEnabled"
:accepted="agreementAccepted"
:documents="loginAgreementDocuments"
:mode="loginAgreementMode"
:updated-at="loginAgreementUpdatedAt"
:visible="showAgreementModal"
@accept="acceptLoginAgreement"
@reject="rejectLoginAgreement"
@open="showAgreementModal = true"
/>
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
<EmailOAuthButtons
:disabled="authActionDisabled"
:github-enabled="githubOAuthEnabled"
:google-enabled="googleOAuthEnabled"
:show-divider="false"
/>
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="authActionDisabled"
:show-divider="false"
/>
<DingTalkOAuthSection
v-if="dingtalkOAuthEnabled"
:disabled="authActionDisabled"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="authActionDisabled"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="authActionDisabled"
:provider-name="oidcOAuthProviderName"
:show-divider="false"
/>
</div>
</form>
</div>
<!-- Footer -->
<template v-if="!backendModeEnabled" #footer>
<p class="text-gray-500 dark:text-dark-400">
{{ t('auth.dontHaveAccount') }}
<router-link
to="/register"
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.signUp') }}
</router-link>
</p>
</template>
</AuthLayout>
<!-- 2FA Modal -->
<TotpLoginModal
v-if="show2FAModal"
ref="totpModalRef"
:temp-token="totpTempToken"
:user-email-masked="totpUserEmailMasked"
@verify="handle2FAVerify"
@cancel="handle2FACancel"
/>
</template>
<script setup lang="ts">
import { computed, ref, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import DingTalkOAuthSection from '@/components/auth/DingTalkOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
import type { LoginAgreementDocument, TotpLoginResponse } from '@/types'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n()
const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
// ==================== Router & Stores ====================
const router = useRouter()
const authStore = useAuthStore()
const appStore = useAppStore()
// ==================== State ====================
const isLoading = ref<boolean>(false)
const errorMessage = ref<string>('')
const showPassword = ref<boolean>(false)
const publicSettingsLoaded = ref<boolean>(false)
// Public settings
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const linuxdoOAuthEnabled = ref<boolean>(false)
const dingtalkOAuthEnabled = ref<boolean>(false)
const wechatOAuthEnabled = ref<boolean>(false)
const backendModeEnabled = ref<boolean>(false)
const oidcOAuthEnabled = ref<boolean>(false)
const oidcOAuthProviderName = ref<string>('OIDC')
const githubOAuthEnabled = ref<boolean>(false)
const googleOAuthEnabled = ref<boolean>(false)
const passwordResetEnabled = ref<boolean>(false)
const loginAgreementEnabled = ref<boolean>(false)
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
const loginAgreementUpdatedAt = ref<string>('')
const loginAgreementRevision = ref<string>('')
const loginAgreementDocuments = ref<LoginAgreementDocument[]>([])
const agreementAccepted = ref<boolean>(false)
const showAgreementModal = ref<boolean>(false)
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileToken = ref<string>('')
// 2FA state
const show2FAModal = ref<boolean>(false)
const totpTempToken = ref<string>('')
const totpUserEmailMasked = ref<string>('')
const totpModalRef = ref<InstanceType<typeof TotpLoginModal> | null>(null)
const formData = reactive({
email: '',
password: ''
})
const errors = reactive({
email: '',
password: '',
turnstile: ''
})
const validationToastMessage = computed(
() => errors.email || errors.password || errors.turnstile || ''
)
const agreementGateActive = computed(
() => loginAgreementEnabled.value && !agreementAccepted.value
)
const authActionDisabled = computed(
() => isLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
)
const showOAuthLogin = computed(
() =>
!backendModeEnabled.value &&
(linuxdoOAuthEnabled.value ||
dingtalkOAuthEnabled.value ||
wechatOAuthEnabled.value ||
oidcOAuthEnabled.value ||
githubOAuthEnabled.value ||
googleOAuthEnabled.value)
)
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
}
})
// ==================== Lifecycle ====================
onMounted(async () => {
const expiredFlag = sessionStorage.getItem('auth_expired')
if (expiredFlag) {
sessionStorage.removeItem('auth_expired')
const message = t('auth.reloginRequired')
errorMessage.value = message
appStore.showWarning(message)
}
try {
const settings = await getPublicSettings()
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
backendModeEnabled.value = settings.backend_mode_enabled
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
githubOAuthEnabled.value = settings.github_oauth_enabled
googleOAuthEnabled.value = settings.google_oauth_enabled
backendModeEnabled.value = settings.backend_mode_enabled
passwordResetEnabled.value = settings.password_reset_enabled
applyLoginAgreementSettings(settings)
} catch (error) {
console.error('Failed to load public settings:', error)
loginAgreementEnabled.value = false
agreementAccepted.value = true
} finally {
publicSettingsLoaded.value = true
}
})
// ==================== Login Agreement ====================
function applyLoginAgreementSettings(settings: {
login_agreement_enabled?: boolean
login_agreement_mode?: string
login_agreement_updated_at?: string
login_agreement_revision?: string
login_agreement_documents?: LoginAgreementDocument[]
}): void {
const documents = Array.isArray(settings.login_agreement_documents)
? settings.login_agreement_documents.filter((doc) => doc.title?.trim())
: []
loginAgreementDocuments.value = documents
loginAgreementEnabled.value = settings.login_agreement_enabled === true && documents.length > 0
loginAgreementMode.value = settings.login_agreement_mode === 'checkbox' ? 'checkbox' : 'modal'
loginAgreementUpdatedAt.value = settings.login_agreement_updated_at || ''
loginAgreementRevision.value =
settings.login_agreement_revision ||
`${loginAgreementUpdatedAt.value}:${documents.map((doc) => `${doc.id}:${doc.title}`).join('|')}`
agreementAccepted.value = !loginAgreementEnabled.value || hasAcceptedLoginAgreement(loginAgreementRevision.value)
showAgreementModal.value =
loginAgreementEnabled.value && !agreementAccepted.value && loginAgreementMode.value !== 'checkbox'
}
function hasAcceptedLoginAgreement(revision: string): boolean {
if (!revision) {
return false
}
try {
const raw = localStorage.getItem(LOGIN_AGREEMENT_STORAGE_KEY)
if (!raw) {
return false
}
const parsed = JSON.parse(raw) as { revision?: string }
return parsed.revision === revision
} catch {
return false
}
}
function acceptLoginAgreement(): void {
if (loginAgreementRevision.value) {
localStorage.setItem(
LOGIN_AGREEMENT_STORAGE_KEY,
JSON.stringify({
revision: loginAgreementRevision.value,
accepted_at: new Date().toISOString()
})
)
}
agreementAccepted.value = true
showAgreementModal.value = false
}
function rejectLoginAgreement(): void {
localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
agreementAccepted.value = false
showAgreementModal.value = false
appStore.showWarning('未同意最新条款前,无法输入账号密码或使用快捷登录。')
}
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
turnstileToken.value = token
errors.turnstile = ''
}
function onTurnstileExpire(): void {
turnstileToken.value = ''
errors.turnstile = t('auth.turnstileExpired')
}
function onTurnstileError(): void {
turnstileToken.value = ''
errors.turnstile = t('auth.turnstileFailed')
}
// ==================== Validation ====================
function validateForm(): boolean {
// Reset errors
errors.email = ''
errors.password = ''
errors.turnstile = ''
let isValid = true
if (agreementGateActive.value) {
appStore.showWarning('请先阅读并同意最新条款后再登录。')
if (loginAgreementMode.value !== 'checkbox') {
showAgreementModal.value = true
}
return false
}
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')
isValid = false
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = t('auth.invalidEmail')
isValid = false
}
// Password validation
if (!formData.password) {
errors.password = t('auth.passwordRequired')
isValid = false
} else if (formData.password.length < 6) {
errors.password = t('auth.passwordMinLength')
isValid = false
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = t('auth.completeVerification')
isValid = false
}
return isValid
}
// ==================== Form Handlers ====================
async function handleLogin(): Promise<void> {
// Clear previous error
errorMessage.value = ''
// Validate form
if (!validateForm()) {
return
}
isLoading.value = true
try {
// Call auth store login
const response = await authStore.login({
email: formData.email,
password: formData.password,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
})
// Check if 2FA is required
if (isTotp2FARequired(response)) {
const totpResponse = response as TotpLoginResponse
totpTempToken.value = totpResponse.temp_token || ''
totpUserEmailMasked.value = totpResponse.user_email_masked || ''
show2FAModal.value = true
isLoading.value = false
return
}
// Show success toast
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
await router.push(redirectTo)
} catch (error: unknown) {
// Reset Turnstile on error
if (turnstileRef.value) {
turnstileRef.value.reset()
turnstileToken.value = ''
}
errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', t('auth.loginFailed'))
// Also show error toast
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false
}
}
// ==================== 2FA Handlers ====================
async function handle2FAVerify(code: string): Promise<void> {
if (totpModalRef.value) {
totpModalRef.value.setVerifying(true)
}
try {
await authStore.login2FA(totpTempToken.value, code)
// Close modal and show success
show2FAModal.value = false
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
await router.push(redirectTo)
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { message?: string } } }
const message = err.response?.data?.message || err.message || t('profile.totp.loginFailed')
if (totpModalRef.value) {
totpModalRef.value.setError(message)
totpModalRef.value.setVerifying(false)
}
}
}
function handle2FACancel(): void {
show2FAModal.value = false
totpTempToken.value = ''
totpUserEmailMasked.value = ''
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>