fix: purge authentication data on hard user deletion (#6168)

* fix: purge authentication data on hard user deletion

* fix: fail closed when 2FA status lookup fails

* fix: reject stale Telegram login callbacks

* fix(twofa): prevent concurrent backup code and lockout bypasses

* fix(auth): harden user deletion and Telegram verification
This commit is contained in:
Seefs
2026-07-14 14:25:54 +08:00
committed by GitHub
parent 7c28993f6b
commit b6b97a66e3
8 changed files with 371 additions and 51 deletions
+52 -21
View File
@@ -4,9 +4,13 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"errors"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
@@ -15,6 +19,13 @@ import (
"github.com/gin-gonic/gin"
)
const (
// The legacy Telegram widget has no nonce. Keep its signed assertion short-lived
// so captured callbacks cannot be reused indefinitely.
telegramAuthorizationMaxAge = 5 * time.Minute
telegramAuthorizationFutureSkew = 2 * time.Minute
)
func TelegramBind(c *gin.Context) {
if !common.TelegramOAuthEnabled {
c.JSON(200, gin.H{
@@ -24,14 +35,15 @@ func TelegramBind(c *gin.Context) {
return
}
params := c.Request.URL.Query()
if !checkTelegramAuthorization(params, common.TelegramBotToken) {
telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now())
if err != nil {
common.SysLog("TelegramBind authorization failed: " + err.Error())
c.JSON(200, gin.H{
"message": "无效的请求",
"success": false,
})
return
}
telegramId := params["id"][0]
if model.IsTelegramIdAlreadyTaken(telegramId) {
c.JSON(200, gin.H{
"message": "该 Telegram 账户已被绑定",
@@ -78,7 +90,9 @@ func TelegramLogin(c *gin.Context) {
return
}
params := c.Request.URL.Query()
if !checkTelegramAuthorization(params, common.TelegramBotToken) {
telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now())
if err != nil {
common.SysLog("TelegramLogin authorization failed: " + err.Error())
c.JSON(200, gin.H{
"message": "无效的请求",
"success": false,
@@ -86,7 +100,6 @@ func TelegramLogin(c *gin.Context) {
return
}
telegramId := params["id"][0]
user := model.User{TelegramId: telegramId}
if err := user.FillUserByTelegramId(); err != nil {
c.JSON(200, gin.H{
@@ -98,28 +111,46 @@ func TelegramLogin(c *gin.Context) {
setupLogin(&user, c)
}
func checkTelegramAuthorization(params map[string][]string, token string) bool {
strs := []string{}
var hash = ""
func verifyTelegramAuthorization(params url.Values, token string, now time.Time) (string, error) {
if token == "" {
return "", errors.New("telegram bot token is empty")
}
for _, values := range params {
if len(values) != 1 {
return "", errors.New("telegram authorization contains duplicate parameters")
}
}
telegramID := params.Get("id")
hash := params.Get("hash")
authDateText := params.Get("auth_date")
if telegramID == "" || hash == "" || authDateText == "" {
return "", errors.New("telegram authorization is incomplete")
}
authDate, err := strconv.ParseInt(authDateText, 10, 64)
if err != nil {
return "", errors.New("telegram authorization date is invalid")
}
if authDate < now.Add(-telegramAuthorizationMaxAge).Unix() ||
authDate > now.Add(telegramAuthorizationFutureSkew).Unix() {
return "", errors.New("telegram authorization has expired")
}
strs := make([]string, 0, len(params)-1)
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
secret := sha256.Sum256([]byte(token))
mac := hmac.New(sha256.New, secret[:])
_, _ = mac.Write([]byte(strings.Join(strs, "\n")))
providedHash, err := hex.DecodeString(hash)
if err != nil || !hmac.Equal(providedHash, mac.Sum(nil)) {
return "", errors.New("telegram authorization signature is invalid")
}
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
return telegramID, nil
}
+78
View File
@@ -0,0 +1,78 @@
package controller
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/url"
"sort"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVerifyTelegramAuthorization(t *testing.T) {
const token = "telegram-test-token"
now := time.Unix(1_700_000_000, 0)
tests := []struct {
name string
authDate time.Time
mutate func(url.Values)
wantID string
wantErr string
}{
{name: "valid", authDate: now, wantID: "123456"},
{name: "small future clock skew", authDate: now.Add(90 * time.Second), wantID: "123456"},
{name: "expired", authDate: now.Add(-telegramAuthorizationMaxAge - time.Second), wantErr: "expired"},
{name: "too far in future", authDate: now.Add(telegramAuthorizationFutureSkew + time.Second), wantErr: "expired"},
{name: "invalid signature", authDate: now, mutate: func(values url.Values) { values.Set("hash", "00") }, wantErr: "signature"},
{name: "duplicate parameter", authDate: now, mutate: func(values url.Values) { values["id"] = append(values["id"], "654321") }, wantErr: "duplicate"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := signedTelegramAuthorization(token, tt.authDate)
if tt.mutate != nil {
tt.mutate(params)
}
telegramID, err := verifyTelegramAuthorization(params, token, now)
if tt.wantErr != "" {
require.Error(t, err)
assert.ErrorContains(t, err, tt.wantErr)
assert.Empty(t, telegramID)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantID, telegramID)
})
}
}
func signedTelegramAuthorization(token string, authDate time.Time) url.Values {
params := url.Values{
"auth_date": {strconv.FormatInt(authDate.Unix(), 10)},
"first_name": {"Test"},
"id": {"123456"},
}
keys := make([]string, 0, len(params))
for key := range params {
keys = append(keys, key)
}
sort.Strings(keys)
dataCheck := make([]string, 0, len(keys))
for _, key := range keys {
dataCheck = append(dataCheck, key+"="+params.Get(key))
}
secret := sha256.Sum256([]byte(token))
mac := hmac.New(sha256.New, secret[:])
_, _ = mac.Write([]byte(strings.Join(dataCheck, "\n")))
params.Set("hash", hex.EncodeToString(mac.Sum(nil)))
return params
}
+7 -1
View File
@@ -73,7 +73,13 @@ func Login(c *gin.Context) {
}
// 检查是否启用2FA
if model.IsTwoFAEnabled(user.Id) {
twoFAEnabled, err := model.IsTwoFAEnabled(user.Id)
if err != nil {
common.SysLog(fmt.Sprintf("Login failed to load 2FA status for user %d: %v", user.Id, err))
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
return
}
if twoFAEnabled {
// 设置pending session,等待2FA验证
session := sessions.Default(c)
session.Set("pending_username", user.Username)