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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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ę.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user