Files
story/internal/onboarding/phone_password_test.go
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

238 lines
9.3 KiB
Go

package onboarding
import (
"bytes"
"context"
"encoding/base64"
"github.com/google/uuid"
"image/png"
"net/http"
"strings"
"testing"
"time"
)
func TestPhonePasswordHash(t *testing.T) {
ctx := context.Background()
a, err := hashPassword(ctx, "LocalOnly123!")
must(t, err)
b, err := hashPassword(ctx, "LocalOnly123!")
must(t, err)
if a == b || strings.Contains(a, "LocalOnly123!") {
t.Fatal("password not salted")
}
ok, err := verifyPassword(ctx, a, "LocalOnly123!")
must(t, err)
if !ok {
t.Fatal("valid password rejected")
}
for _, encoded := range []string{a, "", passwordPrefix + "bad$bad"} {
ok, err = verifyPassword(ctx, encoded, "incorrect")
must(t, err)
if ok {
t.Fatal("invalid password accepted")
}
}
if validNewPassword("short") || validNewPassword(strings.Repeat("a", 65)) || !validNewPassword("123456") || !validNewPassword(strings.Repeat("好", 6)) {
t.Fatal("password length policy")
}
}
func (f *loginFixture) newLoginSession() {
w, data := f.request("GET", "/api/v1/web/login/session", nil, nil, "", "")
if w.Code != 200 {
f.t.Fatal(w.Body.String())
}
f.cookie = w.Result().Cookies()[0]
f.csrf = data["csrf_token"].(string)
}
// A known answer is injected only into isolated test Redis, never via an HTTP bypass.
func (f *loginFixture) knownCaptcha() string {
return f.knownCaptchaFor(digest([]byte(f.cookie.Value)))
}
func (f *fixture) knownCaptchaFor(hash string) string {
id := uuid.NewString()
value := id + ":" + domainMAC(f.s.crypt.hashKey, "CAPTCHA1\n"+hash+"\n"+id+"\n654321")
must(f.t, f.s.redis.Set(f.ctx, f.s.captchaKey(hash), value, 2*time.Minute).Err())
return id
}
func (f *loginFixture) smsAccount() (*http.Cookie, string) {
id := f.send()
w, data := f.request("POST", "/api/v1/web/login/sms/verify", SMSVerifyInput{ChallengeID: id, Code: f.sender.code}, f.cookie, f.csrf, "")
if w.Code != 200 {
f.t.Fatal(w.Body.String())
}
for _, c := range w.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
return c, data["csrf_token"].(string)
}
}
f.t.Fatal("no account cookie")
return nil, ""
}
func TestPhonePasswordSameIdentityAndResetSecurity(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, "wrong", "")
if w.Code != 403 {
t.Fatal("missing CSRF accepted")
}
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOnly123!"}, account, csrf, "")
if w.Code != 403 {
t.Fatal("SMS proof reused")
}
smsPrincipal, err := f.s.AccountPrincipal(f.ctx, account.Value, csrf, true)
must(t, err)
f.s.options.SMSLoginEnabled = false
f.newLoginSession()
input := PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!", CaptchaID: f.knownCaptcha(), CaptchaCode: "654321"}
w, data := f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
var passwordCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == f.s.cookieName(true) {
passwordCookie = c
}
}
if passwordCookie == nil {
t.Fatal("missing password cookie")
}
passwordCSRF := data["csrf_token"].(string)
passwordPrincipal, err := f.s.AccountPrincipal(f.ctx, passwordCookie.Value, passwordCSRF, true)
must(t, err)
if passwordPrincipal.Subject != smsPrincipal.Subject || passwordPrincipal.Issuer != smsPrincipal.Issuer {
t.Fatal("login methods created different identities")
}
w, state := f.request("GET", "/api/v1/web/account/password", nil, passwordCookie, "", "")
if w.Code != 200 || state["sms_verified_recently"] != false || state["password_configured"] != true {
t.Fatal("password login treated as SMS proof", w.Body.String())
}
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOnly123!", CurrentPassword: "wrong"}, passwordCookie, passwordCSRF, "")
if w.Code != 401 {
t.Fatal("wrong current password accepted")
}
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "ChangedOnly123!", CurrentPassword: "LocalOnly123!"}, passwordCookie, passwordCSRF, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
if _, err = f.s.AccountPrincipal(f.ctx, account.Value, csrf, true); err == nil {
t.Fatal("password change did not revoke other sessions")
}
if _, err = f.s.AccountPrincipal(f.ctx, passwordCookie.Value, passwordCSRF, true); err != nil {
t.Fatal("password change revoked current session")
}
f.newLoginSession()
input.CaptchaID = f.knownCaptcha()
w, _ = f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 401 || !strings.Contains(w.Body.String(), "PASSWORD_INVALID") {
t.Fatal("old password accepted")
}
input.Password = "ChangedOnly123!"
input.CaptchaID = f.knownCaptcha()
w, _ = f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
}
func TestPhoneCaptchaBindingRefreshExpiryAndReplay(t *testing.T) {
f := newLoginFixture(t)
hash := digest([]byte(f.cookie.Value))
w, _ := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, "wrong", "")
if w.Code != 403 {
t.Fatal("captcha CSRF missing")
}
old := f.knownCaptcha()
w, data := f.request("POST", "/api/v1/web/login/captcha", struct{}{}, f.cookie, f.csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(data["image"].(string), "data:image/png;base64,"))
must(t, err)
img, err := png.Decode(bytes.NewReader(raw))
must(t, err)
if img.Bounds().Dx() != 240 {
t.Fatal("not a raster captcha")
}
if ttl := f.s.redis.TTL(f.ctx, f.s.captchaKey(hash)).Val(); ttl > 2*time.Minute || ttl <= 0 {
t.Fatal("captcha expiry missing")
}
if err = f.s.verifyLoginCaptcha(f.ctx, hash, old, "654321"); err == nil {
t.Fatal("refreshed captcha still valid")
}
id := f.knownCaptcha()
if err = f.s.verifyLoginCaptcha(f.ctx, hash, id, "wrong"); err == nil {
t.Fatal("wrong captcha accepted")
}
if err = f.s.verifyLoginCaptcha(f.ctx, hash, id, "654321"); err == nil {
t.Fatal("captcha retry reused")
}
id = f.knownCaptcha()
if err = f.s.verifyLoginCaptcha(f.ctx, "another-session", id, "654321"); err == nil {
t.Fatal("cross-session captcha accepted")
}
must(t, f.s.verifyLoginCaptcha(f.ctx, hash, id, "654321"))
if err = f.s.verifyLoginCaptcha(f.ctx, hash, id, "654321"); err == nil {
t.Fatal("captcha replay accepted")
}
id = f.knownCaptcha()
must(t, f.s.redis.PExpire(f.ctx, f.s.captchaKey(hash), -time.Second).Err())
if err = f.s.verifyLoginCaptcha(f.ctx, hash, id, "654321"); err == nil {
t.Fatal("expired captcha accepted")
}
}
func TestPhonePasswordRejectsBypassAndThrottles(t *testing.T) {
f := newLoginFixture(t)
input := PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!"}
w, _ := f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 401 || !strings.Contains(w.Body.String(), "CAPTCHA_INVALID") {
t.Fatal("captcha omission accepted")
}
input.CaptchaID = f.knownCaptcha()
input.CaptchaCode = "654321"
w, _ = f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 401 || !strings.Contains(w.Body.String(), "PASSWORD_INVALID") {
t.Fatal("unknown phone must use generic error")
}
w, _ = f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if !strings.Contains(w.Body.String(), "CAPTCHA_INVALID") {
t.Fatal("failed password reused captcha")
}
phoneHash := domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone)
must(t, f.s.redis.Set(f.ctx, "onboarding:limit:password:phone:"+phoneHash, 10, time.Minute).Err())
input.CaptchaID = f.knownCaptcha()
w, _ = f.request("POST", "/api/v1/web/login/password", input, f.cookie, f.csrf, "")
if w.Code != 429 {
t.Fatal("password phone throttle missing")
}
}
func TestPhonePasswordExpiredSMSProofAndDisabledAccount(t *testing.T) {
f := newLoginFixture(t)
account, csrf := f.smsAccount()
_, err := f.s.pool.Exec(f.ctx, `UPDATE onboarding_account_session SET phone_verified_at=now()-interval '11 minutes' WHERE token_hash=$1`, digest([]byte(account.Value)))
must(t, err)
w, _ := f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
if w.Code != 403 {
t.Fatal("expired SMS proof accepted", w.Body.String())
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE onboarding_account_session SET phone_verified_at=now() WHERE token_hash=$1`, digest([]byte(account.Value)))
must(t, err)
w, _ = f.request("POST", "/api/v1/web/account/password", SetPasswordInput{Password: "LocalOnly123!"}, account, csrf, "")
if w.Code != 200 {
t.Fatal(w.Body.String())
}
_, err = f.s.pool.Exec(f.ctx, `UPDATE user_account SET status='disabled' WHERE oidc_issuer='urn:platform:onboarding:sms' AND oidc_subject=$1`, domainMAC(f.s.crypt.hashKey, "PHONE1\n"+f.phone))
must(t, err)
f.newLoginSession()
w, _ = f.request("POST", "/api/v1/web/login/password", PasswordLoginInput{Phone: f.phone, Password: "LocalOnly123!", CaptchaID: f.knownCaptcha(), CaptchaCode: "654321"}, f.cookie, f.csrf, "")
if w.Code != 403 {
t.Fatal("disabled account authenticated or re-registered", w.Body.String())
}
}