6 Commits

Author SHA1 Message Date
Jakub Zych
c7635c56e3 test: change-password still requires the current password (PHP map)
TestChangePassword/real_password_account_still_requires_current_password
ports the PHP OAuthPasswordBootstrapTest case: a missing or wrong
current_password is a 422 with the unchanged body, and the password stays.
2026-10-21 01:23:13 +02:00
Jakub Zych
d6d8f117ea test: provider source, unlink rule and race, sign-in helpers
- TestOAuthProviders: old bytes without a source, order, per-request lookup
- TestOAuthIdentitiesDestroyRule: the PHP last-identity rule table, 404s,
  microsoft by default, source names and opts.Providers precedence
- TestOAuthIdentitiesDestroyRace: two row-locked transactions in both orders,
  the handler waiting for the lock, concurrent handlers
- TestRegisterUserPasswordLess, TestFinishLogin, TestRegistrationExports
2026-10-21 00:49:00 +02:00
Jakub Zych
c43ba87371 fix: user:backfill-handles opens its own database
- without a published *gorm.DB the command opens one with lagoon.OpenFromApp
  for its run and closes it, as the plugin's other commands do (withDB)
- TestBackfillHandlesOpensFromConfigWhenUnpublished; README CLI note
2026-10-20 23:07:14 +02:00
Jakub Zych
174161895e 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
2026-10-20 23:05:14 +02:00
Jakub Zych
78a867bccb 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
2026-10-20 23:02:37 +02:00
Jakub Zych
1fc0ce7ed0 test: TestTurnstileRoutes pins golem15.user.turnstile per mode
Replaces TestTurnstileRoutesSmoke: empty adds nothing; open, closed and a
space-padded closed add turnstile:{mode} to POST login and register only,
every other route keeps its middleware; invalid values fail naming the key.
2026-10-20 19:19:36 +02:00
15 changed files with 1353 additions and 66 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. - **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. - **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. - **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 ## Usage
@@ -107,12 +108,12 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle
### Host-mounted OAuth identities ### Host-mounted OAuth identities
The host application mounts these constructors on a path it chooses. They are not registered under `/_user/api/v1`. `OAuthIdentitiesOptions.WriteNotFound` is the host's 404 writer so missing, foreign and unknown providers share one body. `OAuthIdentitiesOptions.Providers` defaults to `google`, `facebook` and `github` when empty. The 409 last-method phrase is `golem15.user::lang.oauth.last_method_blocked`. The host application mounts these constructors on a path it chooses. They are not registered under `/_user/api/v1`. `OAuthIdentitiesOptions.WriteNotFound` is the host's 404 writer so missing, foreign and unknown providers share one body. `OAuthIdentitiesOptions.Providers` is the unlink allow-list; when empty it is read on every request from a published `classes.OAuthProviderSource` (its `KnownOAuthProviders`), else it is `google`, `facebook`, `github` and `microsoft`. Unlinking follows the PHP rule: it succeeds when the user has a self-set password (`has_self_set_password`) or another identity remains, and otherwise answers 409 with the phrase `golem15.user::lang.oauth.last_method_blocked` (body unchanged). The guarded delete runs in a transaction that first locks the user's `users` row, so two concurrent unlinks never remove the last sign-in method.
| Constructor | Role | | Constructor | Role |
|-------------|------| |-------------|------|
| `controllers.OAuthIdentitiesIndex` | Lists the caller's linked providers as `{provider, linked_at}` only. | | `controllers.OAuthIdentitiesIndex` | Lists the caller's linked providers as `{provider, linked_at}` only. |
| `controllers.OAuthIdentitiesDestroy` | Unlinks one provider for the caller. | | `controllers.OAuthIdentitiesDestroy` | Unlinks one provider for the caller (204), or answers 409 when it is the last sign-in method of a user without a self-set password. |
| `controllers.OAuthIdentitiesOptions` | Host injects `Providers` and `WriteNotFound`. | | `controllers.OAuthIdentitiesOptions` | Host injects `Providers` and `WriteNotFound`. |
### Admin ### Admin
@@ -160,18 +161,22 @@ Migrations added for @mention handles, all additive:
Packages: Packages:
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins: - `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. - `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. - `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. - `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. - `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. - `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. - `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: - `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. - `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. - `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. - `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)`. - `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; the unlink routes accept its known names.
- `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. - `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. - `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`. - `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
@@ -217,6 +222,8 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
| `user:token:list` | List token metadata without secrets or hashes. | | `user:token:list` | List token metadata without secrets or hashes. |
| `user:token:revoke <id>` | Revoke a shared API token immediately. | | `user:token:revoke <id>` | Revoke a shared API token immediately. |
Each command uses the database the serve command published or, run from the console, opens one from `database.dsn` (and loads the encryption keys from `app.key`) for its duration.
## Dependencies ## Dependencies
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `pact`, `party`, `surf` and others). - The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `pact`, `party`, `surf` and others).

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

