Skip to content

Commit

Permalink
feat: support auth proxy login mode (#466)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
BobDu authored Mar 7, 2024
1 parent 5f1dd01 commit 8052858
Show file tree
Hide file tree
Showing 15 changed files with 121 additions and 17 deletions.
17 changes: 17 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Some unique features have been added:

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

[] 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)

> [!CAUTION]
> 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.
Expand Down Expand Up @@ -353,6 +354,22 @@ Q: The content returned is incomplete?

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.

## Auth Proxy Mode

> [!WARNING]
> 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.

Set env `AUTH_PROXY_ENABLED=true` can enable auth proxy mode.

After activating this feature, it is necessary to ensure that chatgpt-web can only be accessed through a reverse proxy.

Authentication is carried out by the reverse proxy, which then forwards the request with the `X-Email` header to identify the user identity.

Recommended for current IdP to use LDAP protocol, using [authelia](https://www.authelia.com)

Recommended for current IdP to use OIDC protocol, using [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy)


## Contributing

Please read the [Contributing Guidelines](./CONTRIBUTING.en.md) before contributing.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@

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

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


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

## Auth Proxy Mode

> [!WARNING]
> 该功能仅适用于有相关经验的运维人员在集成企业内部账号管理系统时部署 配置不当可能会导致安全风险
设置环境变量 `AUTH_PROXY_ENABLED=true` 即可开启 auth proxy 模式

在开启该功能后 需确保 chatgpt-web 只能通过反向代理访问

由反向代理进行进行身份验证 并再转发请求时携带请求头`X-Email`标识用户身份

推荐当前 Idp 使用 LDAP 协议的 可以选择使用 [authelia](https://www.authelia.com)

当前 Idp 使用 OIDC 协议的 可以选择使用 [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy)


## 常见问题
Q: 为什么 `Git` 提交总是报错?

Expand Down
13 changes: 10 additions & 3 deletions service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,8 +597,9 @@ router.post('/config', rootAuth, async (req, res) => {
router.post('/session', async (req, res) => {
try {
const config = await getCacheConfig()
const hasAuth = config.siteConfig.loginEnabled
const allowRegister = (await getCacheConfig()).siteConfig.registerEnabled
const hasAuth = config.siteConfig.loginEnabled || config.siteConfig.authProxyEnabled
const authProxyEnabled = config.siteConfig.authProxyEnabled
const allowRegister = config.siteConfig.registerEnabled
if (config.apiModel !== 'ChatGPTAPI' && config.apiModel !== 'ChatGPTUnofficialProxyAPI')
config.apiModel = 'ChatGPTAPI'
const userId = await getUserId(req)
Expand Down Expand Up @@ -681,6 +682,7 @@ router.post('/session', async (req, res) => {
message: '',
data: {
auth: hasAuth,
authProxyEnabled,
allowRegister,
model: config.apiModel,
title: config.siteConfig.siteTitle,
Expand All @@ -698,6 +700,7 @@ router.post('/session', async (req, res) => {
message: '',
data: {
auth: hasAuth,
authProxyEnabled,
allowRegister,
model: config.apiModel,
title: config.siteConfig.siteTitle,
Expand Down Expand Up @@ -759,6 +762,10 @@ router.post('/user-login', authLimiter, async (req, res) => {
}
})

router.post('/user-logout', async (req, res) => {
res.send({ status: 'Success', message: '退出登录成功 | Logout successful', data: null })
})

router.post('/user-send-reset-mail', authLimiter, async (req, res) => {
try {
const { username } = req.body as { username: string }
Expand Down Expand Up @@ -923,7 +930,7 @@ router.post('/user-edit', rootAuth, async (req, res) => {
}
else {
const newPassword = md5(password)
const user = await createUser(email, newPassword, roles, remark, Number(useAmount), limit_switch)
const user = await createUser(email, newPassword, roles, null, remark, Number(useAmount), limit_switch)
await updateUserStatus(user._id.toString(), Status.Normal)
}
res.send({ status: 'Success', message: '更新成功 | Update successfully' })
Expand Down
37 changes: 33 additions & 4 deletions service/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import jwt from 'jsonwebtoken'
import type { Request } from 'express'
import { getCacheConfig } from '../storage/config'
import { getUserById } from '../storage/mongo'
import { Status } from '../storage/model'
import { createUser, getUser, getUserById } from '../storage/mongo'
import { Status, UserRole } from '../storage/model'
import type { AuthJwtPayload } from '../types'

async function auth(req, res, next) {
const config = await getCacheConfig()

if (config.siteConfig.authProxyEnabled) {
try {
const username = req.header('X-Email')
if (!username) {
res.send({ status: 'Unauthorized', message: 'Please config auth proxy (usually is nginx) add set proxy header X-Email.', data: null })
return
}
const user = await getUser(username)
req.headers.userId = user._id.toString()
next()
}
catch (error) {
res.send({ status: 'Unauthorized', message: error.message ?? 'Please config auth proxy (usually is nginx) add set proxy header X-Email.', data: null })
}
return
}

if (config.siteConfig.loginEnabled) {
try {
const token = req.header('Authorization').replace('Bearer ', '')
Expand All @@ -32,11 +50,22 @@ async function auth(req, res, next) {
async function getUserId(req: Request): Promise<string | undefined> {
let token: string
try {
// no Authorization info is received withput login
const config = await getCacheConfig()
if (config.siteConfig.authProxyEnabled) {
const username = req.header('X-Email')
let user = await getUser(username)
if (user == null) {
const isRoot = username.toLowerCase() === process.env.ROOT_USER
user = await createUser(username, '', isRoot ? [UserRole.Admin] : [UserRole.User], Status.Normal, 'Created by auth proxy.')
}
return user._id.toString()
}

// no Authorization info is received without login
if (!(req.header('Authorization') as string))
return null // '6406d8c50aedd633885fa16f'
token = req.header('Authorization').replace('Bearer ', '')
const config = await getCacheConfig()

const info = jwt.verify(token, config.siteConfig.loginSalt.trim()) as AuthJwtPayload
return info.userId
}
Expand Down
19 changes: 18 additions & 1 deletion service/src/middleware/rootAuth.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import jwt from 'jsonwebtoken'
import * as dotenv from 'dotenv'
import { Status, UserRole } from '../storage/model'
import { getUserById } from '../storage/mongo'
import { getUser, getUserById } from '../storage/mongo'
import { getCacheConfig } from '../storage/config'
import type { AuthJwtPayload } from '../types'

dotenv.config()

async function rootAuth(req, res, next) {
const config = await getCacheConfig()

if (config.siteConfig.authProxyEnabled) {
try {
const username = req.header('X-Email')
const user = await getUser(username)
req.headers.userId = user._id
if (user == null || user.status !== Status.Normal || !user.roles.includes(UserRole.Admin))
res.send({ status: 'Fail', message: '无权限 | No permission.', data: null })
else
next()
}
catch (error) {
res.send({ status: 'Unauthorized', message: error.message ?? 'Please config auth proxy (usually is nginx) add set proxy header X-Email.', data: null })
}
return
}

if (config.siteConfig.loginEnabled) {
try {
const token = req.header('Authorization').replace('Bearer ', '')
Expand Down
3 changes: 3 additions & 0 deletions service/src/storage/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export async function getOriginConfig() {
new SiteConfig(
process.env.SITE_TITLE || 'ChatGPT Web',
isNotEmptyString(process.env.AUTH_SECRET_KEY),
process.env.AUTH_PROXY_ENABLED === 'true',
process.env.AUTH_SECRET_KEY,
process.env.REGISTER_ENABLED === 'true',
process.env.REGISTER_REVIEW === 'true',
Expand All @@ -58,6 +59,8 @@ export async function getOriginConfig() {
else {
if (config.siteConfig.loginEnabled === undefined)
config.siteConfig.loginEnabled = isNotEmptyString(process.env.AUTH_SECRET_KEY)
if (config.siteConfig.authProxyEnabled === undefined)
config.siteConfig.authProxyEnabled = process.env.AUTH_PROXY_ENABLED === 'true'
if (config.siteConfig.loginSalt === undefined)
config.siteConfig.loginSalt = process.env.AUTH_SECRET_KEY
if (config.apiDisableDebug === undefined)
Expand Down
1 change: 1 addition & 0 deletions service/src/storage/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export class SiteConfig {
constructor(
public siteTitle?: string,
public loginEnabled?: boolean,
public authProxyEnabled?: boolean,
public loginSalt?: string,
public registerEnabled?: boolean,
public registerReview?: boolean,
Expand Down
5 changes: 4 additions & 1 deletion service/src/storage/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,14 @@ export async function deleteChat(roomId: number, uuid: number, inversion: boolea
}

// createUser、updateUserInfo中加入useAmount limit_switch
export async function createUser(email: string, password: string, roles?: UserRole[], remark?: string, useAmount?: number, limit_switch?: boolean): Promise<UserInfo> {
export async function createUser(email: string, password: string, roles?: UserRole[], status?: Status, remark?: string, useAmount?: number, limit_switch?: boolean): Promise<UserInfo> {
email = email.toLowerCase()
const userInfo = new UserInfo(email, password)
if (roles && roles.includes(UserRole.Admin))
userInfo.status = Status.Normal
if (status !== null)
userInfo.status = status

userInfo.roles = roles
userInfo.remark = remark
userInfo.useAmount = useAmount
Expand Down
7 changes: 7 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ export function fetchLogin<T = any>(username: string, password: string, token?:
})
}

export function fetchLogout<T = any>() {
return post<T>({
url: '/user-logout',
data: { },
})
}

export function fetchSendResetMail<T = any>(username: string) {
return post<T>({
url: '/user-send-reset-mail',
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/UserAvatar/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const authStore = useAuthStore()
const { isMobile } = useBasicLayout()
const showPermission = ref(false)
const needPermission = computed(() => !!authStore.session?.auth && !authStore.token && (isMobile.value || showPermission.value))
const needPermission = computed(() => !!authStore.session?.auth && !authStore.token && !authStore.session?.authProxyEnabled && (isMobile.value || showPermission.value))
const userInfo = computed(() => userStore.userInfo)
Expand Down
4 changes: 3 additions & 1 deletion src/store/modules/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import jwt_decode from 'jwt-decode'
import type { UserInfo } from '../user/helper'
import { getToken, removeToken, setToken } from './helper'
import { store, useChatStore, useUserStore } from '@/store'
import { fetchSession } from '@/api'
import { fetchLogout, fetchSession } from '@/api'
import { UserConfig } from '@/components/common/Setting/model'

interface SessionResponse {
auth: boolean
authProxyEnabled: boolean
model: 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI'
allowRegister: boolean
title: string
Expand Down Expand Up @@ -80,6 +81,7 @@ export const useAuthStore = defineStore('auth-store', {
const chatStore = useChatStore()
await chatStore.clearLocalChat()
removeToken()
await fetchLogout()
},
},
})
Expand Down
4 changes: 2 additions & 2 deletions src/views/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ onUnmounted(() => {
style="width: 250px"
:value="currentChatModel"
:options="authStore.session?.chatModels"
:disabled="!!authStore.session?.auth && !authStore.token"
:disabled="!!authStore.session?.auth && !authStore.token && !authStore.session?.authProxyEnabled"
@update-value="(val) => handleSyncChatModel(val)"
/>
<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) }" />
Expand All @@ -706,7 +706,7 @@ onUnmounted(() => {
<NInput
ref="inputRef"
v-model:value="prompt"
:disabled="!!authStore.session?.auth && !authStore.token"
:disabled="!!authStore.session?.auth && !authStore.token && !authStore.session?.authProxyEnabled"
type="textarea"
:placeholder="placeholder"
:autosize="{ minRows: isMobile ? 1 : 4, maxRows: isMobile ? 4 : 8 }"
Expand Down
4 changes: 2 additions & 2 deletions src/views/chat/layout/sider/Footer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ async function handleLogout() {
<div class="flex-1 flex-shrink-0 overflow-hidden">
<UserAvatar />
</div>
<HoverButton v-if="!!authStore.token" :tooltip="$t('common.logOut')" @click="handleLogout">
<HoverButton v-if="!!authStore.token || !!authStore.session?.authProxyEnabled" :tooltip="$t('common.logOut')" @click="handleLogout">
<span class="text-xl text-[#4f555e] dark:text-white">
<SvgIcon icon="uil:exit" />
</span>
</HoverButton>

<HoverButton v-if="!!authStore.token" :tooltip="$t('setting.setting')" @click="show = true">
<HoverButton v-if="!!authStore.token || !!authStore.session?.authProxyEnabled" :tooltip="$t('setting.setting')" @click="show = true">
<span class="text-xl text-[#4f555e] dark:text-white">
<SvgIcon icon="ri:settings-4-line" />
</span>
Expand Down
2 changes: 1 addition & 1 deletion src/views/chat/layout/sider/List.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const loadingRoom = ref(false)
const dataSources = computed(() => chatStore.history)
onMounted(async () => {
if (authStore.session == null || !authStore.session.auth || authStore.token)
if (authStore.session == null || !authStore.session.auth || authStore.token || authStore.session?.authProxyEnabled)
await handleSyncChatRoom()
})
Expand Down
2 changes: 1 addition & 1 deletion src/views/chat/layout/sider/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ watch(
<div class="flex flex-col h-full" :style="mobileSafeArea">
<main class="flex flex-col flex-1 min-h-0">
<div class="p-4">
<NButton dashed block :disabled="!!authStore.session?.auth && !authStore.token" @click="handleAdd">
<NButton dashed block :disabled="!!authStore.session?.auth && !authStore.token && !authStore.session?.authProxyEnabled" @click="handleAdd">
{{ $t('chat.newChatButton') }}
</NButton>
</div>
Expand Down

0 comments on commit 8052858

Please sign in to comment.