Files

366 lines
13 KiB
Go

package api
import (
"encoding/json"
"fmt"
"golang.org/x/crypto/bcrypt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
const changePath = "/api/me/password"
func TestChangePasswordCurrentLength(t *testing.T) {
a, req := authFixture(t)
password := strings.Repeat("a", 72)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if _, err = a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id='alice'", string(hash)); err != nil {
t.Fatal(err)
}
cs := req("POST", "/api/login", resetLogin("alice", password), "", nil).Result().Cookies()
if w := req("POST", changePath, changeBody(password+"x", resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 400 {
t.Fatal("accepted prefix", w.Code)
}
if w := req("POST", changePath, changeBody(password, resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 200 {
t.Fatal("72-byte current rejected", w.Code)
}
}
func TestChangePasswordConcurrentChanges(t *testing.T) {
a, req := authFixture(t)
cs := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
start := make(chan struct{})
results := make(chan int, 2)
for i := 0; i < 2; i++ {
go func() {
<-start
results <- req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(cs, "dsh_csrf"), cs).Code
}()
}
close(start)
successes := 0
for i := 0; i < 2; i++ {
code := <-results
if code == 200 {
successes++
} else if code != 401 && code != 403 {
t.Fatal(code)
}
}
if successes != 1 {
t.Fatal("multiple changes", successes)
}
var n int
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='user.change_password'").Scan(&n); err != nil || n != 1 {
t.Fatal(n, err)
}
}
func TestChangePasswordRechecksWaitingCredentials(t *testing.T) {
for _, revoke := range []string{"session", "password"} {
t.Run(revoke, func(t *testing.T) {
a, req := authFixture(t)
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
reader := &resetSignalReader{reader: strings.NewReader(changeBody("password-123", resetSecret)), started: make(chan struct{})}
r := httptest.NewRequest("POST", changePath, reader)
r.Header.Set("Origin", a.origin)
r.Header.Set("X-CSRF-Token", cookieValue(admin, "dsh_csrf"))
for _, c := range admin {
r.AddCookie(c)
}
newHash, err := bcrypt.GenerateFromPassword([]byte(resetSecret), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if _, err = a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id='bob'", string(newHash)); err != nil {
t.Fatal(err)
}
a.authLoginMu.Lock()
locked := true
defer func() {
if locked {
a.authLoginMu.Unlock()
}
}()
done := make(chan *httptest.ResponseRecorder, 1)
go func() { w := httptest.NewRecorder(); a.Handler().ServeHTTP(w, r); done <- w }()
select {
case <-reader.started:
case <-time.After(5 * time.Second):
t.Fatal("request did not reach decoder")
}
select {
case w := <-done:
t.Fatal("reset bypassed login lock", w.Code)
case <-time.After(150 * time.Millisecond):
}
query := "UPDATE users SET pass_hash=(SELECT pass_hash FROM users WHERE id='bob') WHERE id='alice'"
want := 400
if revoke == "session" {
query = "DELETE FROM sessions WHERE uid='alice'"
want = 401
}
if _, err := a.s.DB.Exec(query); err != nil {
t.Fatal(err)
}
a.authLoginMu.Unlock()
locked = false
select {
case w := <-done:
if w.Code != want {
t.Fatal(w.Code, w.Body.String())
}
case <-time.After(5 * time.Second):
t.Fatal("reset did not finish")
}
expected := resetSecret
if revoke == "session" {
expected = "password-123"
}
hash, err := a.s.Password("alice")
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(expected)) != nil {
t.Fatal("unauthorized reset", err)
}
var n int
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='user.change_password'").Scan(&n); err != nil || n != 0 {
t.Fatal(n, err)
}
})
}
}
func changeBody(current, password string) string {
b, _ := json.Marshal(map[string]string{"current_password": current, "password": password})
return string(b)
}
func TestChangePasswordValidation(t *testing.T) {
a, req := authFixture(t)
cs := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
csrf := cookieValue(cs, "dsh_csrf")
valid := changeBody("password-123", resetSecret)
for _, tc := range []struct {
name, method, body, csrf string
cookies []*http.Cookie
code int
}{
{"anonymous", "POST", valid, "", nil, 401},
{"csrf", "POST", valid, "bad", cs, 403},
{"method", "GET", "", csrf, cs, 405},
{"short", "POST", changeBody("password-123", strings.Repeat("a", 11)), csrf, cs, 400},
{"long", "POST", changeBody("password-123", strings.Repeat("a", 73)), csrf, cs, 400},
{"unicode long", "POST", changeBody("password-123", strings.Repeat("密", 25)), csrf, cs, 400},
{"missing current", "POST", changeBody("", resetSecret), csrf, cs, 400},
{"malformed", "POST", "{", csrf, cs, 400},
{"trailing", "POST", valid + "{}", csrf, cs, 400},
{"oversized", "POST", changeBody("password-123", strings.Repeat("a", 4096)), csrf, cs, 400},
{"target id", "POST", strings.TrimSuffix(valid, "}") + `,"id":"bob"}`, csrf, cs, 400},
{"wrong current", "POST", changeBody("wrong", resetSecret), csrf, cs, 400},
{"missing access", "POST", valid, csrf, []*http.Cookie{{Name: "dsh_session", Value: cookieValue(cs, "dsh_session")}}, 401},
} {
t.Run(tc.name, func(t *testing.T) {
w := req(tc.method, changePath, tc.body, tc.csrf, tc.cookies)
if w.Code != tc.code {
t.Fatal(w.Code, w.Body.String())
}
})
}
for _, origin := range []string{"", "https://evil.test"} {
r := httptest.NewRequest("POST", changePath, strings.NewReader(valid))
r.Header.Set("Origin", origin)
r.Header.Set("X-CSRF-Token", csrf)
for _, c := range cs {
r.AddCookie(c)
}
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal(w.Code)
}
}
if w := req("GET", "/api/me", "", "", cs); w.Code != 200 {
t.Fatal("validation revoked session", w.Code)
}
for _, user := range []string{"alice", "bob"} {
if w := req("POST", "/api/login", resetLogin(user, "password-123"), "", nil); w.Code != 200 {
t.Fatal("validation changed password", user, w.Code)
}
}
}
func TestChangePasswordLifecycle(t *testing.T) {
for _, password := range []string{strings.Repeat("a", 12), strings.Repeat("密", 24)} {
t.Run(fmt.Sprint(len(password)), func(t *testing.T) {
a, req := authFixture(t)
login := func(user string) []*http.Cookie {
w := req("POST", "/api/login", resetLogin(user, "password-123"), "", nil)
if w.Code != 200 {
t.Fatal(w.Code)
}
return w.Result().Cookies()
}
cs, second, other := login("alice"), login("alice"), login("bob")
if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES('alice',2,0)"); err != nil {
t.Fatal(err)
}
w := req("POST", changePath, changeBody("password-123", password), cookieValue(cs, "dsh_csrf"), cs)
var result map[string]bool
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || !result["ok"] || !result["reauthenticate"] || w.Header().Get("Cache-Control") != "no-store" {
t.Fatal(w.Code, w.Body.String())
}
for _, old := range [][]*http.Cookie{cs, second} {
if w := req("GET", "/api/me", "", "", old); w.Code != 401 {
t.Fatal("old access", w.Code)
}
if w := req("POST", "/api/refresh", "", cookieValue(old, "dsh_csrf"), old); w.Code != 403 {
t.Fatal("old refresh", w.Code)
}
}
for query, want := range map[string]int{"SELECT count(*) FROM sessions WHERE uid='alice'": 0, "SELECT count(*) FROM auth_refresh": 1, "SELECT count(*) FROM auth_failures WHERE username='alice'": 0, "SELECT count(*) FROM audit WHERE action='user.change_password' AND uid='alice' AND target='alice' AND detail=''": 1} {
var n int
if err := a.s.DB.QueryRow(query).Scan(&n); err != nil || n != want {
t.Fatal(query, n, err)
}
}
if w := req("GET", "/api/me", "", "", other); w.Code != 200 {
t.Fatal("other session", w.Code)
}
if w := req("POST", "/api/refresh", "", cookieValue(other, "dsh_csrf"), other); w.Code != 200 {
t.Fatal("other refresh", w.Code)
}
if w := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil); w.Code != 401 {
t.Fatal("old password", w.Code)
}
if w := req("POST", "/api/login", resetLogin("alice", password), "", nil); w.Code != 200 {
t.Fatal("new password", w.Code)
}
if w := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil); w.Code != 200 {
t.Fatal("other password", w.Code)
}
})
}
}
func TestChangePasswordConcurrentFailures(t *testing.T) {
a, req := authFixture(t)
cs := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
results := make(chan int, 12)
start := make(chan struct{})
for i := 0; i < 12; i++ {
go func() {
<-start
results <- req("POST", changePath, changeBody("wrong", resetSecret), cookieValue(cs, "dsh_csrf"), cs).Code
}()
}
close(start)
counts := map[int]int{}
for i := 0; i < 12; i++ {
counts[<-results]++
}
if counts[400] != 5 || counts[429] != 7 {
t.Fatal(counts)
}
var n int
var until int64
if err := a.s.DB.QueryRow("SELECT failures,locked_until FROM auth_failures WHERE username='alice'").Scan(&n, &until); err != nil || n != 5 || until <= time.Now().Unix() {
t.Fatal(n, until, err)
}
if w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 429 {
t.Fatal("logged-in bypass", w.Code)
}
if w := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil); w.Code != 429 {
t.Fatal("login bypass", w.Code)
}
if w := req("GET", "/api/me", "", "", cs); w.Code != 200 {
t.Fatal("wrong password logged out", w.Code)
}
if _, err := a.s.DB.Exec("UPDATE auth_failures SET locked_until=? WHERE username='alice'", time.Now().Add(-time.Second).Unix()); err != nil {
t.Fatal(err)
}
if w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 200 {
t.Fatal("expired lock", w.Code)
}
}
func TestChangePasswordRollback(t *testing.T) {
for _, step := range []struct{ table, event string }{{"users", "UPDATE"}, {"auth_refresh", "DELETE"}, {"sessions", "DELETE"}, {"auth_failures", "DELETE"}, {"audit", "INSERT"}} {
t.Run(step.table, func(t *testing.T) {
a, req := authFixture(t)
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
_ = req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies()
target := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies()
before, err := a.s.Password("bob")
if err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES('bob',2,0)"); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec(fmt.Sprintf("CREATE TRIGGER reject_reset BEFORE %s ON %s BEGIN SELECT RAISE(ABORT,'forced failure'); END", step.event, step.table)); err != nil {
t.Fatal(err)
}
w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(target, "dsh_csrf"), target)
if w.Code != 500 || strings.Contains(w.Body.String(), resetSecret) {
t.Fatal(w.Code, w.Body.String())
}
after, err := a.s.Password("bob")
if err != nil || before != after {
t.Fatal("hash changed", err)
}
for query, want := range map[string]int{"SELECT count(*) FROM sessions WHERE uid='bob'": 1, "SELECT count(*) FROM auth_refresh": 2, "SELECT count(*) FROM auth_failures WHERE username='bob' AND failures=2 AND locked_until=0": 1, "SELECT count(*) FROM audit WHERE action='user.change_password'": 0} {
var n int
if err := a.s.DB.QueryRow(query).Scan(&n); err != nil || n != want {
t.Fatal(query, n, err)
}
}
if w := req("GET", "/api/me", "", "", target); w.Code != 200 {
t.Fatal("session changed", w.Code)
}
if w := req("POST", "/api/refresh", "", cookieValue(target, "dsh_csrf"), target); w.Code != 200 {
t.Fatal("refresh changed", w.Code)
}
})
}
}
func TestChangePasswordConcurrentLogin(t *testing.T) {
a, req := authFixture(t)
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil {
t.Fatal(err)
}
admin := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies()
start := make(chan struct{})
results := make(chan *httptest.ResponseRecorder, 8)
for i := 0; i < 8; i++ {
go func() { <-start; results <- req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil) }()
}
close(start)
w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(admin, "dsh_csrf"), admin)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
for i := 0; i < 8; i++ {
w := <-results
if w.Code != 200 && w.Code != 401 && w.Code != 429 {
t.Fatal(w.Code, w.Body.String())
}
if w.Code == 200 {
if check := req("GET", "/api/me", "", "", w.Result().Cookies()); check.Code != 401 {
t.Fatal("old-password login retained session", check.Code)
}
}
}
var n int
if err := a.s.DB.QueryRow("SELECT count(*) FROM sessions WHERE uid='bob'").Scan(&n); err != nil || n != 0 {
t.Fatal("old login sessions", n, err)
}
}