@@ -8,10 +8,14 @@ import (
"git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm" "gorm.io/gorm"
) )
// BackfillHandles fills a unique handle on every user row that still has none. // BackfillHandles fills a unique handle on every user row that still has none.
// It uses the database the serve command published or, when it runs as a
// console command, opens one from the config for the duration of the run,
// as the plugin's other commands do.
func BackfillHandles(app *backpack.App) bonfire.Command { func BackfillHandles(app *backpack.App) bonfire.Command {
return bonfire.Command{ return bonfire.Command{
Name: "user:backfill-handles", Name: "user:backfill-handles",
@@ -22,7 +26,12 @@ func BackfillHandles(app *backpack.App) bonfire.Command {
} }
gdb, ok := app.Lookup[*gorm.DB]() gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil { if !ok || gdb == nil {
return fmt.Errorf("user:backfill-handles: database is not configured") sqlDB, opened, err := lagoon.OpenFromApp(ctx, app)
if err != nil {
return fmt.Errorf("user:backfill-handles: %w", err)
}
defer sqlDB.Close()
gdb = opened
} }
var users []models.User var users []models.User
if err := gdb.WithContext(ctx).Where("handle IS NULL OR handle = ''").Find(&users).Error; err != nil { if err := gdb.WithContext(ctx).Where("handle IS NULL OR handle = ''").Find(&users).Error; err != nil {

View File

@@ -3,6 +3,7 @@ package user
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"strings" "strings"
"testing" "testing"
@@ -77,6 +78,40 @@ func TestConsoleWithDBOpensFromConfigWhenUnpublished(t *testing.T) {
} }
} }
// TestBackfillHandlesOpensFromConfigWhenUnpublished runs user:backfill-handles
// as the console does: no published database, only database.dsn in the config.
func TestBackfillHandlesOpensFromConfigWhenUnpublished(t *testing.T) {
_, gdb := sessionApp(t, "0s")
user := insertUser(t, gdb, "backfill-unpublished@example.test", "correct-horse")
if err := gdb.Model(user).Update("handle", nil).Error; err != nil {
t.Fatal(err)
}
app := backpack.New(sessionConfig(t))
if err := app.Config.Set("database.dsn", sessionDSN); err != nil {
t.Fatal(err)
}
// Opening a database also loads the encryption keys from app.key.
if err := app.Config.Set("app.key", base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))); err != nil {
t.Fatal(err)
}
if _, ok := app.Lookup[*gorm.DB](); ok {
t.Fatal("setup: a database is published")
}
cmd := commandNamed(t, (&Plugin{app: app}).Commands(), "user:backfill-handles")
var buf bytes.Buffer
out := bonfire.NewOutput(nil, &buf, &buf)
if err := cmd.Run(context.Background(), emailArg{}, out); err != nil {
t.Fatalf("run: %v (output %q)", err, buf.String())
}
var fresh models.User
if err := gdb.First(&fresh, user.ID).Error; err != nil {
t.Fatal(err)
}
if fresh.Handle == nil || *fresh.Handle == "" {
t.Fatal("the user still has no handle")
}
}
func commandNamed(t *testing.T, cmds []bonfire.Command, name string) bonfire.Command { func commandNamed(t *testing.T, cmds []bonfire.Command, name string) bonfire.Command {
t.Helper() t.Helper()
for _, candidate := range cmds { for _, candidate := range cmds {

View File

@@ -212,16 +212,35 @@ 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 { func OAuthProviders(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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{ writeJSON(w, http.StatusOK, map[string]any{
"success": true, "success": true,
"providers": wire.Slice([]any{}), "providers": wire.Slice(providers),
}) })
} }
} }
// 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." 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. // ForgotPassword always returns the same 200 body. A real account also gets a reset mail.
@@ -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) { func registrationSettings(app *backpack.App) (allow, throttle bool, mode string, require bool) {
allow, throttle, require = true, true, true allow, throttle, require = true, true, true
mode = "auto" mode = "auto"
@@ -831,6 +867,13 @@ func mintFor(app *backpack.App, secret string, user *models.User, issuer string)
return token, err 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 { func refreshTTL(app *backpack.App) time.Duration {
mins := 20160 mins := 20160
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 { 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 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 { func remoteIP(r *http.Request) string {
if r.RemoteAddr == "" { if r.RemoteAddr == "" {
return "" return ""

View File

@@ -13,13 +13,27 @@ import (
) )
// OAuthIdentitiesOptions configures host-mounted list/unlink handlers. // 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. // WriteNotFound is injected by the host so 404 bodies match that app.
type OAuthIdentitiesOptions struct { type OAuthIdentitiesOptions struct {
Providers []string Providers []string
WriteNotFound func(http.ResponseWriter, *http.Request) 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. // OAuthIdentitiesIndex lists the caller's linked OAuth providers.
func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc { func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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 { 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) { return func(w http.ResponseWriter, r *http.Request) {
db, user, ok := oauthIdentityDeps(w, r, app) db, user, ok := oauthIdentityDeps(w, r, app)
if !ok { if !ok {
return return
} }
provider := r.PathValue("provider") provider := r.PathValue("provider")
if !slices.Contains(providers, provider) { if !slices.Contains(unlinkProviders(app, opts), provider) {
writeOAuthIdentityNotFound(w, r, opts) writeOAuthIdentityNotFound(w, r, opts)
return return
} }
@@ -78,28 +91,49 @@ func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http
writeOpaque500(w) writeOpaque500(w)
return return
} }
var n int64 // D-13: the guarded DELETE alone is not race-safe on Postgres. Under
if err := db.WithContext(r.Context()). // READ COMMITTED two unlinks of different identities each see the
Model(&models.OAuthIdentity{}). // other's row in their COUNT and do not wait for each other, so both
Where("user_id = ?", user.ID). // succeed and a password-less user loses every sign-in method. The
Count(&n).Error; err != nil { // 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) writeOpaque500(w)
return return
} }
if n == 1 { if deleted == 0 {
writeJSON(w, http.StatusConflict, map[string]any{ writeJSON(w, http.StatusConflict, map[string]any{
"error": oauthLastMethodBlocked(r, app), "error": oauthLastMethodBlocked(r, app),
}) })
return return
} }
if err := db.WithContext(r.Context()).Delete(&row).Error; err != nil {
writeOpaque500(w)
return
}
w.WriteHeader(http.StatusNoContent) 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) { func writeOAuthIdentityNotFound(w http.ResponseWriter, r *http.Request, opts OAuthIdentitiesOptions) {
if opts.WriteNotFound != nil { if opts.WriteNotFound != nil {
opts.WriteNotFound(w, r) opts.WriteNotFound(w, r)

View File

@@ -3,6 +3,7 @@ package controllers
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"maps" "maps"
"net/http" "net/http"
"time" "time"
@@ -26,6 +27,11 @@ import (
type RegisterOptions struct { type RegisterOptions struct {
Activate bool Activate bool
IP string 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 // 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 { if err := lagoon.Fill(user, user.Fillable(), input, production); err != nil {
return nil, nil, err return nil, nil, err
} }
user.HasSelfSetPassword = true user.HasSelfSetPassword = !opts.PasswordLess
if opts.Activate { if opts.Activate {
user.IsActivated = true user.IsActivated = true
now := time.Now() now := time.Now()
@@ -83,6 +89,40 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
return user, nil, nil 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 // FireRegisterEvent fires classes.RegisterEvent for user with Payload set
// to a copy of fields without password and password_confirmation, and // to a copy of fields without password and password_confirmation, and
// returns the listeners' error. /register ignores that error, as Winter // returns the listeners' error. /register ignores that error, as Winter

View File

@@ -0,0 +1,331 @@
package user
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"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/towel"
"gorm.io/gorm"
)
// guardedUnlinkSQL is the DELETE OAuthIdentitiesDestroy runs (PHP User.php
// unlinkOAuthProvider), copied so the race test drives the same statement
// under its own transactions.
const guardedUnlinkSQL = `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)`
// ruleUser creates a user named after the test with selfSet as
// has_self_set_password and links the given providers.
func ruleUser(t *testing.T, db *gorm.DB, selfSet bool, providers ...string) *models.User {
t.Helper()
slug := strings.NewReplacer("/", "-", " ", "-").Replace(strings.ToLower(t.Name()))
email := fmt.Sprintf("%s-%d@example.test", slug, time.Now().UnixNano())
u := insertUser(t, db, email, "correct-horse")
if err := db.Model(u).Update("has_self_set_password", selfSet).Error; err != nil {
t.Fatal(err)
}
for _, p := range providers {
seedOAuthIdentity(t, db, u.ID, p, p+"-"+email)
}
return u
}
func linkedProviders(t *testing.T, db *gorm.DB, userID uint) []string {
t.Helper()
var rows []models.OAuthIdentity
if err := db.Where("user_id = ?", userID).Order("provider").Find(&rows).Error; err != nil {
t.Fatal(err)
}
out := make([]string, 0, len(rows))
for _, r := range rows {
out = append(out, r.Provider)
}
return out
}
// unlinkWith runs the destroy handler for userID and provider with opts.
func unlinkWith(t *testing.T, app *backpack.App, opts controllers.OAuthIdentitiesOptions, userID uint, provider, locale string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodDelete, "/_user/api/v1/oauth-identities/"+provider, nil)
req.SetPathValue("provider", provider)
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: userID}), locale))
rec := httptest.NewRecorder()
controllers.OAuthIdentitiesDestroy(app, opts).ServeHTTP(rec, req)
return rec
}
func notFound404(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("host 404"))
}
// TestOAuthIdentitiesDestroyRule is the D-13 unlink rule table: an identity
// goes when the user has a self-set password or another identity remains;
// otherwise 409 with the unchanged EN and PL bodies. Unknown providers and
// identities of other users are 404 through the host's WriteNotFound;
// microsoft is in the default allow-list; without opts.Providers a
// published source's known names decide, and opts.Providers wins over it.
func TestOAuthIdentitiesDestroyRule(t *testing.T) {
skipShortPostgres(t)
app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app)
opts := controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}
cases := []struct {
name string
selfSet bool
linked []string
unlink string
locale string
status int
body string
remaining []string
}{
{name: "self_set_password_one_identity", selfSet: true, linked: []string{"google"}, unlink: "google", status: http.StatusNoContent, remaining: []string{}},
{name: "passwordless_one_identity_en", linked: []string{"facebook"}, unlink: "facebook", locale: "en", status: http.StatusConflict, body: `{"error":"` + lastMethodBlockedEN + `"}`, remaining: []string{"facebook"}},
{name: "passwordless_one_identity_pl", linked: []string{"github"}, unlink: "github", locale: "pl", status: http.StatusConflict, body: `{"error":"` + lastMethodBlockedPL + `"}`, remaining: []string{"github"}},
{name: "passwordless_two_identities", linked: []string{"facebook", "google"}, unlink: "google", status: http.StatusNoContent, remaining: []string{"facebook"}},
{name: "self_set_password_two_identities", selfSet: true, linked: []string{"facebook", "google"}, unlink: "facebook", status: http.StatusNoContent, remaining: []string{"google"}},
{name: "unknown_provider", selfSet: true, linked: []string{"google"}, unlink: "linkedin", status: http.StatusNotFound, body: "host 404", remaining: []string{"google"}},
{name: "not_linked", selfSet: true, linked: []string{"google"}, unlink: "github", status: http.StatusNotFound, body: "host 404", remaining: []string{"google"}},
{name: "microsoft_allowed_by_default", selfSet: true, linked: []string{"microsoft"}, unlink: "microsoft", status: http.StatusNoContent, remaining: []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
u := ruleUser(t, db, tc.selfSet, tc.linked...)
locale := tc.locale
if locale == "" {
locale = "en"
}
rec := unlinkWith(t, app, opts, u.ID, tc.unlink, locale)
if rec.Code != tc.status {
t.Fatalf("status %d, want %d (body %s)", rec.Code, tc.status, rec.Body.String())
}
if got := strings.TrimRight(rec.Body.String(), "\n"); got != tc.body {
t.Fatalf("body %q, want %q", got, tc.body)
}
if got := linkedProviders(t, db, u.ID); strings.Join(got, ",") != strings.Join(tc.remaining, ",") {
t.Fatalf("remaining %v, want %v", got, tc.remaining)
}
})
}
t.Run("foreign_identity", func(t *testing.T) {
owner := ruleUser(t, db, true, "google")
stranger := ruleUser(t, db, true, "facebook")
rec := unlinkWith(t, app, opts, stranger.ID, "google", "en")
if rec.Code != http.StatusNotFound || rec.Body.String() != "host 404" {
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
}
if got := linkedProviders(t, db, owner.ID); len(got) != 1 || got[0] != "google" {
t.Fatalf("owner's identities %v", got)
}
if got := linkedProviders(t, db, stranger.ID); len(got) != 1 || got[0] != "facebook" {
t.Fatalf("stranger's identities %v", got)
}
})
t.Run("source_known_names_when_no_options", func(t *testing.T) {
srcApp, srcDB := sessionApp(t, "0s")
if err := srcApp.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google", "custom"}}); err != nil {
t.Fatal(err)
}
u := ruleUser(t, srcDB, true, "custom", "github")
if rec := unlinkWith(t, srcApp, opts, u.ID, "custom", "en"); rec.Code != http.StatusNoContent {
t.Fatalf("custom: status %d body %s", rec.Code, rec.Body.String())
}
if rec := unlinkWith(t, srcApp, opts, u.ID, "github", "en"); rec.Code != http.StatusNotFound {
t.Fatalf("github outside the source's names: status %d body %s", rec.Code, rec.Body.String())
}
if got := linkedProviders(t, srcDB, u.ID); len(got) != 1 || got[0] != "github" {
t.Fatalf("remaining %v, want [github]", got)
}
})
t.Run("options_win_over_source", func(t *testing.T) {
srcApp, srcDB := sessionApp(t, "0s")
if err := srcApp.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google", "custom"}}); err != nil {
t.Fatal(err)
}
withList := controllers.OAuthIdentitiesOptions{Providers: []string{"github"}, WriteNotFound: notFound404}
u := ruleUser(t, srcDB, true, "custom", "github")
if rec := unlinkWith(t, srcApp, withList, u.ID, "custom", "en"); rec.Code != http.StatusNotFound {
t.Fatalf("custom outside opts.Providers: status %d body %s", rec.Code, rec.Body.String())
}
if rec := unlinkWith(t, srcApp, withList, u.ID, "github", "en"); rec.Code != http.StatusNoContent {
t.Fatalf("github in opts.Providers: status %d body %s", rec.Code, rec.Body.String())
}
if got := linkedProviders(t, srcDB, u.ID); len(got) != 1 || got[0] != "custom" {
t.Fatalf("remaining %v, want [custom]", got)
}
})
}
// TestOAuthIdentitiesDestroyRace forces the D-13 race deterministically:
// two transactions unlink the two identities of a password-less user, each
// taking the users-row lock (SELECT ... FOR UPDATE) and running the guarded
// DELETE the handler runs. The second blocks until the first commits and
// then deletes nothing, in both orders; exactly one identity is left. The
// handler variants prove the handler takes the same lock.
func TestOAuthIdentitiesDestroyRace(t *testing.T) {
skipShortPostgres(t)
app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app)
for _, order := range [][2]string{{"google", "facebook"}, {"facebook", "google"}} {
first, second := order[0], order[1]
t.Run("transactions_"+first+"_first", func(t *testing.T) {
u := ruleUser(t, db, false, "google", "facebook")
tx1 := db.Begin()
if tx1.Error != nil {
t.Fatal(tx1.Error)
}
committed := false
t.Cleanup(func() {
if !committed {
tx1.Rollback()
}
})
if err := tx1.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
t.Fatal(err)
}
res := tx1.Exec(guardedUnlinkSQL, u.ID, first)
if res.Error != nil || res.RowsAffected != 1 {
t.Fatalf("first delete: rows %d err %v", res.RowsAffected, res.Error)
}
type outcome struct {
rows int64
err error
}
done := make(chan outcome, 1)
go func() {
var rows int64
err := db.Transaction(func(tx2 *gorm.DB) error {
if err := tx2.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
return err
}
r := tx2.Exec(guardedUnlinkSQL, u.ID, second)
rows = r.RowsAffected
return r.Error
})
done <- outcome{rows, err}
}()
select {
case got := <-done:
t.Fatalf("the second transaction finished (rows %d, err %v) while the first held the users row lock", got.rows, got.err)
case <-time.After(300 * time.Millisecond):
}
if err := tx1.Commit().Error; err != nil {
t.Fatal(err)
}
committed = true
select {
case got := <-done:
if got.err != nil || got.rows != 0 {
t.Fatalf("second delete after the first committed: rows %d err %v, want 0 rows", got.rows, got.err)
}
case <-time.After(10 * time.Second):
t.Fatal("the second transaction did not finish after the first committed")
}
if got := linkedProviders(t, db, u.ID); len(got) != 1 || got[0] != second {
t.Fatalf("remaining %v, want [%s]", got, second)
}
})
t.Run("handler_waits_"+first+"_first", func(t *testing.T) {
u := ruleUser(t, db, false, "google", "facebook")
tx1 := db.Begin()
committed := false
t.Cleanup(func() {
if !committed {
tx1.Rollback()
}
})
if err := tx1.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
t.Fatal(err)
}
if res := tx1.Exec(guardedUnlinkSQL, u.ID, first); res.Error != nil || res.RowsAffected != 1 {
t.Fatalf("first delete: rows %d err %v", res.RowsAffected, res.Error)
}
recCh := make(chan *httptest.ResponseRecorder, 1)
go func() {
recCh <- unlinkWith(t, app, controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}, u.ID, second, "en")
}()
select {
case rec := <-recCh:
t.Fatalf("the handler answered %d while the users row was locked", rec.Code)
case <-time.After(300 * time.Millisecond):
}
if err := tx1.Commit().Error; err != nil {
t.Fatal(err)
}
committed = true
var rec *httptest.ResponseRecorder
select {
case rec = <-recCh:
case <-time.After(10 * time.Second):
t.Fatal("the handler did not answer after the lock was released")
}
if rec.Code != http.StatusConflict {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body["error"] != lastMethodBlockedEN {
t.Fatalf("409 body %s (%v)", rec.Body.String(), err)
}
if got := linkedProviders(t, db, u.ID); len(got) != 1 || got[0] != second {
t.Fatalf("remaining %v, want [%s]", got, second)
}
})
}
t.Run("concurrent_handlers", func(t *testing.T) {
opts := controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}
for round := range 10 {
u := ruleUser(t, db, false, "google", "facebook")
var wg sync.WaitGroup
start := make(chan struct{})
codes := make([]int, 2)
for i, p := range []string{"google", "facebook"} {
wg.Add(1)
go func() {
defer wg.Done()
<-start
codes[i] = unlinkWith(t, app, opts, u.ID, p, "en").Code
}()
}
close(start)
wg.Wait()
ok, conflict := 0, 0
for _, c := range codes {
switch c {
case http.StatusNoContent:
ok++
case http.StatusConflict:
conflict++
}
}
if ok != 1 || conflict != 1 {
t.Fatalf("round %d: statuses %v, want one 204 and one 409", round, codes)
}
if got := linkedProviders(t, db, u.ID); len(got) != 1 {
t.Fatalf("round %d: remaining %v, want exactly one identity", round, got)
}
}
})
}

