773 lines
29 KiB
Go
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
|
|
}
|