Files
new-api/controller/telegram.go
T
Calcium-Ion 31d70fca39 refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
2026-07-20 16:48:43 +08:00

341 lines
11 KiB
Go

package controller
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
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
telegramBindFlowTTL = 5 * time.Minute
telegramBindErrorDisabled = "TELEGRAM_BIND_DISABLED"
telegramBindErrorInvalidRequest = "TELEGRAM_BIND_INVALID_REQUEST"
telegramBindErrorFlowInvalid = "TELEGRAM_BIND_FLOW_INVALID"
telegramBindErrorSessionInvalid = "TELEGRAM_BIND_SESSION_INVALID"
telegramBindErrorAlreadyBound = "TELEGRAM_BIND_ALREADY_BOUND"
telegramBindErrorUserDeleted = "TELEGRAM_BIND_USER_DELETED"
telegramBindErrorUserDisabled = "TELEGRAM_BIND_USER_DISABLED"
telegramBindErrorInternal = "TELEGRAM_BIND_INTERNAL_ERROR"
)
var (
errTelegramAccountAlreadyBound = errors.New("telegram account is already bound")
errTelegramBindAssertionInvalid = errors.New("telegram bind assertion is invalid")
errTelegramBindUserDeleted = errors.New("telegram bind user was deleted")
errTelegramBindUserDisabled = errors.New("telegram bind user is disabled")
)
func TelegramBindStart(c *gin.Context) {
if !common.TelegramOAuthEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "管理员未开启通过 Telegram 登录以及注册",
"success": false,
})
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
expiresAt := time.Now().Add(telegramBindFlowTTL)
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind,
UserId: identity.UserID,
SessionId: identity.SessionID,
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
}
callbackURL := "/api/oauth/telegram/bind/" + flowToken
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"flow_token": flowToken,
"callback_url": callbackURL,
"expires_at": expiresAt.Unix(),
},
})
}
func TelegramBind(c *gin.Context) {
if !common.TelegramOAuthEnabled {
telegramBindFailure(c, telegramBindErrorDisabled)
return
}
params := c.Request.URL.Query()
telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now())
if err != nil {
common.SysLog("TelegramBind authorization failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInvalidRequest)
return
}
pendingFlow, err := model.GetAuthFlow(c.Param("flow_token"), model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeTelegramBind,
})
if err != nil {
if !errors.Is(err, model.ErrAuthFlowInvalid) &&
!errors.Is(err, model.ErrAuthFlowExpired) &&
!errors.Is(err, model.ErrAuthFlowConsumed) {
common.SysError("TelegramBind flow lookup failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInternal)
return
}
telegramBindFailure(c, telegramBindErrorFlowInvalid)
return
}
if _, err := service.ValidateSessionReference(pendingFlow.UserId, pendingFlow.SessionId); err != nil {
if !errors.Is(err, service.ErrLoginSessionInvalid) &&
!errors.Is(err, service.ErrLoginSessionRevoked) &&
!errors.Is(err, model.ErrUserSessionInactive) &&
!errors.Is(err, gorm.ErrRecordNotFound) {
common.SysError("TelegramBind session validation failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInternal)
return
}
var user model.User
userErr := model.DB.First(&user, pendingFlow.UserId).Error
switch {
case errors.Is(userErr, gorm.ErrRecordNotFound):
telegramBindFailure(c, telegramBindErrorUserDeleted)
case userErr != nil:
common.SysError("TelegramBind user status lookup failed: " + userErr.Error())
telegramBindFailure(c, telegramBindErrorInternal)
case user.Status != common.UserStatusEnabled:
telegramBindFailure(c, telegramBindErrorUserDisabled)
default:
telegramBindFailure(c, telegramBindErrorSessionInvalid)
}
return
}
assertion, assertionExpiresAt, err := telegramAuthorizationClaim(params, time.Now())
if err != nil {
common.SysLog("TelegramBind authorization claim failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInvalidRequest)
return
}
_, err = model.ConsumeAuthFlowWithAction(c.Param("flow_token"), model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeTelegramBind,
UserId: pendingFlow.UserId,
SessionId: pendingFlow.SessionId,
}, func(tx *gorm.DB, flow *model.AuthFlow) error {
if err := model.ClaimExternalAuthAssertionWithTx(tx, model.AuthFlowPurposeTelegramAssertion, assertion, assertionExpiresAt); err != nil {
if errors.Is(err, model.ErrAuthFlowInvalid) || errors.Is(err, model.ErrAuthFlowConsumed) {
return errors.Join(errTelegramBindAssertionInvalid, err)
}
return err
}
var user model.User
if err := tx.First(&user, flow.UserId).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errTelegramBindUserDeleted
}
return err
}
if user.Status != common.UserStatusEnabled {
return errTelegramBindUserDisabled
}
var session model.UserSession
if err := tx.Where("sid = ? AND user_id = ?", flow.SessionId, flow.UserId).First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return service.ErrLoginSessionRevoked
}
return err
}
if session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() {
return service.ErrLoginSessionRevoked
}
if session.UserAuthVersion != user.AuthVersion {
return service.ErrLoginSessionRevoked
}
if user.TelegramId != "" {
return errTelegramAccountAlreadyBound
}
if err := model.ClaimExternalIdentityWithTx(
tx,
model.ExternalIdentityProviderTelegram,
telegramId,
user.Id,
); err != nil {
if errors.Is(err, model.ErrExternalIdentityAlreadyClaimed) {
return errTelegramAccountAlreadyBound
}
return err
}
result := tx.Model(&model.User{}).
Where("id = ? AND status = ? AND auth_version = ? AND telegram_id = ?", user.Id, common.UserStatusEnabled, user.AuthVersion, "").
Update("telegram_id", telegramId)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errTelegramAccountAlreadyBound
}
return nil
})
if err != nil {
switch {
case errors.Is(err, errTelegramBindAssertionInvalid):
telegramBindFailure(c, telegramBindErrorInvalidRequest)
case errors.Is(err, errTelegramAccountAlreadyBound):
telegramBindFailure(c, telegramBindErrorAlreadyBound)
case errors.Is(err, errTelegramBindUserDeleted):
telegramBindFailure(c, telegramBindErrorUserDeleted)
case errors.Is(err, errTelegramBindUserDisabled):
telegramBindFailure(c, telegramBindErrorUserDisabled)
case errors.Is(err, service.ErrLoginSessionRevoked):
telegramBindFailure(c, telegramBindErrorSessionInvalid)
case errors.Is(err, model.ErrAuthFlowInvalid), errors.Is(err, model.ErrAuthFlowExpired), errors.Is(err, model.ErrAuthFlowConsumed):
telegramBindFailure(c, telegramBindErrorFlowInvalid)
default:
common.SysError("TelegramBind failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInternal)
}
return
}
callback := "/oauth/telegram?telegram_bind=success&flow_token=" + url.QueryEscape(c.Param("flow_token"))
c.Redirect(http.StatusFound, callback)
}
func telegramBindFailure(c *gin.Context, errorCode string) {
query := url.Values{
"telegram_bind": {"error"},
"flow_token": {c.Param("flow_token")},
"error_code": {errorCode},
}
c.Redirect(http.StatusFound, "/oauth/telegram?"+query.Encode())
}
func TelegramLogin(c *gin.Context) {
if !common.TelegramOAuthEnabled {
c.JSON(200, gin.H{
"message": "管理员未开启通过 Telegram 登录以及注册",
"success": false,
})
return
}
params := c.Request.URL.Query()
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,
})
return
}
user := model.User{TelegramId: telegramId}
if err := user.FillUserByTelegramId(); err != nil {
c.JSON(200, gin.H{
"message": err.Error(),
"success": false,
})
return
}
if err := claimTelegramAuthorization(params, time.Now()); err != nil {
common.SysLog("TelegramLogin assertion replay rejected: " + err.Error())
c.JSON(http.StatusForbidden, gin.H{
"message": "该登录凭据已被使用",
"success": false,
})
return
}
setupLogin(&user, c)
}
func claimTelegramAuthorization(params url.Values, now time.Time) error {
assertion, expiresAt, err := telegramAuthorizationClaim(params, now)
if err != nil {
return err
}
return model.ClaimExternalAuthAssertion(model.AuthFlowPurposeTelegramAssertion, assertion, expiresAt)
}
func telegramAuthorizationClaim(params url.Values, now time.Time) (string, time.Time, error) {
authDate, err := strconv.ParseInt(params.Get("auth_date"), 10, 64)
if err != nil {
return "", time.Time{}, errors.New("telegram authorization date is invalid")
}
hashBytes, err := hex.DecodeString(params.Get("hash"))
if err != nil {
return "", time.Time{}, errors.New("telegram authorization signature is invalid")
}
expiresAt := time.Unix(authDate, 0).Add(telegramAuthorizationMaxAge)
if !expiresAt.After(now) {
return "", time.Time{}, errors.New("telegram authorization has expired")
}
return hex.EncodeToString(hashBytes), expiresAt, nil
}
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" {
continue
}
strs = append(strs, k+"="+v[0])
}
sort.Strings(strs)
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")
}
return telegramID, nil
}