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
This commit is contained in:
Jakub Zych
2026-10-20 23:02:37 +02:00
parent 1fc0ce7ed0
commit 78a867bccb
6 changed files with 294 additions and 6 deletions

View File

@@ -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`.

View File

@@ -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
}

View File

@@ -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 ""

View File

@@ -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

View File

@@ -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())
}
}

View File

@@ -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)
}
}