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

362 lines
15 KiB
Go

package app
import (
"bytes"
"context"
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// Small in-memory SQL adapter keeps these HTTP policy tests independent of a
// developer's database and credentials. Unexpected queries fail the test.
type oauthTestDB struct {
values map[string]string
code userOAuthLoginCode
codeUsed bool
stateHash []byte
stateProvider, stateVerifier, statePlatform string
stateProof []byte
stateUsed bool
}
type oauthTestConnector struct{ db *oauthTestDB }
func (c oauthTestConnector) Connect(context.Context) (driver.Conn, error) { return c.db, nil }
func (c oauthTestConnector) Driver() driver.Driver { return oauthTestDriver{} }
type oauthTestDriver struct{}
func (oauthTestDriver) Open(string) (driver.Conn, error) { return nil, fmt.Errorf("use connector") }
func (*oauthTestDB) Prepare(string) (driver.Stmt, error) {
return nil, fmt.Errorf("unexpected prepare")
}
func (*oauthTestDB) Close() error { return nil }
func (*oauthTestDB) Begin() (driver.Tx, error) { return nil, fmt.Errorf("unexpected transaction") }
type oauthTestRows struct {
columns []string
values [][]driver.Value
}
func (r *oauthTestRows) Columns() []string { return r.columns }
func (*oauthTestRows) Close() error { return nil }
func (r *oauthTestRows) Next(dest []driver.Value) error {
if len(r.values) == 0 {
return io.EOF
}
copy(dest, r.values[0])
r.values = r.values[1:]
return nil
}
func oauthRow(values ...driver.Value) driver.Rows {
columns := make([]string, len(values))
for i := range columns {
columns[i] = fmt.Sprintf("c%d", i)
}
return &oauthTestRows{columns: columns, values: [][]driver.Value{values}}
}
func (db *oauthTestDB) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
switch {
case strings.Contains(query, "FROM system_configs"):
value, ok := db.values[args[0].Value.(string)]
if !ok {
return &oauthTestRows{columns: []string{"config_value", "value_type"}}, nil
}
return oauthRow(value, "string"), nil
case strings.Contains(query, "SELECT hits FROM api_rate_limits"):
return oauthRow(int64(1)), nil
case strings.Contains(query, "SELECT provider,code_verifier,client_platform,app_proof_hash FROM user_oauth_states"):
if db.stateUsed || !bytes.Equal(db.stateHash, args[0].Value.([]byte)) {
return &oauthTestRows{columns: []string{"provider", "verifier", "platform", "proof"}}, nil
}
return oauthRow(db.stateProvider, db.stateVerifier, db.statePlatform, db.stateProof), nil
case strings.Contains(query, "SELECT user_id FROM user_oauth_identities"):
return &oauthTestRows{columns: []string{"user_id"}}, nil
case strings.Contains(query, "FROM user_oauth_login_codes"):
c := db.code
return oauthRow(c.Provider, c.Subject, c.Email, c.DisplayName, c.AvatarURL, nil, c.Platform, c.IdentityScope, c.AppProofHash), nil
case strings.Contains(query, "SELECT p.nickname,u.status"):
return oauthRow("tester", int64(1)), nil
default:
return nil, fmt.Errorf("unexpected query: %s", query)
}
}
func (db *oauthTestDB) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
switch {
case strings.Contains(query, "INSERT INTO user_oauth_states"):
db.stateHash = args[0].Value.([]byte)
db.stateProvider, db.stateVerifier, db.statePlatform = args[1].Value.(string), args[2].Value.(string), args[4].Value.(string)
db.stateProof, _ = args[5].Value.([]byte)
return driver.RowsAffected(1), nil
case strings.Contains(query, "UPDATE user_oauth_states"):
if db.stateUsed || !bytes.Equal(db.stateHash, args[0].Value.([]byte)) {
return driver.RowsAffected(0), nil
}
db.stateUsed = true
return driver.RowsAffected(1), nil
case strings.Contains(query, "INSERT INTO user_oauth_login_codes"):
db.code.Provider, db.code.Subject, db.code.Platform, db.code.IdentityScope = args[1].Value.(string), args[2].Value.(string), args[8].Value.(string), args[9].Value.(string)
db.code.AppProofHash, _ = args[10].Value.([]byte)
return driver.RowsAffected(1), nil
case strings.HasPrefix(query, "DELETE FROM user_oauth_"):
return driver.RowsAffected(0), nil
case strings.Contains(query, "api_rate_limits"), strings.Contains(query, "UPDATE user_oauth_identities SET last_login_at"):
return driver.RowsAffected(1), nil
case strings.Contains(query, "UPDATE user_oauth_login_codes SET used_at"):
if db.codeUsed {
return driver.RowsAffected(0), nil
}
db.codeUsed = true
return driver.RowsAffected(1), nil
default:
return nil, fmt.Errorf("unexpected exec: %s", query)
}
}
func oauthTestApp(t *testing.T, values map[string]string) (*App, *oauthTestDB) {
t.Helper()
store := &oauthTestDB{values: values}
db := sql.OpenDB(oauthTestConnector{store})
t.Cleanup(func() { _ = db.Close() })
return &App{db: db, config: Config{Environment: "development"}}, store
}
func oauthPost(handler http.HandlerFunc, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/api/v1/auth/oauth/test", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, r)
return w
}
func TestAppOAuthProviderSwitchesAndConfiguration(t *testing.T) {
app, store := oauthTestApp(t, map[string]string{
"oauth.user.qq.enabled": "true", "oauth.app.qq.enabled": "false",
"oauth.app.qq.client_id": "mobile-id", "oauth.app.wechat.enabled": "true",
})
list := func(platform string) string {
w := httptest.NewRecorder()
app.userOAuthProviders(w, httptest.NewRequest("GET", "/?platform="+platform, nil))
return w.Body.String()
}
if strings.Contains(list("app"), `"code":"qq"`) {
t.Fatal("H5 switch must not enable App")
}
store.values["oauth.app.qq.enabled"] = "true"
body := list("app")
if !strings.Contains(body, `"code":"qq"`) || strings.Contains(body, `"code":"wechat"`) {
t.Fatalf("only complete, enabled channels should be returned: %s", body)
}
if strings.Contains(body, "mobile-id") {
t.Fatal("provider discovery must not return credentials")
}
w := httptest.NewRecorder()
app.userOAuthProviders(w, httptest.NewRequest("GET", "/?platform=unknown", nil))
if w.Code != 400 {
t.Fatal("unknown platform must be rejected")
}
}
func TestDisabledAppOAuthBlocksEveryLoginEntry(t *testing.T) {
app, store := oauthTestApp(t, map[string]string{"oauth.user.qq.enabled": "true", "oauth.app.qq.enabled": "false"})
store.code = userOAuthLoginCode{Provider: "qq", Platform: "app", Subject: "openid"}
for name, handler := range map[string]http.HandlerFunc{"native": app.userOAuthNative, "exchange": app.userOAuthExchange, "link": app.userOAuthLink} {
bodies := map[string]string{
"native": `{"provider":"qq","accessToken":"token"}`,
"exchange": `{"code":"ticket"}`,
"link": `{"code":"ticket","phone":"13800138000","smsCode":"123456"}`,
}
w := oauthPost(handler, bodies[name])
if w.Code != 400 || !(strings.Contains(w.Body.String(), "停用") || strings.Contains(w.Body.String(), "未启用")) {
t.Fatalf("%s accepted a disabled App provider: %d %s", name, w.Code, w.Body.String())
}
}
}
func TestAppOAuthCallbackProofAndCodeReplay(t *testing.T) {
proof := strings.Repeat("p", 43)
app, store := oauthTestApp(t, map[string]string{"oauth.app.github.enabled": "true"})
store.code = userOAuthLoginCode{Provider: "github", Platform: "app", AppProofHash: oauthHash(proof)}
for _, supplied := range []string{"", strings.Repeat("x", 43)} {
w := oauthPost(app.userOAuthExchange, `{"code":"intercepted-ticket","appProof":"`+supplied+`"}`)
if w.Code != 400 {
t.Fatal("intercepted callback must not authorize a different App instance")
}
}
w := oauthPost(app.userOAuthExchange, `{"code":"ticket","appProof":"`+proof+`"}`)
if w.Code != 200 || !strings.Contains(w.Body.String(), `"requiresLink":true`) {
t.Fatalf("correct proof rejected: %s", w.Body.String())
}
if _, _, err := app.consumeUserOAuthCode(context.Background(), "ticket", 42); err != nil {
t.Fatal(err)
}
if _, _, err := app.consumeUserOAuthCode(context.Background(), "ticket", 42); err == nil {
t.Fatal("login ticket reused")
}
}
func TestAppOAuthConfigurationAndRouting(t *testing.T) {
values := map[string]string{
"oauth.admin.frontend_callback_url": "http://localhost:5560/auth/social-callback",
"oauth.user.frontend_callback_url": "http://localhost:5174/#/pages/auth/oauth-callback",
"oauth.app.qq.enabled": "true", "oauth.app.qq.client_id": "mobile-id",
}
app, _ := oauthTestApp(t, map[string]string{})
app.config.Environment = "production"
if err := app.validateAdminOAuthConfigValues(context.Background(), values, nil); err != nil {
t.Fatalf("native-only config should not require QQ website credentials: %v", err)
}
delete(values, "oauth.app.qq.client_id")
if err := app.validateAdminOAuthConfigValues(context.Background(), values, nil); err == nil {
t.Fatal("enabled incomplete native config accepted")
}
if validAppOAuthProof(oauthHash("required"), "") {
t.Fatal("missing callback proof accepted")
}
if googleTokenAudienceAllowed("other", "other", "ours") || googleTokenAudienceAllowed("ours", "other", "ours") {
t.Fatal("foreign Google application accepted")
}
if !googleTokenAudienceAllowed("android", "ios", "android, ios") {
t.Fatal("allowed app clients rejected")
}
app, _ = oauthTestApp(t, map[string]string{"oauth.app.frontend_callback_url": "javascript:alert(1)"})
if _, err := app.appOAuthFrontendURL(context.Background()); err == nil {
t.Fatal("unsafe callback accepted")
}
}
type oauthRoundTripper func(*http.Request) (*http.Response, error)
func (f oauthRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func mockOAuthHTTP(t *testing.T, responder func(*http.Request) string) {
t.Helper()
original := http.DefaultTransport
http.DefaultTransport = oauthRoundTripper(func(r *http.Request) (*http.Response, error) {
if r.URL.Scheme != "https" {
t.Fatal("credentials sent over insecure transport")
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(responder(r)))}, nil
})
t.Cleanup(func() { http.DefaultTransport = original })
}
func TestNativeQQValidatesTokenOwner(t *testing.T) {
app := &App{}
clientID := "foreign-app"
profileCalls := 0
mockOAuthHTTP(t, func(r *http.Request) string {
if r.URL.Path == "/oauth2.0/me" {
return `{"openid":"verified-user","client_id":"` + clientID + `"}`
}
profileCalls++
if r.URL.Query().Get("openid") != "verified-user" {
t.Fatal("unverified OpenID used")
}
return `{"ret":0,"nickname":"tester","figureurl_qq_2":"https://example.com/avatar.png"}`
})
p := adminOAuthProvider{Code: "qq", ClientID: "our-app", OpenIDURL: "https://graph.qq.com/oauth2.0/me", UserInfoURL: "https://graph.qq.com/user/get_user_info"}
if _, err := app.fetchNativeTokenIdentity(context.Background(), p, "token"); err == nil || profileCalls != 0 {
t.Fatal("foreign QQ token was accepted")
}
clientID = "our-app"
identity, err := app.fetchNativeTokenIdentity(context.Background(), p, "token")
if err != nil || identity.Subject != "verified-user" {
t.Fatalf("valid QQ token rejected: %v", err)
}
}
func TestNativeGoogleChecksAudienceExpiryAndSubject(t *testing.T) {
app, _ := oauthTestApp(t, map[string]string{"oauth.app.google.client_ids": "our-app"})
audience, expires, subject := "other-app", "3600", "verified-user"
mockOAuthHTTP(t, func(r *http.Request) string {
if r.URL.Path == "/oauth2/v1/tokeninfo" {
return `{"audience":"` + audience + `","issued_to":"` + audience + `","user_id":"verified-user","expires_in":` + expires + `}`
}
if r.Header.Get("Authorization") != "Bearer token" {
t.Fatal("missing bearer token")
}
return `{"sub":"` + subject + `","name":"tester","email":"unverified@example.com","email_verified":false}`
})
p := adminOAuthProvider{Code: "google", UserInfoURL: "https://openidconnect.googleapis.com/v1/userinfo"}
checkRejected := func() {
t.Helper()
if _, err := app.fetchNativeTokenIdentity(context.Background(), p, "token"); err == nil {
t.Fatal("invalid Google token accepted")
}
}
checkRejected()
audience, expires = "our-app", "0"
checkRejected()
expires, subject = "3600", "another-user"
checkRejected()
subject = "verified-user"
identity, err := app.fetchNativeTokenIdentity(context.Background(), p, "token")
if err != nil || identity.Subject != subject || identity.Email != "" {
t.Fatalf("verified Google token failed: %#v %v", identity, err)
}
}
func TestAppGitHubAuthorizationRoundTrip(t *testing.T) {
app, store := oauthTestApp(t, map[string]string{
"oauth.app.github.enabled": "true",
"oauth.app.frontend_callback_url": appOAuthCallbackURL,
"oauth.github.client_id": "our-github-app",
"oauth.github.client_secret": "server-secret",
"oauth.github.authorization_url": "https://github.com/login/oauth/authorize",
"oauth.github.token_url": "https://github.com/login/oauth/access_token",
"oauth.github.userinfo_url": "https://api.github.com/user",
"oauth.github.scope": "read:user",
"oauth.github.redirect_uri": "https://api.example.com/api/v1/auth/oauth/callback",
})
mockOAuthHTTP(t, func(r *http.Request) string {
if r.URL.Path == "/login/oauth/access_token" {
_ = r.ParseForm()
if r.Form.Get("code_verifier") != store.stateVerifier || r.Form.Get("code") != "provider-code" {
t.Fatal("missing PKCE verifier or code")
}
return `{"access_token":"provider-token"}`
}
return `{"id":123,"login":"tester"}`
})
w := oauthPost(app.userOAuthStart, `{"provider":"github","platform":"app"}`)
var start struct {
Data map[string]string `json:"data"`
}
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &start) != nil {
t.Fatalf("start failed: %s", w.Body.String())
}
authURL, _ := url.Parse(start.Data["authorizationUrl"])
if authURL.Query().Get("state") != start.Data["requestId"] || authURL.Query().Get("code_challenge") != pkceChallenge(store.stateVerifier) {
t.Fatal("authorization is not bound to the request")
}
if !bytes.Equal(store.stateProof, oauthHash(start.Data["appProof"])) {
t.Fatal("proof not hashed at rest")
}
callback := "/api/v1/auth/oauth/callback?state=" + url.QueryEscape(start.Data["requestId"]) + "&code=provider-code"
w = httptest.NewRecorder()
app.userOAuthCallback(w, httptest.NewRequest("GET", callback, nil))
resultURL, err := url.Parse(w.Header().Get("Location"))
if w.Code != 302 || err != nil || resultURL.Scheme != "xingyuim" {
t.Fatalf("App callback failed: %d %s", w.Code, w.Body.String())
}
if resultURL.Query().Get("requestId") != start.Data["requestId"] || resultURL.Query().Get("oauthCode") == "" {
t.Fatal("App callback missing correlation or ticket")
}
if strings.Contains(resultURL.String(), start.Data["appProof"]) {
t.Fatal("private proof leaked into browser redirect")
}
if store.code.Platform != "app" || !bytes.Equal(store.code.AppProofHash, store.stateProof) {
t.Fatal("login ticket lost its App binding")
}
w = httptest.NewRecorder()
app.userOAuthCallback(w, httptest.NewRequest("GET", callback, nil))
if w.Code != 400 {
t.Fatal("authorization callback replay accepted")
}
}