评论登录新增 Google OAuth 与 Telegram Login Widget

- Google:授权码流程(openid email profile),handle 优先用已验证邮箱,头像取 picture
- Telegram:官方 widget 直接回传签名资料,后端按官方算法验签(secret=SHA256(bot_token),除 hash 外全字段排序比对)+ auth_date 24h 时效
- providers 列表自动按配置下发(未配置的不显示);widget 型带 bot 用户名
- 三个登录方式共用读者会话与 upsert;Telegram 走 POST 无跳转
- .env.example 补 ONE_GOOGLE_* / ONE_TELEGRAM_* 模板
This commit is contained in:
Sakurasan
2026-09-28 01:32:39 +08:00
parent 9149f672e7
commit baf14ec6ca
8 changed files with 423 additions and 22 deletions
+121
View File
@@ -0,0 +1,121 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// Telegram Login Widget:没有授权码流程,官方脚本把用户资料连同
// hash 一起交给前端,前端 POST 到 /api/auth/telegram,后端用 bot
// token 验签。算法见官方文档:secret = SHA256(bot_token),
// data-check-string 是除 hash 外所有收到的字段按 key 排序的 "k=v" 行。
type Telegram struct {
Bot string // bot 用户名(不含 @),下发给 widget
Token string // bot token,只用于验签,不出后端
}
func (t Telegram) Enabled() bool { return t.Bot != "" && t.Token != "" }
// telegramAuthTTL 是验签窗口:widget 回传的 auth_date 超过它视为过期
const telegramAuthTTL = 24 * time.Hour
// TelegramUser 是 widget 回传的身份字段(验签通过后从中取)。
// widget 把所有值都当字符串发(id 也不例外),但前端测试或手工
// 调用可能发数字 —— FlexStr 两种都收。
type TelegramUser struct {
ID FlexStr `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
PhotoURL string `json:"photo_url"`
}
// FlexStr 兼容 JSON 里的字符串和数字
type FlexStr string
func (f *FlexStr) UnmarshalJSON(b []byte) error {
if len(b) > 0 && b[0] == '"' {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
*f = FlexStr(s)
return nil
}
*f = FlexStr(b)
return nil
}
func (f FlexStr) Int64() (int64, error) {
return strconv.ParseInt(strings.Trim(string(f), `"`), 10, 64)
}
func (u TelegramUser) IDInt() int64 { id, _ := u.ID.Int64(); return id }
func (u TelegramUser) DisplayName() string {
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
if name == "" {
name = u.Username
}
return name
}
// normalize 把 JSON 值规整成 widget 发送时的字符串形态
// (widget 的数值字段也是字符串,但调用方可能发真数字)
func normalize(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatInt(int64(x), 10)
case json.Number:
return x.String()
case bool:
return strconv.FormatBool(x)
default:
return fmt.Sprint(x)
}
}
// VerifyMap 校验 hash 与时效。fields 是前端原样 POST 的 JSON 对象;
// exclude 里的 key(我们附加的非 Telegram 字段)不参与验签。
func (t Telegram) VerifyMap(fields map[string]any, exclude ...string) error {
hash := normalize(fields["hash"])
if hash == "" {
return fmt.Errorf("telegram: missing hash")
}
authDate, err := strconv.ParseInt(normalize(fields["auth_date"]), 10, 64)
if err != nil || authDate == 0 || time.Since(time.Unix(authDate, 0)) > telegramAuthTTL {
return fmt.Errorf("telegram: auth_date expired")
}
skip := make(map[string]bool, len(exclude)+1)
skip["hash"] = true
for _, k := range exclude {
skip[k] = true
}
keys := make([]string, 0, len(fields))
for k := range fields {
if !skip[k] {
keys = append(keys, k)
}
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, k := range keys {
lines = append(lines, k+"="+normalize(fields[k]))
}
secret := sha256.Sum256([]byte(t.Token))
mac := hmac.New(sha256.New, secret[:])
mac.Write([]byte(strings.Join(lines, "\n")))
if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(strings.ToLower(hash))) {
return fmt.Errorf("telegram: hash mismatch")
}
return nil
}