/web/account, offered to everyone, lists every browser and client signed in as you, with when each was last used, and signs any of them out, or all but the current one. It sets up, replaces and removes the second factor, and holds the password form, moved from the vault page. Administrators can sign a user out everywhere from the People page. The API gains GET and DELETE /api/v1/auth/sessions and DELETE /api/v1/auth/totp. Sessions are named by a digest of their token, never the token, and record when they were last used. Replacing or removing an active second factor now needs the password and a current code. Server version 0.10.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
5.4 KiB
Go
146 lines
5.4 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"cc.tiredsysadmin.keeporg/internal/store"
|
|
"cc.tiredsysadmin.keeporg/internal/totp"
|
|
)
|
|
|
|
const newAdminPassword = "a-much-better-password"
|
|
|
|
func TestChangePasswordRequiresTheCurrentOne(t *testing.T) {
|
|
h := newHarness(t)
|
|
tok, _, _ := h.login("admin", adminPassword, "")
|
|
|
|
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
|
"current_password": "not-the-password", "new_password": newAdminPassword})
|
|
if status != http.StatusUnauthorized || body["code"] != "bad_credentials" {
|
|
t.Fatalf("wrong current password: %d %v", status, body)
|
|
}
|
|
// And the password really did not change.
|
|
if _, s, _ := h.login("admin", adminPassword, ""); s != http.StatusOK {
|
|
t.Fatal("the old password stopped working after a failed change")
|
|
}
|
|
}
|
|
|
|
func TestChangePasswordRejectsAWeakOrUnchangedPassword(t *testing.T) {
|
|
h := newHarness(t)
|
|
tok, _, _ := h.login("admin", adminPassword, "")
|
|
for _, next := range []string{"short", adminPassword} {
|
|
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
|
"current_password": adminPassword, "new_password": next})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("new password %q: %d %v", next, status, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChangePasswordSwapsThePasswordAndRevokesOtherSessions(t *testing.T) {
|
|
h := newHarness(t)
|
|
keep, _, _ := h.login("admin", adminPassword, "")
|
|
elsewhere, _, _ := h.login("admin", adminPassword, "")
|
|
|
|
status, body := h.call("POST", "/api/v1/auth/password", keep, map[string]string{
|
|
"current_password": adminPassword, "new_password": newAdminPassword})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("change: %d %v", status, body)
|
|
}
|
|
|
|
if _, s, _ := h.login("admin", adminPassword, ""); s != http.StatusUnauthorized {
|
|
t.Fatal("the old password still works")
|
|
}
|
|
if _, s, _ := h.login("admin", newAdminPassword, ""); s != http.StatusOK {
|
|
t.Fatal("the new password does not work")
|
|
}
|
|
// The session that made the change survives; the other one does not.
|
|
if s, _ := h.call("GET", "/api/v1/auth/whoami", keep, nil); s != http.StatusOK {
|
|
t.Fatalf("the calling session was revoked: %d", s)
|
|
}
|
|
if s, _ := h.call("GET", "/api/v1/auth/whoami", elsewhere, nil); s != http.StatusUnauthorized {
|
|
t.Fatalf("another session survived the password change: %d", s)
|
|
}
|
|
}
|
|
|
|
func TestChangePasswordRequiresTheSecondFactorWhenEnrolled(t *testing.T) {
|
|
h := newHarness(t)
|
|
tok, _, _ := h.login("admin", adminPassword, "")
|
|
_, enroll := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil)
|
|
sec := enroll["secret"].(string)
|
|
code, _ := totp.Code(sec, time.Now())
|
|
if s, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok,
|
|
map[string]string{"code": code}); s != http.StatusOK {
|
|
t.Fatal("could not enrol the second factor")
|
|
}
|
|
|
|
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
|
"current_password": adminPassword, "new_password": newAdminPassword})
|
|
if status != http.StatusUnauthorized || body["code"] != "totp_required" {
|
|
t.Fatalf("change without a code: %d %v", status, body)
|
|
}
|
|
code, _ = totp.Code(sec, time.Now())
|
|
if s, b := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
|
"current_password": adminPassword, "new_password": newAdminPassword,
|
|
"totp": code}); s != http.StatusOK {
|
|
t.Fatalf("change with a code: %d %v", s, b)
|
|
}
|
|
}
|
|
|
|
func TestWebChangesThePassword(t *testing.T) {
|
|
h := newHarness(t)
|
|
b := newBrowser(t, h)
|
|
csrf := b.signIn()
|
|
|
|
// The form is on the account page, and it is guarded like the others.
|
|
_, page := b.get("/web/account")
|
|
if !strings.Contains(page, "Change password") {
|
|
t.Fatalf("no password form on the account page:\n%s", page)
|
|
}
|
|
if status, _ := b.post("/web/account", url.Values{"action": {"password"},
|
|
"current_password": {adminPassword}, "new_password": {newAdminPassword},
|
|
"new_password2": {newAdminPassword}}); status != http.StatusForbidden {
|
|
t.Fatalf("a form without a CSRF token returned %d", status)
|
|
}
|
|
|
|
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"password"},
|
|
"current_password": {adminPassword}, "new_password": {newAdminPassword},
|
|
"new_password2": {"something-else"}})
|
|
if !strings.Contains(page, "do not match") {
|
|
t.Fatalf("mismatch not reported:\n%s", page)
|
|
}
|
|
|
|
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"password"},
|
|
"current_password": {adminPassword}, "new_password": {newAdminPassword},
|
|
"new_password2": {newAdminPassword}})
|
|
if !strings.Contains(page, "Password changed") {
|
|
t.Fatalf("no confirmation:\n%s", page)
|
|
}
|
|
if _, s, _ := h.login("admin", newAdminPassword, ""); s != http.StatusOK {
|
|
t.Fatal("the new password does not work")
|
|
}
|
|
}
|
|
|
|
// An account that exists only through single sign-on has no password to
|
|
// change, and must not be able to acquire one this way.
|
|
func TestChangePasswordRefusesAnSSOOnlyAccount(t *testing.T) {
|
|
h := newHarness(t)
|
|
if err := h.srv.Store().Update(func(st *store.State) error {
|
|
st.Users["sso"] = &store.User{ID: "sso", Username: "ssouser",
|
|
OIDCIssuer: "https://id.example", OIDCSubject: "s"}
|
|
st.Sessions["sso-token"] = &store.Session{Token: "sso-token", UserID: "sso",
|
|
ExpiresAt: time.Now().Add(time.Hour)}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status, body := h.call("POST", "/api/v1/auth/password", "sso-token", map[string]string{
|
|
"current_password": "", "new_password": newAdminPassword})
|
|
if status != http.StatusUnauthorized {
|
|
t.Fatalf("an account with no password set one: %d %v", status, body)
|
|
}
|
|
}
|