评论登录新增 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:
@@ -0,0 +1,95 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Google OAuth2(OIDC 简化用法:openid email profile 三个 scope,
|
||||
// userinfo 接口拿资料)。配置来源 .env 的 ONE_GOOGLE_CLIENT_ID / SECRET。
|
||||
const (
|
||||
googleAuthURL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
googleTokenURL = "https://oauth2.googleapis.com/token"
|
||||
googleUserURL = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||
)
|
||||
|
||||
type Google struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
func (g Google) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
|
||||
|
||||
func (g Google) LoginURL(redirectURI, state string) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", g.ClientID)
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", "openid email profile")
|
||||
v.Set("state", state)
|
||||
return googleAuthURL + "?" + v.Encode()
|
||||
}
|
||||
|
||||
// GoogleUser 是 userinfo 接口里我们关心的字段。
|
||||
// sub 是 Google 账号的稳定唯一 id;邮箱需要已验证才当 handle 用。
|
||||
type GoogleUser struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
func (g Google) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", g.ClientID)
|
||||
v.Set("client_secret", g.ClientSecret)
|
||||
v.Set("code", code)
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
v.Set("grant_type", "authorization_code")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, googleTokenURL, strings.NewReader(v.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" {
|
||||
return "", fmt.Errorf("google: token exchange failed")
|
||||
}
|
||||
return out.AccessToken, nil
|
||||
}
|
||||
|
||||
func (g Google) FetchUser(ctx context.Context, accessToken string) (GoogleUser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleUserURL, nil)
|
||||
if err != nil {
|
||||
return GoogleUser{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return GoogleUser{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return GoogleUser{}, fmt.Errorf("google: userinfo %d", res.StatusCode)
|
||||
}
|
||||
var u GoogleUser
|
||||
if err := json.NewDecoder(res.Body).Decode(&u); err != nil {
|
||||
return GoogleUser{}, err
|
||||
}
|
||||
if u.Sub == "" {
|
||||
return GoogleUser{}, fmt.Errorf("google: userinfo missing sub")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user