gengx
This commit is contained in:
@@ -0,0 +1,747 @@
|
||||
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 {
|
||||
if _, err := a.adminOAuthFrontendURL(ctx); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := a.userOAuthFrontendURL(ctx); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := a.enabledAdminOAuthProviders(ctx); err != nil {
|
||||
return false
|
||||
}
|
||||
_, err := a.enabledUserOAuthProviders(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, ""))
|
||||
}
|
||||
frontendURL := value("oauth.admin.frontend_callback_url")
|
||||
if frontendURL == "" {
|
||||
return errors.New("管理端登录结果页不能为空")
|
||||
}
|
||||
if err := a.validateAdminOAuthRedirectURL(frontendURL); err != nil {
|
||||
return fmt.Errorf("管理端登录结果页无效:%w", err)
|
||||
}
|
||||
userFrontendURL := value("oauth.user.frontend_callback_url")
|
||||
if userFrontendURL == "" {
|
||||
return errors.New("客户端 H5 登录结果页不能为空")
|
||||
}
|
||||
if err := a.validateAdminOAuthRedirectURL(userFrontendURL); err != nil {
|
||||
return fmt.Errorf("客户端 H5 登录结果页无效:%w", err)
|
||||
}
|
||||
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"
|
||||
if !adminEnabled && !userEnabled {
|
||||
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
|
||||
}
|
||||
token, err := a.consumeAdminOAuthCodeAndIssueToken(r.Context(), strings.TrimSpace(req.Code), loginCode.AdminUserID.Int64)
|
||||
if err != nil {
|
||||
fail(w, http.StatusUnauthorized, 10001, err.Error())
|
||||
return
|
||||
}
|
||||
reply(w, map[string]any{"accessToken": token, "requiresLink": false})
|
||||
}
|
||||
|
||||
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))
|
||||
token, _ := a.token(adminID, "admin", realName, 8*time.Hour)
|
||||
reply(w, map[string]any{"accessToken": token, "requiresLink": false})
|
||||
}
|
||||
|
||||
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) consumeAdminOAuthCodeAndIssueToken(ctx context.Context, code string, adminID 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 "", 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 "", errors.New("第三方登录处理失败")
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected != 1 {
|
||||
return "", 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)
|
||||
token, err := a.token(adminID, "admin", realName, 8*time.Hour)
|
||||
if err != nil {
|
||||
return "", errors.New("创建登录令牌失败")
|
||||
}
|
||||
return token, 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
|
||||
}
|
||||
Reference in New Issue
Block a user