238 lines
9.3 KiB
Go
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())
|
||
|
|
}
|
||
|
|
}
|