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.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user