fix: activate-by-code asks the login gates before it signs in
After the mailed code verifies, ActivateByCode consults the login gates with source password, as Login does. A gate answer (for example a second-factor challenge) replaces the token response and sets no session cookie; a gate error answers the opaque 500. The account is activated either way. Without a gate, or when every gate allows, the answer is unchanged. With require_activation off an account can enable a second factor before it activates, so the mailed code no longer stands in for it.
This commit is contained in:
@@ -6,6 +6,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -399,3 +402,136 @@ func TestLoginGateOrder(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestActivateByCodeGates pins where POST /activate-by-code consults the
|
||||
// login gates: after the mailed code verifies, with source password (the
|
||||
// code is the first factor here), and before the mint. A gate answer
|
||||
// replaces the token response with its status and body and sets no session
|
||||
// cookie, a gate error answers the opaque 500, and the account is activated
|
||||
// whatever the gate answers. An invalid code never reaches a gate.
|
||||
func TestActivateByCodeGates(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Postgres")
|
||||
}
|
||||
challenge := &classes.LoginGateResult{Body: map[string]any{
|
||||
"two_factor_required": true,
|
||||
"challenge_token": "c",
|
||||
"available_methods": []string{"totp"},
|
||||
"expires_at": "2026-10-26T12:00:00+00:00",
|
||||
}}
|
||||
inactive := func(t *testing.T, env *adminEnv, label string) (*models.User, string) {
|
||||
t.Helper()
|
||||
user := env.seedUser(t, label, func(u *models.User) { u.IsActivated = false })
|
||||
code, err := classes.IssueActivationCode(t.Context(), env.gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return user, strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||
}
|
||||
activate := func(t *testing.T, env *adminEnv, packed string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return env.call(t, http.MethodPost, "/_user/api/v1/activate-by-code", "", map[string]string{"code": packed})
|
||||
}
|
||||
noSession := func(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if cookies := rec.Header().Values("Set-Cookie"); len(cookies) != 0 {
|
||||
t.Fatalf("a held-back activation set cookies %v", cookies)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), `"token"`) {
|
||||
t.Fatalf("a held-back activation answered a token: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("challenge_replaces_the_token", func(t *testing.T) {
|
||||
env, gate := gatedEnv(t, challenge)
|
||||
user, packed := inactive(t, env, "abc-challenge")
|
||||
rec := activate(t, env, packed)
|
||||
want := `{"available_methods":["totp"],"challenge_token":"c","expires_at":"2026-10-26T12:00:00+00:00","two_factor_required":true}`
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||
t.Fatalf("challenged activation = %d %s, want 200 %s", rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
noSession(t, rec)
|
||||
if gate.count() != 1 {
|
||||
t.Fatalf("gate calls = %d, want 1", gate.count())
|
||||
}
|
||||
seen := gate.seen[0]
|
||||
if seen.Source != classes.LoginSourcePassword || seen.User == nil || seen.User.ID != user.ID || seen.Request == nil {
|
||||
t.Fatalf("attempt = %+v", seen)
|
||||
}
|
||||
if seen.Fields["code"] != packed {
|
||||
t.Fatalf("attempt fields = %#v", seen.Fields)
|
||||
}
|
||||
if !env.reload(t, user.ID).IsActivated {
|
||||
t.Fatal("a challenged activation left the account not activated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refusal_passes_through", func(t *testing.T) {
|
||||
env, _ := gatedEnv(t, &classes.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}})
|
||||
user, packed := inactive(t, env, "abc-refusal")
|
||||
rec := activate(t, env, packed)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Invalid or expired challenge."}` {
|
||||
t.Fatalf("refused activation = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
noSession(t, rec)
|
||||
if !env.reload(t, user.ID).IsActivated {
|
||||
t.Fatal("a refused activation left the account not activated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowing_gate_mints_as_before", func(t *testing.T) {
|
||||
env, gate := gatedEnv(t, nil)
|
||||
user, packed := inactive(t, env, "abc-allow")
|
||||
rec := activate(t, env, packed)
|
||||
body := decodeBody(t, rec.Body.Bytes())
|
||||
userBody, _ := body["user"].(map[string]any)
|
||||
token, _ := body["token"].(string)
|
||||
if rec.Code != http.StatusOK || token == "" || body["message"] != "Account activated" || userBody["is_activated"] != true {
|
||||
t.Fatalf("allowed activation = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gate.count() != 1 {
|
||||
t.Fatalf("gate calls = %d, want 1", gate.count())
|
||||
}
|
||||
if !env.reload(t, user.ID).IsActivated {
|
||||
t.Fatal("an allowed activation left the account not activated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gate_error_fails_closed", func(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
gates, ok := env.app.Lookup[*classes.LoginGates]()
|
||||
if !ok || gates == nil {
|
||||
t.Fatal("the plugin's Register published no *classes.LoginGates")
|
||||
}
|
||||
gates.Add(gateFunc(func(context.Context, classes.LoginAttempt) (*classes.LoginGateResult, error) {
|
||||
return nil, errors.New("challenge store down")
|
||||
}))
|
||||
user, packed := inactive(t, env, "abc-error")
|
||||
rec := activate(t, env, packed)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("erroring gate = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
noSession(t, rec)
|
||||
if !env.reload(t, user.ID).IsActivated {
|
||||
t.Fatal("an erroring gate left the account not activated")
|
||||
}
|
||||
// The code was spent: the account is activated, so a second try
|
||||
// answers the Winter error page as before.
|
||||
assertWinterErrorPage(t, activate(t, env, packed))
|
||||
})
|
||||
|
||||
t.Run("bad_code_never_reaches_the_gate", func(t *testing.T) {
|
||||
env, gate := gatedEnv(t, challenge)
|
||||
user, _ := inactive(t, env, "abc-bad")
|
||||
rec := activate(t, env, strconv.FormatUint(uint64(user.ID), 10)+"!nope")
|
||||
if rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != `{"error":"This activation link is invalid or has expired"}` {
|
||||
t.Fatalf("bad code = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gate.count() != 0 {
|
||||
t.Fatalf("the gate ran %d times for a bad code", gate.count())
|
||||
}
|
||||
if env.reload(t, user.ID).IsActivated {
|
||||
t.Fatal("a bad code activated the account")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user