Files

238 lines
9.3 KiB
Go
Raw Permalink Normal View History

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