View File

@@ -109,6 +109,11 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL(t *testing.T) {
} { } {
t.Run(tc.locale, func(t *testing.T) { t.Run(tc.locale, func(t *testing.T) {
user := insertUser(t, db, tc.email, "correct-horse") user := insertUser(t, db, tc.email, "correct-horse")
// The column defaults to TRUE; a user without a self-set
// password is the one the last identity is kept for.
if err := db.Model(user).Update("has_self_set_password", false).Error; err != nil {
t.Fatal(err)
}
seedOAuthIdentity(t, db, user.ID, "facebook", "fb-only-"+tc.locale) seedOAuthIdentity(t, db, user.ID, "facebook", "fb-only-"+tc.locale)
rec := destroyOAuthIdentity(t, app, user.ID, "facebook", tc.locale, winter404OrFatal(t)) rec := destroyOAuthIdentity(t, app, user.ID, "facebook", tc.locale, winter404OrFatal(t))
if rec.Code != http.StatusConflict { if rec.Code != http.StatusConflict {
@@ -128,7 +133,9 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL(t *testing.T) {
} }
} }
func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword(t *testing.T) { // TestOAuthIdentitiesDestroyLastIdentityWithPassword: under the PHP rule a
// user with a self-set password may unlink the last identity (D-13).
func TestOAuthIdentitiesDestroyLastIdentityWithPassword(t *testing.T) {
app, db := sessionApp(t, "0s") app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app) activateOAuthIdentitiesLang(t, app)
user := insertUser(t, db, "oauth-last-password@example.test", "correct-horse") user := insertUser(t, db, "oauth-last-password@example.test", "correct-horse")
@@ -137,6 +144,80 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword(t *testing.T) {
} }
seedOAuthIdentity(t, db, user.ID, "google", "g-only") seedOAuthIdentity(t, db, user.ID, "google", "g-only")
rec := destroyOAuthIdentity(t, app, user.ID, "google", "en", winter404OrFatal(t)) rec := destroyOAuthIdentity(t, app, user.ID, "google", "en", winter404OrFatal(t))
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if rec.Body.Len() != 0 {
t.Fatalf("body = %q", rec.Body.String())
}
if remaining := countOAuthIdentities(t, db, user.ID); remaining != 0 {
t.Fatalf("identities left = %d, want 0", remaining)
}
}
// TestOAuthIdentitiesDestroyConcurrentSmoke: two unlinks on a password-less
// user with two identities never remove both (D-13, RESEARCH Pitfall 1).
// Transaction A holds the users row lock and has deleted google; the
// handler's unlink of facebook must wait for A, then see one identity left
// and answer 409.
func TestOAuthIdentitiesDestroyConcurrentSmoke(t *testing.T) {
app, db := sessionApp(t, "0s")
activateOAuthIdentitiesLang(t, app)
user := insertUser(t, db, "oauth-concurrent@example.test", "correct-horse")
if err := db.Model(user).Update("has_self_set_password", false).Error; err != nil {
t.Fatal(err)
}
seedOAuthIdentity(t, db, user.ID, "google", "g-concurrent")
seedOAuthIdentity(t, db, user.ID, "facebook", "fb-concurrent")
txA := db.Begin()
if txA.Error != nil {
t.Fatal(txA.Error)
}
committed := false
t.Cleanup(func() {
if !committed {
txA.Rollback()
}
})
if err := txA.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, user.ID).Error; err != nil {
t.Fatal(err)
}
res := txA.Exec(`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)`,
user.ID, "google")
if res.Error != nil || res.RowsAffected != 1 {
t.Fatalf("transaction A delete: rows %d err %v", res.RowsAffected, res.Error)
}
req := httptest.NewRequest(http.MethodDelete, "/_fonoteka/api/v1/oauth-identities/facebook", nil)
req.SetPathValue("provider", "facebook")
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID}), "en"))
rec := httptest.NewRecorder()
h := controllers.OAuthIdentitiesDestroy(app, controllers.OAuthIdentitiesOptions{
WriteNotFound: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) },
})
done := make(chan struct{})
go func() {
defer close(done)
h.ServeHTTP(rec, req)
}()
select {
case <-done:
t.Fatalf("the unlink answered %d while transaction A held the user row lock", rec.Code)
case <-time.After(300 * time.Millisecond):
}
if err := txA.Commit().Error; err != nil {
t.Fatal(err)
}
committed = true
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the unlink did not answer after transaction A committed")
}
if rec.Code != http.StatusConflict { if rec.Code != http.StatusConflict {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
} }
@@ -145,10 +226,14 @@ func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if body["error"] != lastMethodBlockedEN { if body["error"] != lastMethodBlockedEN {
t.Fatalf("last_method_blocked with password = %#v", body["error"]) t.Fatalf("409 body = %s", rec.Body.String())
} }
if remaining := countOAuthIdentities(t, db, user.ID); remaining != 1 { var left []models.OAuthIdentity
t.Fatalf("row still present = %d", remaining) if err := db.Where("user_id = ?", user.ID).Find(&left).Error; err != nil {
t.Fatal(err)
}
if len(left) != 1 || left[0].Provider != "facebook" {
t.Fatalf("remaining = %#v, want facebook only", left)
} }
} }

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

