Files
keeporg/internal/server/password_test.go
T
root 7eed722ff5 Fix single sign-on from the web, and make passwords changeable
Why: two questions about operating this turned up two gaps. "Where do I get
the admin password" had no answer once the line scrolled out of the log,
and "how do I set up OIDC" ran straight into a sign-in link that could not
work.

What:
- /oidc/login with no parameters now signs a browser in and ends in a
  session cookie. It previously required the user code of a waiting client,
  so the link on the web sign-in page always failed. With a code it still
  approves a device, and the two paths no longer overlap: an empty user
  code matches no pending device.
- POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults.
  Changing a password requires the current one, and the one-time code when
  a second factor is enrolled, and revokes that account's other sessions.
  Until now nothing could change a password, which made the advice printed
  at first start impossible to follow.
- keeporgd -reset-admin, for when that first password was not kept: a new
  one printed once, any second factor cleared, sessions revoked. It writes
  the state document directly and says to stop the server first.
- The client read stdin through a new buffered reader per prompt, so the
  first prompt swallowed the rest and anything asking twice could not be
  scripted. One shared reader now. A prompt for a password being set no
  longer reads $KEEPORG_PASSWORD either; that variable holds the password
  you sign in with, and taking it here would have set the new password to
  the old one.
- HANDOVER gains a first-sign-in section and an OIDC setup section covering
  the redirect URI, the config block, both entry points and how an identity
  becomes an account.

Proved: OIDC now has tests, against a stub provider that serves discovery,
authorization, token and userinfo. They cover the web sign-in that was
broken, device approval, linking an existing account by username, the
refusal when auto_create is off, and a forged callback state. Eight more
cover the password change. Checked by hand with the real binaries: the
first-start password logs once and works, -reset-admin replaces it while
the old one stops working, and `keeporg passwd` changes it from a pipe.
Server 0.5.0, desktop clients 0.3.0.
2026-10-30 10:47:51 +01:00

146 lines
5.3 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 page, and it is guarded like the others.
_, page := b.get("/web/vaults")
if !strings.Contains(page, "Change your account password") {
t.Fatalf("no password form on the vault page:\n%s", page)
}
if status, _ := b.post("/web/password", url.Values{
"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/password", url.Values{"csrf": {csrf},
"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/password", url.Values{"csrf": {csrf},
"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)
}
}