From 9ccf1381e85429250475dd35a0712a456a536bfd Mon Sep 17 00:00:00 2001 From: Martial BE Date: Tue, 16 Apr 2024 13:03:05 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20Lark=20OAuth=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/constants.go | 3 + controller/lark.go | 267 ++++++++++++++++++ controller/misc.go | 1 + model/option.go | 2 + model/user.go | 13 + router/api-router.go | 1 + web/src/assets/images/icons/lark.svg | 1 + web/src/hooks/useLogin.js | 24 +- web/src/routes/OtherRoutes.js | 5 + web/src/utils/common.js | 7 + .../views/Authentication/Auth/LarkOAuth.js | 95 +++++++ .../Authentication/AuthForms/AuthLogin.js | 28 +- web/src/views/Profile/index.js | 40 ++- .../views/Setting/component/SystemSetting.js | 70 ++++- 14 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 controller/lark.go create mode 100644 web/src/assets/images/icons/lark.svg create mode 100644 web/src/views/Authentication/Auth/LarkOAuth.js 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 && ( + + + + )} + + + +