Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/chat/views/v3/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion apps/common/auth/handle/impl/chat_user_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions ui/src/router/ROUTE_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

Expand Down
21 changes: 16 additions & 5 deletions ui/src/router/chat/index.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 页面并保留原地址,刷新时可重新尝试。 */
Expand All @@ -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
}
Expand Down
35 changes: 35 additions & 0 deletions ui/src/stores/chat/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down
2 changes: 2 additions & 0 deletions ui/src/views/VIEW_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 装配,保留各智能体最近会话分组。
没有当前智能体时不查询其信息/会话、禁用输入,分组加载后选首个;无可用智能体显示空态。
Expand Down
160 changes: 90 additions & 70 deletions ui/src/views/chat/login/index.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import JSEncrypt from 'jsencrypt'
import { computed } from 'vue'
import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus'
import { v4 as uuidv4 } from 'uuid'
import ChatAuthApi from '@/api/chat/auth'
import type { ApplicationAuthProfileMeta } from '@/api/types'
import { CHAT_API_BASE_PATH } from '@/api/constants'
import { LOGIN_METHOD } from '@/api/enums'
import type { ApplicationAuthProfileMeta, LoginConfig, LoginMethod } from '@/api/types'
import { useStore } from '@/stores/chat'

interface ChatLoginForm {
captcha: string
password: string
username: string
}
import LoginModePanel from '@/views/login/components/LoginModePanel.vue'
import type { LoginService } from '@/views/login/types'

defineOptions({ name: 'ChatLogin' })

Expand All @@ -25,27 +22,63 @@ const loginTitle = computed(() => {
return (auth.authProfile?.meta as ApplicationAuthProfileMeta | undefined)?.application_name || '登录'
})

const isSubmitting = ref(false)
const identifyCode = ref('')
const loginFormRef = ref<FormInstance>()
const loginForm = reactive<ChatLoginForm>({ captcha: '', password: '', username: '' })

