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 }