diff --git a/common/constants.go b/common/constants.go index 8352d14..c89280d 100644 --- a/common/constants.go +++ b/common/constants.go @@ -51,6 +51,7 @@ var PasswordRegisterEnabled = true var EmailVerificationEnabled = false var GitHubOAuthEnabled = false var WeChatAuthEnabled = false +var TelegramOAuthEnabled = false var TurnstileCheckEnabled = false var RegisterEnabled = true @@ -88,6 +89,9 @@ var WeChatAccountQRCodeImageURL = "" var TurnstileSiteKey = "" var TurnstileSecretKey = "" +var TelegramBotToken = "" +var TelegramBotName = "" + var QuotaForNewUser = 0 var QuotaForInviter = 0 var QuotaForInvitee = 0 diff --git a/controller/misc.go b/controller/misc.go index b992a0c..0a4f1d8 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -20,6 +20,8 @@ func GetStatus(c *gin.Context) { "email_verification": common.EmailVerificationEnabled, "github_oauth": common.GitHubOAuthEnabled, "github_client_id": common.GitHubClientId, + "telegram_oauth": common.TelegramOAuthEnabled, + "telegram_bot_name": common.TelegramBotName, "system_name": common.SystemName, "logo": common.Logo, "footer_html": common.Footer, diff --git a/controller/telegram.go b/controller/telegram.go new file mode 100644 index 0000000..b5bc0c0 --- /dev/null +++ b/controller/telegram.go @@ -0,0 +1,116 @@ +package controller + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "one-api/common" + "one-api/model" + "sort" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +func TelegramBind(c *gin.Context) { + if !common.TelegramOAuthEnabled { + c.JSON(200, gin.H{ + "message": "管理员未开启通过 Telegram 登录以及注册", + "success": false, + }) + return + } + params := c.Request.URL.Query() + if !checkTelegramAuthorization(params, common.TelegramBotToken) { + c.JSON(200, gin.H{ + "message": "无效的请求", + "success": false, + }) + return + } + telegramId := params["id"][0] + if model.IsTelegramIdAlreadyTaken(telegramId) { + c.JSON(200, gin.H{ + "message": "该 Telegram 账户已被绑定", + "success": false, + }) + return + } + + session := sessions.Default(c) + id := session.Get("id") + user := model.User{Id: id.(int)} + if err := user.FillUserById(); err != nil { + c.JSON(200, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + user.TelegramId = telegramId + if err := user.Update(false); err != nil { + c.JSON(200, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + + c.Redirect(302, "/setting") +} + +func TelegramLogin(c *gin.Context) { + if !common.TelegramOAuthEnabled { + c.JSON(200, gin.H{ + "message": "管理员未开启通过 Telegram 登录以及注册", + "success": false, + }) + return + } + params := c.Request.URL.Query() + if !checkTelegramAuthorization(params, common.TelegramBotToken) { + c.JSON(200, gin.H{ + "message": "无效的请求", + "success": false, + }) + return + } + + telegramId := params["id"][0] + user := model.User{TelegramId: telegramId} + if err := user.FillUserByTelegramId(); err != nil { + c.JSON(200, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + setupLogin(&user, c) +} + +func checkTelegramAuthorization(params map[string][]string, token string) bool { + strs := []string{} + var hash = "" + for k, v := range params { + if k == "hash" { + hash = v[0] + continue + } + strs = append(strs, k+"="+v[0]) + } + sort.Strings(strs) + var imploded = "" + for _, s := range strs { + if imploded != "" { + imploded += "\n" + } + imploded += s + } + sha256hash := sha256.New() + io.WriteString(sha256hash, token) + hmachash := hmac.New(sha256.New, sha256hash.Sum(nil)) + io.WriteString(hmachash, imploded) + ss := hex.EncodeToString(hmachash.Sum(nil)) + return hash == ss +} diff --git a/docker-compose.yml b/docker-compose.yml index 40da248..fff2716 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ version: '3.4' services: new-api: image: calciumion/new-api:latest + # build: . container_name: new-api restart: always command: --log-dir /app/logs diff --git a/makefile b/makefile new file mode 100644 index 0000000..f846d30 --- /dev/null +++ b/makefile @@ -0,0 +1,14 @@ +FRONTEND_DIR = ./web +BACKEND_DIR = . + +.PHONY: all build-frontend start-backend + +all: build-frontend start-backend + +build-frontend: + @echo "Building frontend..." + @cd $(FRONTEND_DIR) && npm install && DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build npm run build + +start-backend: + @echo "Starting backend dev server..." + @cd $(BACKEND_DIR) && go run main.go & diff --git a/model/option.go b/model/option.go index c2aeecd..9a7ad60 100644 --- a/model/option.go +++ b/model/option.go @@ -30,6 +30,7 @@ func InitOptionMap() { common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled) common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled) common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled) + common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled) common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) @@ -64,6 +65,8 @@ func InitOptionMap() { common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["GitHubClientId"] = "" common.OptionMap["GitHubClientSecret"] = "" + common.OptionMap["TelegramBotToken"] = "" + common.OptionMap["TelegramBotName"] = "" common.OptionMap["WeChatServerAddress"] = "" common.OptionMap["WeChatServerToken"] = "" common.OptionMap["WeChatAccountQRCodeImageURL"] = "" @@ -154,6 +157,8 @@ func updateOptionMap(key string, value string) (err error) { common.GitHubOAuthEnabled = boolValue case "WeChatAuthEnabled": common.WeChatAuthEnabled = boolValue + case "TelegramOAuthEnabled": + common.TelegramOAuthEnabled = boolValue case "TurnstileCheckEnabled": common.TurnstileCheckEnabled = boolValue case "RegisterEnabled": @@ -224,6 +229,10 @@ func updateOptionMap(key string, value string) (err error) { common.WeChatServerToken = value case "WeChatAccountQRCodeImageURL": common.WeChatAccountQRCodeImageURL = value + case "TelegramBotToken": + common.TelegramBotToken = value + case "TelegramBotName": + common.TelegramBotName = value case "TurnstileSiteKey": common.TurnstileSiteKey = value case "TurnstileSecretKey": diff --git a/model/user.go b/model/user.go index 3e727a0..00294b2 100644 --- a/model/user.go +++ b/model/user.go @@ -3,10 +3,11 @@ package model import ( "errors" "fmt" - "gorm.io/gorm" "one-api/common" "strings" "time" + + "gorm.io/gorm" ) // User if you add sensitive fields, don't forget to clean them in setupLogin function. @@ -21,6 +22,7 @@ type User struct { Email string `json:"email" gorm:"index" validate:"max=50"` GitHubId string `json:"github_id" gorm:"column:github_id;index"` WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` + TelegramId string `json:"telegram_id" gorm:"column:telegram_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"` @@ -286,6 +288,17 @@ func (user *User) FillUserByUsername() error { return nil } +func (user *User) FillUserByTelegramId() error { + if user.TelegramId == "" { + return errors.New("Telegram id 为空!") + } + err := DB.Where(User{TelegramId: user.TelegramId}).First(user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("该 Telegram 账户未绑定") + } + return nil +} + func IsEmailAlreadyTaken(email string) bool { return DB.Where("email = ?", email).Find(&User{}).RowsAffected == 1 } @@ -302,6 +315,10 @@ func IsUsernameAlreadyTaken(username string) bool { return DB.Where("username = ?", username).Find(&User{}).RowsAffected == 1 } +func IsTelegramIdAlreadyTaken(telegramId string) bool { + return DB.Where("telegram_id = ?", telegramId).Find(&User{}).RowsAffected == 1 +} + func ResetUserPasswordByEmail(email string, password string) error { if email == "" || password == "" { return errors.New("邮箱地址或密码为空!") diff --git a/router/api-router.go b/router/api-router.go index 48d62fa..1683a4f 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -26,6 +26,8 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind) apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind) + apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin) + apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.TelegramBind) userRoute := apiRouter.Group("/user") { diff --git a/web/package.json b/web/package.json index 009092a..d6d7ad5 100644 --- a/web/package.json +++ b/web/package.json @@ -3,10 +3,10 @@ "version": "0.1.0", "private": true, "dependencies": { - "@douyinfe/semi-ui": "^2.46.1", "@douyinfe/semi-icons": "^2.46.1", - "@visactor/vchart": "~1.8.8", + "@douyinfe/semi-ui": "^2.46.1", "@visactor/react-vchart": "~1.8.8", + "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", "axios": "^0.27.2", "history": "^5.3.0", @@ -17,6 +17,7 @@ "react-fireworks": "^1.0.4", "react-router-dom": "^6.3.0", "react-scripts": "5.0.1", + "react-telegram-login": "^1.1.2", "react-toastify": "^9.0.8", "react-turnstile": "^1.0.5", "semantic-ui-css": "^2.5.0", diff --git a/web/src/components/LoginForm.js b/web/src/components/LoginForm.js index 03aec65..eb94784 100644 --- a/web/src/components/LoginForm.js +++ b/web/src/components/LoginForm.js @@ -1,14 +1,15 @@ -import React, {useContext, useEffect, useState} from 'react'; -import {Link, useNavigate, useSearchParams} from 'react-router-dom'; -import {UserContext} from '../context/User'; -import {API, getLogo, isMobile, showError, showInfo, showSuccess, showWarning} from '../helpers'; -import {onGitHubOAuthClicked} from './utils'; +import React, { useContext, useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { UserContext } from '../context/User'; +import { API, getLogo, isMobile, showError, showInfo, showSuccess, showWarning } from '../helpers'; +import { onGitHubOAuthClicked } from './utils'; import Turnstile from "react-turnstile"; -import {Layout, Card, Image, Form, Button, Divider, Modal} from "@douyinfe/semi-ui"; +import { Layout, Card, Image, Form, Button, Divider, Modal } from "@douyinfe/semi-ui"; import Title from "@douyinfe/semi-ui/lib/es/typography/title"; import Text from "@douyinfe/semi-ui/lib/es/typography/text"; +import TelegramLoginButton from 'react-telegram-login'; -import {IconGithubLogo} from '@douyinfe/semi-icons'; +import { IconGithubLogo } from '@douyinfe/semi-icons'; const LoginForm = () => { const [inputs, setInputs] = useState({ @@ -18,7 +19,7 @@ const LoginForm = () => { }); const [searchParams, setSearchParams] = useSearchParams(); const [submitted, setSubmitted] = useState(false); - const {username, password} = inputs; + const { username, password } = inputs; const [userState, userDispatch] = useContext(UserContext); const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); @@ -56,9 +57,9 @@ const LoginForm = () => { const res = await API.get( `/api/oauth/wechat?code=${inputs.wechat_verification_code}` ); - const {success, message, data} = res.data; + const { success, message, data } = res.data; if (success) { - userDispatch({type: 'login', payload: data}); + userDispatch({ type: 'login', payload: data }); localStorage.setItem('user', JSON.stringify(data)); navigate('/'); showSuccess('登录成功!'); @@ -69,7 +70,7 @@ const LoginForm = () => { }; function handleChange(name, value) { - setInputs((inputs) => ({...inputs, [name]: value})); + setInputs((inputs) => ({ ...inputs, [name]: value })); } async function handleSubmit(e) { @@ -83,13 +84,13 @@ const LoginForm = () => { username, password }); - const {success, message, data} = res.data; + const { success, message, data } = res.data; if (success) { - userDispatch({type: 'login', payload: data}); + userDispatch({ type: 'login', payload: data }); localStorage.setItem('user', JSON.stringify(data)); showSuccess('登录成功!'); if (username === 'root' && password === '123456') { - Modal.error({title: '您正在使用默认密码!', content: '请立刻修改默认密码!', centered: true}); + Modal.error({ title: '您正在使用默认密码!', content: '请立刻修改默认密码!', centered: true }); } navigate('/token'); } else { @@ -100,16 +101,37 @@ const LoginForm = () => { } } + // 添加Telegram登录处理函数 + const onTelegramLoginClicked = async (response) => { + const fields = ["id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash", "lang"]; + const params = {}; + fields.forEach((field) => { + if (response[field]) { + params[field] = response[field]; + } + }); + const res = await API.get(`/api/oauth/telegram/login`, { params }); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + navigate('/'); + } else { + showError(message); + } + }; + return (
+ Telegram 身份验证: + {statusState?.status?.telegram_oauth === true + ? '已启用' + : '未启用'} +
diff --git a/web/src/pages/User/EditUser.js b/web/src/pages/User/EditUser.js index 705f7a2..6d79127 100644 --- a/web/src/pages/User/EditUser.js +++ b/web/src/pages/User/EditUser.js @@ -1,9 +1,9 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import {API, isMobile, showError, showSuccess} from '../../helpers'; +import { API, isMobile, showError, showSuccess } from '../../helpers'; import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render'; import Title from "@douyinfe/semi-ui/lib/es/typography/title"; -import {SideSheet, Space, Button, Spin, Input, Typography, Select, Divider} from "@douyinfe/semi-ui"; +import { SideSheet, Space, Button, Spin, Input, Typography, Select, Divider } from "@douyinfe/semi-ui"; const EditUser = (props) => { const userId = props.editingUser.id; @@ -19,8 +19,8 @@ const EditUser = (props) => { group: 'default' }); const [groupOptions, setGroupOptions] = useState([]); - const { username, display_name, password, github_id, wechat_id, email, quota, group } = - inputs; + const { username, display_name, password, github_id, wechat_id, telegram_id, email, quota, group } = + inputs; const handleInputChange = (name, value) => { setInputs((inputs) => ({ ...inputs, [name]: value })); }; @@ -88,126 +88,132 @@ const EditUser = (props) => { }; return ( - <> -