Files
sm-user-plugin/controllers/oauth_identities.go
Jakub Zych 8e172c5087 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
2026-10-05 21:11:24 +02:00

141 lines
3.8 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 defaults to google, facebook, github when nil or empty.
// WriteNotFound is injected by the host so 404 bodies match that app.
type OAuthIdentitiesOptions struct {
Providers []string
WriteNotFound func(http.ResponseWriter, *http.Request)
}
// 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.
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)
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
}