fix: login refuses a not-activated account while require_activation is on
The refusal is the same 401 invalid-login answer as a wrong password and is counted by the login throttle. The activation is checked after the password and before the deactivation restore, so a refused account stays deactivated. require_activation false keeps the previous behaviour.
This commit is contained in:
@@ -16,14 +16,14 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
||||
|
||||
## Features
|
||||
|
||||
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only.
|
||||
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore.
|
||||
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. A background sweep prunes the blacklist. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh.
|
||||
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password.
|
||||
- **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it.
|
||||
- **Login throttling.** Failed logins are counted per user and IP in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time.
|
||||
- **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls.
|
||||
- **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage).
|
||||
- **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. Neither unban nor restore revives a token. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) or `RevokeUserAccess` runs for them.
|
||||
- **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. Neither unban nor restore revives a token. Activation gates login, deactivation does not: while `require_activation` is on, an account that is both deactivated and not activated is refused and stays deactivated, with no reactivate mail. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) or `RevokeUserAccess` runs for them.
|
||||
- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name by `Slugify` when it is left empty), a description, an avatar and its members: the users whose `organisation_id` points at it. `organisation_role` belongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that sets `organisation_id` itself sets or clears the role with it.
|
||||
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. The admin Users list can filter by group.
|
||||
- **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the user's groups, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete, and deactivate and ban both end the user's earlier sessions and personal API tokens (see Deactivation and bans); the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account.
|
||||
@@ -207,7 +207,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
|
||||
| `golem15.user.jwt.blacklist_grace` | `0` | Grace period in seconds for a blacklisted token. |
|
||||
| `golem15.user.jwt.leeway` | `0` | Clock leeway in seconds (declared for parity, not read yet). |
|
||||
| `golem15.user.jwt.blacklist_sweep_interval` | `10m` | How often expired blacklist rows are deleted (a Go duration). |
|
||||
| `golem15.user.activation.require_activation` | `true` | Whether a new account must be activated before login. |
|
||||
| `golem15.user.activation.require_activation` | `true` | Whether an account must be activated before login. When `true`, `POST /login` refuses a not-activated account (the opaque invalid-login answer, counted as a failed attempt) and `/register` in `admin` mode returns no token; when `false`, both sign such an account in. |
|
||||
| `golem15.user.activation.activate_mode` | `auto` | `auto`, `user` (mailed code) or `admin`. |
|
||||
| `golem15.user.activation.reset_ttl_minutes` | `60` | Password reset code lifetime. |
|
||||
| `golem15.user.activation.activation_ttl_hours` | `72` | Activation code lifetime. |
|
||||
@@ -258,6 +258,8 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
|
||||
|
||||
`TestLogoutEndsTheSession` signs in through `POST /login` and logs out with the bearer header, with only the `token` or `auth_token` cookie, and with both a bearer and a different cookie token, then checks that the token is refused through every carrier on a `jwt.auth` route, at `/fetch` and at `/refresh`, that the forget cookies match the token cookie's attributes (also with a configured domain and SameSite), that the blacklist row lasts for the refresh window so `/refresh` stays refused after the sweep that follows the access token's expiry, that an expired but refreshable token is revoked, and which tokens a logout refuses with 401.
|
||||
|
||||
`TestLoginRequiresActivation` pins the activation gate of `POST /login`: under the default and an explicit `require_activation: true` a not-activated account's correct password gets the byte-identical 401 (status, body, `Content-Type` and `Cache-Control`) of a wrong password, stamps no `last_seen`, adds one throttle attempt and suspends the account after `attempt_limit` refusals; `require_activation: false` and activated accounts sign in; a deactivated activated account is restored with the reactivate mail, and a deactivated not-activated account is refused and stays deactivated unless the key is off.
|
||||
|
||||
`TestAdminOrganisationMembershipClearsRole` checks that the members manager's unlink and link and a Users form save that changes the organisation clear `organisation_role`, that a save keeping the organisation keeps it, and that deleting an organisation clears both columns.
|
||||
|
||||
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestResetPassword(t *testing.T) {
|
||||
|
||||
func TestActivate(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
token := loginToken(t, app, gdb, "activate-auth@example.test")
|
||||
token := inactiveSessionToken(t, app, gdb, "activate-auth@example.test")
|
||||
rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"wrong"}`)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":false`) {
|
||||
t.Fatalf("activate %d %s", rec.Code, rec.Body.String())
|
||||
@@ -97,7 +97,7 @@ func TestActivate(t *testing.T) {
|
||||
|
||||
func TestActivateByCode(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
user := insertUser(t, gdb, "activate-code@example.test", "correct-horse")
|
||||
user := insertInactiveUser(t, gdb, "activate-code@example.test", "correct-horse")
|
||||
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1000,7 +1000,18 @@ func TestAdminUserPassword(t *testing.T) {
|
||||
} {
|
||||
assertNoSecret(t, rec.Body.String(), first, user.Password)
|
||||
}
|
||||
// A request cannot turn the activation on, and cannot store a hash directly.
|
||||
// Escalation G2: the account the administrator created is not activated,
|
||||
// so its password does not sign it in while require_activation is on
|
||||
// (the default); it gets the invalid-login answer of a wrong password.
|
||||
// A request still cannot turn the activation on, and cannot store a hash
|
||||
// directly. Once an administrator activates it, the password signs in.
|
||||
refused := env.call(t, http.MethodPost, "/_user/api/v1/login", "", map[string]string{"email": email, "password": first})
|
||||
if refused.Code != http.StatusUnauthorized || refused.Body.String() != invalidLoginBody {
|
||||
t.Fatalf("login of the not-activated user = %d %s", refused.Code, refused.Body.String())
|
||||
}
|
||||
if rec := env.bulk(t, token, "activate", user); rec.Code != http.StatusOK {
|
||||
t.Fatalf("activate = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if status, _ := env.userLogin(t, email, first); status != http.StatusOK {
|
||||
t.Fatalf("login with the created password = %d", status)
|
||||
}
|
||||
@@ -1094,8 +1105,8 @@ func TestAdminUserPassword(t *testing.T) {
|
||||
if status, _ := env.userLogin(t, email, second); status != http.StatusOK {
|
||||
t.Fatalf("the new password does not sign in: %d", status)
|
||||
}
|
||||
// The reset did not activate the user or touch anything else.
|
||||
if after.IsActivated || after.Email != email {
|
||||
// The reset kept the activation and the email.
|
||||
if !after.IsActivated || after.Email != email {
|
||||
t.Fatalf("the reset changed more than the password: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,17 @@ var errNoAvatar = errors.New("golem15.user: no avatar")
|
||||
const invalidLoginBody = "Invalid email or password"
|
||||
|
||||
// Login authenticates email and password and returns a JWT plus the user payload.
|
||||
//
|
||||
// It checks in this order: a ban or an active suspension
|
||||
// (classes.RejectIfThrottled), the password (bcrypt), the activation, then
|
||||
// restores a deactivated (deleted_at) account and sends it the reactivate
|
||||
// mail, records the successful attempt and mints the token. With
|
||||
// golem15.user.activation.require_activation on (the default) the correct
|
||||
// password of a not-activated account gets the same 401 invalid-login answer
|
||||
// as a wrong password and counts as a failed attempt for the throttle; an
|
||||
// account that is also deactivated is refused before the restore, so it
|
||||
// stays deactivated and gets no mail. With the key off such an account signs
|
||||
// in as before. Activation gates login, deactivation does not.
|
||||
func Login(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gdb, secret, ok := sessionDeps(w, app)
|
||||
@@ -75,13 +86,14 @@ func Login(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
if user == nil || !bouncer.CheckPassword(user.Password, password) {
|
||||
if user != nil {
|
||||
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeInvalidLogin(w, r, app)
|
||||
refuseLogin(ctx, w, r, app, gdb, user, ip)
|
||||
return
|
||||
}
|
||||
// The activation gate runs after the password check, so a wrong
|
||||
// password and a not-activated account cost the same and answer the
|
||||
// same, and before the restore, so a refused account is not revived.
|
||||
if _, _, _, requireActivation := registrationSettings(app); requireActivation && !user.IsActivated {
|
||||
refuseLogin(ctx, w, r, app, gdb, user, ip)
|
||||
return
|
||||
}
|
||||
if user.DeletedAt.Valid {
|
||||
@@ -1174,6 +1186,18 @@ func writeInvalidLogin(w http.ResponseWriter, r *http.Request, app *backpack.App
|
||||
})
|
||||
}
|
||||
|
||||
// refuseLogin is the one refusal /login gives after the throttle check: it
|
||||
// records a failed attempt for user (a nil user, an unknown email, records
|
||||
// nothing) and answers the 401 invalid-login body. The wrong-password and
|
||||
// the not-activated branches share it, so their answers cannot be told apart.
|
||||
func refuseLogin(ctx context.Context, w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, user *models.User, ip string) {
|
||||
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeInvalidLogin(w, r, app)
|
||||
}
|
||||
|
||||
func writeUnauthorized(w http.ResponseWriter) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthorized"})
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestEmailVerifiedAt(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("activate_by_code_stamps", func(t *testing.T) {
|
||||
user := insertUser(t, gdb, email("activate-by-code"), "correct-horse")
|
||||
user := insertInactiveUser(t, gdb, email("activate-by-code"), "correct-horse")
|
||||
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -146,7 +146,7 @@ func TestEmailVerifiedAt(t *testing.T) {
|
||||
})
|
||||
t.Run("activate_stamps", func(t *testing.T) {
|
||||
addr := email("activate")
|
||||
token := loginToken(t, app, gdb, addr)
|
||||
token := inactiveSessionToken(t, app, gdb, addr)
|
||||
user := &models.User{ID: userIDByEmail(t, gdb, addr)}
|
||||
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
|
||||
if err != nil {
|
||||
@@ -163,7 +163,7 @@ func TestEmailVerifiedAt(t *testing.T) {
|
||||
})
|
||||
t.Run("activate_wrong_code_no_proof", func(t *testing.T) {
|
||||
addr := email("activate-wrong")
|
||||
token := loginToken(t, app, gdb, addr)
|
||||
token := inactiveSessionToken(t, app, gdb, addr)
|
||||
user := &models.User{ID: userIDByEmail(t, gdb, addr)}
|
||||
if _, err := classes.IssueActivationCode(t.Context(), gdb, user); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
237
session_test.go
237
session_test.go
@@ -57,7 +57,7 @@ func TestLogin(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := &models.User{Email: "ada-login@example.test", Password: hash}
|
||||
user := &models.User{Email: "ada-login@example.test", Password: hash, IsActivated: true}
|
||||
if err := gdb.Create(user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func TestLogin(t *testing.T) {
|
||||
|
||||
now := time.Now()
|
||||
ip := "192.0.2.1"
|
||||
throttled := &models.User{Email: "throttled@example.test", Password: hash}
|
||||
throttled := &models.User{Email: "throttled@example.test", Password: hash, IsActivated: true}
|
||||
if err := gdb.Create(throttled).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestLogin(t *testing.T) {
|
||||
}
|
||||
|
||||
loc := "en"
|
||||
soft := &models.User{Email: "soft@example.test", Password: hash, PreferredLocale: &loc}
|
||||
soft := &models.User{Email: "soft@example.test", Password: hash, IsActivated: true, PreferredLocale: &loc}
|
||||
if err := gdb.Create(soft).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func TestLoginSixthAttempt(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := &models.User{Email: "sixth@example.test", Password: hash}
|
||||
user := &models.User{Email: "sixth@example.test", Password: hash, IsActivated: true}
|
||||
if err := gdb.Create(user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -192,6 +192,201 @@ func TestLoginSixthAttempt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginRequiresActivation pins the activation gate of POST /login:
|
||||
// with golem15.user.activation.require_activation on (the default) the
|
||||
// correct password of a not-activated account gets the same 401
|
||||
// invalid-login answer as a wrong password and counts as a failed attempt,
|
||||
// and an account that is also deactivated stays deactivated. With the key
|
||||
// off, and for activated accounts, login and the deactivation restore work
|
||||
// as before.
|
||||
func TestLoginRequiresActivation(t *testing.T) {
|
||||
hash, err := bouncer.HashPassword(10, "correct-horse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newApp := func(t *testing.T, require *bool) (*backpack.App, *gorm.DB) {
|
||||
t.Helper()
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
if require != nil {
|
||||
if err := app.Config.Set("golem15.user.activation.require_activation", *require); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return app, gdb
|
||||
}
|
||||
on, off := true, false
|
||||
create := func(t *testing.T, gdb *gorm.DB, email string, activated, deleted bool) *models.User {
|
||||
t.Helper()
|
||||
loc := "en"
|
||||
user := &models.User{Email: email, Password: hash, IsActivated: activated, PreferredLocale: &loc}
|
||||
if err := gdb.Create(user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted {
|
||||
if err := gdb.Model(user).Update("deleted_at", time.Now()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return user
|
||||
}
|
||||
login := func(app *backpack.App, email, password string) *httptest.ResponseRecorder {
|
||||
return postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"`+email+`","password":"`+password+`"}`)
|
||||
}
|
||||
reload := 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
|
||||
}
|
||||
attempts := func(t *testing.T, gdb *gorm.DB, id uint) models.Throttle {
|
||||
t.Helper()
|
||||
var row models.Throttle
|
||||
if err := gdb.Where("user_id = ?", id).Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
refused := func(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
|
||||
t.Fatalf("want the invalid-login 401, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
signedIn := func(t *testing.T, rec *httptest.ResponseRecorder, user *models.User) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusOK {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := body["token"].(string)
|
||||
if token == "" {
|
||||
t.Fatalf("no token in %s", rec.Body.String())
|
||||
}
|
||||
if sub := parseToken(t, token)["sub"]; sub != strconv.FormatUint(uint64(user.ID), 10) {
|
||||
t.Fatalf("sub = %v", sub)
|
||||
}
|
||||
}
|
||||
mailer := func(t *testing.T, app *backpack.App) *captureMail {
|
||||
t.Helper()
|
||||
mail := &captureMail{}
|
||||
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return mail
|
||||
}
|
||||
|
||||
t.Run("default_refuses", func(t *testing.T) {
|
||||
app, gdb := newApp(t, nil)
|
||||
user := create(t, gdb, "act-default@example.test", false, false)
|
||||
rec := login(app, user.Email, "correct-horse")
|
||||
refused(t, rec)
|
||||
if strings.Contains(rec.Body.String(), "token") {
|
||||
t.Fatalf("refusal carries a token: %s", rec.Body.String())
|
||||
}
|
||||
if got := reload(t, gdb, user.ID); got.LastSeen != nil {
|
||||
t.Fatalf("refusal stamped last_seen %v", got.LastSeen)
|
||||
}
|
||||
if row := attempts(t, gdb, user.ID); row.Attempts != 1 {
|
||||
t.Fatalf("throttle after refusal: %+v", row)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same_as_wrong_password", func(t *testing.T) {
|
||||
app, gdb := newApp(t, nil)
|
||||
user := create(t, gdb, "act-same@example.test", false, false)
|
||||
right := login(app, user.Email, "correct-horse")
|
||||
if row := attempts(t, gdb, user.ID); row.Attempts != 1 {
|
||||
t.Fatalf("throttle after the correct password: %+v", row)
|
||||
}
|
||||
wrong := login(app, user.Email, "wrong-horse")
|
||||
if row := attempts(t, gdb, user.ID); row.Attempts != 2 {
|
||||
t.Fatalf("throttle after the wrong password: %+v", row)
|
||||
}
|
||||
refused(t, right)
|
||||
if right.Code != wrong.Code || right.Body.String() != wrong.Body.String() {
|
||||
t.Fatalf("correct %d %s, wrong %d %s", right.Code, right.Body.String(), wrong.Code, wrong.Body.String())
|
||||
}
|
||||
for _, h := range []string{"Content-Type", "Cache-Control"} {
|
||||
if a, b := right.Header().Get(h), wrong.Header().Get(h); a != b || a == "" {
|
||||
t.Fatalf("%s: correct %q, wrong %q", h, a, b)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit_true_refuses", func(t *testing.T) {
|
||||
app, gdb := newApp(t, &on)
|
||||
user := create(t, gdb, "act-true@example.test", false, false)
|
||||
refused(t, login(app, user.Email, "correct-horse"))
|
||||
})
|
||||
|
||||
t.Run("refusals_suspend", func(t *testing.T) {
|
||||
app, gdb := newApp(t, nil)
|
||||
user := create(t, gdb, "act-suspend@example.test", false, false)
|
||||
for i := 1; i <= 5; i++ {
|
||||
refused(t, login(app, user.Email, "correct-horse"))
|
||||
}
|
||||
if row := attempts(t, gdb, user.ID); row.Attempts != 5 || !row.IsSuspended {
|
||||
t.Fatalf("after 5 refusals: %+v", row)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("off_allows", func(t *testing.T) {
|
||||
app, gdb := newApp(t, &off)
|
||||
user := create(t, gdb, "act-off@example.test", false, false)
|
||||
signedIn(t, login(app, user.Email, "correct-horse"), user)
|
||||
})
|
||||
|
||||
t.Run("activated_allows", func(t *testing.T) {
|
||||
app, gdb := newApp(t, nil)
|
||||
user := create(t, gdb, "act-yes@example.test", true, false)
|
||||
signedIn(t, login(app, user.Email, "correct-horse"), user)
|
||||
})
|
||||
|
||||
t.Run("deactivated_activated_restores", func(t *testing.T) {
|
||||
app, gdb := newApp(t, nil)
|
||||
user := create(t, gdb, "act-restore@example.test", true, true)
|
||||
mail := mailer(t, app)
|
||||
signedIn(t, login(app, user.Email, "correct-horse"), user)
|
||||
if got := reload(t, gdb, user.ID); got.DeletedAt.Valid {
|
||||
t.Fatal("deactivated activated account was not restored")
|
||||
}
|
||||
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
|
||||
t.Fatalf("reactivate template = %#v", mail.got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deactivated_inactive_refused", func(t *testing.T) {
|
||||
app, gdb := newApp(t, nil)
|
||||
user := create(t, gdb, "act-both@example.test", false, true)
|
||||
mail := mailer(t, app)
|
||||
refused(t, login(app, user.Email, "correct-horse"))
|
||||
if got := reload(t, gdb, user.ID); !got.DeletedAt.Valid {
|
||||
t.Fatal("refused account was restored")
|
||||
}
|
||||
if len(mail.got) != 0 {
|
||||
t.Fatalf("refusal sent mail %#v", mail.got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deactivated_inactive_off_restores", func(t *testing.T) {
|
||||
app, gdb := newApp(t, &off)
|
||||
user := create(t, gdb, "act-both-off@example.test", false, true)
|
||||
mail := mailer(t, app)
|
||||
signedIn(t, login(app, user.Email, "correct-horse"), user)
|
||||
if got := reload(t, gdb, user.ID); got.DeletedAt.Valid {
|
||||
t.Fatal("account was not restored with require_activation off")
|
||||
}
|
||||
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
|
||||
t.Fatalf("reactivate template = %#v", mail.got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
token := loginToken(t, app, gdb, "logout@example.test")
|
||||
@@ -361,6 +556,8 @@ func (c *captureMail) Send(_ context.Context, msg postcard.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginToken inserts an activated account with the password "correct-horse"
|
||||
// (insertUser) and signs it in through POST /login, returning the token.
|
||||
func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string {
|
||||
t.Helper()
|
||||
insertUser(t, gdb, email, "correct-horse")
|
||||
@@ -379,19 +576,49 @@ func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) str
|
||||
return token
|
||||
}
|
||||
|
||||
// insertUser creates an activated account with password, an ordinary
|
||||
// account that can sign in through POST /login.
|
||||
func insertUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User {
|
||||
t.Helper()
|
||||
return createUser(t, gdb, email, password, true)
|
||||
}
|
||||
|
||||
// insertInactiveUser creates a not-activated account with password. While
|
||||
// golem15.user.activation.require_activation is on (the default) POST /login
|
||||
// refuses it.
|
||||
func insertInactiveUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User {
|
||||
t.Helper()
|
||||
return createUser(t, gdb, email, password, false)
|
||||
}
|
||||
|
||||
func createUser(t *testing.T, gdb *gorm.DB, email, password string, activated bool) *models.User {
|
||||
t.Helper()
|
||||
hash, err := bouncer.HashPassword(10, password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := &models.User{Email: email, Password: hash}
|
||||
user := &models.User{Email: email, Password: hash, IsActivated: activated}
|
||||
if err := gdb.Create(user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// inactiveSessionToken inserts a not-activated account (insertInactiveUser)
|
||||
// and mints its session with controllers.IssueToken, the session such an
|
||||
// account gets from /register when require_activation is off.
|
||||
func inactiveSessionToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string {
|
||||
t.Helper()
|
||||
user := insertInactiveUser(t, gdb, email, "correct-horse")
|
||||
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/login", nil)
|
||||
req.Host = "example.com"
|
||||
token, err := controllers.IssueToken(app, user, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, h http.HandlerFunc, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
|
||||
Reference in New Issue
Block a user