package controllers import ( "errors" "net/http" "slices" "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/wire" "gorm.io/gorm" ) // OAuthIdentitiesOptions configures host-mounted list/unlink handlers. // Providers is the unlink allow-list. When nil or empty, the allow-list is // read on every request: the KnownOAuthProviders of a published // classes.OAuthProviderSource, else google, facebook, github and microsoft. // WriteNotFound is injected by the host so 404 bodies match that app. type OAuthIdentitiesOptions struct { Providers []string WriteNotFound func(http.ResponseWriter, *http.Request) } // defaultOAuthProviders is the unlink allow-list without options and without // a published provider source. var defaultOAuthProviders = []string{"google", "facebook", "github", "microsoft"} // oauthGuardedDelete is the PHP unlink rule (User.php unlinkOAuthProvider): // the identity goes only when the user has a self-set password or another // identity remains. const oauthGuardedDelete = `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)` // OAuthIdentitiesIndex lists the caller's linked OAuth providers. func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { db, user, ok := oauthIdentityDeps(w, r, app) if !ok { return } var rows []models.OAuthIdentity if err := db.WithContext(r.Context()). Where("user_id = ?", user.ID). Order("provider"). Find(&rows).Error; err != nil { writeOpaque500(w) return } data := make([]map[string]any, 0, len(rows)) for i := range rows { item := map[string]any{ "provider": rows[i].Provider, "linked_at": nil, } if rows[i].LinkedAt != nil { item["linked_at"] = &wire.Time{Time: rows[i].LinkedAt.UTC()} } data = append(data, item) } writeJSON(w, http.StatusOK, map[string]any{"data": wire.Slice(data)}) } } // OAuthIdentitiesDestroy unlinks one provider for the caller, under the PHP // rule: it succeeds (204) when the user has a self-set password or another // identity remains, and answers 409 with the last_method_blocked phrase // otherwise. An unknown provider or a missing identity goes to WriteNotFound. func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http.HandlerFunc { 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(unlinkProviders(app, opts), 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 } // D-13: the guarded DELETE alone is not race-safe on Postgres. Under // READ COMMITTED two unlinks of different identities each see the // other's row in their COUNT and do not wait for each other, so both // succeed and a password-less user loses every sign-in method. The // lock on the users row serialises them: the second DELETE runs after // the first commits and sees one identity left. var deleted int64 err = db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { if err := tx.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, user.ID).Error; err != nil { return err } res := tx.Exec(oauthGuardedDelete, user.ID, provider) if res.Error != nil { return res.Error } deleted = res.RowsAffected return nil }) if err != nil { writeOpaque500(w) return } if deleted == 0 { writeJSON(w, http.StatusConflict, map[string]any{ "error": oauthLastMethodBlocked(r, app), }) return } w.WriteHeader(http.StatusNoContent) } } // unlinkProviders is the unlink allow-list for one request. func unlinkProviders(app *backpack.App, opts OAuthIdentitiesOptions) []string { if len(opts.Providers) > 0 { return opts.Providers } if src, ok := oauthProviderSource(app); ok { return src.KnownOAuthProviders() } return defaultOAuthProviders } 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) return nil, nil, false } db, ok := app.Lookup[*gorm.DB]() if !ok || db == nil { writeOpaque500(w) return nil, nil, false } user, ok := bouncer.User(r.Context()) if !ok || user == nil || user.ID == 0 { writeUnauthorized(w) return nil, nil, false } return db, user, true }