评论登录新增 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
|
||||
}
|
||||
Reference in New Issue
Block a user