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.
252 lines
6.9 KiB
Go
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
|
|
}
|