Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c43ba87371 | ||
|
|
174161895e | ||
|
|
78a867bccb | ||
|
|
1fc0ce7ed0 |
15
README.md
15
README.md
@@ -37,7 +37,8 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
|||||||
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
- **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).
|
||||||
|
|||||||
31
classes/oauth_providers.go
Normal file
31
classes/oauth_providers.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
oauth_providers_smoke_test.go
Normal file
76
oauth_providers_smoke_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
86
social_hooks_smoke_test.go
Normal file
86
social_hooks_smoke_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user