From 8e172c5087d58f93a4088f2f93c2843af7a852fa Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 21:11:24 +0200 Subject: [PATCH] feat(14.1-01): unlink OAuth identities with 204 and last-method 409 - Destroy allow-lists providers and shares WriteNotFound for unknown, missing and foreign rows - Last remaining identity is refused with a localized 409; password flags are unused - EN and PL last_method_blocked strings match the PHP texts --- controllers/oauth_identities.go | 73 +++++++++++++++++++++++++++++++++ lang/en/lang.yaml | 2 + lang/pl/lang.yaml | 2 + oauth_identities_test.go | 36 ++++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/controllers/oauth_identities.go b/controllers/oauth_identities.go index d7d6807..79f8e54 100644 --- a/controllers/oauth_identities.go +++ b/controllers/oauth_identities.go @@ -1,7 +1,9 @@ package controllers import ( + "errors" "net/http" + "slices" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" @@ -48,6 +50,77 @@ func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc { } } +// OAuthIdentitiesDestroy unlinks one provider for the caller. +func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http.HandlerFunc { + providers := opts.Providers + if len(providers) == 0 { + providers = []string{"google", "facebook", "github"} + } + return func(w http.ResponseWriter, r *http.Request) { + db, user, ok := oauthIdentityDeps(w, r, app) + if !ok { + return + } + provider := r.PathValue("provider") + if !slices.Contains(providers, provider) { + writeOAuthIdentityNotFound(w, r, opts) + return + } + var row models.OAuthIdentity + err := db.WithContext(r.Context()). + Where("user_id = ? AND provider = ?", user.ID, provider). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + writeOAuthIdentityNotFound(w, r, opts) + return + } + if err != nil { + writeOpaque500(w) + return + } + var n int64 + if err := db.WithContext(r.Context()). + Model(&models.OAuthIdentity{}). + Where("user_id = ?", user.ID). + Count(&n).Error; err != nil { + writeOpaque500(w) + return + } + if n == 1 { + writeJSON(w, http.StatusConflict, map[string]any{ + "error": oauthLastMethodBlocked(r, app), + }) + return + } + if err := db.WithContext(r.Context()).Delete(&row).Error; err != nil { + writeOpaque500(w) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func writeOAuthIdentityNotFound(w http.ResponseWriter, r *http.Request, opts OAuthIdentitiesOptions) { + if opts.WriteNotFound != nil { + opts.WriteNotFound(w, r) + return + } + writeOpaque500(w) +} + +func oauthLastMethodBlocked(r *http.Request, app *backpack.App) string { + const fallback = "This is the only remaining way to sign in. Link another method before disconnecting this one." + tr := appTranslator(app) + if tr == nil { + return fallback + } + s := tr.Get(r.Context(), "golem15.user::lang.oauth.last_method_blocked", nil) + if s == "" || s == "golem15.user::lang.oauth.last_method_blocked" { + return fallback + } + return s +} + func oauthIdentityDeps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*gorm.DB, *bouncer.Principal, bool) { if app == nil { writeOpaque500(w) diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 487fbbb..caa9dd0 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -113,3 +113,5 @@ organisation: remove_members_confirm: Remove the selected (:count) from this organisation? The users are not deleted. members_empty: This organisation has no members yet. Add users with Add member. empty_organisation: — No organisation — +oauth: + last_method_blocked: This is the only remaining way to sign in. Link another method before disconnecting this one. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index b638254..67d381c 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -113,3 +113,5 @@ organisation: remove_members_confirm: Usunąć zaznaczonych (:count) z tej organizacji? Użytkownicy nie zostaną usunięci. members_empty: Ta organizacja nie ma jeszcze członków. Dodaj użytkowników przyciskiem Dodaj członka. empty_organisation: — Brak organizacji — +oauth: + last_method_blocked: To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę. diff --git a/oauth_identities_test.go b/oauth_identities_test.go index e536dfa..30c4f1f 100644 --- a/oauth_identities_test.go +++ b/oauth_identities_test.go @@ -8,6 +8,7 @@ import ( "testing" "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/bouncer" ) @@ -35,3 +36,38 @@ func TestOAuthIdentitiesIndexEmptyList(t *testing.T) { 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) + } +}