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()) } }