diff --git a/apps/chat/views/v3/chat.py b/apps/chat/views/v3/chat.py index f53a03c1c76..bc364f42c55 100644 --- a/apps/chat/views/v3/chat.py +++ b/apps/chat/views/v3/chat.py @@ -485,7 +485,7 @@ def create_token_and_cache(user, access_token, operate): .first() ) token = ChatToken( - str(user.id), AuthenticationType.CHAT_USER, str(operate), application_id=application_id + str(user.id), AuthenticationType.CHAT_USER, str(operate), application_id=str(application_id) ).to_token() version, get_key = Cache_Version.CHAT_USER_TOKEN.value cache.set(get_key(token), user, timeout=60 * 60 * 2, version=version) diff --git a/apps/common/auth/handle/impl/chat_user_token.py b/apps/common/auth/handle/impl/chat_user_token.py index 2dbc91d3edd..d973f5f9ee2 100644 --- a/apps/common/auth/handle/impl/chat_user_token.py +++ b/apps/common/auth/handle/impl/chat_user_token.py @@ -92,7 +92,7 @@ def get_auth(login_type, user_id, application_id): if authentication_value.get("type") == "login": login_value = authentication_value.get("login_value") or [] for _value in login_value: - permission_str = f"{Group.CHAT_USER}_{_value.upper()}" + permission_str = f"{Group.CHAT_USER}:{_value.upper()}" permission = CHAT_PERMISSION_STR_MAP.get(permission_str) if permission: permission_list.append(permission.value) diff --git a/ui/src/router/ROUTE_README.md b/ui/src/router/ROUTE_README.md index 4840981d27a..a9ae21821dd 100644 --- a/ui/src/router/ROUTE_README.md +++ b/ui/src/router/ROUTE_README.md @@ -121,11 +121,15 @@ Chat 导航守卫(`chat/index.ts`): 2. 获取目标场景的认证前置配置(`AuthProfile`),与当前场景相同时复用。accessToken 无效、应用已停用或门户 未开放导致获取失败时,进入 `chat-not-found` 并保留原地址;匿名认证失败时同样处理。 3. 判断时只读取目标场景在本地存储中的 token,不切换 Store 的当前场景。 -4. 进入登录页或已有 token 时,调用 `auth.activate()` 切换当前场景后放行。 -5. `enable_auth` 为 false 时获取匿名 token,切换当前场景后放行。匿名认证与登录都携带该场景本地保存的 +4. `enable_auth` 为 false 时登录页无用:进入登录页会被重定向到 `redirect` 记录的原地址,没有时进入 + 当前场景首页(`portal-home` 或 `chat-home`),再按下一条匿名认证。 +5. 进入登录页或已有可用 token 时,调用 `auth.activate()` 切换当前场景后放行。`enable_auth` 为 true 时, + 匿名认证颁发的 token(载荷 `login_type` 为 `ANNOTATION`,由 `isAnonymousToken` 判断)不可用, + 即使本地已保存也跳转登录页;该 token 保留在本地存储,登录时携带。 +6. `enable_auth` 为 false 时获取匿名 token,切换当前场景后放行。匿名认证与登录都携带该场景本地保存的 token,后端据此沿用同一对话用户。401 时 token 只在 Store 中清除并标记为失效,本地存储保留以便 重新认证时携带;退出登录才从本地存储删除。 -6. 否则跳转到对应登录页(`portal-login` 或 `chat-login`),并通过 `redirect` 查询参数保留原地址。 +7. 否则跳转到对应登录页(`portal-login` 或 `chat-login`),并通过 `redirect` 查询参数保留原地址。 当前场景只在放行前切换,请求层直接读取 `auth.token`,导航未完成时不会带上目标场景的 token。 diff --git a/ui/src/router/chat/index.ts b/ui/src/router/chat/index.ts index 5b061da6746..a2a44481be2 100644 --- a/ui/src/router/chat/index.ts +++ b/ui/src/router/chat/index.ts @@ -1,6 +1,6 @@ import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' import { useStore } from '@/stores/chat' -import { PORTAL_AUTH_SCOPE } from '@/stores/chat/auth' +import { PORTAL_AUTH_SCOPE, isAnonymousToken } from '@/stores/chat/auth' import type { AuthProfile } from '@/api/types' import { chatRoutes } from './routes' @@ -25,9 +25,14 @@ function isUnknownPortalPath(to: RouteLocationNormalized) { function getLoginRoute(scope: string, to: RouteLocationNormalized) { const query = { redirect: to.fullPath } - return scope === PORTAL_AUTH_SCOPE - ? { name: 'portal-login', query } - : { name: 'chat-login', params: { accessToken: scope }, query } + return scope === PORTAL_AUTH_SCOPE ? { name: 'portal-login', query } : { name: 'chat-login', params: { accessToken: scope }, query } +} + +/** 未开启认证时登录页无用,回到 `redirect` 记录的原地址,没有时进入当前场景首页。 */ +function getLoginRedirectRoute(scope: string, to: RouteLocationNormalized) { + const { redirect } = to.query + if (typeof redirect === 'string' && redirect.startsWith('/')) return { path: redirect, replace: true } + return scope === PORTAL_AUTH_SCOPE ? { name: 'portal-home', replace: true } : { name: 'chat-home', params: { accessToken: scope }, replace: true } } /** 渲染 404 页面并保留原地址,刷新时可重新尝试。 */ @@ -51,8 +56,14 @@ chatRouter.beforeEach(async (to) => { return getNotFoundRoute(to) } + const isLoginRoute = LOGIN_ROUTE_NAMES.has(String(to.name)) + if (isLoginRoute && !authProfile.enable_auth) return getLoginRedirectRoute(scope, to) + // 判断时只读取目标场景已保存的 token,放行前再切换当前场景,避免导航未完成时请求带上目标场景的 token - if (LOGIN_ROUTE_NAMES.has(String(to.name)) || auth.getStoredToken(scope)) { + // 开启认证后,此前保存的匿名 token 不再放行,需要登录;本地存储保留,登录时携带以沿用同一对话用户 + const storedToken = auth.getStoredToken(scope) + const canUseStoredToken = Boolean(storedToken) && !(authProfile.enable_auth && isAnonymousToken(storedToken)) + if (isLoginRoute || canUseStoredToken) { auth.activate(scope, authProfile) return true } diff --git a/ui/src/stores/chat/auth.ts b/ui/src/stores/chat/auth.ts index f4d1c780716..8b83be6cc3c 100644 --- a/ui/src/stores/chat/auth.ts +++ b/ui/src/stores/chat/auth.ts @@ -31,6 +31,25 @@ function getSavedToken(scope: string) { return localStorage.getItem(getTokenStorageKey(scope)) || undefined } +/** 匿名认证颁发的 token 的登录方式。 */ +const ANONYMOUS_LOGIN_TYPE = 'ANNOTATION' + +/** + * 判断是否为匿名认证颁发的 token。token 为 Django signing 格式「base64url(JSON):时间戳:签名」, + * 前端只读取载荷中的 `login_type`,不校验签名;无法解析时视为非匿名,交由后端校验。 + */ +export function isAnonymousToken(token: string) { + const [payload] = token.split(':') + // 以 . 开头的载荷经过压缩,后端未使用 + if (!payload || payload.startsWith('.')) return false + try { + const tokenDetails = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))) as { login_type?: string } + return tokenDetails.login_type === ANONYMOUS_LOGIN_TYPE + } catch { + return false + } +} + interface ChatAuthState { /** 当前认证场景,只在守卫放行前切换。 */ scope: string @@ -93,6 +112,22 @@ export const useChatAuthStore = defineStore('chat-auth', { }) }, + /** 使用 LDAP 账号登录当前场景,携带本地保存的 token。 */ + ldapLogin(loginRequest: ChatLoginRequest) { + return ChatAuthApi.postLdapLogin(loginRequest, getAccessToken(this.scope), getSavedToken(this.scope)).then(({ token }) => { + this.setToken(token, this.scope) + return token + }) + }, + + /** 使用钉钉扫码授权码登录当前场景。 */ + loginWithDingTalk(code: string) { + return ChatAuthApi.getDingTalkCallback(code, getAccessToken(this.scope)).then(({ token }) => { + this.setToken(token, this.scope) + return token + }) + }, + /** 退出当前场景的登录状态。 */ logout() { return ChatAuthApi.postLogout().then(() => this.clearToken()) diff --git a/ui/src/views/VIEW_README.md b/ui/src/views/VIEW_README.md index b9f007cd31e..12f5b41b47b 100644 --- a/ui/src/views/VIEW_README.md +++ b/ui/src/views/VIEW_README.md @@ -428,6 +428,8 @@ View 负责路由、资源与模式、图加载、保存/发布/调试编排 - 系统操作日志的操作时间使用服务端表头排序,切换后回到第一页,保留搜索及筛选条件。 分页和导出沿用当前排序条件,取消排序不传排序值。 - 登录页使用 `formEl.validate` 回调和清晰的 LDAP/本地分支,不额外抽象或改写提交控制流程。 + Admin 与 Chat 登录页共用 `login/components/LoginModePanel.vue` 及其账号/扫码登录组件, + 组件不直接引用某个入口的 Store 或 API;接口、回调地址与登录后跳转由页面传入的 `LoginService` 提供。 - Chat/Portal 页面以 auth token 为面板 key,token 变化重建并加载用户档案;路由认证见路由文档。 会话装配在 conversation-panel,列表 Store 不感知 Router;门户复用 chat 装配,保留各智能体最近会话分组。 没有当前智能体时不查询其信息/会话、禁用输入,分组加载后选首个;无可用智能体显示空态。 diff --git a/ui/src/views/chat/login/index.vue b/ui/src/views/chat/login/index.vue index 058e87ca091..7db2690df7d 100644 --- a/ui/src/views/chat/login/index.vue +++ b/ui/src/views/chat/login/index.vue @@ -1,17 +1,14 @@ diff --git a/ui/src/views/login/admin-login-service.ts b/ui/src/views/login/admin-login-service.ts new file mode 100644 index 00000000000..c53ad1a0cf2 --- /dev/null +++ b/ui/src/views/login/admin-login-service.ts @@ -0,0 +1,62 @@ +/** Admin 登录页与外观设置登录预览使用的登录服务,对接 Admin 认证接口与 Store。 */ + +import { useRouter } from 'vue-router' +import { v4 as uuidv4 } from 'uuid' +import ExternalLoginApi from '@/api/admin/auth/external-login' +import LoginApi from '@/api/admin/auth/login' +import { ADMIN_API_BASE_PATH } from '@/api/constants' +import { LOGIN_METHOD } from '@/api/enums' +import type { LoginMethod } from '@/api/types' +import { useStore } from '@/stores' +import type { LoginService } from './types' + +const getExternalLoginUrl = (authType: LoginMethod): Promise => { + if (authType === LOGIN_METHOD.SAML2) return ExternalLoginApi.getSamlLoginUrl() + + return ExternalLoginApi.getExternalAuthSetting(authType).then(({ config }) => { + if (!config) return '' + + const redirectUrl = `${config.redirectUrl}` + if (authType === LOGIN_METHOD.CAS) { + if (!config.ldpUri) return '' + const separator = config.ldpUri.includes('?') ? '&' : '?' + return `${config.ldpUri}${separator}service=${encodeURIComponent(redirectUrl)}` + } + if (authType === LOGIN_METHOD.OIDC) { + if (!config.authEndpoint || !config.clientId) return '' + const scope = config.scope || 'openid+profile+email' + let url = `${config.authEndpoint}?client_id=${config.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}` + if (config.state) url += `&state=${config.state}` + return url + } + if (authType === LOGIN_METHOD.OAUTH2) { + if (!config.authEndpoint || !config.clientId) return '' + let url = `${config.authEndpoint}?client_id=${config.clientId}&response_type=code&redirect_uri=${redirectUrl}&state=${uuidv4()}` + if (config.scope) url += `&scope=${config.scope}` + return url + } + + return '' + }) +} + +export function useAdminLoginService(): LoginService { + const router = useRouter() + const { auth } = useStore() + + return { + get rsaKey() { + return auth.baseProfile?.rsa ?? '' + }, + login: (loginRequest) => auth.asyncLogin(loginRequest), + ldapLogin: (loginForm) => auth.asyncLdapLogin(loginForm), + getCaptcha: (username) => LoginApi.getCaptcha(username).then(({ captcha }) => captcha), + getExternalLoginUrl, + getQrCodeSources: () => ExternalLoginApi.getQrCodeSources(), + loginWithDingTalk: (authCode) => auth.asyncLoginWithDingTalk(authCode), + larkRedirectUri: `${window.location.origin}${ADMIN_API_BASE_PATH}/lark`, + wecomState: 'fit2cloud-wecom-qr', + onLoginSuccess: () => router.push({ name: 'workspace-home', params: { workspaceId: 'default' } }), + forgotPassword: () => void router.push({ name: 'forgot-password' }), + } +} diff --git a/ui/src/views/login/components/LoginModePanel.vue b/ui/src/views/login/components/LoginModePanel.vue new file mode 100644 index 00000000000..a1e427bb71b --- /dev/null +++ b/ui/src/views/login/components/LoginModePanel.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/ui/src/views/login/index.vue b/ui/src/views/login/index.vue index b0647b5347b..24fd97882df 100644 --- a/ui/src/views/login/index.vue +++ b/ui/src/views/login/index.vue @@ -1,41 +1,18 @@