From 78a867bccbcbd9ff8f878fb6ad06a1cfe25fa241 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 20 Oct 2026 23:02:37 +0200 Subject: [PATCH] feat: provider source for oauth-providers and sign-in helpers for social login - classes.OAuthProvider and classes.OAuthProviderSource; GET /oauth-providers looks the source up per request and keeps the empty body without one - exported RegistrationSettings, RegistrationsFromIP, RefreshTTL, RemoteIP - RegisterOptions.PasswordLess (has_self_set_password=false) and FinishLogin (restore + reactivate mail, last_ip_address, last_seen; no event) - TestOAuthProvidersSourceSmoke, TestSocialHooksSmoke; README --- README.md | 9 +++- classes/oauth_providers.go | 31 +++++++++++++ controllers/api_controller.go | 56 +++++++++++++++++++++-- controllers/registration.go | 42 ++++++++++++++++- oauth_providers_smoke_test.go | 76 +++++++++++++++++++++++++++++++ social_hooks_smoke_test.go | 86 +++++++++++++++++++++++++++++++++++ 6 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 classes/oauth_providers.go create mode 100644 oauth_providers_smoke_test.go create mode 100644 social_hooks_smoke_test.go diff --git a/README.md b/README.md index ace65a9..e5d2bc7 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password. - **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never). `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `GetApiArrayEvent` lets other plugins merge keys into the user payload. - **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload. -- **OAuth providers.** `GET /oauth-providers` lists configured social login providers (none yet: it returns an empty list). +- **OAuth providers.** `GET /oauth-providers` lists the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before. +- **Sign-in helpers for social login.** `RegisterOptions.PasswordLess`, `FinishLogin`, `RegistrationSettings`, `RegistrationsFromIP`, `RefreshTTL` and `RemoteIP` let a social-login plugin create accounts and finish logins with the user plugin's own rules instead of copies. ## Usage @@ -160,18 +161,22 @@ Migrations added for @mention handles, all additive: Packages: - `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins: - - `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP. + - `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP; `RegisterOptions.PasswordLess` stores `has_self_set_password=false` for an account whose random password the user never knew (a social sign-in), while the zero value keeps `true`. - `FireRegisterEvent(ctx, app, user, fields)` fires `RegisterEvent` with `Payload` set to a copy of `fields` without `password` and `password_confirmation`, and returns the listeners' error (`/register` ignores it). Fire it after the user row is committed. - `FireCreatedEvent(ctx, app, user)` fires `CreatedEvent` (`user.Created`). `/register` ignores that error. - `FireDeletingEvent(ctx, app, user, tx)` fires `DeletingEvent` (`user.Deleting`) inside the delete transaction. - `FireDeactivatingEvent(ctx, app, user, tx)` fires `DeactivatingEvent` (`user.Deactivating`) inside the deactivation transaction. The admin deactivate bulk action fires it after `DeactivateUsers` for every selected user that was not deactivated yet; a listener error rolls the action back. - `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do. - `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners. + - `FinishLogin(ctx, app, db, user, ip)` runs the steps `POST /login` takes after authentication, for a caller that authenticated the user another way (a social sign-in): it restores a soft-deleted account and sends the reactivate mail, records `ip` as `last_ip_address` and touches `last_seen` (a failure there is only logged). It checks no password, throttle, activation or ban and fires no event; the caller does those checks. + - `RegistrationSettings(app)` returns `allow_registration`, `use_register_throttle`, `activate_mode` and `require_activation` with their defaults (`true`, `true`, `auto`, `true`), and `RegistrationsFromIP(ctx, db, ip)` counts the accounts created from an IP in the last 60 minutes (`/register` refuses at 3 with the throttle on). + - `RefreshTTL(app)` is the refresh window, `golem15.user.jwt.refresh_ttl` minutes (default 20160), and `RemoteIP(r)` the host part of `r.RemoteAddr` that `/login` uses for its throttle. - `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has: - `MergedPermissions(ctx, db, userID)`, `HasPermission(merged, code)` and `UserHasPermission(ctx, db, userID, code)`, the frontend permission resolver, and `FrontendPermissionOptions(ctx, db)`, the catalogue by tab and code. - `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule. - `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction. - `TouchLastSeen(ctx, db, userID)`. + - `OAuthProvider` (`Name`, `Label`) and `OAuthProviderSource` (`EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), the seam a social-login plugin publishes with an explicit type argument, `app.Publish[classes.OAuthProviderSource](src)`, so the lookup finds it. `GET /oauth-providers` lists its enabled providers in order. - `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` blacklists the request's JWT until it expires (as `POST /logout` does) and writes the forget cookie of `bouncer.LoadCookieConfig`, best effort: a blacklist failure is logged, never returned. - `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128. - `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`. diff --git a/classes/oauth_providers.go b/classes/oauth_providers.go new file mode 100644 index 0000000..27eadff --- /dev/null +++ b/classes/oauth_providers.go @@ -0,0 +1,31 @@ +package classes + +import "context" + +// OAuthProvider is one social sign-in provider as GET +// /_user/api/v1/oauth-providers lists it: Name is the route segment +// (/oauth/{name}) and Label the button text. +type OAuthProvider struct { + Name string `json:"name"` + Label string `json:"label"` +} + +// OAuthProviderSource is what a social-login plugin publishes so the user +// plugin can list its providers and accept them in the unlink routes. The +// user plugin looks it up on every request, so a plugin that boots later is +// still found. +// +// Publish it with an explicit type argument, +// +// app.Publish[classes.OAuthProviderSource](src) +// +// because a bare app.Publish(src) keys the service on its concrete type and +// the lookup misses it (the list then stays empty). +type OAuthProviderSource interface { + // EnabledOAuthProviders returns the providers that are configured right + // now, in display order. + EnabledOAuthProviders(ctx context.Context) []OAuthProvider + // KnownOAuthProviders returns the name of every registered provider, + // enabled or not: an identity of any of them may be unlinked. + KnownOAuthProviders() []string +} diff --git a/controllers/api_controller.go b/controllers/api_controller.go index a141bf9..9bd8626 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -212,17 +212,36 @@ func Refresh(app *backpack.App) http.HandlerFunc { } } -// OAuthProviders reports that this app configures no social providers. +// OAuthProviders lists the social sign-in providers a social-login plugin +// has enabled, as {success: true, providers: [{name, label}]}. The plugin +// publishes a classes.OAuthProviderSource, looked up on every request +// because it boots after this plugin; without one the list is empty. func OAuthProviders(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + providers := []any{} + if src, ok := oauthProviderSource(app); ok { + for _, p := range src.EnabledOAuthProviders(r.Context()) { + providers = append(providers, map[string]any{"name": p.Name, "label": p.Label}) + } + } writeJSON(w, http.StatusOK, map[string]any{ "success": true, - "providers": wire.Slice([]any{}), + "providers": wire.Slice(providers), }) } } -const forgotPasswordBody = "If that email exists, a reset link has been sent." +// oauthProviderSource returns the social-login plugin's published provider +// source, if any. +func oauthProviderSource(app *backpack.App) (classes.OAuthProviderSource, bool) { + if app == nil { + return nil, false + } + src, ok := app.Lookup[classes.OAuthProviderSource]() + return src, ok && src != nil +} + +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 { @@ -682,6 +701,23 @@ func Register(app *backpack.App) http.HandlerFunc { } } +// RegistrationSettings returns the registration settings /register +// applies: allow is golem15.user.registration.allow_registration (default +// true), throttle is golem15.user.registration.use_register_throttle +// (default true), mode is golem15.user.activation.activate_mode (auto, user +// or admin; default auto) and require is +// golem15.user.activation.require_activation (default true). +func RegistrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) { + return registrationSettings(app) +} + +// RegistrationsFromIP counts the users created from ip in the last 60 +// minutes (created_ip_address). /register refuses a registration when the +// throttle is on and the count is 3 or more. A query error counts as 0. +func RegistrationsFromIP(ctx context.Context, gdb *gorm.DB, ip string) int64 { + return registrationsFromIP(ctx, gdb, ip) +} + func registrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) { allow, throttle, require = true, true, true mode = "auto" @@ -831,6 +867,13 @@ func mintFor(app *backpack.App, secret string, user *models.User, issuer string) return token, err } +// RefreshTTL is the refresh window of a session token: +// golem15.user.jwt.refresh_ttl minutes, default 20160 (two weeks). A cookie +// carrying the token should live this long. +func RefreshTTL(app *backpack.App) time.Duration { + return refreshTTL(app) +} + func refreshTTL(app *backpack.App) time.Duration { mins := 20160 if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 { @@ -1012,6 +1055,13 @@ func requestURL(r *http.Request) string { return scheme + "://" + r.Host + r.URL.Path } +// RemoteIP returns the host part of r.RemoteAddr, the address /login uses +// for its throttle (r.RemoteAddr itself when it has no port, "" when it is +// empty). /register records surf.ClientIP with the trusted proxies instead. +func RemoteIP(r *http.Request) string { + return remoteIP(r) +} + func remoteIP(r *http.Request) string { if r.RemoteAddr == "" { return "" diff --git a/controllers/registration.go b/controllers/registration.go index 55cc2cf..89206c3 100644 --- a/controllers/registration.go +++ b/controllers/registration.go @@ -3,6 +3,7 @@ package controllers import ( "context" "errors" + "log/slog" "maps" "net/http" "time" @@ -26,6 +27,11 @@ import ( type RegisterOptions struct { Activate bool IP string + // PasswordLess creates the account with has_self_set_password=false: its + // password is random and was never known to the user (social sign-in, + // PHP password_bootstrap_source oauth_registration). The zero value + // keeps true. + PasswordLess bool } // RegisterUser validates fields against the user model's rules, hashes the @@ -68,7 +74,7 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma if err := lagoon.Fill(user, user.Fillable(), input, production); err != nil { return nil, nil, err } - user.HasSelfSetPassword = true + user.HasSelfSetPassword = !opts.PasswordLess if opts.Activate { user.IsActivated = true now := time.Now() @@ -83,6 +89,40 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma return user, nil, nil } +// FinishLogin runs the steps password Login takes after it has +// authenticated a user, for a caller that authenticated the user another way +// (a social sign-in). It restores a soft-deleted account and sends the +// reactivate mail, records ip as last_ip_address, and touches last_seen. It +// checks no password, throttle, activation or ban and fires no event; the +// caller does those checks (Go has no login event). A failed last_seen +// write is logged and does not fail it, as in Login. +func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *models.User, ip string) error { + if db == nil { + return errors.New("golem15.user: gorm handle is missing") + } + if user == nil || user.ID == 0 { + return errors.New("golem15.user: finishing a login needs a stored user") + } + if user.DeletedAt.Valid { + if err := db.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil { + return err + } + user.DeletedAt = gorm.DeletedAt{} + sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", "")) + } + if ip != "" { + if err := db.WithContext(ctx).Unscoped().Model(user).Update("last_ip_address", ip).Error; err != nil { + return err + } + addr := ip + user.LastIPAddress = &addr + } + if err := classes.TouchLastSeen(ctx, db, user.ID); err != nil { + slog.Default().Warn("golem15.user: stamp last_seen on login", "user_id", user.ID, "err", err) + } + return nil +} + // FireRegisterEvent fires classes.RegisterEvent for user with Payload set // to a copy of fields without password and password_confirmation, and // returns the listeners' error. /register ignores that error, as Winter diff --git a/oauth_providers_smoke_test.go b/oauth_providers_smoke_test.go new file mode 100644 index 0000000..fd375a6 --- /dev/null +++ b/oauth_providers_smoke_test.go @@ -0,0 +1,76 @@ +package user + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/summercms/modules/backpack" +) + +// fakeProviderSource is a social-login plugin's provider source. +type fakeProviderSource struct { + enabled []classes.OAuthProvider + known []string +} + +func (f fakeProviderSource) EnabledOAuthProviders(context.Context) []classes.OAuthProvider { + return f.enabled +} + +func (f fakeProviderSource) KnownOAuthProviders() []string { return f.known } + +// TestOAuthProvidersSourceSmoke checks that oauth-providers keeps the empty +// body without a published source and lists the source's enabled providers, +// looked up per request, once one is published. +func TestOAuthProvidersSourceSmoke(t *testing.T) { + app := backpack.New(sessionConfig(t)) + h := controllers.OAuthProviders(app) + get := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/oauth-providers", nil) + rec := httptest.NewRecorder() + h(rec, req) + return rec + } + + empty := get() + if empty.Code != http.StatusOK { + t.Fatalf("status without a source = %d", empty.Code) + } + // The bytes the handler answered before sources existed (wire encodes + // map keys sorted; the PHP recording has the same JSON value). + if got := strings.TrimRight(empty.Body.String(), "\n"); got != `{"providers":[],"success":true}` { + t.Fatalf("body without a source = %s", got) + } + + src := fakeProviderSource{ + enabled: []classes.OAuthProvider{{Name: "google", Label: "Google"}}, + known: []string{"google", "facebook"}, + } + if err := app.Publish[classes.OAuthProviderSource](src); err != nil { + t.Fatal(err) + } + rec := get() + if rec.Code != http.StatusOK { + t.Fatalf("status with a source = %d", rec.Code) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" { + t.Fatalf("Cache-Control = %q", cc) + } + var body struct { + Success bool `json:"success"` + Providers []classes.OAuthProvider `json:"providers"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode %s: %v", rec.Body.String(), err) + } + if !body.Success || !reflect.DeepEqual(body.Providers, src.enabled) { + t.Fatalf("body with a source = %s", rec.Body.String()) + } +} diff --git a/social_hooks_smoke_test.go b/social_hooks_smoke_test.go new file mode 100644 index 0000000..48ad74f --- /dev/null +++ b/social_hooks_smoke_test.go @@ -0,0 +1,86 @@ +package user + +import ( + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/postcard" +) + +// TestSocialHooksSmoke drives the helpers a social-login plugin calls: a +// password-less registration and FinishLogin on a soft-deleted account. +func TestSocialHooksSmoke(t *testing.T) { + if testing.Short() { + t.Skip("Postgres-backed; skipped under -short") + } + app, gdb := sessionApp(t, "0s") + ctx := t.Context() + + fields := map[string]any{ + "email": "social-hooks@example.test", + "password": "random-password-never-shown", + "password_confirmation": "random-password-never-shown", + } + user, errs, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{ + Activate: true, IP: "203.0.113.7", PasswordLess: true, + }) + if err != nil || len(errs) > 0 { + t.Fatalf("RegisterUser: %v %v", err, errs) + } + var stored models.User + if err := gdb.First(&stored, user.ID).Error; err != nil { + t.Fatal(err) + } + if stored.HasSelfSetPassword { + t.Fatal("a password-less registration stored has_self_set_password=true") + } + if !stored.IsActivated { + t.Fatal("the registration was not activated") + } + if stored.Handle == nil || *stored.Handle == "" { + t.Fatal("the registration got no handle") + } + + // FinishLogin restores a soft-deleted account, records the IP, stamps + // last_seen and sends exactly one reactivate mail. + loc := "en" + soft := &models.User{Email: "social-soft@example.test", Password: "x", PreferredLocale: &loc} + if err := gdb.Create(soft).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Model(soft).Update("deleted_at", time.Now()).Error; err != nil { + t.Fatal(err) + } + var trashed models.User + if err := gdb.Unscoped().First(&trashed, soft.ID).Error; err != nil { + t.Fatal(err) + } + if !trashed.DeletedAt.Valid { + t.Fatal("setup: the user is not soft-deleted") + } + mail := &captureMail{} + if err := app.Publish[postcard.Mailer](mail); err != nil { + t.Fatal(err) + } + if err := controllers.FinishLogin(ctx, app, gdb, &trashed, "198.51.100.4"); err != nil { + t.Fatal(err) + } + var after models.User + if err := gdb.Unscoped().First(&after, soft.ID).Error; err != nil { + t.Fatal(err) + } + if after.DeletedAt.Valid { + t.Fatal("FinishLogin did not restore the account") + } + if after.LastIPAddress == nil || *after.LastIPAddress != "198.51.100.4" { + t.Fatalf("last_ip_address = %v", after.LastIPAddress) + } + if after.LastSeen == nil { + t.Fatal("FinishLogin did not stamp last_seen") + } + if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" { + t.Fatalf("mails = %#v", mail.got) + } +}