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:
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -301,6 +302,65 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// changePassword replaces a user's password after proving the current one.
|
||||
// It is the one place a password changes, so the rules live here: the
|
||||
// current password is required even though the caller already holds a
|
||||
// session, the second factor is required when one is enrolled, and every
|
||||
// other session of that user is revoked, because a password change is what
|
||||
// someone does when they think a session is no longer theirs.
|
||||
func changePassword(st *store.State, username, current, totpCode, next, keepToken string) error {
|
||||
u, err := checkPassword(st, username, current, totpCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(next) < MinPasswordLen {
|
||||
return fmt.Errorf("the new password must be at least %d characters", MinPasswordLen)
|
||||
}
|
||||
if next == current {
|
||||
return errors.New("the new password is the same as the old one")
|
||||
}
|
||||
hash, err := secret.Hash(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.PassHash = hash
|
||||
for token, sess := range st.Sessions {
|
||||
if sess.UserID == u.ID && token != keepToken {
|
||||
delete(st.Sessions, token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
TOTP string `json:"totp"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
return changePassword(st, u.Username, req.CurrentPassword, req.TOTP,
|
||||
req.NewPassword, bearer(r))
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials", "the current password is wrong")
|
||||
case err != nil:
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "password changed; other sessions on this account were signed out",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- invites ----
|
||||
|
||||
func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -30,14 +30,27 @@ type harness struct {
|
||||
const adminPassword = "admin-password-1"
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
return newHarnessWith(t, nil)
|
||||
}
|
||||
|
||||
// newHarnessWith builds a server whose configuration the caller may adjust,
|
||||
// for the cases that need OIDC or a different work factor.
|
||||
func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
srv, err := New(Config{
|
||||
// The listener is created first so the server's external URL is the one
|
||||
// it actually answers on, which is what an OIDC redirect needs.
|
||||
ts := httptest.NewUnstartedServer(nil)
|
||||
cfg := Config{
|
||||
DataDir: dir,
|
||||
ExternalURL: "http://keeporg.test",
|
||||
ExternalURL: "http://" + ts.Listener.Addr().String(),
|
||||
MasterKeyFile: filepath.Join(t.TempDir(), "master.key"),
|
||||
VaultKDF: testKDF,
|
||||
}, log.New(io.Discard, "", 0))
|
||||
}
|
||||
if adjust != nil {
|
||||
adjust(&cfg)
|
||||
}
|
||||
srv, err := New(cfg, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -54,7 +67,8 @@ func newHarness(t *testing.T) *harness {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
ts.Config = &http.Server{Handler: srv.Handler()}
|
||||
ts.Start()
|
||||
t.Cleanup(ts.Close)
|
||||
return &harness{t: t, srv: srv, http: ts, dataDir: dir}
|
||||
}
|
||||
|
||||
+24
-1
@@ -165,9 +165,18 @@ func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.")
|
||||
return
|
||||
}
|
||||
// With a user code this approves a waiting client; without one it signs
|
||||
// a browser in to the web interface.
|
||||
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
|
||||
state := secret.Token()
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
if userCode == "" {
|
||||
st.Devices[state] = &store.DeviceAuth{
|
||||
DeviceCode: state, Web: true, Label: "web", OIDCState: state,
|
||||
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
d := st.DeviceByUserCode(userCode)
|
||||
if d == nil || time.Now().After(d.ExpiresAt) {
|
||||
return errors.New("unknown or expired device code")
|
||||
@@ -223,6 +232,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
|
||||
return
|
||||
}
|
||||
var webToken string
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
var dev *store.DeviceAuth
|
||||
for _, d := range st.Devices {
|
||||
@@ -238,12 +248,25 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return err
|
||||
}
|
||||
dev.OIDCState = ""
|
||||
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
|
||||
token := s.newSession(st, u.ID, dev.Label).Token
|
||||
if dev.Web {
|
||||
// Nothing is waiting to poll for this one; the browser in front
|
||||
// of us is the client.
|
||||
webToken = token
|
||||
delete(st.Devices, dev.DeviceCode)
|
||||
return nil
|
||||
}
|
||||
dev.SessionToken = token
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.renderDone(w, "Sign-in failed", err.Error())
|
||||
return
|
||||
}
|
||||
if webToken != "" {
|
||||
s.setWebCookie(w, webToken, s.cfg.SessionTTL)
|
||||
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderDone(w, "Device approved", "You can close this page and return to the application.")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// fakeIdP is the smallest thing that behaves like an OIDC provider: a
|
||||
// discovery document, an authorization endpoint that redirects straight
|
||||
// back, a token endpoint and a userinfo endpoint.
|
||||
type fakeIdP struct {
|
||||
*httptest.Server
|
||||
subject, email, username string
|
||||
// tokenAuth records the client credentials the server presented.
|
||||
tokenAuth [2]string
|
||||
authCalls int
|
||||
}
|
||||
|
||||
func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
idp := &fakeIdP{subject: subject, email: email, username: username}
|
||||
mux := http.NewServeMux()
|
||||
idp.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(idp.Close)
|
||||
|
||||
mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": idp.URL,
|
||||
"authorization_endpoint": idp.URL + "/authorize",
|
||||
"token_endpoint": idp.URL + "/token",
|
||||
"userinfo_endpoint": idp.URL + "/userinfo",
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("GET /authorize", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.authCalls++
|
||||
q := r.URL.Query()
|
||||
back, err := url.Parse(q.Get("redirect_uri"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rq := back.Query()
|
||||
rq.Set("code", "authorization-code")
|
||||
rq.Set("state", q.Get("state"))
|
||||
back.RawQuery = rq.Encode()
|
||||
http.Redirect(w, r, back.String(), http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, _ := r.BasicAuth()
|
||||
idp.tokenAuth = [2]string{user, pass}
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("code") != "authorization-code" {
|
||||
http.Error(w, "bad code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "an-access-token", "token_type": "Bearer", "expires_in": 300,
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("GET /userinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer an-access-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"sub": idp.subject, "email": idp.email, "preferred_username": idp.username,
|
||||
})
|
||||
})
|
||||
return idp
|
||||
}
|
||||
|
||||
// withOIDC configures a harness against this provider.
|
||||
func (idp *fakeIdP) withOIDC(autoCreate bool, defaultGroup string) func(*Config) {
|
||||
return func(c *Config) {
|
||||
c.OIDC = OIDCConfig{
|
||||
Issuer: idp.URL, ClientID: "keeporg", ClientSecret: "a-client-secret",
|
||||
AutoCreate: autoCreate, DefaultGroupID: defaultGroup,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSignsInToTheWebInterface(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-1", "sso@example.com", "ssouser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
||||
b := newBrowser(t, h)
|
||||
|
||||
// The sign-in page offers it, and following that link is the whole flow.
|
||||
status, page := b.get("/web")
|
||||
if status != http.StatusOK || !strings.Contains(page, "/oidc/login") {
|
||||
t.Fatalf("the sign-in page does not offer single sign-on:\n%s", page)
|
||||
}
|
||||
status, page = b.get("/oidc/login")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("oidc login: %d\n%s", status, page)
|
||||
}
|
||||
if !strings.Contains(page, "Your vaults") {
|
||||
t.Fatalf("single sign-on did not land on the vault list:\n%s", page)
|
||||
}
|
||||
if b.cookie() == "" {
|
||||
t.Fatal("no session cookie after signing in with single sign-on")
|
||||
}
|
||||
if idp.authCalls != 1 {
|
||||
t.Fatalf("the provider's authorize endpoint was called %d times", idp.authCalls)
|
||||
}
|
||||
if idp.tokenAuth != [2]string{"keeporg", "a-client-secret"} {
|
||||
t.Fatalf("the server authenticated to the token endpoint as %v", idp.tokenAuth)
|
||||
}
|
||||
|
||||
// The account was provisioned, linked to the subject, and put in the
|
||||
// configured group.
|
||||
var linked *store.User
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
linked = st.UserByOIDC(idp.URL, "subject-1")
|
||||
})
|
||||
if linked == nil {
|
||||
t.Fatal("no account was linked to the identity")
|
||||
}
|
||||
if linked.Username != "ssouser" || linked.Email != "sso@example.com" {
|
||||
t.Fatalf("provisioned account = %+v", linked)
|
||||
}
|
||||
var inGroup bool
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
for _, m := range st.Memberships {
|
||||
if m.UserID == linked.ID && m.GroupID == "everyone" {
|
||||
inGroup = true
|
||||
}
|
||||
}
|
||||
})
|
||||
if !inGroup {
|
||||
t.Fatal("the new account did not join the default group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCApprovesAWaitingClient(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
||||
|
||||
status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("device start: %d %v", status, start)
|
||||
}
|
||||
userCode := start["user_code"].(string)
|
||||
|
||||
b := newBrowser(t, h)
|
||||
status, page := b.get("/oidc/login?code=" + url.QueryEscape(userCode))
|
||||
if status != http.StatusOK || !strings.Contains(page, "Device approved") {
|
||||
t.Fatalf("device approval through single sign-on: %d\n%s", status, page)
|
||||
}
|
||||
// A device approval must not also hand the browser a session cookie.
|
||||
if b.cookie() != "" {
|
||||
t.Fatal("approving a device set a web session cookie")
|
||||
}
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)})
|
||||
if status != http.StatusOK || body["token"] == "" {
|
||||
t.Fatalf("poll after single sign-on: %d %v", status, body)
|
||||
}
|
||||
if u := body["user"].(map[string]any); u["username"] != "devuser" {
|
||||
t.Fatalf("the session belongs to %v", u["username"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCLinksAnExistingAccountByUsername(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-3", "", "admin")
|
||||
h := newHarnessWith(t, idp.withOIDC(false, ""))
|
||||
b := newBrowser(t, h)
|
||||
|
||||
if _, page := b.get("/oidc/login"); !strings.Contains(page, "Your vaults") {
|
||||
t.Fatalf("an existing account was not matched:\n%s", page)
|
||||
}
|
||||
var u *store.User
|
||||
h.srv.Store().Read(func(st *store.State) { u = st.Users["admin"] })
|
||||
if u.OIDCSubject != "subject-3" {
|
||||
t.Fatalf("the existing account was not linked: %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCRefusesAnUnknownIdentityWhenProvisioningIsOff(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-4", "stranger@example.com", "stranger")
|
||||
h := newHarnessWith(t, idp.withOIDC(false, ""))
|
||||
b := newBrowser(t, h)
|
||||
|
||||
_, page := b.get("/oidc/login")
|
||||
if !strings.Contains(page, "no account is linked") {
|
||||
t.Fatalf("an unknown identity was let in:\n%s", page)
|
||||
}
|
||||
if b.cookie() != "" {
|
||||
t.Fatal("a session cookie was issued to an unknown identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCCallbackRejectsAnUnknownState(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-5", "", "someone")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
b := newBrowser(t, h)
|
||||
|
||||
_, page := b.get("/oidc/callback?code=authorization-code&state=not-a-real-state")
|
||||
if !strings.Contains(page, "expired") {
|
||||
t.Fatalf("a forged callback was accepted:\n%s", page)
|
||||
}
|
||||
if b.cookie() != "" {
|
||||
t.Fatal("a forged callback issued a session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInPageHidesSingleSignOnWhenItIsNotConfigured(t *testing.T) {
|
||||
b := newBrowser(t, newHarness(t))
|
||||
_, page := b.get("/web")
|
||||
if strings.Contains(page, "oidc/login") {
|
||||
t.Fatal("single sign-on is offered although no provider is configured")
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ background:var(--accent);border:0;border-radius:8px;cursor:pointer}
|
||||
.err{margin:0 0 1rem;padding:.6rem .7rem;border-radius:8px;background:#f8d7da;color:#842029;font-size:.9rem}
|
||||
.alt{display:block;margin-top:1rem;text-align:center;font-size:.9rem;color:var(--accent)}
|
||||
code{font-size:1.1rem;letter-spacing:.08em}
|
||||
details{margin-top:1.5rem;border-top:1px solid var(--line);padding-top:1rem}
|
||||
summary{cursor:pointer;font-size:.9rem;font-weight:600}
|
||||
details p.sub{margin:.6rem 0 0}
|
||||
header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;flex-wrap:wrap}
|
||||
header form{margin:0}
|
||||
header button{width:auto;margin:0;padding:.35rem .7rem;font-size:.85rem;font-weight:500;
|
||||
@@ -157,6 +160,24 @@ by whoever controls the server. Any other setting keeps the key off the server.<
|
||||
<p class="empty">You do not administer a group yet, so there is nowhere to put a
|
||||
vault. Ask an administrator to add you to one.</p>
|
||||
{{end}}
|
||||
|
||||
<details>
|
||||
<summary>Change your account password</summary>
|
||||
<p class="sub">This is the password you sign in with, not any vault's password.
|
||||
Changing it signs out your other sessions.</p>
|
||||
<form method="post" action="/web/password">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label for="current_password">Current password</label>
|
||||
<input id="current_password" name="current_password" type="password" autocomplete="current-password" required>
|
||||
<label for="cp_totp">One-time code <small>(if enabled)</small></label>
|
||||
<input id="cp_totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
|
||||
<label for="new_password">New password</label>
|
||||
<input id="new_password" name="new_password" type="password" autocomplete="new-password" minlength="10" required>
|
||||
<label for="new_password2">Repeat new password</label>
|
||||
<input id="new_password2" name="new_password2" type="password" autocomplete="new-password" minlength="10" required>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
</details>
|
||||
{{end}}`))
|
||||
|
||||
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||
m.HandleFunc("POST /api/v1/auth/logout", s.authed(s.handleLogout))
|
||||
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami))
|
||||
m.HandleFunc("POST /api/v1/auth/password", s.authed(s.handleChangePassword))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(s.handleTOTPConfirm))
|
||||
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
|
||||
@@ -188,6 +189,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST /web/signout", s.handleWebSignOut)
|
||||
m.HandleFunc("GET /web/vaults", s.handleWebVaults)
|
||||
m.HandleFunc("POST /web/vaults", s.handleWebCreateVault)
|
||||
m.HandleFunc("POST /web/password", s.handleWebChangePassword)
|
||||
|
||||
// Browser-facing pages: device approval, invite registration, OIDC.
|
||||
m.HandleFunc("GET /device", s.handleDevicePage)
|
||||
|
||||
@@ -176,6 +176,45 @@ func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request) {
|
||||
"with the password you just set.")
|
||||
}
|
||||
|
||||
func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.webUser(r)
|
||||
if sess == nil {
|
||||
s.renderSignIn(w, "")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) {
|
||||
fail(w, http.StatusForbidden, "csrf", "stale form, reload the page")
|
||||
return
|
||||
}
|
||||
next := r.FormValue("new_password")
|
||||
if next != r.FormValue("new_password2") {
|
||||
s.renderVaults(w, sess, "The two new passwords do not match.", "")
|
||||
return
|
||||
}
|
||||
cookie, _ := r.Cookie(webCookie)
|
||||
keep := ""
|
||||
if cookie != nil {
|
||||
keep = cookie.Value
|
||||
}
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
return changePassword(st, sess.user.Username, r.FormValue("current_password"),
|
||||
r.FormValue("totp"), next, keep)
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
s.renderVaults(w, sess, "Enter your one-time code to change the password.", "")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
s.renderVaults(w, sess, "That one-time code was rejected.", "")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
s.renderVaults(w, sess, "The current password is wrong.", "")
|
||||
case err != nil:
|
||||
s.renderVaults(w, sess, err.Error(), "")
|
||||
default:
|
||||
s.renderVaults(w, sess, "",
|
||||
"Password changed. Other sessions on this account were signed out.")
|
||||
}
|
||||
}
|
||||
|
||||
// humanError keeps internal wording out of the browser.
|
||||
func humanError(err error) string {
|
||||
if errors.Is(err, errForbidden) {
|
||||
|
||||
Reference in New Issue
Block a user