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
This commit is contained in:
@@ -241,7 +241,7 @@ func oauthProviderSource(app *backpack.App) (classes.OAuthProviderSource, bool)
|
||||
return src, ok && src != nil
|
||||
}
|
||||
|
||||
const forgotPasswordBody ="If that email exists, a reset link has been sent."
|
||||
const forgotPasswordBody = "If that email exists, a reset link has been sent."
|
||||
|
||||
// ForgotPassword always returns the same 200 body. A real account also gets a reset mail.
|
||||
func ForgotPassword(app *backpack.App) http.HandlerFunc {
|
||||
|
||||
@@ -13,13 +13,27 @@ import (
|
||||
)
|
||||
|
||||
// OAuthIdentitiesOptions configures host-mounted list/unlink handlers.
|
||||
// Providers defaults to google, facebook, github when nil or empty.
|
||||
// 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) {
|
||||
@@ -50,19 +64,18 @@ func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// OAuthIdentitiesDestroy unlinks one provider for the caller.
|
||||
// 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 {
|
||||
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) {
|
||||
if !slices.Contains(unlinkProviders(app, opts), provider) {
|
||||
writeOAuthIdentityNotFound(w, r, opts)
|
||||
return
|
||||
}
|
||||
@@ -78,28 +91,49 @@ func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http
|
||||
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 {
|
||||
// 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 n == 1 {
|
||||
if deleted == 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user