362 lines
15 KiB
Go
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")
|
|
}
|
|
}
|