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:
Jakub Zych
2026-11-05 17:54:21 +01:00
parent 97f83cc2cf
commit b179e6b2e5
5 changed files with 181 additions and 12 deletions

View File

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