136
oauth_providers_test.go Normal file
View File

@@ -0,0 +1,136 @@
package user
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// countingProviderSource answers a mutable provider list and counts the
// lookups, so a test can prove the handler asks on every request.
type countingProviderSource struct {
enabled atomic.Pointer[[]classes.OAuthProvider]
calls atomic.Int32
ctxSeen atomic.Bool
}
type ctxProbeKey struct{}
func (s *countingProviderSource) EnabledOAuthProviders(ctx context.Context) []classes.OAuthProvider {
s.calls.Add(1)
if ctx.Value(ctxProbeKey{}) == "probe" {
s.ctxSeen.Store(true)
}
if p := s.enabled.Load(); p != nil {
return *p
}
return nil
}
func (s *countingProviderSource) KnownOAuthProviders() []string { return nil }
// TestOAuthProviders pins GET /_user/api/v1/oauth-providers (D-15) without a
// database: the old bytes without a source, an empty list from a source
// with nothing enabled, the source's order, and a lookup on every request.
func TestOAuthProviders(t *testing.T) {
get := func(t *testing.T, h http.HandlerFunc, ctx context.Context) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/oauth-providers", nil)
if ctx != nil {
req = req.WithContext(ctx)
}
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
t.Fatalf("Cache-Control = %q", cc)
}
return rec
}
body := func(rec *httptest.ResponseRecorder) string {
return strings.TrimRight(rec.Body.String(), "\n")
}
t.Run("no_source_old_bytes", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("body = %s", got)
}
})
t.Run("source_without_enabled_providers", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
if err := app.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google"}}); err != nil {
t.Fatal(err)
}
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("body = %s", got)
}
})
t.Run("order_preserved", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
src := fakeProviderSource{enabled: []classes.OAuthProvider{
{Name: "microsoft", Label: "Microsoft"},
{Name: "github", Label: "GitHub"},
{Name: "google", Label: "Google"},
}}
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
t.Fatal(err)
}
want := `{"providers":[{"label":"Microsoft","name":"microsoft"},{"label":"GitHub","name":"github"},{"label":"Google","name":"google"}],"success":true}`
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != want {
t.Fatalf("body = %s\nwant %s", got, want)
}
})
t.Run("lookup_per_request", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
h := controllers.OAuthProviders(app)
if got := body(get(t, h, nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("before publishing: %s", got)
}
src := &countingProviderSource{}
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
t.Fatal(err)
}
first := []classes.OAuthProvider{{Name: "google", Label: "Google"}}
src.enabled.Store(&first)
probe := context.WithValue(context.Background(), ctxProbeKey{}, "probe")
if got := body(get(t, h, probe)); got != `{"providers":[{"label":"Google","name":"google"}],"success":true}` {
t.Fatalf("after publishing: %s", got)
}
second := []classes.OAuthProvider{{Name: "github", Label: "GitHub"}}
src.enabled.Store(&second)
if got := body(get(t, h, nil)); got != `{"providers":[{"label":"GitHub","name":"github"}],"success":true}` {
t.Fatalf("after the source changed: %s", got)
}
if n := src.calls.Load(); n != 2 {
t.Fatalf("EnabledOAuthProviders called %d times for 2 requests", n)
}
if !src.ctxSeen.Load() {
t.Fatal("EnabledOAuthProviders did not receive the request context")
}
})
t.Run("bare_publish_is_not_found", func(t *testing.T) {
// A source published without the explicit type argument is keyed on
// its concrete type; the list stays empty (the documented pitfall).
app := backpack.New(sessionConfig(t))
if err := app.Publish(fakeProviderSource{enabled: []classes.OAuthProvider{{Name: "google", Label: "Google"}}}); err != nil {
t.Fatal(err)
}
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("body = %s", got)
}
})
}

