Files
keeporg/internal/server/web.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

252 lines
6.9 KiB
Go

package server
import (
"errors"
"net/http"
"sort"
"strings"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
)
// The web interface exists for one job the command line cannot do well:
// creating a vault and setting its password in a browser. It is not a
// password manager and never shows a vault's contents; the server does not
// open a vault except to merge one.
const webCookie = "keeporg_web"
// webSession is a signed-in browser.
type webSession struct {
user *store.User
csrf string
}
// webUser resolves the session cookie, or nil when the browser is not
// signed in.
func (s *Server) webUser(r *http.Request) *webSession {
c, err := r.Cookie(webCookie)
if err != nil || c.Value == "" {
return nil
}
var out *webSession
s.st.Read(func(st *store.State) {
sess := st.Sessions[c.Value]
if sess == nil || time.Now().After(sess.ExpiresAt) {
return
}
if u := st.Users[sess.UserID]; u != nil && !u.Disabled {
cp := *u
out = &webSession{user: &cp, csrf: sess.CSRF}
}
})
return out
}
// checkCSRF verifies the form token against the session's.
func (s *Server) checkCSRF(r *http.Request, sess *webSession) bool {
return sess.csrf != "" && secret.Equal(sess.csrf, r.FormValue("csrf"))
}
func (s *Server) setWebCookie(w http.ResponseWriter, token string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: webCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
SameSite: http.SameSiteStrictMode,
MaxAge: int(ttl.Seconds()),
})
}
func (s *Server) handleWebHome(w http.ResponseWriter, r *http.Request) {
if s.webUser(r) != nil {
http.Redirect(w, r, "/web/vaults", http.StatusFound)
return
}
s.renderSignIn(w, "")
}
func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
fail(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
var token string
err := s.st.Update(func(st *store.State) error {
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"),
r.FormValue("totp"))
if err != nil {
return err
}
token = s.newSession(st, u.ID, "web").Token
return nil
})
switch {
case errors.Is(err, errTOTPRequired):
s.renderSignIn(w, "This account needs its one-time code.")
case errors.Is(err, errTOTPInvalid):
s.renderSignIn(w, "That one-time code was rejected.")
case errors.Is(err, errBadCredentials):
s.renderSignIn(w, "Unknown user or wrong password.")
case err != nil:
s.renderSignIn(w, err.Error())
default:
s.setWebCookie(w, token, s.cfg.SessionTTL)
http.Redirect(w, r, "/web/vaults", http.StatusFound)
}
}
func (s *Server) handleWebSignOut(w http.ResponseWriter, r *http.Request) {
sess := s.webUser(r)
if sess == nil {
http.Redirect(w, r, "/web", http.StatusFound)
return
}
if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) {
fail(w, http.StatusForbidden, "csrf", "stale form, reload the page")
return
}
if c, err := r.Cookie(webCookie); err == nil {
_ = s.st.Update(func(st *store.State) error {
delete(st.Sessions, c.Value)
return nil
})
}
s.setWebCookie(w, "", -time.Second)
http.Redirect(w, r, "/web", http.StatusFound)
}
// webVault is one row of the vault list.
type webVault struct {
ID string
Name string
Perm string
Strategy string
Version int64
Updated string
By string
HasKey bool
}
// webGroup is one option in the owning-group selector.
type webGroup struct {
ID string
Name string
}
func (s *Server) handleWebVaults(w http.ResponseWriter, r *http.Request) {
sess := s.webUser(r)
if sess == nil {
s.renderSignIn(w, "")
return
}
s.renderVaults(w, sess, "", "")
}
func (s *Server) handleWebCreateVault(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
}
password := r.FormValue("password")
if password != r.FormValue("password2") {
s.renderVaults(w, sess, "The two vault passwords do not match.", "")
return
}
strategy := store.Strategy(r.FormValue("strategy"))
if strategy == "" {
strategy = store.StrategyMerge
}
view, err := s.createVault(sess.user, r.FormValue("name"), r.FormValue("group"),
strategy, password)
if err != nil {
s.renderVaults(w, sess, humanError(err), "")
return
}
s.renderVaults(w, sess, "",
"Created "+view.Name+". Open "+view.Name+".kdbx from your KeepOrg client "+
"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) {
return "You do not administer that group."
}
return err.Error()
}
// vaultRows collects what the signed-in user may see, and the groups they
// may create a vault in.
func (s *Server) vaultRows(u *store.User) ([]webVault, []webGroup) {
vaults := []webVault{}
groups := []webGroup{}
s.st.Read(func(st *store.State) {
for _, a := range st.Assignments(u.ID) {
updated := "never"
if a.DB.Version > 0 {
updated = a.DB.UpdatedAt.UTC().Format("2006-01-02 15:04 UTC")
}
vaults = append(vaults, webVault{
ID: a.DB.ID, Name: a.DB.Name, Perm: string(a.Perm),
Strategy: string(a.DB.MergeStrategy), Version: a.DB.Version,
Updated: updated, By: a.DB.UpdatedBy, HasKey: a.DB.WrappedKey != "",
})
}
for id, g := range st.Groups {
if st.IsGroupAdmin(u.ID, id) {
groups = append(groups, webGroup{ID: id, Name: g.Name})
}
}
})
sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
return vaults, groups
}