265 lines
9.5 KiB
Go
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
|
|
}
|