View File

@@ -82,6 +82,29 @@ func TestChangePassword(t *testing.T) {
t.Fatalf("same password %d %s", same.Code, same.Body.String()) t.Fatalf("same password %d %s", same.Code, same.Body.String())
} }
// PHP OAuthPasswordBootstrapTest::test_real_password_account_still_requires_current_password:
// a self-set password still needs current_password, and a wrong one is
// refused with the unchanged invalid_login body; the password stays.
t.Run("real_password_account_still_requires_current_password", func(t *testing.T) {
missing := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"password":"another-password-2","password_confirmation":"another-password-2"}`)
var body map[string]any
if err := json.Unmarshal(missing.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
errs, _ := body["errors"].(map[string]any)
if missing.Code != http.StatusUnprocessableEntity || errs["current_password"] == nil {
t.Fatalf("missing current_password %d %s", missing.Code, missing.Body.String())
}
wrong := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"current_password":"definitely-wrong","password":"another-password-2","password_confirmation":"another-password-2"}`)
if wrong.Code != http.StatusUnprocessableEntity || wrong.Body.String() != `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}` {
t.Fatalf("wrong current_password %d %s", wrong.Code, wrong.Body.String())
}
again := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"change@example.test","password":"brand-new-password"}`)
if again.Code != http.StatusOK {
t.Fatalf("the password changed: login %d %s", again.Code, again.Body.String())
}
})
locked := insertUser(t, gdb, "locked-password@example.test", "correct-horse") locked := insertUser(t, gdb, "locked-password@example.test", "correct-horse")
if err := gdb.Model(locked).UpdateColumn("has_self_set_password", false).Error; err != nil { if err := gdb.Model(locked).UpdateColumn("has_self_set_password", false).Error; err != nil {
t.Fatal(err) t.Fatal(err)

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

332
social_hooks_test.go Normal file
View File

@@ -0,0 +1,332 @@
package user
import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/postcard"
"gorm.io/gorm"
)
// skipShortPostgres skips a Postgres-backed test under -short (the phase
// gate runs these by name without -short on a Docker host).
func skipShortPostgres(t *testing.T) {
t.Helper()
if testing.Short() {
t.Skip("Postgres-backed; skipped under -short")
}
}
// hookEmail is a unique address for one test.
func hookEmail(prefix string) string {
return fmt.Sprintf("%s-%d@example.test", prefix, time.Now().UnixNano())
}
func registerFields(email string) map[string]any {
return map[string]any{
"email": email,
"name": "Social Person",
"password": "random-password-never-shown",
"password_confirmation": "random-password-never-shown",
}
}
// TestRegisterUserPasswordLess pins RegisterOptions.PasswordLess (D-22): the
// zero value keeps has_self_set_password true, true stores false; the handle
// is assigned either way; validation errors are the same with and without
// it and nothing is written.
func TestRegisterUserPasswordLess(t *testing.T) {
skipShortPostgres(t)
app, gdb := sessionApp(t, "0s")
ctx := t.Context()
stored := func(t *testing.T, id uint) models.User {
t.Helper()
var u models.User
if err := gdb.First(&u, id).Error; err != nil {
t.Fatal(err)
}
return u
}
t.Run("password_less_false_keeps_self_set", func(t *testing.T) {
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail("pwless-false")), controllers.RegisterOptions{Activate: true})
if err != nil || len(errs) > 0 {
t.Fatalf("RegisterUser: %v %v", err, errs)
}
if got := stored(t, u.ID); !got.HasSelfSetPassword || !u.HasSelfSetPassword {
t.Fatalf("has_self_set_password stored %v (returned %v), want true", got.HasSelfSetPassword, u.HasSelfSetPassword)
}
})
t.Run("password_less_true_stores_false", func(t *testing.T) {
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail("pwless-true")), controllers.RegisterOptions{Activate: true, PasswordLess: true, IP: "198.18.7.1"})
if err != nil || len(errs) > 0 {
t.Fatalf("RegisterUser: %v %v", err, errs)
}
got := stored(t, u.ID)
if got.HasSelfSetPassword || u.HasSelfSetPassword {
t.Fatalf("has_self_set_password stored %v (returned %v), want false", got.HasSelfSetPassword, u.HasSelfSetPassword)
}
if !got.IsActivated || got.ActivatedAt == nil {
t.Fatalf("activation %v %v", got.IsActivated, got.ActivatedAt)
}
if got.CreatedIPAddress == nil || *got.CreatedIPAddress != "198.18.7.1" || got.LastIPAddress == nil || *got.LastIPAddress != "198.18.7.1" {
t.Fatalf("ip columns %v %v", got.CreatedIPAddress, got.LastIPAddress)
}
})
t.Run("handle_assigned", func(t *testing.T) {
for _, pl := range []bool{false, true} {
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail(fmt.Sprintf("pwless-handle-%v", pl))), controllers.RegisterOptions{PasswordLess: pl})
if err != nil || len(errs) > 0 {
t.Fatalf("RegisterUser(PasswordLess=%v): %v %v", pl, err, errs)
}
if got := stored(t, u.ID); got.Handle == nil || *got.Handle == "" {
t.Fatalf("PasswordLess=%v: no handle", pl)
}
}
})
t.Run("validation_errors_unchanged", func(t *testing.T) {
email := "not-an-email"
fields := map[string]any{"email": email, "password": "short", "password_confirmation": "other"}
_, plain, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{})
if err != nil || len(plain) == 0 {
t.Fatalf("no validation errors without PasswordLess: %v %v", err, plain)
}
u, less, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{PasswordLess: true})
if err != nil || u != nil {
t.Fatalf("PasswordLess registration with invalid fields: user %v err %v", u, err)
}
if !reflect.DeepEqual(plain, less) {
t.Fatalf("validation errors differ:\n without %v\n with %v", plain, less)
}
if _, ok := less["email"]; !ok {
t.Fatalf("no email error: %v", less)
}
var n int64
if err := gdb.Unscoped().Model(&models.User{}).Where("email = ?", email).Count(&n).Error; err != nil || n != 0 {
t.Fatalf("an invalid registration wrote %d rows (%v)", n, err)
}
})
}
// TestFinishLogin pins the social sign-in tail of a password login: a
// trashed account is restored with exactly one reactivate mail, an active
// one gets none; last_ip_address and last_seen are written, and an empty
// ip leaves last_ip_address alone.
func TestFinishLogin(t *testing.T) {
skipShortPostgres(t)
newApp := func(t *testing.T) (*backpack.App, *gorm.DB, *captureMail) {
t.Helper()
app, gdb := sessionApp(t, "0s")
mail := &captureMail{}
if err := app.Publish[postcard.Mailer](mail); err != nil {
t.Fatal(err)
}
return app, gdb, mail
}
load := func(t *testing.T, gdb *gorm.DB, id uint) models.User {
t.Helper()
var u models.User
if err := gdb.Unscoped().First(&u, id).Error; err != nil {
t.Fatal(err)
}
return u
}
newUser := func(t *testing.T, gdb *gorm.DB, prefix string) *models.User {
t.Helper()
loc := "en"
u := &models.User{Email: hookEmail(prefix), Password: "x", PreferredLocale: &loc, IsActivated: true}
if err := gdb.Create(u).Error; err != nil {
t.Fatal(err)
}
return u
}
t.Run("trashed_user_restored_with_one_mail", func(t *testing.T) {
app, gdb, mail := newApp(t)
u := newUser(t, gdb, "finish-trashed")
if err := gdb.Model(u).Update("deleted_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
trashed := load(t, gdb, u.ID)
if !trashed.DeletedAt.Valid {
t.Fatal("setup: not soft-deleted")
}
if err := controllers.FinishLogin(t.Context(), app, gdb, &trashed, "198.18.8.1"); err != nil {
t.Fatal(err)
}
if after := load(t, gdb, u.ID); after.DeletedAt.Valid {
t.Fatal("not restored")
}
if trashed.DeletedAt.Valid {
t.Fatal("the passed user still reads as trashed")
}
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
t.Fatalf("mails %#v, want one reactivate-en", mail.got)
}
// A second sign-in of the restored user sends no further mail.
if err := controllers.FinishLogin(t.Context(), app, gdb, &trashed, "198.18.8.1"); err != nil {
t.Fatal(err)
}
if len(mail.got) != 1 {
t.Fatalf("mails after a second sign-in %#v", mail.got)
}
})
t.Run("active_user_no_mail", func(t *testing.T) {
app, gdb, mail := newApp(t)
u := newUser(t, gdb, "finish-active")
if err := controllers.FinishLogin(t.Context(), app, gdb, u, "198.18.8.2"); err != nil {
t.Fatal(err)
}
if len(mail.got) != 0 {
t.Fatalf("mails %#v, want none", mail.got)
}
})
t.Run("ip_and_last_seen", func(t *testing.T) {
app, gdb, _ := newApp(t)
u := newUser(t, gdb, "finish-ip")
before := time.Now().Add(-time.Second)
if err := controllers.FinishLogin(t.Context(), app, gdb, u, "198.18.8.3"); err != nil {
t.Fatal(err)
}
after := load(t, gdb, u.ID)
if after.LastIPAddress == nil || *after.LastIPAddress != "198.18.8.3" {
t.Fatalf("last_ip_address %v", after.LastIPAddress)
}
if u.LastIPAddress == nil || *u.LastIPAddress != "198.18.8.3" {
t.Fatalf("the passed user's LastIPAddress %v", u.LastIPAddress)
}
if after.LastSeen == nil || after.LastSeen.Before(before) {
t.Fatalf("last_seen %v, want at or after %v", after.LastSeen, before)
}
})
t.Run("empty_ip_keeps_last_ip", func(t *testing.T) {
app, gdb, _ := newApp(t)
u := newUser(t, gdb, "finish-noip")
if err := gdb.Model(u).Update("last_ip_address", "198.18.8.4").Error; err != nil {
t.Fatal(err)
}
if err := controllers.FinishLogin(t.Context(), app, gdb, u, ""); err != nil {
t.Fatal(err)
}
if after := load(t, gdb, u.ID); after.LastIPAddress == nil || *after.LastIPAddress != "198.18.8.4" {
t.Fatalf("last_ip_address %v, want the old 198.18.8.4", after.LastIPAddress)
}
})
t.Run("refuses_unsaved_user", func(t *testing.T) {
app, gdb, _ := newApp(t)
if err := controllers.FinishLogin(t.Context(), app, gdb, &models.User{}, "198.18.8.5"); err == nil {
t.Fatal("FinishLogin accepted a user without an id")
}
if err := controllers.FinishLogin(t.Context(), app, nil, &models.User{ID: 1}, ""); err == nil {
t.Fatal("FinishLogin accepted a nil database")
}
})
}
// TestRegistrationExports pins the helpers golem15.user exports for a social
// login plugin: RegistrationSettings (defaults and config), RegistrationsFromIP
// (the last 60 minutes, that IP only), RefreshTTL (default 20160 minutes and
// config) and RemoteIP (with and without a port).
func TestRegistrationExports(t *testing.T) {
t.Run("registration_settings", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
allow, throttle, mode, require := controllers.RegistrationSettings(app)
if !allow || !throttle || mode != "auto" || !require {
t.Fatalf("defaults = %v %v %q %v, want true true auto true", allow, throttle, mode, require)
}
for key, value := range map[string]any{
"golem15.user.registration.allow_registration": false,
"golem15.user.registration.use_register_throttle": false,
"golem15.user.activation.activate_mode": "admin",
"golem15.user.activation.require_activation": false,
} {
if err := app.Config.Set(key, value); err != nil {
t.Fatal(err)
}
}
allow, throttle, mode, require = controllers.RegistrationSettings(app)
if allow || throttle || mode != "admin" || require {
t.Fatalf("configured = %v %v %q %v, want false false admin false", allow, throttle, mode, require)
}
if a, th, m, r := controllers.RegistrationSettings(nil); !a || !th || m != "auto" || !r {
t.Fatalf("nil app = %v %v %q %v", a, th, m, r)
}
})
t.Run("registrations_from_ip", func(t *testing.T) {
skipShortPostgres(t)
_, gdb := sessionApp(t, "0s")
ip := fmt.Sprintf("198.18.%d.%d", time.Now().UnixNano()%200+20, time.Now().UnixNano()%250+1)
other := "198.19.0.1"
mk := func(addr string, age time.Duration) {
t.Helper()
u := &models.User{Email: hookEmail("regip"), Password: "x", CreatedIPAddress: &addr}
if err := gdb.Create(u).Error; err != nil {
t.Fatal(err)
}
if age > 0 {
if err := gdb.Exec(`UPDATE users SET created_at = ? WHERE id = ?`, time.Now().Add(-age), u.ID).Error; err != nil {
t.Fatal(err)
}
}
}
before := controllers.RegistrationsFromIP(t.Context(), gdb, ip)
mk(ip, 0)
mk(ip, 30*time.Minute)
mk(ip, 61*time.Minute)
mk(ip, 3*time.Hour)
mk(other, 0)
if got := controllers.RegistrationsFromIP(t.Context(), gdb, ip); got-before != 2 {
t.Fatalf("RegistrationsFromIP = %d (before %d), want 2 more: the last 60 minutes only", got, before)
}
if got := controllers.RegistrationsFromIP(t.Context(), gdb, "198.19.255.254"); got != 0 {
t.Fatalf("an unused ip counts %d", got)
}
})
t.Run("refresh_ttl", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
if got := controllers.RefreshTTL(app); got != 20160*time.Minute {
t.Fatalf("default RefreshTTL = %s, want 20160m", got)
}
if err := app.Config.Set("golem15.user.jwt.refresh_ttl", 90); err != nil {
t.Fatal(err)
}
if got := controllers.RefreshTTL(app); got != 90*time.Minute {
t.Fatalf("configured RefreshTTL = %s, want 90m", got)
}
if got := controllers.RefreshTTL(nil); got != 20160*time.Minute {
t.Fatalf("nil app RefreshTTL = %s", got)
}
})
t.Run("remote_ip", func(t *testing.T) {
for remote, want := range map[string]string{
"203.0.113.9:4242": "203.0.113.9",
"203.0.113.9": "203.0.113.9",
"[2001:db8::1]:443": "2001:db8::1",
"": "",
} {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remote
req.Header.Set("X-Forwarded-For", "192.0.2.200")
if got := controllers.RemoteIP(req); got != want {
t.Fatalf("RemoteIP(%q) = %q, want %q", remote, got, want)
}
}
})
}

View File

@@ -88,7 +88,12 @@ func turnstileOf(route recordedRoute) []string {
return out return out
} }
func TestTurnstileRoutesSmoke(t *testing.T) { // TestTurnstileRoutes pins golem15.user.turnstile (D-07): empty adds no
// middleware; open and closed (surrounding spaces trimmed) append
// turnstile:{mode} to POST /_user/api/v1/login and /register only, leaving
// every other route's middleware as it is with the key empty; any other
// value fails Routes naming the key.
func TestTurnstileRoutes(t *testing.T) {
off, err := recordUserRoutes(t, "") off, err := recordUserRoutes(t, "")
if err != nil { if err != nil {
t.Fatalf("empty turnstile: Routes error %v", err) t.Fatalf("empty turnstile: Routes error %v", err)
@@ -96,51 +101,58 @@ func TestTurnstileRoutesSmoke(t *testing.T) {
if len(off) == 0 { if len(off) == 0 {
t.Fatal("no routes recorded") t.Fatal("no routes recorded")
} }
guarded := map[string]bool{
http.MethodPost + " /_user/api/v1/login": true,
http.MethodPost + " /_user/api/v1/register": true,
}
seen := 0
for _, route := range off { for _, route := range off {
if got := turnstileOf(route); len(got) > 0 { if got := turnstileOf(route); len(got) > 0 {
t.Errorf("empty turnstile: %s %s carries %v", route.method, route.path, got) t.Errorf("empty turnstile: %s %s carries %v", route.method, route.path, got)
} }
} if guarded[route.method+" "+route.path] {
seen++
closed, err := recordUserRoutes(t, "closed")
if err != nil {
t.Fatalf("closed turnstile: Routes error %v", err)
}
if len(closed) != len(off) {
t.Fatalf("closed turnstile: %d routes, want %d", len(closed), len(off))
}
guarded := map[string]bool{"/_user/api/v1/login": true, "/_user/api/v1/register": true}
for i, route := range closed {
got := turnstileOf(route)
if guarded[route.path] && route.method == http.MethodPost {
if len(got) != 1 || got[0] != "turnstile:closed" {
t.Errorf("closed turnstile: POST %s middleware %v, want one turnstile:closed", route.path, route.middleware)
}
if strings.Join(route.middleware[:len(route.middleware)-1], ",") != strings.Join(off[i].middleware, ",") {
t.Errorf("closed turnstile: POST %s middleware %v, want %v plus turnstile:closed", route.path, route.middleware, off[i].middleware)
}
continue
}
if route.method != off[i].method || route.path != off[i].path ||
strings.Join(route.middleware, ",") != strings.Join(off[i].middleware, ",") {
t.Errorf("closed turnstile: %s %s %v changed from %s %s %v",
route.method, route.path, route.middleware, off[i].method, off[i].path, off[i].middleware)
} }
} }
if seen != len(guarded) {
open, err := recordUserRoutes(t, "open") t.Fatalf("found %d of the guarded routes, want %d", seen, len(guarded))
if err != nil {
t.Fatalf("open turnstile: Routes error %v", err)
} }
for _, route := range open {
if guarded[route.path] { cases := []struct {
if got := turnstileOf(route); len(got) != 1 || got[0] != "turnstile:open" { name, mode, want string
t.Errorf("open turnstile: %s middleware %v", route.path, route.middleware) }{
{name: "open", mode: "open", want: "turnstile:open"},
{name: "closed", mode: "closed", want: "turnstile:closed"},
{name: "closed with spaces", mode: " closed ", want: "turnstile:closed"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := recordUserRoutes(t, c.mode)
if err != nil {
t.Fatalf("Routes error %v", err)
} }
} if len(got) != len(off) {
t.Fatalf("%d routes, want %d", len(got), len(off))
}
for i, route := range got {
base := off[i]
if route.method != base.method || route.path != base.path {
t.Fatalf("route %d is %s %s, want %s %s", i, route.method, route.path, base.method, base.path)
}
want := base.middleware
if guarded[route.method+" "+route.path] {
want = append(append([]string{}, base.middleware...), c.want)
}
if strings.Join(route.middleware, ",") != strings.Join(want, ",") {
t.Errorf("%s %s middleware %v, want %v", route.method, route.path, route.middleware, want)
}
}
})
} }
if _, err := recordUserRoutes(t, "bogus"); err == nil || !strings.Contains(err.Error(), "golem15.user.turnstile") { for _, bad := range []string{"bogus", "Closed", "on", "true"} {
t.Fatalf("bogus turnstile: error %v, want one naming golem15.user.turnstile", err) if _, err := recordUserRoutes(t, bad); err == nil || !strings.Contains(err.Error(), "golem15.user.turnstile") {
t.Errorf("turnstile %q: error %v, want one naming golem15.user.turnstile", bad, err)
}
} }
} }