Files
kefu/im/backend/internal/app/admin_oauth.go
T
2026-09-03 08:38:17 +08:00

773 lines
29 KiB
Go

package app
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
adminOAuthStateTTL = 10 * time.Minute
adminOAuthCodeTTL = 5 * time.Minute
adminOAuthBodyMax = 1 << 20
)
var adminOAuthProviderNames = map[string]string{
"wechat": "微信",
"qq": "QQ",
"github": "GitHub",
"google": "Google",
}
var adminOAuthAllowedHosts = map[string]map[string]bool{
"wechat": {"open.weixin.qq.com": true, "api.weixin.qq.com": true},
"qq": {"graph.qq.com": true},
"github": {"github.com": true, "api.github.com": true},
"google": {"accounts.google.com": true, "oauth2.googleapis.com": true, "openidconnect.googleapis.com": true},
}
type adminOAuthProvider struct {
Code string
Name string
ClientID string
ClientSecret string
AuthorizationURL string
TokenURL string
OpenIDURL string
UserInfoURL string
Scope string
RedirectURI string
}
type adminOAuthIdentity struct {
Subject string
Email string
DisplayName string
AvatarURL string
}
type adminOAuthLoginCode struct {
Provider string
Subject string
Email string
DisplayName string
AvatarURL string
AdminUserID sql.NullInt64
}
func oauthHash(value string) []byte {
hash := sha256.Sum256([]byte(value))
return hash[:]
}
func pkceChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
func (a *App) adminOAuthProvider(ctx context.Context, code string) (adminOAuthProvider, error) {
name, ok := adminOAuthProviderNames[code]
if !ok {
return adminOAuthProvider{}, errors.New("不支持的第三方登录渠道")
}
prefix := "oauth." + code + "."
provider := adminOAuthProvider{
Code: code,
Name: name,
ClientID: strings.TrimSpace(a.configPlain(ctx, prefix+"client_id", "")),
ClientSecret: strings.TrimSpace(a.configPlain(ctx, prefix+"client_secret", "")),
AuthorizationURL: strings.TrimSpace(a.configPlain(ctx, prefix+"authorization_url", "")),
TokenURL: strings.TrimSpace(a.configPlain(ctx, prefix+"token_url", "")),
OpenIDURL: strings.TrimSpace(a.configPlain(ctx, prefix+"openid_url", "")),
UserInfoURL: strings.TrimSpace(a.configPlain(ctx, prefix+"userinfo_url", "")),
Scope: strings.TrimSpace(a.configPlain(ctx, prefix+"scope", "")),
RedirectURI: strings.TrimSpace(a.configPlain(ctx, prefix+"redirect_uri", "")),
}
if provider.ClientID == "" || provider.ClientSecret == "" || provider.AuthorizationURL == "" || provider.TokenURL == "" || provider.UserInfoURL == "" || provider.Scope == "" || provider.RedirectURI == "" {
return adminOAuthProvider{}, fmt.Errorf("%s登录配置不完整", name)
}
if code == "qq" && provider.OpenIDURL == "" {
return adminOAuthProvider{}, errors.New("QQ 登录 OpenID 地址未配置")
}
for label, raw := range map[string]string{
"授权地址": provider.AuthorizationURL,
"令牌地址": provider.TokenURL,
"用户信息地址": provider.UserInfoURL,
} {
if err := validateAdminOAuthEndpoint(code, raw); err != nil {
return adminOAuthProvider{}, fmt.Errorf("%s%s无效:%w", name, label, err)
}
}
if provider.OpenIDURL != "" {
if err := validateAdminOAuthEndpoint(code, provider.OpenIDURL); err != nil {
return adminOAuthProvider{}, fmt.Errorf("%s OpenID 地址无效:%w", name, err)
}
}
if err := a.validateAdminOAuthRedirectURL(provider.RedirectURI); err != nil {
return adminOAuthProvider{}, fmt.Errorf("%s回调地址无效:%w", name, err)
}
return provider, nil
}
func validateAdminOAuthEndpoint(provider, raw string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil {
return errors.New("必须是合法的 HTTPS 地址")
}
if !adminOAuthAllowedHosts[provider][strings.ToLower(parsed.Hostname())] {
return errors.New("域名不在该渠道的官方白名单内")
}
if port := parsed.Port(); port != "" && port != "443" {
return errors.New("仅允许使用标准 HTTPS 端口")
}
return nil
}
func (a *App) validateAdminOAuthRedirectURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Hostname() == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return errors.New("必须是合法的 HTTP(S) 地址")
}
if parsed.Scheme == "http" {
host := strings.ToLower(parsed.Hostname())
if a.config.Environment == "production" || (host != "localhost" && host != "127.0.0.1" && host != "::1") {
return errors.New("仅本地开发允许 HTTP,生产环境必须使用 HTTPS")
}
}
return nil
}
func (a *App) adminOAuthFrontendURL(ctx context.Context) (string, error) {
raw := strings.TrimSpace(a.configPlain(ctx, "oauth.admin.frontend_callback_url", ""))
if raw == "" {
return "", errors.New("管理端登录结果页未配置")
}
if err := a.validateAdminOAuthRedirectURL(raw); err != nil {
return "", fmt.Errorf("管理端登录结果页无效:%w", err)
}
return raw, nil
}
func (a *App) enabledAdminOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) {
providers := make([]adminOAuthProvider, 0, len(adminOAuthProviderNames))
for _, code := range []string{"wechat", "qq", "github", "google"} {
if !a.configBool(ctx, "oauth."+code+".enabled", false) {
continue
}
provider, err := a.adminOAuthProvider(ctx, code)
if err != nil {
return nil, err
}
providers = append(providers, provider)
}
return providers, nil
}
func (a *App) oauthConfigurationReady(ctx context.Context) bool {
adminProviders, err := a.enabledAdminOAuthProviders(ctx)
if err != nil {
return false
}
userProviders, err := a.enabledUserOAuthProviders(ctx)
if err != nil {
return false
}
if len(adminProviders) > 0 {
if _, err := a.adminOAuthFrontendURL(ctx); err != nil {
return false
}
}
if len(userProviders) > 0 {
if _, err := a.userOAuthFrontendURL(ctx); err != nil {
return false
}
}
_, err = a.enabledAppOAuthProviders(ctx)
return err == nil
}
func (a *App) validateAdminOAuthConfigValues(ctx context.Context, values map[string]string, clearSecrets map[string]bool) error {
value := func(key string) string {
if clearSecrets[key] {
return ""
}
if candidate, exists := values[key]; exists {
if candidate != "" || !strings.HasSuffix(key, "client_secret") {
return strings.TrimSpace(candidate)
}
}
return strings.TrimSpace(a.configPlain(ctx, key, ""))
}
for _, client := range []struct{ prefix, callback, name string }{
{"oauth.", "oauth.admin.frontend_callback_url", "管理端"},
{"oauth.user.", "oauth.user.frontend_callback_url", "H5"},
} {
for _, code := range []string{"wechat", "qq", "github", "google"} {
if value(client.prefix+code+".enabled") == "true" {
if err := a.validateAdminOAuthRedirectURL(value(client.callback)); err != nil {
return fmt.Errorf("%s 登录结果页无效:%w", client.name, err)
}
break
}
}
}
for _, code := range []string{"wechat", "qq", "github", "google"} {
adminEnabled := strings.ToLower(value("oauth."+code+".enabled")) == "true"
userEnabled := strings.ToLower(value("oauth.user."+code+".enabled")) == "true"
appEnabled := value("oauth.app."+code+".enabled") == "true"
if appEnabled {
if code == "github" {
if value("oauth.app.frontend_callback_url") != appOAuthCallbackURL {
return errors.New("App 回调地址必须为 " + appOAuthCallbackURL)
}
} else if err := validateNativeOAuthConfig(code, value); err != nil {
return err
}
}
if !adminEnabled && !userEnabled && !(appEnabled && code == "github") {
continue
}
prefix := "oauth." + code + "."
required := []string{"client_id", "client_secret", "authorization_url", "token_url", "userinfo_url", "scope", "redirect_uri"}
if code == "qq" {
required = append(required, "openid_url")
}
for _, suffix := range required {
if value(prefix+suffix) == "" {
return fmt.Errorf("%s登录的%s不能为空", adminOAuthProviderNames[code], suffix)
}
}
for label, raw := range map[string]string{
"授权地址": value(prefix + "authorization_url"),
"令牌地址": value(prefix + "token_url"),
"用户信息地址": value(prefix + "userinfo_url"),
} {
if err := validateAdminOAuthEndpoint(code, raw); err != nil {
return fmt.Errorf("%s%s无效:%w", adminOAuthProviderNames[code], label, err)
}
}
if code == "qq" {
if err := validateAdminOAuthEndpoint(code, value(prefix+"openid_url")); err != nil {
return fmt.Errorf("QQ OpenID 地址无效:%w", err)
}
}
if err := a.validateAdminOAuthRedirectURL(value(prefix + "redirect_uri")); err != nil {
return fmt.Errorf("%s回调地址无效:%w", adminOAuthProviderNames[code], err)
}
}
return nil
}
func (a *App) adminOAuthProviders(w http.ResponseWriter, r *http.Request) {
items := make([]map[string]string, 0, len(adminOAuthProviderNames))
for _, code := range []string{"wechat", "qq", "github", "google"} {
if !a.configBool(r.Context(), "oauth."+code+".enabled", false) {
continue
}
provider, err := a.adminOAuthProvider(r.Context(), code)
if err != nil {
// A broken channel must not hide other correctly configured channels.
continue
}
items = append(items, map[string]string{"code": provider.Code, "name": provider.Name})
}
reply(w, map[string]any{"items": items})
}
func (a *App) adminOAuthStart(w http.ResponseWriter, r *http.Request) {
var req struct {
Provider string `json:"provider"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "请选择第三方登录渠道")
return
}
req.Provider = strings.ToLower(strings.TrimSpace(req.Provider))
if !a.rateLimit(w, r, "admin_oauth_start", clientIP(r), 30, 10*time.Minute) {
return
}
if !a.configBool(r.Context(), "oauth."+req.Provider+".enabled", false) {
fail(w, http.StatusBadRequest, 20001, "该登录方式未启用")
return
}
provider, err := a.adminOAuthProvider(r.Context(), req.Provider)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "该登录方式配置不完整")
return
}
state := randomToken()
verifier := ""
if provider.Code == "github" || provider.Code == "google" {
verifier = randomToken() + randomToken()
}
_, err = a.db.ExecContext(r.Context(), `INSERT INTO admin_oauth_states(state_hash,provider,code_verifier,expires_at) VALUES(?,?,?,?)`, oauthHash(state), provider.Code, verifier, time.Now().Add(adminOAuthStateTTL))
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建第三方登录请求失败")
return
}
a.cleanupAdminOAuthRecords(r.Context())
authorizationURL, _ := url.Parse(provider.AuthorizationURL)
query := authorizationURL.Query()
if provider.Code == "wechat" {
query.Set("appid", provider.ClientID)
} else {
query.Set("client_id", provider.ClientID)
}
query.Set("redirect_uri", provider.RedirectURI)
query.Set("response_type", "code")
query.Set("scope", provider.Scope)
query.Set("state", state)
if verifier != "" {
query.Set("code_challenge", pkceChallenge(verifier))
query.Set("code_challenge_method", "S256")
}
authorizationURL.RawQuery = query.Encode()
if provider.Code == "wechat" {
authorizationURL.Fragment = "wechat_redirect"
}
reply(w, map[string]string{"authorizationUrl": authorizationURL.String(), "provider": provider.Code})
}
func (a *App) adminOAuthCallback(w http.ResponseWriter, r *http.Request) {
frontendURL, frontendErr := a.adminOAuthFrontendURL(r.Context())
if frontendErr != nil {
fail(w, http.StatusServiceUnavailable, 50001, "第三方登录回调未配置")
return
}
state := strings.TrimSpace(r.URL.Query().Get("state"))
if state == "" {
a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态无效或已过期")
return
}
var providerCode, verifier string
err := a.db.QueryRowContext(r.Context(), `SELECT provider,code_verifier FROM admin_oauth_states WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)).Scan(&providerCode, &verifier)
if err != nil {
a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态无效或已过期")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE admin_oauth_states SET used_at=NOW(3) WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state))
if err != nil {
a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方登录处理失败")
return
}
affected, _ := result.RowsAffected()
if affected != 1 {
a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态已被使用")
return
}
if providerError := strings.TrimSpace(r.URL.Query().Get("error")); providerError != "" {
a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方授权已取消或失败")
return
}
code := strings.TrimSpace(r.URL.Query().Get("code"))
if code == "" {
a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方平台未返回授权码")
return
}
if !a.configBool(r.Context(), "oauth."+providerCode+".enabled", false) {
a.redirectAdminOAuthResult(w, r, frontendURL, "", "该登录方式已停用")
return
}
provider, err := a.adminOAuthProvider(r.Context(), providerCode)
if err != nil {
a.redirectAdminOAuthResult(w, r, frontendURL, "", "该登录方式配置不可用")
return
}
identity, err := a.fetchAdminOAuthIdentity(r.Context(), provider, code, verifier)
if err != nil {
a.redirectAdminOAuthResult(w, r, frontendURL, "", "获取第三方账号信息失败")
return
}
var adminUserID sql.NullInt64
_ = a.db.QueryRowContext(r.Context(), `SELECT admin_user_id FROM admin_oauth_identities WHERE provider=? AND subject=?`, provider.Code, identity.Subject).Scan(&adminUserID)
loginCode := randomToken()
_, err = a.db.ExecContext(r.Context(), `INSERT INTO admin_oauth_login_codes(code_hash,provider,subject,email,display_name,avatar_url,admin_user_id,expires_at) VALUES(?,?,?,?,?,?,?,?)`, oauthHash(loginCode), provider.Code, identity.Subject, identity.Email, identity.DisplayName, identity.AvatarURL, adminUserID, time.Now().Add(adminOAuthCodeTTL))
if err != nil {
a.redirectAdminOAuthResult(w, r, frontendURL, "", "创建登录凭证失败")
return
}
a.redirectAdminOAuthResult(w, r, frontendURL, loginCode, "")
}
func (a *App) redirectAdminOAuthResult(w http.ResponseWriter, r *http.Request, frontendURL, code, message string) {
target, err := url.Parse(frontendURL)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "登录结果页地址无效")
return
}
query := target.Query()
if code != "" {
query.Set("oauthCode", code)
} else {
query.Set("oauthError", message)
}
target.RawQuery = query.Encode()
http.Redirect(w, r, target.String(), http.StatusFound)
}
func (a *App) adminOAuthExchange(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
}
if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" {
fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效")
return
}
if !a.rateLimit(w, r, "admin_oauth_exchange", clientIP(r), 20, 10*time.Minute) {
return
}
loginCode, err := a.readAdminOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code), false)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期")
return
}
if !loginCode.AdminUserID.Valid {
reply(w, map[string]any{
"requiresLink": true,
"provider": loginCode.Provider,
"providerName": adminOAuthProviderNames[loginCode.Provider],
"displayName": loginCode.DisplayName,
"email": loginCode.Email,
"avatarUrl": loginCode.AvatarURL,
})
return
}
adminID, realName, err := a.consumeAdminOAuthCode(r.Context(), strings.TrimSpace(req.Code), loginCode.AdminUserID.Int64)
if err != nil {
fail(w, http.StatusUnauthorized, 10001, err.Error())
return
}
payload, err := a.newAdminSession(r.Context(), w, r, adminID, realName)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败")
return
}
payload["requiresLink"] = false
reply(w, payload)
}
func (a *App) adminOAuthLink(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
Username string `json:"username"`
Password string `json:"password"`
}
if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || strings.TrimSpace(req.Username) == "" || req.Password == "" {
fail(w, http.StatusBadRequest, 20001, "请输入管理员账号和密码完成绑定")
return
}
username := strings.TrimSpace(req.Username)
if !a.rateLimit(w, r, "admin_oauth_link_ip", clientIP(r), 10, 15*time.Minute) || !a.rateLimit(w, r, "admin_oauth_link_user", strings.ToLower(username), 10, 15*time.Minute) {
return
}
loginCode, err := a.readAdminOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code), true)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期")
return
}
var adminID int64
var passwordHash, realName string
var status int
err = a.db.QueryRowContext(r.Context(), `SELECT id,password_hash,real_name,status FROM admin_users WHERE username=?`, username).Scan(&adminID, &passwordHash, &realName, &status)
if err != nil || !checkPassword(passwordHash, req.Password) {
// 这里返回 400,避免前端全局 401 拦截器丢弃尚可重试的一次性绑定码。
fail(w, http.StatusBadRequest, 10001, "管理员账号或密码错误")
return
}
if status != 1 {
fail(w, http.StatusForbidden, 10006, "管理员账号已停用")
return
}
tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败")
return
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(r.Context(), `UPDATE admin_oauth_login_codes SET used_at=NOW(3),admin_user_id=? WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, adminID, oauthHash(strings.TrimSpace(req.Code)))
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败")
return
}
affected, _ := result.RowsAffected()
if affected != 1 {
fail(w, http.StatusBadRequest, 20001, "第三方登录凭证已被使用")
return
}
_, err = tx.ExecContext(r.Context(), `INSERT INTO admin_oauth_identities(provider,subject,admin_user_id,email,display_name,avatar_url,last_login_at) VALUES(?,?,?,?,?,?,NOW(3))`, loginCode.Provider, loginCode.Subject, adminID, loginCode.Email, loginCode.DisplayName, loginCode.AvatarURL)
if err != nil {
fail(w, http.StatusConflict, 20001, "该第三方账号或管理员账号已绑定此渠道")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, adminID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败")
return
}
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败")
return
}
auditPayload, _ := json.Marshal(map[string]any{"provider": loginCode.Provider, "externalSubjectHash": fmt.Sprintf("%x", sha256.Sum256([]byte(loginCode.Subject)))})
_, _ = a.db.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip) VALUES(?,?,?,?,?,?)`, adminID, "bind_oauth_identity", "admin_user", adminID, auditPayload, clientIP(r))
payload, err := a.newAdminSession(r.Context(), w, r, adminID, realName)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败")
return
}
payload["requiresLink"] = false
reply(w, payload)
}
func (a *App) readAdminOAuthLoginCode(ctx context.Context, code string, requireUnlinked bool) (adminOAuthLoginCode, error) {
var result adminOAuthLoginCode
query := `SELECT provider,subject,email,display_name,avatar_url,admin_user_id FROM admin_oauth_login_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)`
if requireUnlinked {
query += ` AND admin_user_id IS NULL`
}
err := a.db.QueryRowContext(ctx, query, oauthHash(code)).Scan(&result.Provider, &result.Subject, &result.Email, &result.DisplayName, &result.AvatarURL, &result.AdminUserID)
return result, err
}
func (a *App) consumeAdminOAuthCode(ctx context.Context, code string, adminID int64) (int64, string, error) {
var realName string
var status int
if err := a.db.QueryRowContext(ctx, `SELECT real_name,status FROM admin_users WHERE id=?`, adminID).Scan(&realName, &status); err != nil || status != 1 {
return 0, "", errors.New("管理员账号不存在或已停用")
}
result, err := a.db.ExecContext(ctx, `UPDATE admin_oauth_login_codes SET used_at=NOW(3) WHERE code_hash=? AND admin_user_id=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code), adminID)
if err != nil {
return 0, "", errors.New("第三方登录处理失败")
}
affected, _ := result.RowsAffected()
if affected != 1 {
return 0, "", errors.New("第三方登录凭证无效或已使用")
}
_, _ = a.db.ExecContext(ctx, `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, adminID)
_, _ = a.db.ExecContext(ctx, `UPDATE admin_oauth_identities SET last_login_at=NOW(3) WHERE admin_user_id=?`, adminID)
return adminID, realName, nil
}
func (a *App) cleanupAdminOAuthRecords(ctx context.Context) {
_, _ = a.db.ExecContext(ctx, `DELETE FROM admin_oauth_states WHERE expires_at<DATE_SUB(NOW(3),INTERVAL 1 DAY) LIMIT 500`)
_, _ = a.db.ExecContext(ctx, `DELETE FROM admin_oauth_login_codes WHERE expires_at<DATE_SUB(NOW(3),INTERVAL 1 DAY) LIMIT 500`)
}
func (a *App) oauthHTTPClient() *http.Client {
return &http.Client{
Timeout: 8 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Token/UserInfo endpoints are fixed official endpoints and must not
// redirect credentials or bearer tokens to another host.
return http.ErrUseLastResponse
},
}
}
func (a *App) fetchAdminOAuthIdentity(ctx context.Context, provider adminOAuthProvider, code, verifier string) (adminOAuthIdentity, error) {
accessToken, tokenOpenID, err := a.exchangeAdminOAuthToken(ctx, provider, code, verifier)
if err != nil {
return adminOAuthIdentity{}, err
}
switch provider.Code {
case "github":
var payload struct {
ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
if err = a.oauthBearerJSON(ctx, provider.UserInfoURL, accessToken, &payload); err != nil || payload.ID <= 0 {
return adminOAuthIdentity{}, errors.New("GitHub 用户信息无效")
}
displayName := strings.TrimSpace(payload.Name)
if displayName == "" {
displayName = payload.Login
}
return adminOAuthIdentity{Subject: strconv.FormatInt(payload.ID, 10), Email: payload.Email, DisplayName: displayName, AvatarURL: payload.AvatarURL}, nil
case "google":
var payload struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
Verified bool `json:"email_verified"`
}
if err = a.oauthBearerJSON(ctx, provider.UserInfoURL, accessToken, &payload); err != nil || strings.TrimSpace(payload.Subject) == "" {
return adminOAuthIdentity{}, errors.New("Google 用户信息无效")
}
if !payload.Verified {
payload.Email = ""
}
return adminOAuthIdentity{Subject: payload.Subject, Email: payload.Email, DisplayName: payload.Name, AvatarURL: payload.Picture}, nil
case "wechat":
values := url.Values{"access_token": {accessToken}, "openid": {tokenOpenID}, "lang": {"zh_CN"}}
var payload struct {
OpenID string `json:"openid"`
Nickname string `json:"nickname"`
AvatarURL string `json:"headimgurl"`
ErrorCode int `json:"errcode"`
}
if err = a.oauthGetJSON(ctx, provider.UserInfoURL, values, &payload); err != nil || payload.ErrorCode != 0 || payload.OpenID == "" {
return adminOAuthIdentity{}, errors.New("微信用户信息无效")
}
return adminOAuthIdentity{Subject: payload.OpenID, DisplayName: payload.Nickname, AvatarURL: payload.AvatarURL}, nil
case "qq":
openid, err := a.fetchQQOpenID(ctx, provider.OpenIDURL, accessToken)
if err != nil {
return adminOAuthIdentity{}, err
}
values := url.Values{"access_token": {accessToken}, "oauth_consumer_key": {provider.ClientID}, "openid": {openid}, "format": {"json"}}
var payload struct {
ReturnCode int `json:"ret"`
Message string `json:"msg"`
Nickname string `json:"nickname"`
AvatarURL string `json:"figureurl_qq_2"`
}
if err = a.oauthGetJSON(ctx, provider.UserInfoURL, values, &payload); err != nil || payload.ReturnCode != 0 {
return adminOAuthIdentity{}, errors.New("QQ 用户信息无效")
}
return adminOAuthIdentity{Subject: openid, DisplayName: payload.Nickname, AvatarURL: payload.AvatarURL}, nil
default:
return adminOAuthIdentity{}, errors.New("不支持的第三方登录渠道")
}
}
func (a *App) exchangeAdminOAuthToken(ctx context.Context, provider adminOAuthProvider, code, verifier string) (string, string, error) {
if provider.Code == "wechat" {
values := url.Values{"appid": {provider.ClientID}, "secret": {provider.ClientSecret}, "code": {code}, "grant_type": {"authorization_code"}}
var payload struct {
AccessToken string `json:"access_token"`
OpenID string `json:"openid"`
ErrorCode int `json:"errcode"`
}
if err := a.oauthGetJSON(ctx, provider.TokenURL, values, &payload); err != nil || payload.ErrorCode != 0 || payload.AccessToken == "" || payload.OpenID == "" {
return "", "", errors.New("微信令牌交换失败")
}
return payload.AccessToken, payload.OpenID, nil
}
values := url.Values{
"client_id": {provider.ClientID},
"client_secret": {provider.ClientSecret},
"code": {code},
"redirect_uri": {provider.RedirectURI},
"grant_type": {"authorization_code"},
}
if verifier != "" {
values.Set("code_verifier", verifier)
}
request, _ := http.NewRequestWithContext(ctx, http.MethodPost, provider.TokenURL, strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
response, err := a.oauthHTTPClient().Do(request)
if err != nil {
return "", "", err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, adminOAuthBodyMax))
if err != nil || response.StatusCode < 200 || response.StatusCode >= 300 {
return "", "", errors.New("第三方令牌服务请求失败")
}
var payload struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if json.Unmarshal(body, &payload) != nil || payload.AccessToken == "" {
parsed, parseErr := url.ParseQuery(string(body))
if parseErr != nil {
return "", "", errors.New("第三方令牌响应无效")
}
payload.AccessToken = parsed.Get("access_token")
payload.Error = parsed.Get("error")
}
if payload.Error != "" || payload.AccessToken == "" {
return "", "", errors.New("第三方平台拒绝了令牌请求")
}
return payload.AccessToken, "", nil
}
func (a *App) oauthBearerJSON(ctx context.Context, endpoint, accessToken string, out any) error {
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
request.Header.Set("Authorization", "Bearer "+accessToken)
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "XingYu-Admin-OAuth/1.0")
return a.oauthDoJSON(request, out)
}
func (a *App) oauthGetJSON(ctx context.Context, endpoint string, values url.Values, out any) error {
parsed, err := url.Parse(endpoint)
if err != nil {
return err
}
query := parsed.Query()
for key, items := range values {
for _, item := range items {
query.Add(key, item)
}
}
parsed.RawQuery = query.Encode()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "XingYu-Admin-OAuth/1.0")
return a.oauthDoJSON(request, out)
}
func (a *App) oauthDoJSON(request *http.Request, out any) error {
response, err := a.oauthHTTPClient().Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("第三方平台返回 HTTP %d", response.StatusCode)
}
decoder := json.NewDecoder(io.LimitReader(response.Body, adminOAuthBodyMax))
return decoder.Decode(out)
}
func (a *App) fetchQQOpenID(ctx context.Context, endpoint, accessToken string) (string, error) {
parsed, _ := url.Parse(endpoint)
query := parsed.Query()
query.Set("access_token", accessToken)
query.Set("fmt", "json")
parsed.RawQuery = query.Encode()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
request.Header.Set("Accept", "application/json")
response, err := a.oauthHTTPClient().Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, adminOAuthBodyMax))
if err != nil || response.StatusCode < 200 || response.StatusCode >= 300 {
return "", errors.New("QQ OpenID 请求失败")
}
text := strings.TrimSpace(string(body))
if strings.HasPrefix(text, "callback") {
start, end := strings.Index(text, "("), strings.LastIndex(text, ")")
if start >= 0 && end > start {
text = text[start+1 : end]
}
}
var payload struct {
OpenID string `json:"openid"`
Error int `json:"error"`
}
if json.Unmarshal([]byte(text), &payload) != nil || payload.Error != 0 || payload.OpenID == "" {
return "", errors.New("QQ OpenID 响应无效")
}
return payload.OpenID, nil
}