const loginRules = reactive<FormRules<ChatLoginForm>>({
captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
username: [{ whitespace: true, required: true, message: '请输入用户名', trigger: 'blur' }],
// 认证前置配置只有可用登录方式,默认登录方式依次取本地、LDAP、第一个可用方式
const loginConfig = computed<LoginConfig>(() => {
const loginMethods = auth.authProfile?.login_value?.length ? auth.authProfile.login_value : [LOGIN_METHOD.LOCAL]
const defaultLoginMethod =
[LOGIN_METHOD.LOCAL, LOGIN_METHOD.LDAP].find((method) => loginMethods.includes(method)) ?? loginMethods[0] ?? LOGIN_METHOD.LOCAL
return { default_value: defaultLoginMethod, login_methods: loginMethods, max_attempts: auth.authProfile?.max_attempts ?? 1 }
})

// 验证码:后端按失败次数决定是否需要,无需时返回空字符串;目前仅应用场景支持
const refreshCaptcha = () => {
const { accessToken } = auth
if (!accessToken || !loginForm.username.trim()) return
/** 第三方认证回调后需要恢复的当前查询参数,如守卫记录的 redirect。 */
const getRouteQueryString = () => {
const queryParams = new URLSearchParams()
Object.entries(route.query).forEach(([key, value]) => {
if (typeof value === 'string') queryParams.append(key, value)
})
return queryParams.toString()
}

ChatAuthApi.getCaptcha(loginForm.username, accessToken).then(({ captcha }) => {
identifyCode.value = captcha
// 回调地址携带 accessToken 和当前查询参数,后端据此回到对应应用的登录场景
const getExternalLoginUrl = (authType: LoginMethod): Promise<string> => {
if (![LOGIN_METHOD.CAS, LOGIN_METHOD.OIDC, LOGIN_METHOD.OAUTH2].some((method) => method === authType)) return Promise.resolve('')

return ChatAuthApi.getExternalAuthSetting(authType).then(({ config }) => {
if (!config) return ''

const { accessToken } = auth
const queryString = getRouteQueryString()
const callbackUrl = `${config.redirectUrl}${accessToken ? `/${accessToken}` : ''}${queryString ? `?${queryString}` : ''}`
if (authType === LOGIN_METHOD.CAS) {
if (!config.ldpUri) return ''
const separator = config.ldpUri.includes('?') ? '&' : '?'
return `${config.ldpUri}${separator}service=${encodeURIComponent(callbackUrl)}`
}
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=${callbackUrl}&response_type=code&scope=${scope}`
if (config.state) url += `&state=${config.state}`
return url
}

// OAuth2 通过 state 透传 accessToken
if (!config.authEndpoint || !config.clientId) return ''
const redirectUrl = `${config.redirectUrl}${queryString ? `&${queryString}` : ''}`
const state = accessToken ? `${uuidv4()}_${accessToken}` : uuidv4()
let url = `${config.authEndpoint}?client_id=${config.clientId}&response_type=code&redirect_uri=${redirectUrl}&state=${state}`
if (config.scope) url += `&scope=${config.scope}`
return url
})
}

const getLarkRedirectUri = () => {
const queryParams = new URLSearchParams(getRouteQueryString())
if (auth.accessToken) queryParams.set('accessToken', auth.accessToken)
const queryString = queryParams.toString()
return `${window.location.origin}${CHAT_API_BASE_PATH}/auth/lark${queryString ? `?${queryString}` : ''}`
}

// 登录成功后回到守卫记录的原地址,没有时进入当前场景首页
const getRedirectRoute = (): RouteLocationRaw => {
const { redirect } = route.query
Expand All @@ -54,62 +87,47 @@ const getRedirectRoute = (): RouteLocationRaw => {
return { name: 'portal-home' }
}

const handleLogin = () => {
loginFormRef.value?.validate((valid) => {
if (!valid) return

const encryptor = new JSEncrypt()
encryptor.setPublicKey(auth.authProfile?.rsa_key ?? '')
const encryptedData = encryptor.encrypt(JSON.stringify(loginForm))
if (!encryptedData) return

isSubmitting.value = true
auth
.login({ encryptedData, username: loginForm.username })
.then(() => router.replace(getRedirectRoute()))
.catch(() => {
// 登录失败后失败次数可能达到上限,重新获取验证码
loginForm.captcha = ''
refreshCaptcha()
})
.finally(() => {
isSubmitting.value = false
})
})
const loginService: LoginService = {
get rsaKey() {
return auth.authProfile?.rsa_key ?? ''
},
login: (loginRequest) => auth.login(loginRequest),
ldapLogin: (loginForm) => auth.ldapLogin(loginForm),
// 验证码:后端按失败次数决定是否需要,无需时返回空字符串;目前仅应用场景支持
getCaptcha: (username) => {
const { accessToken } = auth
if (!accessToken || !username.trim()) return Promise.resolve('')
return ChatAuthApi.getCaptcha(username, accessToken).then(({ captcha }) => captcha)
},
getExternalLoginUrl,
getQrCodeSources: () => ChatAuthApi.getQrCodeSources(),
loginWithDingTalk: (authCode) => auth.loginWithDingTalk(authCode),
get larkRedirectUri() {
return getLarkRedirectUri()
},
get wecomState() {
return auth.accessToken ?? ''
},
onLoginSuccess: () => router.replace(getRedirectRoute()),
}
</script>

<template>
<div class="chat-login flex-center">
<div class="chat-login flex-center flex-col">
<div class="mb-6 flex-col-center w-100 max-w-full gap-4">
<img src="@/assets/mk-logo/MaxKB-logo.svg" alt="MaxKB" class="h-9" />
<h2 class="max-w-full truncate" :title="loginTitle">{{ loginTitle }}</h2>
</div>
<el-card class="chat-login__card w-100">
<img src="@/assets/mk-logo/MaxKB-logo.svg" alt="MaxKB" class="mb-8 h-9" />
<h2 class="mb-6 truncate" :title="loginTitle">{{ loginTitle }}</h2>

<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" size="large" @submit.prevent="handleLogin">
<el-form-item prop="username">
<el-input v-model="loginForm.username" autocomplete="username" placeholder="请输入用户名" @blur="refreshCaptcha" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" autocomplete="current-password" placeholder="请输入密码" show-password type="password" />
</el-form-item>
<div v-if="identifyCode" class="flex gap-2">
<el-form-item prop="captcha" class="flex-1">
<el-input v-model="loginForm.captcha" autocomplete="off" placeholder="请输入验证码" />
</el-form-item>
<img :src="identifyCode" alt="验证码" class="w-35 h-10 cursor-pointer border" @click="refreshCaptcha" />
</div>
<el-form-item>
<!-- 登录 -->
<el-button :loading="isSubmitting" native-type="submit" type="primary" class="w-full">登录</el-button>
</el-form-item>
</el-form>
<LoginModePanel :login-config="loginConfig" :login-service="loginService" />
</el-card>
</div>
</template>

<style scoped lang="scss">
.chat-login {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(var(--mk-primary-rgb) / 5%) 20%, rgba(var(--mk-primary-rgb) / 10%) 100%),
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(var(--mk-primary-rgb) / 5%) 20%, rgba(var(--mk-primary-rgb) / 10%) 100%),
var(--mk-N100);
min-height: 100vh;
padding: 16px;
Expand All @@ -118,6 +136,8 @@ const handleLogin = () => {
.chat-login__card {
--el-card-padding: 40px;
border: none;
height: 526px;
max-width: 100%;
position: relative;
}
</style>
Loading
Loading