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