diff --git a/common/constants.go b/common/constants.go
index 192303b2..6d6c9ca7 100644
--- a/common/constants.go
+++ b/common/constants.go
@@ -70,6 +70,9 @@ var SMTPToken = ""
var GitHubClientId = ""
var GitHubClientSecret = ""
+var LarkClientId = ""
+var LarkClientSecret = ""
+
var WeChatServerAddress = ""
var WeChatServerToken = ""
var WeChatAccountQRCodeImageURL = ""
diff --git a/controller/lark.go b/controller/lark.go
new file mode 100644
index 00000000..0a77fc2c
--- /dev/null
+++ b/controller/lark.go
@@ -0,0 +1,267 @@
+package controller
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "one-api/common"
+ "one-api/model"
+ "strconv"
+ "time"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+)
+
+type LarkAppAccessTokenResponse struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ AppAccessToken string `json:"app_access_token"`
+}
+
+type LarkUserAccessTokenResponse struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ Data struct {
+ AccessToken string `json:"access_token"`
+ } `json:"data"`
+}
+
+type LarkUser struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ Data struct {
+ OpenID string `json:"open_id"`
+ Name string `json:"name"`
+ } `json:"data"`
+}
+
+func getLarkAppAccessToken() (string, error) {
+ values := map[string]string{
+ "app_id": common.LarkClientId,
+ "app_secret": common.LarkClientSecret,
+ }
+ jsonData, err := json.Marshal(values)
+ if err != nil {
+ return "", err
+ }
+ req, err := http.NewRequest("POST", "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal/", bytes.NewBuffer(jsonData))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ client := http.Client{
+ Timeout: 5 * time.Second,
+ }
+ res, err := client.Do(req)
+ if err != nil {
+ common.SysLog(err.Error())
+ return "", errors.New("无法连接至飞书服务器,请稍后重试!")
+ }
+ defer res.Body.Close()
+ var appAccessTokenResponse LarkAppAccessTokenResponse
+ err = json.NewDecoder(res.Body).Decode(&appAccessTokenResponse)
+ if err != nil {
+ return "", err
+ }
+
+ if appAccessTokenResponse.Code != 0 {
+ return "", errors.New(appAccessTokenResponse.Msg)
+ }
+ return appAccessTokenResponse.AppAccessToken, nil
+
+}
+
+func getLarkUserAccessToken(code string) (string, error) {
+ appAccessToken, err := getLarkAppAccessToken()
+ if err != nil {
+ return "", err
+ }
+ values := map[string]string{
+ "grant_type": "authorization_code",
+ "code": code,
+ }
+ jsonData, err := json.Marshal(values)
+ if err != nil {
+ return "", err
+ }
+ req, err := http.NewRequest("POST", "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token", bytes.NewBuffer(jsonData))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", appAccessToken))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ client := http.Client{
+ Timeout: 5 * time.Second,
+ }
+ res, err := client.Do(req)
+ if err != nil {
+ common.SysLog(err.Error())
+ return "", errors.New("无法连接至飞书服务器,请稍后重试!")
+ }
+ defer res.Body.Close()
+ var larkUserAccessTokenResponse LarkUserAccessTokenResponse
+ err = json.NewDecoder(res.Body).Decode(&larkUserAccessTokenResponse)
+ if err != nil {
+ return "", err
+ }
+ if larkUserAccessTokenResponse.Code != 0 {
+ return "", errors.New(larkUserAccessTokenResponse.Msg)
+ }
+ return larkUserAccessTokenResponse.Data.AccessToken, nil
+}
+
+func getLarkUserInfoByCode(code string) (*LarkUser, error) {
+ if code == "" {
+ return nil, errors.New("无效的参数")
+ }
+
+ userAccessToken, err := getLarkUserAccessToken(code)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("GET", "https://open.feishu.cn/open-apis/authen/v1/user_info", nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", userAccessToken))
+ client := http.Client{
+ Timeout: 5 * time.Second,
+ }
+ res2, err := client.Do(req)
+ if err != nil {
+ common.SysLog(err.Error())
+ return nil, errors.New("无法连接至飞书服务器,请稍后重试!")
+ }
+ var larkUser LarkUser
+ err = json.NewDecoder(res2.Body).Decode(&larkUser)
+ if err != nil {
+ return nil, err
+ }
+ fmt.Println("larkUser", larkUser)
+ return &larkUser, nil
+}
+
+func LarkOAuth(c *gin.Context) {
+ session := sessions.Default(c)
+ state := c.Query("state")
+ if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
+ c.JSON(http.StatusForbidden, gin.H{
+ "success": false,
+ "message": "state is empty or not same",
+ })
+ return
+ }
+ username := session.Get("username")
+ if username != nil {
+ LarkBind(c)
+ return
+ }
+ code := c.Query("code")
+ larkUser, err := getLarkUserInfoByCode(code)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+ user := model.User{
+ LarkId: larkUser.Data.OpenID,
+ }
+ if model.IsLarkIdAlreadyTaken(user.LarkId) {
+ err := user.FillUserByLarkId()
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+ } else {
+ if common.RegisterEnabled {
+ user.Username = "lark_" + strconv.Itoa(model.GetMaxUserId()+1)
+ if larkUser.Data.Name != "" {
+ user.DisplayName = larkUser.Data.Name
+ } else {
+ user.DisplayName = "Lark User"
+ }
+ user.Role = common.RoleCommonUser
+ user.Status = common.UserStatusEnabled
+
+ if err := user.Insert(0); err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+ } else {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "管理员关闭了新用户注册",
+ })
+ return
+ }
+ }
+
+ if user.Status != common.UserStatusEnabled {
+ c.JSON(http.StatusOK, gin.H{
+ "message": "用户已被封禁",
+ "success": false,
+ })
+ return
+ }
+ setupLogin(&user, c)
+}
+
+func LarkBind(c *gin.Context) {
+ code := c.Query("code")
+ larkUser, err := getLarkUserInfoByCode(code)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+ user := model.User{
+ LarkId: larkUser.Data.OpenID,
+ }
+ if model.IsLarkIdAlreadyTaken(user.LarkId) {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "该飞书账户已被绑定",
+ })
+ return
+ }
+ session := sessions.Default(c)
+ id := session.Get("id")
+ user.Id = id.(int)
+ err = user.FillUserById()
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+ user.LarkId = larkUser.Data.OpenID
+ err = user.Update(false)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "bind",
+ })
+}
diff --git a/controller/misc.go b/controller/misc.go
index 1ad29e63..3c3af82e 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -28,6 +28,7 @@ func GetStatus(c *gin.Context) {
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
+ "lark_client_id": common.LarkClientId,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
diff --git a/model/option.go b/model/option.go
index fd5cb05f..8ce6166a 100644
--- a/model/option.go
+++ b/model/option.go
@@ -166,6 +166,8 @@ var optionStringMap = map[string]*string{
"TurnstileSecretKey": &common.TurnstileSecretKey,
"TopUpLink": &common.TopUpLink,
"ChatLink": &common.ChatLink,
+ "LarkClientId": &common.LarkClientId,
+ "LarkClientSecret": &common.LarkClientSecret,
}
func updateOptionMap(key string, value string) (err error) {
diff --git a/model/user.go b/model/user.go
index 532e3fb5..d339e61f 100644
--- a/model/user.go
+++ b/model/user.go
@@ -22,6 +22,7 @@ type User struct {
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
TelegramId int64 `json:"telegram_id" gorm:"bigint,column:telegram_id;default:0;"`
+ LarkId string `json:"lark_id" gorm:"column:lark_id;index"`
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
AccessToken string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
Quota int `json:"quota" gorm:"type:int;default:0"`
@@ -248,6 +249,14 @@ func (user *User) FillUserByWeChatId() error {
return nil
}
+func (user *User) FillUserByLarkId() error {
+ if user.LarkId == "" {
+ return errors.New("lark id 为空!")
+ }
+ DB.Where(User{LarkId: user.LarkId}).First(user)
+ return nil
+}
+
func (user *User) FillUserByUsername() error {
if user.Username == "" {
return errors.New("username 为空!")
@@ -268,6 +277,10 @@ func IsGitHubIdAlreadyTaken(githubId string) bool {
return DB.Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1
}
+func IsLarkIdAlreadyTaken(githubId string) bool {
+ return DB.Where("lark_id = ?", githubId).Find(&User{}).RowsAffected == 1
+}
+
func IsTelegramIdAlreadyTaken(telegramId int64) bool {
return DB.Where("telegram_id = ?", telegramId).Find(&User{}).RowsAffected == 1
}
diff --git a/router/api-router.go b/router/api-router.go
index 923048ef..1aaf7373 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -25,6 +25,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth)
+ apiRouter.GET("/oauth/lark", middleware.CriticalRateLimit(), controller.LarkOAuth)
apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
diff --git a/web/src/assets/images/icons/lark.svg b/web/src/assets/images/icons/lark.svg
new file mode 100644
index 00000000..239e1bef
--- /dev/null
+++ b/web/src/assets/images/icons/lark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/src/hooks/useLogin.js b/web/src/hooks/useLogin.js
index 53626577..39d8b407 100644
--- a/web/src/hooks/useLogin.js
+++ b/web/src/hooks/useLogin.js
@@ -48,6 +48,28 @@ const useLogin = () => {
}
};
+ const larkLogin = async (code, state) => {
+ try {
+ const res = await API.get(`/api/oauth/lark?code=${code}&state=${state}`);
+ const { success, message, data } = res.data;
+ if (success) {
+ if (message === 'bind') {
+ showSuccess('绑定成功!');
+ navigate('/panel');
+ } else {
+ dispatch({ type: LOGIN, payload: data });
+ localStorage.setItem('user', JSON.stringify(data));
+ showSuccess('登录成功!');
+ navigate('/panel');
+ }
+ }
+ return { success, message };
+ } catch (err) {
+ // 请求失败,设置错误信息
+ return { success: false, message: '' };
+ }
+ };
+
const wechatLogin = async (code) => {
try {
const res = await API.get(`/api/oauth/wechat?code=${code}`);
@@ -72,7 +94,7 @@ const useLogin = () => {
navigate('/');
};
- return { login, logout, githubLogin, wechatLogin };
+ return { login, logout, githubLogin, wechatLogin, larkLogin };
};
export default useLogin;
diff --git a/web/src/routes/OtherRoutes.js b/web/src/routes/OtherRoutes.js
index 7531d72d..479e9ad8 100644
--- a/web/src/routes/OtherRoutes.js
+++ b/web/src/routes/OtherRoutes.js
@@ -8,6 +8,7 @@ import MinimalLayout from 'layout/MinimalLayout';
const AuthLogin = Loadable(lazy(() => import('views/Authentication/Auth/Login')));
const AuthRegister = Loadable(lazy(() => import('views/Authentication/Auth/Register')));
const GitHubOAuth = Loadable(lazy(() => import('views/Authentication/Auth/GitHubOAuth')));
+const LarkOAuth = Loadable(lazy(() => import('views/Authentication/Auth/LarkOAuth')));
const ForgetPassword = Loadable(lazy(() => import('views/Authentication/Auth/ForgetPassword')));
const ResetPassword = Loadable(lazy(() => import('views/Authentication/Auth/ResetPassword')));
const Home = Loadable(lazy(() => import('views/Home')));
@@ -49,6 +50,10 @@ const OtherRoutes = {
path: '/oauth/github',
element:
},
+ {
+ path: '/oauth/lark',
+ element:
+ },
{
path: '/404',
element:
diff --git a/web/src/utils/common.js b/web/src/utils/common.js
index 32059e94..b15a5a0e 100644
--- a/web/src/utils/common.js
+++ b/web/src/utils/common.js
@@ -106,6 +106,13 @@ export async function onGitHubOAuthClicked(github_client_id, openInNewTab = fals
}
}
+export async function onLarkOAuthClicked(lark_client_id) {
+ const state = await getOAuthState();
+ if (!state) return;
+ let redirect_uri = `${window.location.origin}/oauth/lark`;
+ window.open(`https://open.feishu.cn/open-apis/authen/v1/authorize?redirect_uri=${redirect_uri}&app_id=${lark_client_id}&state=${state}`);
+}
+
export function isAdmin() {
let user = localStorage.getItem('user');
if (!user) return false;
diff --git a/web/src/views/Authentication/Auth/LarkOAuth.js b/web/src/views/Authentication/Auth/LarkOAuth.js
new file mode 100644
index 00000000..ac9b1d55
--- /dev/null
+++ b/web/src/views/Authentication/Auth/LarkOAuth.js
@@ -0,0 +1,95 @@
+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
+import React, { useEffect, useState } from 'react';
+import { showError } from 'utils/common';
+import useLogin from 'hooks/useLogin';
+
+// material-ui
+import { useTheme } from '@mui/material/styles';
+import { Grid, Stack, Typography, useMediaQuery, CircularProgress } from '@mui/material';
+
+// project imports
+import AuthWrapper from '../AuthWrapper';
+import AuthCardWrapper from '../AuthCardWrapper';
+import Logo from 'ui-component/Logo';
+
+// assets
+
+// ================================|| AUTH3 - LOGIN ||================================ //
+
+const LarkOAuth = () => {
+ const theme = useTheme();
+ const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));
+
+ const [searchParams] = useSearchParams();
+ const [prompt, setPrompt] = useState('处理中...');
+ const { larkLogin } = useLogin();
+
+ let navigate = useNavigate();
+
+ const sendCode = async (code, state, count) => {
+ const { success, message } = await larkLogin(code, state);
+ if (!success) {
+ if (message) {
+ showError(message);
+ }
+ if (count === 0) {
+ setPrompt(`操作失败,重定向至登录界面中...`);
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ navigate('/login');
+ return;
+ }
+ count++;
+ setPrompt(`出现错误,第 ${count} 次重试中...`);
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ await sendCode(code, state, count);
+ }
+ };
+
+ useEffect(() => {
+ let code = searchParams.get('code');
+ let state = searchParams.get('state');
+ sendCode(code, state, 0).then();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 飞书 登录
+
+
+
+
+
+
+
+
+ {prompt}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default LarkOAuth;
diff --git a/web/src/views/Authentication/AuthForms/AuthLogin.js b/web/src/views/Authentication/AuthForms/AuthLogin.js
index a03738df..81d9f6ff 100644
--- a/web/src/views/Authentication/AuthForms/AuthLogin.js
+++ b/web/src/views/Authentication/AuthForms/AuthLogin.js
@@ -35,7 +35,8 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff';
import Github from 'assets/images/icons/github.svg';
import Wechat from 'assets/images/icons/wechat.svg';
-import { onGitHubOAuthClicked } from 'utils/common';
+import Lark from 'assets/images/icons/lark.svg';
+import { onGitHubOAuthClicked, onLarkOAuthClicked } from 'utils/common';
// ============================|| FIREBASE - LOGIN ||============================ //
@@ -49,7 +50,7 @@ const LoginForm = ({ ...others }) => {
// const [checked, setChecked] = useState(true);
let tripartiteLogin = false;
- if (siteInfo.github_oauth || siteInfo.wechat_login) {
+ if (siteInfo.github_oauth || siteInfo.wechat_login || siteInfo.lark_client_id) {
tripartiteLogin = true;
}
@@ -121,6 +122,29 @@ const LoginForm = ({ ...others }) => {
)}
+ {siteInfo.lark_client_id && (
+
+
+
+
+
+ )}
state.siteInfo);
+ const theme = useTheme();
+ const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));
const handleWechatOpen = () => {
setOpenWechat(true);
@@ -120,7 +137,13 @@ export default function Profile() {
-
+
@@ -133,6 +156,9 @@ export default function Profile() {
+
@@ -202,6 +228,14 @@ export default function Profile() {
)}
+ {status.lark_client_id && !inputs.lark_id && (
+
+
+
+ )}
+
+
+ {' '}
+ 用以支持通过飞书进行登录注册,
+
+ 点击此处
+
+ 管理你的飞书应用
+
+ }
+ >
+
+
+
+ 主页链接填 {inputs.ServerAddress}
+ ,重定向 URL 填 {`${inputs.ServerAddress}/oauth/lark`}
+
+
+
+
+ App ID
+
+
+
+
+
+ App Secret
+
+
+
+
+
+
+
+