- 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_* 模板
122 lines
3.4 KiB
Go
122 lines
3.4 KiB
Go
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
|
||
}
|