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:
2026-10-30 10:47:51 +01:00
parent 0848c0186c
commit 7eed722ff5
18 changed files with 828 additions and 28 deletions
+60
View File
@@ -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) {
+18 -4
View File
@@ -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
View File
@@ -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.")
}
+218
View File
@@ -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")
}
}
+21
View File
@@ -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(`
+145
View File
@@ -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)
}
}
+2
View File
@@ -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)
+39
View File
@@ -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) {