583 lines
24 KiB
Go
583 lines
24 KiB
Go
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// Reauth: once an account has a passkey or a verified email, adding or removing a
|
||
// passkey and changing the email need a factor proven within reauthWindow. These
|
||
// tests drive the real SessionAuth, so the proof is read from the session row the
|
||
// cookie names, exactly as in production.
|
||
|
||
const opTok = "tok-op"
|
||
|
||
// reauthFixture is the sessions fixture (steve: a player with a verified email,
|
||
// signed in on the laptop and the phone; alex: a player with no factor) plus a
|
||
// passkey verifier and an operator, pam, with a verified email. Every session
|
||
// starts with no proof on it.
|
||
func reauthFixture(t *testing.T) *sessionsFixture {
|
||
t.Helper()
|
||
f := newSessionsFixture(t)
|
||
f.api.Passkey = &fakePasskeyVerifier{}
|
||
f.repo.staff["pam"] = &StaffUser{ID: "u3", Username: "pam", Email: "[email protected]", Role: "admin", EmailVerified: true}
|
||
now := f.api.now()
|
||
f.repo.sessions[hashCookie(opTok)] = &fakeSession{userID: "u3", lastSeen: now, expiresAt: now.Add(time.Hour)}
|
||
for _, s := range f.repo.sessions {
|
||
s.reauthAt = time.Time{}
|
||
}
|
||
f.eh = f.api.ExternalHandler()
|
||
return f
|
||
}
|
||
|
||
func (f *sessionsFixture) reauthAt(tok string) time.Time {
|
||
return f.repo.sessions[hashCookie(tok)].reauthAt
|
||
}
|
||
|
||
func (f *sessionsFixture) setReauth(tok string, at time.Time) {
|
||
f.repo.sessions[hashCookie(tok)].reauthAt = at
|
||
}
|
||
|
||
func jsonCookie(tok string) map[string]string {
|
||
h := asCookie(tok)
|
||
h["Content-Type"] = "application/json"
|
||
return h
|
||
}
|
||
|
||
func TestSigningInByEmailCodeCountsAsReauth(t *testing.T) {
|
||
api, repo, mailer := seedLoginEmailAPI(t)
|
||
eh := api.ExternalHandler()
|
||
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
|
||
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"`+mailer.code+`"}`, jsonHeader)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
s := repo.sessions[hashCookie(sessionCookieValue(t, w))]
|
||
if !s.reauthAt.Equal(api.now()) {
|
||
t.Fatalf("reauth_at = %v, want the sign-in time %v", s.reauthAt, api.now())
|
||
}
|
||
}
|
||
|
||
// A bind code proves the Minecraft account, and nothing about the account's own
|
||
// passkey or mailbox.
|
||
func TestSigningInByBindCodeIsNoReauth(t *testing.T) {
|
||
api, repo := seedBindAPI(t)
|
||
mintBindCode(t, api, repo, "ABCD2345", bindTestUUID, authSourceMojang)
|
||
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"ABCD2345"}`, jsonHeader)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("bind = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if s := repo.sessions[hashCookie(sessionCookieValue(t, w))]; !s.reauthAt.IsZero() {
|
||
t.Fatalf("reauth_at = %v, want none after a bind-code sign-in", s.reauthAt)
|
||
}
|
||
}
|
||
|
||
// guardedChange is a request the gate covers, the status it gets once the gate
|
||
// lets it through, and a check that it changed nothing when refused.
|
||
type guardedChange struct {
|
||
name, method, path, body string
|
||
ok int
|
||
untouched func(f *sessionsFixture) bool
|
||
}
|
||
|
||
var guardedChanges = []guardedChange{
|
||
{"add a passkey", "POST", "/api/v1/account/passkey/register/begin", "", http.StatusOK,
|
||
func(f *sessionsFixture) bool { return len(f.repo.passkeyChallenges) == 0 }},
|
||
{"remove a passkey", "DELETE", "/api/v1/account/passkey/credentials/a", "", http.StatusNoContent,
|
||
func(f *sessionsFixture) bool { _, ok := f.repo.passkeyCreds["a"]; return ok }},
|
||
{"change the email", "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, http.StatusAccepted,
|
||
func(f *sessionsFixture) bool { return len(f.repo.otps) == 0 }},
|
||
{"record an unverified email", "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, http.StatusOK,
|
||
func(f *sessionsFixture) bool { return f.repo.staff["steve"].EmailVerified }},
|
||
}
|
||
|
||
func TestGuardedChangesNeedARecentReauth(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
proof time.Duration // how long ago the session proved a factor; -1 = never
|
||
wantOK bool
|
||
}{
|
||
{"never proved", -1, false},
|
||
{"proved 6 minutes ago", 6 * time.Minute, false},
|
||
{"proved exactly 5 minutes ago", 5 * time.Minute, false},
|
||
{"proved 4m59s ago", 5*time.Minute - time.Second, true},
|
||
{"proved just now", 0, true},
|
||
} {
|
||
for _, g := range guardedChanges {
|
||
t.Run(tc.name+"/"+g.name, func(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
f.api.Mailer = &captureMailer{}
|
||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||
if tc.proof >= 0 {
|
||
f.setReauth(laptopTok, f.api.now().Add(-tc.proof))
|
||
}
|
||
w := do(f.eh, g.method, g.path, g.body, jsonCookie(laptopTok))
|
||
if tc.wantOK {
|
||
if w.Code != g.ok {
|
||
t.Fatalf("%s = %d (%s), want %d", g.name, w.Code, w.Body.String(), g.ok)
|
||
}
|
||
return
|
||
}
|
||
if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" {
|
||
t.Fatalf("%s = %d (%s), want 403 reauth_required", g.name, w.Code, w.Body.String())
|
||
}
|
||
if !g.untouched(f) {
|
||
t.Fatalf("%s went through despite the refusal", g.name)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// With no passkey and no verified email the session is the account's only way
|
||
// in, so there is nothing a reauth could protect and nothing to give one with.
|
||
func TestAccountWithoutAFactorNeedsNoReauth(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
f.api.Mailer = &captureMailer{}
|
||
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK {
|
||
t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(alexTok)); w.Code != http.StatusAccepted {
|
||
t.Fatalf("email start = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
// An unverified address is no factor: it never received a code.
|
||
func TestUnverifiedEmailIsNoFactor(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
f.repo.staff["alex"].Email = "[email protected]"
|
||
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK {
|
||
t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
// A passkey alone is a factor worth guarding.
|
||
func TestPasskeyAloneNeedsReauth(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
f.repo.passkeyCreds["x"] = PasskeyCredential{ID: "x", UserID: "u2", CredentialID: "c-x", CreatedAt: frozenNow}
|
||
w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok))
|
||
if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" {
|
||
t.Fatalf("register begin = %d (%s), want 403 reauth_required", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
// A Cloudflare Access caller is authenticated by the proxy on every request and
|
||
// has no session to mark.
|
||
func TestAccessCallerNeedsNoReauth(t *testing.T) {
|
||
repo := newFakeRepo()
|
||
repo.staff["op"] = &StaffUser{ID: "u1", Username: "op", Role: "admin", Email: "[email protected]", EmailVerified: true}
|
||
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||
api := newTestAPI(repo, newFakeCluster())
|
||
api.Passkey = &fakePasskeyVerifier{}
|
||
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "admin", EmailVerified: true}}
|
||
if w := do(api.ExternalHandler(), "POST", "/api/v1/account/passkey/register/begin", "", nil); w.Code != http.StatusOK {
|
||
t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
type reauthStatusBody struct {
|
||
Needed bool `json:"needed"`
|
||
Until *time.Time `json:"until"`
|
||
Factors []string `json:"factors"`
|
||
}
|
||
|
||
func getReauthStatus(t *testing.T, f *sessionsFixture, tok string) reauthStatusBody {
|
||
t.Helper()
|
||
w := do(f.eh, "GET", "/api/v1/account/reauth", "", asCookie(tok))
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
var b reauthStatusBody
|
||
if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
func TestReauthStatusNamesTheFactors(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
f.api.Mailer = &captureMailer{}
|
||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
|
||
|
||
steve := getReauthStatus(t, f, laptopTok)
|
||
if !steve.Needed || steve.Until != nil || strings.Join(steve.Factors, ",") != "passkey,email" {
|
||
t.Fatalf("player status = %+v, want needed with passkey,email", steve)
|
||
}
|
||
// An operator's verified email is no factor: signing in as staff by email
|
||
// also takes in-game approval.
|
||
pam := getReauthStatus(t, f, opTok)
|
||
if !pam.Needed || strings.Join(pam.Factors, ",") != "passkey,sign_in" {
|
||
t.Fatalf("operator status = %+v, want needed with passkey,sign_in", pam)
|
||
}
|
||
alex := getReauthStatus(t, f, alexTok)
|
||
if alex.Needed || len(alex.Factors) != 0 {
|
||
t.Fatalf("no-factor status = %+v, want not needed and no factors", alex)
|
||
}
|
||
|
||
proved := f.api.now().Add(-time.Minute)
|
||
f.setReauth(laptopTok, proved)
|
||
steve = getReauthStatus(t, f, laptopTok)
|
||
if steve.Needed || steve.Until == nil || !steve.Until.Equal(proved.Add(reauthWindow)) {
|
||
t.Fatalf("after a proof status = %+v, want not needed until %v", steve, proved.Add(reauthWindow))
|
||
}
|
||
}
|
||
|
||
// TestReauthWithoutMailRelay: with no [smtp] relay a verified email is no way in
|
||
// (the email and op-login doors answer 503), so it is neither offered as a factor
|
||
// nor guarded; a passkey still is, and the email start door says why it cannot help.
|
||
func TestReauthWithoutMailRelay(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
|
||
|
||
steve := getReauthStatus(t, f, laptopTok)
|
||
if steve.Needed || len(steve.Factors) != 0 {
|
||
t.Fatalf("email-only player status = %+v, want not needed and no factors", steve)
|
||
}
|
||
pam := getReauthStatus(t, f, opTok)
|
||
if !pam.Needed || strings.Join(pam.Factors, ",") != "passkey" {
|
||
t.Fatalf("operator with a passkey status = %+v, want needed with passkey only", pam)
|
||
}
|
||
w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(laptopTok))
|
||
if code, _ := errEnvelope(t, w); w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" {
|
||
t.Fatalf("email start = %d %s, want 503 mail_unavailable", w.Code, w.Body.String())
|
||
}
|
||
if n := len(f.repo.otps); n != 0 {
|
||
t.Errorf("a refused start minted %d codes", n)
|
||
}
|
||
}
|
||
|
||
func TestReauthByEmailCode(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
mailer := &captureMailer{}
|
||
f.api.Mailer = mailer
|
||
|
||
if w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(laptopTok)); w.Code != http.StatusAccepted {
|
||
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if mailer.email != "[email protected]" || mailer.code == "" {
|
||
t.Fatalf("code went to %q (%q), want [email protected]", mailer.email, mailer.code)
|
||
}
|
||
wrong := "000000"
|
||
if mailer.code == wrong {
|
||
wrong = "111111"
|
||
}
|
||
w := do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+wrong+`"}`, jsonCookie(laptopTok))
|
||
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||
t.Fatalf("wrong code = %d (%s), want 400 invalid_code", w.Code, w.Body.String())
|
||
}
|
||
if !f.reauthAt(laptopTok).IsZero() {
|
||
t.Fatal("a wrong code marked the session")
|
||
}
|
||
|
||
w = do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(laptopTok))
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
var body struct {
|
||
OK bool `json:"ok"`
|
||
Until time.Time `json:"until"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||
if !body.OK || !body.Until.Equal(f.api.now().Add(reauthWindow)) {
|
||
t.Fatalf("verify body = %s, want ok until now+5m", w.Body.String())
|
||
}
|
||
if !f.reauthAt(laptopTok).Equal(f.api.now()) {
|
||
t.Fatalf("laptop reauth_at = %v, want now", f.reauthAt(laptopTok))
|
||
}
|
||
// The proof belongs to the session that gave it.
|
||
if !f.reauthAt(phoneTok).IsZero() {
|
||
t.Fatal("the phone was marked by the laptop's code")
|
||
}
|
||
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
|
||
t.Fatalf("register begin after reauth = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
var audited bool
|
||
for _, e := range f.repo.audits {
|
||
audited = audited || (e.Action == "account.reauth" && e.ServerName == "email")
|
||
}
|
||
if !audited {
|
||
t.Fatalf("no account.reauth audit naming the email factor: %+v", f.repo.audits)
|
||
}
|
||
}
|
||
|
||
// A code from another step-up (the email change, a migration) does not reauth.
|
||
func TestReauthCodeIsItsOwnPurpose(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
mailer := &captureMailer{}
|
||
f.api.Mailer = mailer
|
||
f.setReauth(laptopTok, f.api.now())
|
||
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(laptopTok)); w.Code != http.StatusAccepted {
|
||
t.Fatalf("email start = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
w := do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(phoneTok))
|
||
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||
t.Fatalf("onboarding code on the reauth door = %d (%s), want 400 invalid_code", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestOperatorsCannotReauthByEmail(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
mailer := &captureMailer{}
|
||
f.api.Mailer = mailer
|
||
for _, path := range []string{"/api/v1/account/reauth/email/start", "/api/v1/account/reauth/email/verify"} {
|
||
w := do(f.eh, "POST", path, `{"code":"123456"}`, jsonCookie(opTok))
|
||
if w.Code != http.StatusForbidden || decodeErr(t, w) != "staff_reauth" {
|
||
t.Fatalf("%s = %d (%s), want 403 staff_reauth", path, w.Code, w.Body.String())
|
||
}
|
||
}
|
||
if mailer.calls != 0 {
|
||
t.Fatal("a code was mailed to an operator")
|
||
}
|
||
}
|
||
|
||
func TestReauthByEmailNeedsAVerifiedAddress(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
f.api.Mailer = &captureMailer{}
|
||
f.repo.staff["alex"].Email = "[email protected]"
|
||
w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(alexTok))
|
||
if w.Code != http.StatusConflict || decodeErr(t, w) != "no_step_up_factor" {
|
||
t.Fatalf("start = %d (%s), want 409 no_step_up_factor", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestReauthByPasskey(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
pv := f.api.Passkey.(*fakePasskeyVerifier)
|
||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", SignCount: 4, CreatedAt: frozenNow}
|
||
finish := func() int {
|
||
t.Helper()
|
||
if w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
|
||
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if len(pv.lastUser.Credentials) != 1 || pv.lastUser.Credentials[0].CredentialID != "c-a" {
|
||
t.Fatalf("assertion offered %+v, want the caller's own passkey", pv.lastUser.Credentials)
|
||
}
|
||
return do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok)).Code
|
||
}
|
||
|
||
pv.failErr = errors.New("assertion rejected")
|
||
if code := finish(); code != http.StatusBadRequest {
|
||
t.Fatalf("bad assertion = %d, want 400", code)
|
||
}
|
||
pv.failErr = nil
|
||
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 2, CloneWarning: true}
|
||
if code := finish(); code != http.StatusBadRequest {
|
||
t.Fatalf("cloned authenticator = %d, want 400", code)
|
||
}
|
||
if !f.reauthAt(laptopTok).IsZero() {
|
||
t.Fatal("a failed assertion marked the session")
|
||
}
|
||
|
||
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5}
|
||
if code := finish(); code != http.StatusOK {
|
||
t.Fatalf("finish = %d, want 200", code)
|
||
}
|
||
if !f.reauthAt(laptopTok).Equal(f.api.now()) {
|
||
t.Fatalf("reauth_at = %v, want now", f.reauthAt(laptopTok))
|
||
}
|
||
if got := f.repo.passkeyCreds["a"].SignCount; got != 5 {
|
||
t.Fatalf("sign count = %d, want it advanced to 5", got)
|
||
}
|
||
// The challenge was spent: a replayed finish has nothing to consume.
|
||
w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok))
|
||
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
|
||
t.Fatalf("replayed finish = %d (%s), want 400 passkey_login_invalid", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
// A migration's passkey challenge cannot be spent on a reauth, or the reverse.
|
||
func TestReauthPasskeyChallengeIsItsOwnPurpose(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
pv := f.api.Passkey.(*fakePasskeyVerifier)
|
||
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5}
|
||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||
if err := f.repo.CreatePasskeyChallenge(t.Context(), "m1", "u1", passkeyPurposeMigrate, []byte("s"), f.api.now().Add(time.Minute)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok))
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Fatalf("finish on a migration challenge = %d (%s), want 400", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
// Proving an address by code is an email reauth, so a first-time setup can go on
|
||
// to its next guarded step.
|
||
func TestVerifyingAnEmailCountsAsReauth(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
mailer := &captureMailer{}
|
||
f.api.Mailer = mailer
|
||
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(alexTok)); w.Code != http.StatusAccepted {
|
||
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(alexTok)); w.Code != http.StatusOK {
|
||
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if !f.reauthAt(alexTok).Equal(f.api.now()) {
|
||
t.Fatalf("reauth_at = %v, want now", f.reauthAt(alexTok))
|
||
}
|
||
}
|
||
|
||
func TestRegisteringAPasskeyCountsAsReauth(t *testing.T) {
|
||
f := reauthFixture(t)
|
||
f.api.Passkey.(*fakePasskeyVerifier).credential = VerifiedCredential{CredentialID: "c-new", PublicKey: "pk"}
|
||
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK {
|
||
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/finish", `{"attestation":{"id":"x"}}`, jsonCookie(alexTok)); w.Code != http.StatusCreated {
|
||
t.Fatalf("finish = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if !f.reauthAt(alexTok).Equal(f.api.now()) {
|
||
t.Fatalf("reauth_at = %v, want now", f.reauthAt(alexTok))
|
||
}
|
||
}
|
||
|
||
// ---- change notices ----
|
||
|
||
func noticeFixture(t *testing.T) (*sessionsFixture, *noticeMailer) {
|
||
t.Helper()
|
||
f := reauthFixture(t)
|
||
mailer := ¬iceMailer{}
|
||
f.api.Mailer = mailer
|
||
f.api.ClientIPHeader = "CF-Connecting-IP"
|
||
f.setReauth(laptopTok, f.api.now())
|
||
return f, mailer
|
||
}
|
||
|
||
func fromIP(h map[string]string) map[string]string {
|
||
h["CF-Connecting-IP"] = "203.0.113.9"
|
||
return h
|
||
}
|
||
|
||
func onlyNotice(t *testing.T, m *noticeMailer) (to, subject, body string) {
|
||
t.Helper()
|
||
if len(m.notices) != 1 {
|
||
t.Fatalf("notices = %q, want exactly one", m.notices)
|
||
}
|
||
parts := strings.SplitN(m.notices[0], "|", 3)
|
||
return parts[0], parts[1], parts[2]
|
||
}
|
||
|
||
func TestRemovingAPasskeyMailsTheAccount(t *testing.T) {
|
||
f, mailer := noticeFixture(t)
|
||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", fromIP(asCookie(laptopTok))); w.Code != http.StatusNoContent {
|
||
t.Fatalf("delete = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
to, subject, body := onlyNotice(t, mailer)
|
||
if to != "[email protected]" || subject != "Felis 已删除 Passkey · passkey removed" {
|
||
t.Fatalf("notice to %q subject %q", to, subject)
|
||
}
|
||
for _, want := range []string{
|
||
"A passkey was just removed from your Felis account, and every other device was signed out.",
|
||
"Time: 2023-11-14 22:13 UTC",
|
||
"From IP: 203.0.113.9",
|
||
"check the passkeys that remain",
|
||
"来源 IP:203.0.113.9",
|
||
} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("notice body lacks %q:\n%s", want, body)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAddingAPasskeyMailsTheAccount(t *testing.T) {
|
||
f, mailer := noticeFixture(t)
|
||
f.api.Passkey.(*fakePasskeyVerifier).credential = VerifiedCredential{CredentialID: "c-new", PublicKey: "pk"}
|
||
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
|
||
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/finish", `{"attestation":{"id":"x"}}`, fromIP(jsonCookie(laptopTok))); w.Code != http.StatusCreated {
|
||
t.Fatalf("finish = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
to, subject, body := onlyNotice(t, mailer)
|
||
if to != "[email protected]" || subject != "Felis 已添加 Passkey · passkey added" ||
|
||
!strings.Contains(body, "A passkey was just added to your Felis account.") ||
|
||
!strings.Contains(body, "remove that passkey") {
|
||
t.Fatalf("notice to %q subject %q body:\n%s", to, subject, body)
|
||
}
|
||
}
|
||
|
||
// Replacing the address mails the OLD one, which is the mailbox the owner still
|
||
// reads if someone else made the change. The new address is masked.
|
||
func TestChangingTheEmailMailsTheOldAddress(t *testing.T) {
|
||
f, mailer := noticeFixture(t)
|
||
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(laptopTok)); w.Code != http.StatusAccepted {
|
||
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, fromIP(jsonCookie(laptopTok))); w.Code != http.StatusOK {
|
||
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
to, subject, body := onlyNotice(t, mailer)
|
||
if to != "[email protected]" || subject != "Felis 邮箱已更换 · email changed" {
|
||
t.Fatalf("notice to %q subject %q", to, subject)
|
||
}
|
||
if !strings.Contains(body, "The email on your Felis account was just changed to s***@new.example.") ||
|
||
!strings.Contains(body, "From IP: 203.0.113.9") {
|
||
t.Fatalf("notice body:\n%s", body)
|
||
}
|
||
if strings.Contains(body, "[email protected]") {
|
||
t.Fatalf("notice hands the new address to the old mailbox:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// A first verification and a re-verification of the same address move nothing.
|
||
func TestVerifyingAFirstOrSameEmailMailsNoNotice(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name, tok, address string
|
||
}{
|
||
{"first address", alexTok, "[email protected]"},
|
||
{"same address", laptopTok, "[email protected]"},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
f, mailer := noticeFixture(t)
|
||
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"`+tc.address+`"}`, jsonCookie(tc.tok)); w.Code != http.StatusAccepted {
|
||
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(tc.tok)); w.Code != http.StatusOK {
|
||
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if len(mailer.notices) != 0 {
|
||
t.Fatalf("notices = %q, want none", mailer.notices)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Nothing proves an unverified address belongs to the owner, so nothing is sent
|
||
// there.
|
||
func TestPasskeyNoticeSkipsAnUnverifiedAddress(t *testing.T) {
|
||
f, mailer := noticeFixture(t)
|
||
f.repo.staff["alex"].Email = "[email protected]"
|
||
// Two passkeys, so removing one is allowed without a verified email.
|
||
f.repo.passkeyCreds["x"] = PasskeyCredential{ID: "x", UserID: "u2", CredentialID: "c-x", CreatedAt: frozenNow}
|
||
f.repo.passkeyCreds["y"] = PasskeyCredential{ID: "y", UserID: "u2", CredentialID: "c-y", CreatedAt: frozenNow}
|
||
f.setReauth(alexTok, f.api.now())
|
||
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/x", "", asCookie(alexTok)); w.Code != http.StatusNoContent {
|
||
t.Fatalf("delete = %d (%s)", w.Code, w.Body.String())
|
||
}
|
||
if len(mailer.notices) != 0 {
|
||
t.Fatalf("notices = %q, want none", mailer.notices)
|
||
}
|
||
}
|
||
|
||
func TestMaskEmail(t *testing.T) {
|
||
for in, want := range map[string]string{
|
||
"[email protected]": "a***@example.com",
|
||
"李雷@example.cn": "李***@example.cn",
|
||
"[email protected]": "a***@b.c",
|
||
"broken": "***",
|
||
"@nolocal.example": "***",
|
||
} {
|
||
if got := maskEmail(in); got != want {
|
||
t.Errorf("maskEmail(%q) = %q, want %q", in, got, want)
|
||
}
|
||
}
|
||
}
|