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

402 lines
15 KiB
Go

package app
import (
"context"
"database/sql"
"errors"
"net/http"
"net/url"
"strings"
"time"
)
type userOAuthLoginCode struct {
Provider string
Platform string
IdentityScope string
AppProofHash []byte
Subject string
Email string
DisplayName string
AvatarURL string
UserID sql.NullInt64
}
func (a *App) userOAuthFrontendURL(ctx context.Context) (string, error) {
raw := strings.TrimSpace(a.configPlain(ctx, "oauth.user.frontend_callback_url", ""))
if raw == "" {
return "", errors.New("客户端第三方登录结果页未配置")
}
if err := a.validateAdminOAuthRedirectURL(raw); err != nil {
return "", err
}
return raw, nil
}
func (a *App) enabledUserOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) {
providers := make([]adminOAuthProvider, 0, len(adminOAuthProviderNames))
for _, code := range []string{"wechat", "qq", "github", "google"} {
if !a.configBool(ctx, "oauth.user."+code+".enabled", false) {
continue
}
provider, err := a.adminOAuthProvider(ctx, code)
if err != nil {
return nil, err
}
providers = append(providers, provider)
}
return providers, nil
}
// oauthCallback lets one provider callback URL safely serve both the admin
// console and the uni-app H5 client. The random state value selects the
// audience; it is never accepted by both state tables.
func (a *App) oauthCallback(w http.ResponseWriter, r *http.Request) {
state := strings.TrimSpace(r.URL.Query().Get("state"))
if state != "" {
var exists int
if a.db.QueryRowContext(r.Context(), `SELECT 1 FROM user_oauth_states WHERE state_hash=?`, oauthHash(state)).Scan(&exists) == nil {
a.userOAuthCallback(w, r)
return
}
if a.db.QueryRowContext(r.Context(), `SELECT 1 FROM admin_oauth_states WHERE state_hash=?`, oauthHash(state)).Scan(&exists) == nil {
a.adminOAuthCallback(w, r)
return
}
}
if strings.HasPrefix(r.URL.Path, "/api/") {
a.userOAuthCallback(w, r)
return
}
a.adminOAuthCallback(w, r)
}
func (a *App) userOAuthProviders(w http.ResponseWriter, r *http.Request) {
platform, err := oauthClientPlatform(r.URL.Query().Get("platform"))
if err != nil {
fail(w, 400, 20001, err.Error())
return
}
items := make([]map[string]string, 0, len(adminOAuthProviderNames))
for _, code := range []string{"wechat", "qq", "github", "google"} {
if !a.configBool(r.Context(), userOAuthEnabledKey(platform, code), false) {
continue
}
var provider adminOAuthProvider
var err error
if platform == "app" {
provider, err = a.appOAuthProvider(r.Context(), code)
} else {
provider, err = a.adminOAuthProvider(r.Context(), code)
}
if err != nil {
continue
}
items = append(items, map[string]string{"code": provider.Code, "name": provider.Name})
}
w.Header().Set("Cache-Control", "no-store")
reply(w, map[string]any{"items": items})
}
func (a *App) userOAuthStart(w http.ResponseWriter, r *http.Request) {
var req struct {
Provider string `json:"provider"`
Platform string `json:"platform"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "请选择第三方登录渠道")
return
}
req.Provider = strings.ToLower(strings.TrimSpace(req.Provider))
platform, err := oauthClientPlatform(req.Platform)
if err != nil {
fail(w, 400, 20001, err.Error())
return
}
if platform == "app" && req.Provider != "github" {
fail(w, 400, 20001, "该 App 渠道需使用原生 SDK 授权")
return
}
if !a.rateLimit(w, r, "user_oauth_start", clientIP(r), 30, 10*time.Minute) {
return
}
if !a.configBool(r.Context(), userOAuthEnabledKey(platform, req.Provider), false) {
fail(w, http.StatusBadRequest, 20001, "该客户端登录方式未启用")
return
}
provider, err := a.adminOAuthProvider(r.Context(), req.Provider)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "该登录方式配置不完整")
return
}
if platform == "app" {
_, err = a.appOAuthFrontendURL(r.Context())
} else {
_, err = a.userOAuthFrontendURL(r.Context())
}
if err != nil {
fail(w, http.StatusBadRequest, 20001, "客户端登录结果页配置不完整")
return
}
state := randomToken()
verifier := ""
if provider.Code == "github" || provider.Code == "google" {
verifier = randomToken() + randomToken()
}
appProof := ""
var proofHash []byte
if platform == "app" {
appProof = randomToken()
proofHash = oauthHash(appProof)
}
_, err = a.db.ExecContext(r.Context(), `INSERT INTO user_oauth_states(state_hash,provider,code_verifier,expires_at,client_platform,app_proof_hash) VALUES(?,?,?,?,?,?)`, oauthHash(state), provider.Code, verifier, time.Now().Add(adminOAuthStateTTL), platform, proofHash)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建第三方登录请求失败")
return
}
a.cleanupUserOAuthRecords(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"
}
w.Header().Set("Cache-Control", "no-store")
response := map[string]string{"authorizationUrl": authorizationURL.String(), "provider": provider.Code}
if platform == "app" {
response["appProof"] = appProof
response["requestId"] = state
response["callbackUrl"] = appOAuthCallbackURL
}
reply(w, response)
}
func (a *App) userOAuthCallback(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
state := strings.TrimSpace(r.URL.Query().Get("state"))
var providerCode, verifier, platform string
var proofHash []byte
err := a.db.QueryRowContext(r.Context(), `SELECT provider,code_verifier,client_platform,app_proof_hash FROM user_oauth_states WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)).Scan(&providerCode, &verifier, &platform, &proofHash)
if err != nil {
fail(w, 400, 20001, "登录状态无效或已过期,请返回客户端重试")
return
}
var frontendURL string
if platform == "app" {
frontendURL, err = a.appOAuthFrontendURL(r.Context())
if err == nil {
frontendURL += "?requestId=" + url.QueryEscape(state)
}
} else {
frontendURL, err = a.userOAuthFrontendURL(r.Context())
}
if err != nil {
fail(w, 503, 50001, "客户端第三方登录回调未配置")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE user_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.redirectUserOAuthResult(w, r, frontendURL, "", "第三方登录处理失败")
return
}
affected, _ := result.RowsAffected()
if affected != 1 {
a.redirectUserOAuthResult(w, r, frontendURL, "", "登录状态已被使用")
return
}
if strings.TrimSpace(r.URL.Query().Get("error")) != "" {
a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方授权已取消或失败")
return
}
code := strings.TrimSpace(r.URL.Query().Get("code"))
if code == "" {
a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方平台未返回授权码")
return
}
if !a.configBool(r.Context(), userOAuthEnabledKey(platform, providerCode), false) {
a.redirectUserOAuthResult(w, r, frontendURL, "", "该客户端登录方式已停用")
return
}
provider, err := a.adminOAuthProvider(r.Context(), providerCode)
if err != nil {
a.redirectUserOAuthResult(w, r, frontendURL, "", "该登录方式配置不可用")
return
}
identity, err := a.fetchAdminOAuthIdentity(r.Context(), provider, code, verifier)
if err != nil {
a.redirectUserOAuthResult(w, r, frontendURL, "", "获取第三方账号信息失败")
return
}
loginCode, err := a.issueUserOAuthCode(r.Context(), provider.Code, platform, "", proofHash, identity)
if err != nil {
a.redirectUserOAuthResult(w, r, frontendURL, "", "创建登录凭证失败")
return
}
a.redirectUserOAuthResult(w, r, frontendURL, loginCode, "")
}
func (a *App) redirectUserOAuthResult(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) userOAuthExchange(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
DeviceID string `json:"deviceId"`
AppProof string `json:"appProof"`
}
if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" {
fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效")
return
}
if !a.rateLimit(w, r, "user_oauth_exchange", clientIP(r), 20, 10*time.Minute) {
return
}
loginCode, err := a.readUserOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code))
if err != nil {
fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期")
return
}
if err = a.validateUserOAuthCode(r.Context(), loginCode, req.AppProof); err != nil {
fail(w, 400, 20001, err.Error())
return
}
if !loginCode.UserID.Valid {
reply(w, map[string]any{
"requiresLink": true,
"provider": loginCode.Provider,
"providerName": adminOAuthProviderNames[loginCode.Provider],
"displayName": loginCode.DisplayName,
"avatarUrl": loginCode.AvatarURL,
})
return
}
userID, nickname, err := a.consumeUserOAuthCode(r.Context(), strings.TrimSpace(req.Code), loginCode.UserID.Int64)
if err != nil {
fail(w, http.StatusUnauthorized, 10001, err.Error())
return
}
a.finishLogin(w, r, userID, nickname, req.DeviceID)
}
func (a *App) userOAuthLink(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
Phone string `json:"phone"`
SMSCode string `json:"smsCode"`
DeviceID string `json:"deviceId"`
AppProof string `json:"appProof"`
}
if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || !validPhone(req.Phone) || len(req.SMSCode) != 6 {
fail(w, http.StatusBadRequest, 20001, "请输入已注册手机号和正确的短信验证码")
return
}
phone := strings.TrimSpace(req.Phone)
if !a.rateLimit(w, r, "user_oauth_link_ip", clientIP(r), 10, 15*time.Minute) || !a.rateLimit(w, r, "user_oauth_link_phone", phone, 10, 15*time.Minute) {
return
}
loginCode, err := a.readUserOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code))
if err != nil || loginCode.UserID.Valid {
fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效、已绑定或已过期")
return
}
if err = a.validateUserOAuthCode(r.Context(), loginCode, req.AppProof); err != nil {
fail(w, 400, 20001, err.Error())
return
}
var userID int64
var nickname string
var status int
err = a.db.QueryRowContext(r.Context(), `SELECT u.id,p.nickname,u.status FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.phone_hash=? AND u.deleted_at IS NULL`, phoneHash(phone)).Scan(&userID, &nickname, &status)
if err != nil || status != 1 {
fail(w, http.StatusBadRequest, 20001, "手机号未注册或账号当前不可用")
return
}
if !a.consumeSMSCode(r, phone, "login", req.SMSCode) {
fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期")
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 user_oauth_login_codes SET used_at=NOW(3),user_id=? WHERE code_hash=? AND user_id IS NULL AND used_at IS NULL AND expires_at>NOW(3)`, userID, 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 user_oauth_identities(provider,subject,user_id,email,display_name,avatar_url,identity_scope,last_login_at) VALUES(?,?,?,?,?,?,?,NOW(3))`, loginCode.Provider, loginCode.Subject, userID, loginCode.Email, loginCode.DisplayName, loginCode.AvatarURL, loginCode.IdentityScope)
if err != nil {
fail(w, http.StatusConflict, 20001, "该第三方账号或手机号已绑定此渠道")
return
}
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败")
return
}
a.finishLogin(w, r, userID, nickname, req.DeviceID)
}
func (a *App) readUserOAuthLoginCode(ctx context.Context, code string) (userOAuthLoginCode, error) {
var result userOAuthLoginCode
err := a.db.QueryRowContext(ctx, `SELECT provider,subject,email,display_name,avatar_url,user_id,client_platform,identity_scope,app_proof_hash FROM user_oauth_login_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code)).Scan(&result.Provider, &result.Subject, &result.Email, &result.DisplayName, &result.AvatarURL, &result.UserID, &result.Platform, &result.IdentityScope, &result.AppProofHash)
return result, err
}
func (a *App) consumeUserOAuthCode(ctx context.Context, code string, userID int64) (int64, string, error) {
var nickname string
var status int
if err := a.db.QueryRowContext(ctx, `SELECT p.nickname,u.status FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, userID).Scan(&nickname, &status); err != nil || status != 1 {
return 0, "", errors.New("账号不存在或当前不可用")
}
result, err := a.db.ExecContext(ctx, `UPDATE user_oauth_login_codes SET used_at=NOW(3) WHERE code_hash=? AND user_id=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code), userID)
if err != nil {
return 0, "", errors.New("第三方登录处理失败")
}
affected, _ := result.RowsAffected()
if affected != 1 {
return 0, "", errors.New("第三方登录凭证无效或已使用")
}
_, _ = a.db.ExecContext(ctx, `UPDATE user_oauth_identities SET last_login_at=NOW(3) WHERE user_id=?`, userID)
return userID, nickname, nil
}
func (a *App) cleanupUserOAuthRecords(ctx context.Context) {
_, _ = a.db.ExecContext(ctx, `DELETE FROM user_oauth_states WHERE expires_at<DATE_SUB(NOW(3),INTERVAL 1 DAY) LIMIT 500`)
_, _ = a.db.ExecContext(ctx, `DELETE FROM user_oauth_login_codes WHERE expires_at<DATE_SUB(NOW(3),INTERVAL 1 DAY) LIMIT 500`)
}