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

265 lines
9.5 KiB
Go

package app
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// This URL is registered in the Android/iOS manifest. Never accept a callback
// supplied by an unauthenticated caller (including arbitrary custom schemes).
const appOAuthCallbackURL = "xingyuim://oauth/callback"
func oauthClientPlatform(raw string) (string, error) {
switch raw {
case "", "h5":
return "h5", nil
case "app":
return "app", nil
default:
return "", errors.New("不支持的登录客户端")
}
}
func userOAuthEnabledKey(platform, provider string) string {
if platform == "app" {
return "oauth.app." + provider + ".enabled"
}
return "oauth.user." + provider + ".enabled"
}
func (a *App) appOAuthFrontendURL(ctx context.Context) (string, error) {
raw := a.configPlain(ctx, "oauth.app.frontend_callback_url", "")
if raw != appOAuthCallbackURL {
return "", errors.New("App 回调地址必须为 " + appOAuthCallbackURL)
}
return raw, nil
}
func validateNativeOAuthConfig(code string, value func(string) string) error {
prefix := "oauth.app." + code + "."
switch code {
case "wechat", "qq":
id := value(prefix + "client_id")
if id == "" || len(id) > 128 {
return fmt.Errorf("App %s AppID 未配置或过长", adminOAuthProviderNames[code])
}
if code == "wechat" && value(prefix+"client_secret") == "" {
return errors.New("App 微信 AppSecret 未配置")
}
case "google":
ids := strings.Split(value(prefix+"client_ids"), ",")
for _, id := range ids {
id = strings.TrimSpace(id)
if !strings.HasSuffix(id, ".apps.googleusercontent.com") || strings.ContainsAny(id, " \r\n\t/") {
return errors.New("App Google Client ID 列表无效,请用英文逗号分隔 Android/iOS 客户端 ID")
}
}
default:
return errors.New("不支持的 App 原生登录渠道")
}
return nil
}
func (a *App) appOAuthProvider(ctx context.Context, code string) (adminOAuthProvider, error) {
if code == "github" {
if _, err := a.appOAuthFrontendURL(ctx); err != nil {
return adminOAuthProvider{}, err
}
return a.adminOAuthProvider(ctx, code)
}
value := func(key string) string { return strings.TrimSpace(a.configPlain(ctx, key, "")) }
if err := validateNativeOAuthConfig(code, value); err != nil {
return adminOAuthProvider{}, err
}
p := adminOAuthProvider{Code: code, Name: adminOAuthProviderNames[code], ClientID: value("oauth.app." + code + ".client_id")}
switch code {
case "wechat":
p.ClientSecret = value("oauth.app.wechat.client_secret")
p.TokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token"
p.UserInfoURL = "https://api.weixin.qq.com/sns/userinfo"
case "qq":
p.OpenIDURL = "https://graph.qq.com/oauth2.0/me"
p.UserInfoURL = "https://graph.qq.com/user/get_user_info"
case "google":
p.UserInfoURL = "https://openidconnect.googleapis.com/v1/userinfo"
}
return p, nil
}
func (a *App) enabledAppOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) {
providers := []adminOAuthProvider{}
for _, code := range []string{"wechat", "qq", "github", "google"} {
if !a.configBool(ctx, userOAuthEnabledKey("app", code), false) {
continue
}
p, err := a.appOAuthProvider(ctx, code)
if err != nil {
return nil, err
}
providers = append(providers, p)
}
return providers, nil
}
func (a *App) issueUserOAuthCode(ctx context.Context, provider, platform, scope string, proofHash []byte, identity adminOAuthIdentity) (string, error) {
var userID sql.NullInt64
err := a.db.QueryRowContext(ctx, `SELECT user_id FROM user_oauth_identities WHERE provider=? AND identity_scope=? AND subject=?`, provider, scope, identity.Subject).Scan(&userID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
code := randomToken()
_, err = a.db.ExecContext(ctx, `INSERT INTO user_oauth_login_codes(code_hash,provider,subject,email,display_name,avatar_url,user_id,expires_at,client_platform,identity_scope,app_proof_hash) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, oauthHash(code), provider, identity.Subject, identity.Email, identity.DisplayName, identity.AvatarURL, userID, time.Now().Add(adminOAuthCodeTTL), platform, scope, proofHash)
return code, err
}
func validAppOAuthProof(expected []byte, proof string) bool {
return len(expected) == 0 || (len(proof) >= 32 && subtle.ConstantTimeCompare(expected, oauthHash(proof)) == 1)
}
func (a *App) validateUserOAuthCode(ctx context.Context, code userOAuthLoginCode, proof string) error {
if !validAppOAuthProof(code.AppProofHash, proof) {
return errors.New("授权结果与发起登录的 App 不匹配,请重新登录")
}
if !a.configBool(ctx, userOAuthEnabledKey(code.Platform, code.Provider), false) {
return errors.New("该客户端登录方式已停用")
}
return nil
}
func (a *App) userOAuthNative(w http.ResponseWriter, r *http.Request) {
var req struct {
Provider string `json:"provider"`
Code string `json:"code"`
AccessToken string `json:"accessToken"`
}
if decode(r, &req) != nil || len(req.Code) > 4096 || len(req.AccessToken) > 8192 {
fail(w, 400, 20001, "原生授权参数无效")
return
}
if req.Provider != "wechat" && req.Provider != "qq" && req.Provider != "google" {
fail(w, 400, 20001, "不支持的 App 原生登录渠道")
return
}
if !a.rateLimit(w, r, "user_oauth_native", clientIP(r), 20, 10*time.Minute) {
return
}
if !a.configBool(r.Context(), userOAuthEnabledKey("app", req.Provider), false) {
fail(w, 400, 20001, "该 App 登录方式未启用")
return
}
p, err := a.appOAuthProvider(r.Context(), req.Provider)
if err != nil {
fail(w, 400, 20001, "该 App 登录方式配置不完整")
return
}
var identity adminOAuthIdentity
if req.Provider == "wechat" {
if strings.TrimSpace(req.Code) == "" {
fail(w, 400, 20001, "微信授权码不能为空")
return
}
identity, err = a.fetchAdminOAuthIdentity(r.Context(), p, req.Code, "")
} else {
identity, err = a.fetchNativeTokenIdentity(r.Context(), p, req.AccessToken)
}
if err != nil {
fail(w, 400, 20001, "App 授权验证失败,请检查平台凭证、AppID 和 SDK 配置后重试")
return
}
// OpenID from a mobile application must not collide with a website OpenID.
scope := ""
if p.Code == "wechat" || p.Code == "qq" {
scope = "app:" + p.ClientID
}
code, err := a.issueUserOAuthCode(r.Context(), p.Code, "app", scope, nil, identity)
if err != nil {
fail(w, 500, 50001, "创建 App 登录凭证失败")
return
}
a.cleanupUserOAuthRecords(r.Context())
w.Header().Set("Cache-Control", "no-store")
reply(w, map[string]string{"oauthCode": code})
}
func googleTokenAudienceAllowed(audience, issuedTo, allowed string) bool {
// Both the recipient and the party to which the token was issued must be
// ours. A valid Google token minted for another application is insufficient.
ids := strings.Split(allowed, ",")
for i := range ids {
ids[i] = strings.TrimSpace(ids[i])
}
return audience != "" && issuedTo != "" && containsString(ids, audience) && containsString(ids, issuedTo)
}
func (a *App) fetchNativeTokenIdentity(ctx context.Context, p adminOAuthProvider, token string) (adminOAuthIdentity, error) {
if strings.TrimSpace(token) == "" {
return adminOAuthIdentity{}, errors.New("missing access token")
}
if p.Code == "qq" {
var info struct {
ClientID string `json:"client_id"`
OpenID string `json:"openid"`
Error int `json:"error"`
}
if err := a.oauthGetJSON(ctx, p.OpenIDURL, url.Values{"access_token": {token}, "fmt": {"json"}}, &info); err != nil {
return adminOAuthIdentity{}, err
}
if info.Error != 0 || info.OpenID == "" || info.ClientID != p.ClientID {
return adminOAuthIdentity{}, errors.New("QQ token audience mismatch")
}
var profile struct {
Ret int `json:"ret"`
Name string `json:"nickname"`
Picture string `json:"figureurl_qq_2"`
}
if err := a.oauthGetJSON(ctx, p.UserInfoURL, url.Values{"access_token": {token}, "oauth_consumer_key": {p.ClientID}, "openid": {info.OpenID}, "format": {"json"}}, &profile); err != nil {
return adminOAuthIdentity{}, err
}
if profile.Ret != 0 {
return adminOAuthIdentity{}, errors.New("QQ user info rejected")
}
return adminOAuthIdentity{Subject: info.OpenID, DisplayName: profile.Name, AvatarURL: profile.Picture}, nil
}
if p.Code != "google" {
return adminOAuthIdentity{}, errors.New("unsupported token provider")
}
var info struct {
Audience string `json:"audience"`
IssuedTo string `json:"issued_to"`
UserID string `json:"user_id"`
ExpiresIn json.Number `json:"expires_in"`
}
if err := a.oauthGetJSON(ctx, "https://www.googleapis.com/oauth2/v1/tokeninfo", url.Values{"access_token": {token}}, &info); err != nil {
return adminOAuthIdentity{}, err
}
expires, _ := info.ExpiresIn.Int64()
if expires <= 0 || info.UserID == "" || !googleTokenAudienceAllowed(info.Audience, info.IssuedTo, a.configPlain(ctx, "oauth.app.google.client_ids", "")) {
return adminOAuthIdentity{}, errors.New("Google token audience or expiry invalid")
}
var profile struct {
Subject string `json:"sub"`
Name string `json:"name"`
Picture string `json:"picture"`
Email string `json:"email"`
Verified bool `json:"email_verified"`
}
if err := a.oauthBearerJSON(ctx, p.UserInfoURL, token, &profile); err != nil {
return adminOAuthIdentity{}, err
}
if profile.Subject != info.UserID {
return adminOAuthIdentity{}, errors.New("Google token subject mismatch")
}
if !profile.Verified {
profile.Email = ""
}
return adminOAuthIdentity{Subject: profile.Subject, DisplayName: profile.Name, AvatarURL: profile.Picture, Email: profile.Email}, nil
}