- 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
175 lines
5.6 KiB
Go
175 lines
5.6 KiB
Go
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
|
|
}
|