402 lines
15 KiB
Go
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`)
|
|
}
|