diff --git a/controllers/oauth_identities.go b/controllers/oauth_identities.go new file mode 100644 index 0000000..d7d6807 --- /dev/null +++ b/controllers/oauth_identities.go @@ -0,0 +1,67 @@ +package controllers + +import ( + "net/http" + + "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)}) + } +} + +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 +} diff --git a/models/oauth_identity.go b/models/oauth_identity.go new file mode 100644 index 0000000..5d45be4 --- /dev/null +++ b/models/oauth_identity.go @@ -0,0 +1,32 @@ +package models + +import ( + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" +) + +// OAuthIdentity is one linked social-login provider for a user. +type OAuthIdentity struct { + ID uint `gorm:"column:id;primaryKey"` + UserID uint `gorm:"column:user_id"` + Provider string `gorm:"column:provider"` + ProviderID string `gorm:"column:provider_id"` + AccessToken lagoon.Encrypted `gorm:"column:access_token" json:"-"` + RefreshToken lagoon.Encrypted `gorm:"column:refresh_token" json:"-"` + TokenExpiresAt *time.Time `gorm:"column:token_expires_at"` + ProfileData lagoon.Jsonable[map[string]any] `gorm:"column:profile_data"` + LinkedAt *time.Time `gorm:"column:linked_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (OAuthIdentity) TableName() string { return "golem15_user_oauth_identities" } + +func (OAuthIdentity) Fillable() []string { return []string{} } + +func (OAuthIdentity) Hidden() []string { + return []string{"access_token", "refresh_token", "profile_data"} +} + +func init() { Register(OAuthIdentity{}) } diff --git a/oauth_identities_test.go b/oauth_identities_test.go new file mode 100644 index 0000000..e536dfa --- /dev/null +++ b/oauth_identities_test.go @@ -0,0 +1,37 @@ +package user + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +func TestOAuthIdentitiesIndexEmptyList(t *testing.T) { + app, db := sessionApp(t, "0s") + user := insertUser(t, db, "oauth-empty@example.test", "correct-horse") + req := httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/oauth-identities", nil) + req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID})) + rec := httptest.NewRecorder() + controllers.OAuthIdentitiesIndex(app).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if rec.Body.String() != `{"data":[]}` { + t.Fatalf("body = %s", rec.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body) != 1 { + t.Fatalf("top-level keys = %#v", body) + } + if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-cache") { + t.Fatalf("Cache-Control = %q", cc) + } +} diff --git a/updates/202610050001_create_oauth_identities.go b/updates/202610050001_create_oauth_identities.go new file mode 100644 index 0000000..915ba82 --- /dev/null +++ b/updates/202610050001_create_oauth_identities.go @@ -0,0 +1,36 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var oauthIdentityMigrations = []*gormigrate.Migration{ + { + ID: "202610050001_create_oauth_identities", + Migrate: func(tx *gorm.DB) error { + return execStmts(tx, []string{ + `CREATE TABLE golem15_user_oauth_identities ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider VARCHAR(50) NOT NULL, + provider_id VARCHAR(255) NOT NULL, + access_token TEXT NULL, + refresh_token TEXT NULL, + token_expires_at TIMESTAMPTZ NULL, + profile_data jsonb NULL, + linked_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +)`, + `CREATE UNIQUE INDEX oauth_identities_user_provider_unique ON golem15_user_oauth_identities (user_id, provider)`, + `CREATE UNIQUE INDEX oauth_identities_provider_identity_unique ON golem15_user_oauth_identities (provider, provider_id)`, + }) + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS golem15_user_oauth_identities").Error + }, + }, +} + +func init() { Register(oauthIdentityMigrations...) }