Skip to content

Commit 8052858

Browse files
authored
feat: support auth proxy login mode (#466)
* feat: support auth proxy login mode Signed-off-by: BobDu <[email protected]> * feat: support auth proxy login mode(logout) Signed-off-by: BobDu <[email protected]> * fix: userId is string type Signed-off-by: BobDu <[email protected]> * docs: auth proxy mode Signed-off-by: BobDu <[email protected]> --------- Signed-off-by: BobDu <[email protected]>
1 parent 5f1dd01 commit 8052858

File tree

15 files changed

+121
-17
lines changed

15 files changed

+121
-17
lines changed

README.en.md

+17
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Some unique features have been added:
3030

3131
[] Conversation round limit & setting different limits by user & giftcards
3232

33+
[] Implement SSO login through the auth proxy feature (need to integrate a third-party authentication reverse proxy, it can support login protocols such as LDAP/OIDC/SAML)
3334

3435
> [!CAUTION]
3536
> This project is only published on GitHub, based on the MIT license, free and for open source learning usage. And there will be no any form of account selling, paid service, discussion group, discussion group and other behaviors. Beware of being deceived.
@@ -353,6 +354,22 @@ Q: The content returned is incomplete?
353354

354355
A: There is a length limit for the content returned by the API each time. You can modify the `VITE_GLOB_OPEN_LONG_REPLY` field in the `.env` file under the root directory, set it to `true`, and rebuild the front-end to enable the long reply feature, which can return the full content. It should be noted that using this feature may bring more API usage fees.
355356

357+
## Auth Proxy Mode
358+
359+
> [!WARNING]
360+
> This feature is only provided for Operations Engineer with relevant experience to deploy during the integration of the enterprise's internal account management system. Improper configuration may lead to security risks.
361+
362+
Set env `AUTH_PROXY_ENABLED=true` can enable auth proxy mode.
363+
364+
After activating this feature, it is necessary to ensure that chatgpt-web can only be accessed through a reverse proxy.
365+
366+
Authentication is carried out by the reverse proxy, which then forwards the request with the `X-Email` header to identify the user identity.
367+
368+
Recommended for current IdP to use LDAP protocol, using [authelia](https://www.authelia.com)
369+
370+
Recommended for current IdP to use OIDC protocol, using [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy)
371+
372+
356373
## Contributing
357374

358375
Please read the [Contributing Guidelines](./CONTRIBUTING.en.md) before contributing.

README.md

+18
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030

3131
[] 对话数量限制 & 设置不同用户对话数量 & 兑换数量
3232

33+
[] 通过 auth proxy 功能实现sso登录 (配合第三方身份验证反向代理 可实现支持 LDAP/OIDC/SAML 等协议登录)
34+
3335

3436
> [!CAUTION]
3537
> 声明:此项目只发布于 Github,基于 MIT 协议,免费且作为开源学习使用。并且不会有任何形式的卖号、付费服务、讨论群、讨论组等行为。谨防受骗。
@@ -349,6 +351,22 @@ PS: 不进行打包,直接在服务器上运行 `pnpm start` 也可
349351
pnpm build
350352
```
351353

354+
## Auth Proxy Mode
355+
356+
> [!WARNING]
357+
> 该功能仅适用于有相关经验的运维人员在集成企业内部账号管理系统时部署 配置不当可能会导致安全风险
358+
359+
设置环境变量 `AUTH_PROXY_ENABLED=true` 即可开启 auth proxy 模式
360+
361+
在开启该功能后 需确保 chatgpt-web 只能通过反向代理访问
362+
363+
由反向代理进行进行身份验证 并再转发请求时携带请求头`X-Email`标识用户身份
364+
365+
推荐当前 Idp 使用 LDAP 协议的 可以选择使用 [authelia](https://www.authelia.com)
366+
367+
当前 Idp 使用 OIDC 协议的 可以选择使用 [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy)
368+
369+
352370
## 常见问题
353371
Q: 为什么 `Git` 提交总是报错?
354372

service/src/index.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -597,8 +597,9 @@ router.post('/config', rootAuth, async (req, res) => {
597597
router.post('/session', async (req, res) => {
598598
try {
599599
const config = await getCacheConfig()
600-
const hasAuth = config.siteConfig.loginEnabled
601-
const allowRegister = (await getCacheConfig()).siteConfig.registerEnabled
600+
const hasAuth = config.siteConfig.loginEnabled || config.siteConfig.authProxyEnabled
601+
const authProxyEnabled = config.siteConfig.authProxyEnabled
602+
const allowRegister = config.siteConfig.registerEnabled
602603
if (config.apiModel !== 'ChatGPTAPI' && config.apiModel !== 'ChatGPTUnofficialProxyAPI')
603604
config.apiModel = 'ChatGPTAPI'
604605
const userId = await getUserId(req)
@@ -681,6 +682,7 @@ router.post('/session', async (req, res) => {
681682
message: '',
682683
data: {
683684
auth: hasAuth,
685+
authProxyEnabled,
684686
allowRegister,
685687
model: config.apiModel,
686688
title: config.siteConfig.siteTitle,
@@ -698,6 +700,7 @@ router.post('/session', async (req, res) => {
698700
message: '',
699701
data: {
700702
auth: hasAuth,
703+
authProxyEnabled,
701704
allowRegister,
702705
model: config.apiModel,
703706
title: config.siteConfig.siteTitle,
@@ -759,6 +762,10 @@ router.post('/user-login', authLimiter, async (req, res) => {
759762
}
760763
})
761764

765+
router.post('/user-logout', async (req, res) => {
766+
res.send({ status: 'Success', message: '退出登录成功 | Logout successful', data: null })
767+
})
768+
762769
router.post('/user-send-reset-mail', authLimiter, async (req, res) => {
763770
try {
764771
const { username } = req.body as { username: string }
@@ -923,7 +930,7 @@ router.post('/user-edit', rootAuth, async (req, res) => {
923930
}
924931
else {
925932
const newPassword = md5(password)
926-
const user = await createUser(email, newPassword, roles, remark, Number(useAmount), limit_switch)
933+
const user = await createUser(email, newPassword, roles, null, remark, Number(useAmount), limit_switch)
927934
await updateUserStatus(user._id.toString(), Status.Normal)
928935
}
929936
res.send({ status: 'Success', message: '更新成功 | Update successfully' })

service/src/middleware/auth.ts

+33-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
import jwt from 'jsonwebtoken'
22
import type { Request } from 'express'
33
import { getCacheConfig } from '../storage/config'
4-
import { getUserById } from '../storage/mongo'
5-
import { Status } from '../storage/model'
4+
import { createUser, getUser, getUserById } from '../storage/mongo'
5+
import { Status, UserRole } from '../storage/model'
66
import type { AuthJwtPayload } from '../types'
77

88
async function auth(req, res, next) {
99
const config = await getCacheConfig()
10+
11+
if (config.siteConfig.authProxyEnabled) {
12+
try {
13+
const username = req.header('X-Email')
14+
if (!username) {
15+
res.send({ status: 'Unauthorized', message: 'Please config auth proxy (usually is nginx) add set proxy header X-Email.', data: null })
16+
return
17+
}
18+
const user = await getUser(username)
19+
req.headers.userId = user._id.toString()
20+
next()
21+
}
22+
catch (error) {
23+
res.send({ status: 'Unauthorized', message: error.message ?? 'Please config auth proxy (usually is nginx) add set proxy header X-Email.', data: null })
24+
}
25+
return
26+
}
27+
1028
if (config.siteConfig.loginEnabled) {
1129
try {
1230
const token = req.header('Authorization').replace('Bearer ', '')
@@ -32,11 +50,22 @@ async function auth(req, res, next) {
3250
async function getUserId(req: Request): Promise<string | undefined> {
3351
let token: string
3452
try {
35-
// no Authorization info is received withput login
53+
const config = await getCacheConfig()
54+
if (config.siteConfig.authProxyEnabled) {
55+
const username = req.header('X-Email')
56+
let user = await getUser(username)
57+
if (user == null) {
58+
const isRoot = username.toLowerCase() === process.env.ROOT_USER
59+
user = await createUser(username, '', isRoot ? [UserRole.Admin] : [UserRole.User], Status.Normal, 'Created by auth proxy.')
60+
}
61+
return user._id.toString()
62+
}
63+
64+
// no Authorization info is received without login
3665
if (!(req.header('Authorization') as string))
3766
return null // '6406d8c50aedd633885fa16f'
3867
token = req.header('Authorization').replace('Bearer ', '')
39-
const config = await getCacheConfig()
68+
4069
const info = jwt.verify(token, config.siteConfig.loginSalt.trim()) as AuthJwtPayload
4170
return info.userId
4271
}

service/src/middleware/rootAuth.ts

+18-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
11
import jwt from 'jsonwebtoken'
22
import * as dotenv from 'dotenv'
33
import { Status, UserRole } from '../storage/model'
4-
import { getUserById } from '../storage/mongo'
4+
import { getUser, getUserById } from '../storage/mongo'
55
import { getCacheConfig } from '../storage/config'
66
import type { AuthJwtPayload } from '../types'
77

88
dotenv.config()
99

1010
async function rootAuth(req, res, next) {
1111
const config = await getCacheConfig()
12+
13+
if (config.siteConfig.authProxyEnabled) {
14+
try {
15+
const username = req.header('X-Email')
16+
const user = await getUser(username)
17+
req.headers.userId = user._id
18+
if (user == null || user.status !== Status.Normal || !user.roles.includes(UserRole.Admin))
19+
res.send({ status: 'Fail', message: '无权限 | No permission.', data: null })
20+
else
21+
next()
22+
}
23+
catch (error) {
24+
res.send({ status: 'Unauthorized', message: error.message ?? 'Please config auth proxy (usually is nginx) add set proxy header X-Email.', data: null })
25+
}
26+
return
27+
}
28+
1229
if (config.siteConfig.loginEnabled) {
1330
try {
1431
const token = req.header('Authorization').replace('Bearer ', '')

service/src/storage/config.ts

+3
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export async function getOriginConfig() {
4444
new SiteConfig(
4545
process.env.SITE_TITLE || 'ChatGPT Web',
4646
isNotEmptyString(process.env.AUTH_SECRET_KEY),
47+
process.env.AUTH_PROXY_ENABLED === 'true',
4748
process.env.AUTH_SECRET_KEY,
4849
process.env.REGISTER_ENABLED === 'true',
4950
process.env.REGISTER_REVIEW === 'true',
@@ -58,6 +59,8 @@ export async function getOriginConfig() {
5859
else {
5960
if (config.siteConfig.loginEnabled === undefined)
6061
config.siteConfig.loginEnabled = isNotEmptyString(process.env.AUTH_SECRET_KEY)
62+
if (config.siteConfig.authProxyEnabled === undefined)
63+
config.siteConfig.authProxyEnabled = process.env.AUTH_PROXY_ENABLED === 'true'
6164
if (config.siteConfig.loginSalt === undefined)
6265
config.siteConfig.loginSalt = process.env.AUTH_SECRET_KEY
6366
if (config.apiDisableDebug === undefined)

service/src/storage/model.ts

+1
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export class SiteConfig {
191191
constructor(
192192
public siteTitle?: string,
193193
public loginEnabled?: boolean,
194+
public authProxyEnabled?: boolean,
194195
public loginSalt?: string,
195196
public registerEnabled?: boolean,
196197
public registerReview?: boolean,

service/src/storage/mongo.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,14 @@ export async function deleteChat(roomId: number, uuid: number, inversion: boolea
248248
}
249249

250250
// createUser、updateUserInfo中加入useAmount limit_switch
251-
export async function createUser(email: string, password: string, roles?: UserRole[], remark?: string, useAmount?: number, limit_switch?: boolean): Promise<UserInfo> {
251+
export async function createUser(email: string, password: string, roles?: UserRole[], status?: Status, remark?: string, useAmount?: number, limit_switch?: boolean): Promise<UserInfo> {
252252
email = email.toLowerCase()
253253
const userInfo = new UserInfo(email, password)
254254
if (roles && roles.includes(UserRole.Admin))
255255
userInfo.status = Status.Normal
256+
if (status !== null)
257+
userInfo.status = status
258+
256259
userInfo.roles = roles
257260
userInfo.remark = remark
258261
userInfo.useAmount = useAmount

src/api/index.ts

+7
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ export function fetchLogin<T = any>(username: string, password: string, token?:
8989
})
9090
}
9191

92+
export function fetchLogout<T = any>() {
93+
return post<T>({
94+
url: '/user-logout',
95+
data: { },
96+
})
97+
}
98+
9299
export function fetchSendResetMail<T = any>(username: string) {
93100
return post<T>({
94101
url: '/user-send-reset-mail',

src/components/common/UserAvatar/index.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const authStore = useAuthStore()
1515
const { isMobile } = useBasicLayout()
1616
const showPermission = ref(false)
1717
18-
const needPermission = computed(() => !!authStore.session?.auth && !authStore.token && (isMobile.value || showPermission.value))
18+
const needPermission = computed(() => !!authStore.session?.auth && !authStore.token && !authStore.session?.authProxyEnabled && (isMobile.value || showPermission.value))
1919
2020
const userInfo = computed(() => userStore.userInfo)
2121

src/store/modules/auth/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import jwt_decode from 'jwt-decode'
33
import type { UserInfo } from '../user/helper'
44
import { getToken, removeToken, setToken } from './helper'
55
import { store, useChatStore, useUserStore } from '@/store'
6-
import { fetchSession } from '@/api'
6+
import { fetchLogout, fetchSession } from '@/api'
77
import { UserConfig } from '@/components/common/Setting/model'
88

99
interface SessionResponse {
1010
auth: boolean
11+
authProxyEnabled: boolean
1112
model: 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI'
1213
allowRegister: boolean
1314
title: string
@@ -80,6 +81,7 @@ export const useAuthStore = defineStore('auth-store', {
8081
const chatStore = useChatStore()
8182
await chatStore.clearLocalChat()
8283
removeToken()
84+
await fetchLogout()
8385
},
8486
},
8587
})

src/views/chat/index.vue

+2-2
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ onUnmounted(() => {
695695
style="width: 250px"
696696
:value="currentChatModel"
697697
:options="authStore.session?.chatModels"
698-
:disabled="!!authStore.session?.auth && !authStore.token"
698+
:disabled="!!authStore.session?.auth && !authStore.token && !authStore.session?.authProxyEnabled"
699699
@update-value="(val) => handleSyncChatModel(val)"
700700
/>
701701
<NSlider v-model:value="userStore.userInfo.advanced.maxContextCount" :max="100" :min="0" :step="1" style="width: 88px" :format-tooltip="formatTooltip" @update:value="() => { userStore.updateSetting(false) }" />
@@ -706,7 +706,7 @@ onUnmounted(() => {
706706
<NInput
707707
ref="inputRef"
708708
v-model:value="prompt"
709-
:disabled="!!authStore.session?.auth && !authStore.token"
709+
:disabled="!!authStore.session?.auth && !authStore.token && !authStore.session?.authProxyEnabled"
710710
type="textarea"
711711
:placeholder="placeholder"
712712
:autosize="{ minRows: isMobile ? 1 : 4, maxRows: isMobile ? 4 : 8 }"

src/views/chat/layout/sider/Footer.vue

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ async function handleLogout() {
1818
<div class="flex-1 flex-shrink-0 overflow-hidden">
1919
<UserAvatar />
2020
</div>
21-
<HoverButton v-if="!!authStore.token" :tooltip="$t('common.logOut')" @click="handleLogout">
21+
<HoverButton v-if="!!authStore.token || !!authStore.session?.authProxyEnabled" :tooltip="$t('common.logOut')" @click="handleLogout">
2222
<span class="text-xl text-[#4f555e] dark:text-white">
2323
<SvgIcon icon="uil:exit" />
2424
</span>
2525
</HoverButton>
2626

27-
<HoverButton v-if="!!authStore.token" :tooltip="$t('setting.setting')" @click="show = true">
27+
<HoverButton v-if="!!authStore.token || !!authStore.session?.authProxyEnabled" :tooltip="$t('setting.setting')" @click="show = true">
2828
<span class="text-xl text-[#4f555e] dark:text-white">
2929
<SvgIcon icon="ri:settings-4-line" />
3030
</span>

src/views/chat/layout/sider/List.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const loadingRoom = ref(false)
1818
const dataSources = computed(() => chatStore.history)
1919
2020
onMounted(async () => {
21-
if (authStore.session == null || !authStore.session.auth || authStore.token)
21+
if (authStore.session == null || !authStore.session.auth || authStore.token || authStore.session?.authProxyEnabled)
2222
await handleSyncChatRoom()
2323
})
2424

src/views/chat/layout/sider/index.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ watch(
7373
<div class="flex flex-col h-full" :style="mobileSafeArea">
7474
<main class="flex flex-col flex-1 min-h-0">
7575
<div class="p-4">
76-
<NButton dashed block :disabled="!!authStore.session?.auth && !authStore.token" @click="handleAdd">
76+
<NButton dashed block :disabled="!!authStore.session?.auth && !authStore.token && !authStore.session?.authProxyEnabled" @click="handleAdd">
7777
{{ $t('chat.newChatButton') }}
7878
</NButton>
7979
</div>

0 commit comments

Comments
 (0)