评论登录新增 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
+95
View File
@@ -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
}