Files
sm-user-plugin/oauth_identities_test.go
Jakub Zych 174161895e fix: atomic unlink with the PHP last-identity rule
- OAuthIdentitiesDestroy locks the users row (SELECT ... FOR UPDATE) and runs
  the PHP guarded DELETE in one transaction: a self-set password or another
  identity allows the unlink; otherwise 409 with the unchanged body
- the default allow-list adds microsoft, and an OAuthProviderSource's known
  names are used when the host passes no Providers
- TestOAuthIdentitiesDestroyLastIdentityWithPassword (204),
  TestOAuthIdentitiesDestroyConcurrentSmoke; README
- gofmt of the forgotPasswordBody line touched in the previous commit
2026-10-20 23:05:14 +02:00

421 lines
15 KiB
Go

package user
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
const (
lastMethodBlockedEN = "This is the only remaining way to sign in. Link another method before disconnecting this one."
lastMethodBlockedPL = "To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę."
winter404Body = `<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<title>Nie znaleziono strony</title>
<link href="http://127.0.0.1:8423/modules/system/assets/css/styles.css" rel="stylesheet">
</head>
<body>
<div class="container">
<h1><i class="icon-chain-broken warning"></i> Nie znaleziono strony</h1>
<p class="lead">Żądana strona nie została znaleziona.</p>
</div>
</body>
</html>`
)
func TestOAuthIdentitiesIndexEmptyList(t *testing.T) {
app, db := sessionApp(t, "0s")
user := insertUser(t, db, "oauth-empty@example.test", "correct-horse")
req := httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/oauth-identities", nil)
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID}))
rec := httptest.NewRecorder()
controllers.OAuthIdentitiesIndex(app).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != `{"data":[]}` {
t.Fatalf("body = %s", rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != 1 {
t.Fatalf("top-level keys = %#v", body)
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-cache") {
t.Fatalf("Cache-Control = %q", cc)
}
}
func TestOAuthIdentitiesDestroyNoContentKeepsSibling(t *testing.T) {
app, db := sessionApp(t, "0s")
user := insertUser(t, db, "oauth-unlink@example.test", "correct-horse")
for _, row := range []models.OAuthIdentity{
{UserID: user.ID, Provider: "facebook", ProviderID: "fb-alice"},
{UserID: user.ID, Provider: "google", ProviderID: "goog-alice"},
} {
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
req := httptest.NewRequest(http.MethodDelete, "/_fonoteka/api/v1/oauth-identities/facebook", nil)
req.SetPathValue("provider", "facebook")
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID}))
rec := httptest.NewRecorder()
controllers.OAuthIdentitiesDestroy(app, controllers.OAuthIdentitiesOptions{
WriteNotFound: func(http.ResponseWriter, *http.Request) {
t.Fatal("WriteNotFound called")
},
}).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.Len() != 0 {
t.Fatalf("body = %q", rec.Body.String())
}
var leftover []models.OAuthIdentity
if err := db.Where("user_id = ?", user.ID).Order("provider").Find(&leftover).Error; err != nil {
t.Fatal(err)
}
if len(leftover) != 1 || leftover[0].Provider != "google" {
t.Fatalf("remaining = %#v", leftover)
}
}
func TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL(t *testing.T) {
app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app)
for _, tc := range []struct {
locale, email, want string
}{
{"en", "oauth-last-en@example.test", lastMethodBlockedEN},
{"pl", "oauth-last-pl@example.test", lastMethodBlockedPL},
} {
t.Run(tc.locale, func(t *testing.T) {
user := insertUser(t, db, tc.email, "correct-horse")
// The column defaults to TRUE; a user without a self-set
// password is the one the last identity is kept for.
if err := db.Model(user).Update("has_self_set_password", false).Error; err != nil {
t.Fatal(err)
}
seedOAuthIdentity(t, db, user.ID, "facebook", "fb-only-"+tc.locale)
rec := destroyOAuthIdentity(t, app, user.ID, "facebook", tc.locale, winter404OrFatal(t))
if rec.Code != http.StatusConflict {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["error"] != tc.want {
t.Fatalf("golem15.user::lang.oauth.last_method_blocked %s = %#v", tc.locale, body["error"])
}
if remaining := countOAuthIdentities(t, db, user.ID); remaining != 1 {
t.Fatalf("row still present = %d", remaining)
}
})
}
}
// TestOAuthIdentitiesDestroyLastIdentityWithPassword: under the PHP rule a
// user with a self-set password may unlink the last identity (D-13).
func TestOAuthIdentitiesDestroyLastIdentityWithPassword(t *testing.T) {
app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app)
user := insertUser(t, db, "oauth-last-password@example.test", "correct-horse")
if err := db.Model(user).Update("has_self_set_password", true).Error; err != nil {
t.Fatal(err)
}
seedOAuthIdentity(t, db, user.ID, "google", "g-only")
rec := destroyOAuthIdentity(t, app, user.ID, "google", "en", winter404OrFatal(t))
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.Len() != 0 {
t.Fatalf("body = %q", rec.Body.String())
}
if remaining := countOAuthIdentities(t, db, user.ID); remaining != 0 {
t.Fatalf("identities left = %d, want 0", remaining)
}
}
// TestOAuthIdentitiesDestroyConcurrentSmoke: two unlinks on a password-less
// user with two identities never remove both (D-13, RESEARCH Pitfall 1).
// Transaction A holds the users row lock and has deleted google; the
// handler's unlink of facebook must wait for A, then see one identity left
// and answer 409.
func TestOAuthIdentitiesDestroyConcurrentSmoke(t *testing.T) {
app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app)
user := insertUser(t, db, "oauth-concurrent@example.test", "correct-horse")
if err := db.Model(user).Update("has_self_set_password", false).Error; err != nil {
t.Fatal(err)
}
seedOAuthIdentity(t, db, user.ID, "google", "g-concurrent")
seedOAuthIdentity(t, db, user.ID, "facebook", "fb-concurrent")
txA := db.Begin()
if txA.Error != nil {
t.Fatal(txA.Error)
}
committed := false
t.Cleanup(func() {
if !committed {
txA.Rollback()
}
})
if err := txA.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, user.ID).Error; err != nil {
t.Fatal(err)
}
res := txA.Exec(`DELETE FROM golem15_user_oauth_identities
WHERE user_id = ? AND provider = ?
AND (EXISTS (SELECT 1 FROM users u WHERE u.id = golem15_user_oauth_identities.user_id AND u.has_self_set_password)
OR (SELECT COUNT(*) FROM golem15_user_oauth_identities oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`,
user.ID, "google")
if res.Error != nil || res.RowsAffected != 1 {
t.Fatalf("transaction A delete: rows %d err %v", res.RowsAffected, res.Error)
}
req := httptest.NewRequest(http.MethodDelete, "/_fonoteka/api/v1/oauth-identities/facebook", nil)
req.SetPathValue("provider", "facebook")
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID}), "en"))
rec := httptest.NewRecorder()
h := controllers.OAuthIdentitiesDestroy(app, controllers.OAuthIdentitiesOptions{
WriteNotFound: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) },
})
done := make(chan struct{})
go func() {
defer close(done)
h.ServeHTTP(rec, req)
}()
select {
case <-done:
t.Fatalf("the unlink answered %d while transaction A held the user row lock", rec.Code)
case <-time.After(300 * time.Millisecond):
}
if err := txA.Commit().Error; err != nil {
t.Fatal(err)
}
committed = true
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the unlink did not answer after transaction A committed")
}
if rec.Code != http.StatusConflict {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["error"] != lastMethodBlockedEN {
t.Fatalf("409 body = %s", rec.Body.String())
}
var left []models.OAuthIdentity
if err := db.Where("user_id = ?", user.ID).Find(&left).Error; err != nil {
t.Fatal(err)
}
if len(left) != 1 || left[0].Provider != "facebook" {
t.Fatalf("remaining = %#v, want facebook only", left)
}
}
func TestOAuthIdentitiesDestroyWinter404BodiesAreByteIdentical(t *testing.T) {
app, db := sessionApp(t, "0s")
owner := insertUser(t, db, "oauth-404-owner@example.test", "correct-horse")
stranger := insertUser(t, db, "oauth-404-stranger@example.test", "correct-horse")
seedOAuthIdentity(t, db, owner.ID, "google", "g-owner")
seedOAuthIdentity(t, db, stranger.ID, "facebook", "fb-stranger")
foreign := destroyOAuthIdentity(t, app, stranger.ID, "google", "en", writeWinter404)
missing := destroyOAuthIdentity(t, app, stranger.ID, "github", "en", writeWinter404)
unknown := destroyOAuthIdentity(t, app, stranger.ID, "linkedin", "en", writeWinter404)
for name, rec := range map[string]*httptest.ResponseRecorder{
"foreign": foreign, "missing": missing, "unknown": unknown,
} {
if rec.Code != http.StatusNotFound {
t.Fatalf("%s status %d body %s", name, rec.Code, rec.Body.String())
}
if rec.Header().Get("Content-Type") != "text/html; charset=UTF-8" {
t.Fatalf("%s Content-Type = %q", name, rec.Header().Get("Content-Type"))
}
if !bytes.Equal(rec.Body.Bytes(), []byte(winter404Body)) {
t.Fatalf("%s body = %q", name, rec.Body.String())
}
}
if !bytes.Equal(foreign.Body.Bytes(), missing.Body.Bytes()) || !bytes.Equal(missing.Body.Bytes(), unknown.Body.Bytes()) {
t.Fatalf("404 bodies differ")
}
if remaining := countOAuthIdentities(t, db, owner.ID); remaining != 1 {
t.Fatalf("owner google row = %d", remaining)
}
}
func TestOAuthIdentitiesIndexDoesNotLeakEncryptedTokensOrProfileData(t *testing.T) {
app, db := sessionApp(t, "0s")
if err := lagoon.PublishEncryptionKeys(nil, bytes.Repeat([]byte("K"), 32), nil); err != nil {
t.Fatal(err)
}
user := insertUser(t, db, "oauth-secret-leak@example.test", "correct-horse")
const (
plaintext = "secret-access-token-oauth-leak-probe"
refresh = "secret-refresh-token-oauth-leak-probe"
marker = "profile-distinctive-xyzzy-oauth"
)
now := time.Now().UTC()
row := models.OAuthIdentity{
UserID: user.ID,
Provider: "google",
ProviderID: "g-secret-leak",
AccessToken: lagoon.NewEncrypted(plaintext),
RefreshToken: lagoon.NewEncrypted(refresh),
ProfileData: lagoon.Jsonable[map[string]any]{
Data: map[string]any{"email": "leak@example.test", "marker": marker},
Valid: true,
},
LinkedAt: &now,
}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/oauth-identities", nil)
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID}))
rec := httptest.NewRecorder()
controllers.OAuthIdentitiesIndex(app).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, needle := range []string{plaintext, refresh, marker, "access_token", "refresh_token", "profile_data", "[redacted]"} {
if strings.Contains(body, needle) {
t.Fatalf("GET list leaked %q: %s", needle, body)
}
}
var parsed struct {
Data []map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &parsed); err != nil {
t.Fatal(err)
}
if len(parsed.Data) != 1 {
t.Fatalf("data = %#v", parsed.Data)
}
if len(parsed.Data[0]) != 2 {
t.Fatalf("row keys = %#v, want provider and linked_at only", parsed.Data[0])
}
if parsed.Data[0]["provider"] != "google" {
t.Fatalf("provider = %#v", parsed.Data[0]["provider"])
}
if _, ok := parsed.Data[0]["linked_at"]; !ok {
t.Fatalf("linked_at missing: %#v", parsed.Data[0])
}
}
func TestOAuthIdentitiesUnauthorizedOnGoogle(t *testing.T) {
app, _ := sessionApp(t, "0s")
getReq := httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/oauth-identities", nil)
getRec := httptest.NewRecorder()
controllers.OAuthIdentitiesIndex(app).ServeHTTP(getRec, getReq)
assertOAuthIdentityUnauthorized(t, "GET", getRec)
delReq := httptest.NewRequest(http.MethodDelete, "/_fonoteka/api/v1/oauth-identities/google", nil)
delReq.SetPathValue("provider", "google")
delRec := httptest.NewRecorder()
controllers.OAuthIdentitiesDestroy(app, controllers.OAuthIdentitiesOptions{
WriteNotFound: func(http.ResponseWriter, *http.Request) {
t.Fatal("unauthenticated /google must 401 before WriteNotFound")
},
}).ServeHTTP(delRec, delReq)
assertOAuthIdentityUnauthorized(t, "DELETE /google", delRec)
}
func activateOAuthIdentitiesLang(t *testing.T, app *backpack.App) {
t.Helper()
if err := phrasebook.Activate(app, []*Plugin{{}}); err != nil {
t.Fatal(err)
}
}
func seedOAuthIdentity(t *testing.T, db *gorm.DB, userID uint, provider, providerID string) {
t.Helper()
now := time.Now().UTC()
row := models.OAuthIdentity{
UserID: userID,
Provider: provider,
ProviderID: providerID,
LinkedAt: &now,
}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
func countOAuthIdentities(t *testing.T, db *gorm.DB, userID uint) int64 {
t.Helper()
var n int64
if err := db.Model(&models.OAuthIdentity{}).Where("user_id = ?", userID).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
func writeWinter404(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
w.Header().Set("Cache-Control", "no-cache, private")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(winter404Body))
}
func winter404OrFatal(t *testing.T) func(http.ResponseWriter, *http.Request) {
t.Helper()
return func(http.ResponseWriter, *http.Request) {
t.Fatal("WriteNotFound called")
}
}
func destroyOAuthIdentity(t *testing.T, app *backpack.App, userID uint, provider, locale string, notFound func(http.ResponseWriter, *http.Request)) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodDelete, "/_fonoteka/api/v1/oauth-identities/"+provider, nil)
req.SetPathValue("provider", provider)
ctx := bouncer.WithUser(req.Context(), &bouncer.Principal{ID: userID})
if locale != "" {
ctx = towel.WithLocale(ctx, locale)
}
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
controllers.OAuthIdentitiesDestroy(app, controllers.OAuthIdentitiesOptions{
WriteNotFound: notFound,
}).ServeHTTP(rec, req)
return rec
}
func assertOAuthIdentityUnauthorized(t *testing.T, name string, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s status %d body %s", name, rec.Code, rec.Body.String())
}
if rec.Body.String() != `{"error":true,"message":"Unauthorized"}` {
t.Fatalf("%s body = %s", name, rec.Body.String())
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
t.Fatalf("%s Cache-Control = %q", name, cc)
}
}