/web/account, offered to everyone, lists every browser and client signed in as you, with when each was last used, and signs any of them out, or all but the current one. It sets up, replaces and removes the second factor, and holds the password form, moved from the vault page. Administrators can sign a user out everywhere from the People page. The API gains GET and DELETE /api/v1/auth/sessions and DELETE /api/v1/auth/totp. Sessions are named by a digest of their token, never the token, and record when they were last used. Replacing or removing an active second factor now needs the password and a current code. Server version 0.10.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
302 lines
8.6 KiB
Go
302 lines
8.6 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"cc.tiredsysadmin.keeporg/internal/secret"
|
|
"cc.tiredsysadmin.keeporg/internal/store"
|
|
)
|
|
|
|
// The web interface covers what a browser is better at than the command
|
|
// line: creating a vault and setting its password, and the administration of
|
|
// people — invites, accounts, groups and who reaches which vault. 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
|
|
token 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
|
|
seen time.Time
|
|
)
|
|
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, token: c.Value}
|
|
seen = sess.LastSeenAt
|
|
}
|
|
})
|
|
if out != nil {
|
|
s.markSeen(c.Value, seen)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// webHandler is a handler that needs a signed-in browser.
|
|
type webHandler func(http.ResponseWriter, *http.Request, *webSession)
|
|
|
|
// webPage serves a page to a signed-in browser, and the sign-in form to
|
|
// anyone else.
|
|
func (s *Server) webPage(fn webHandler) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sess := s.webUser(r)
|
|
if sess == nil {
|
|
s.renderSignIn(w, "")
|
|
return
|
|
}
|
|
fn(w, r, sess)
|
|
}
|
|
}
|
|
|
|
// webForm accepts a form from a signed-in browser. Every form on every page
|
|
// goes through here, so the CSRF check cannot be forgotten on one of them.
|
|
func (s *Server) webForm(fn webHandler) http.HandlerFunc {
|
|
return func(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
|
|
}
|
|
fn(w, r, sess)
|
|
}
|
|
}
|
|
|
|
// 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.newBrowserSession(st, u.ID, r).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, _ *webSession) {
|
|
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 a group selector.
|
|
type webGroup struct {
|
|
ID string
|
|
Name string
|
|
}
|
|
|
|
func (s *Server) handleWebVaults(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
|
s.renderVaults(w, sess, "", "")
|
|
}
|
|
|
|
func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
|
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.")
|
|
}
|
|
|
|
// webDeleteVault is what the confirmation page shows before a vault goes.
|
|
type webDeleteVault struct {
|
|
ID string
|
|
Name string
|
|
Group string
|
|
Version int64
|
|
Updated string
|
|
Grants int
|
|
// NeedsName marks an account with no password of its own, which confirms
|
|
// by typing the vault's name instead; NeedsTOTP marks one with a second
|
|
// factor enrolled.
|
|
NeedsName bool
|
|
NeedsTOTP bool
|
|
}
|
|
|
|
// deleteVaultView gathers what is about to be destroyed, or nil when the
|
|
// caller does not administer the vault.
|
|
func (s *Server) deleteVaultView(u *store.User, id string) *webDeleteVault {
|
|
var out *webDeleteVault
|
|
s.st.Read(func(st *store.State) {
|
|
db := st.Databases[id]
|
|
if db == nil || st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
|
return
|
|
}
|
|
v := &webDeleteVault{ID: db.ID, Name: db.Name, Version: db.Version, Updated: "never"}
|
|
if db.Version > 0 {
|
|
v.Updated = db.UpdatedAt.UTC().Format("2006-01-02 15:04 UTC")
|
|
}
|
|
if g := st.Groups[db.OwnerGroupID]; g != nil {
|
|
v.Group = g.Name
|
|
}
|
|
for _, gr := range st.Grants {
|
|
if gr.DatabaseID == id {
|
|
v.Grants++
|
|
}
|
|
}
|
|
if target := st.Users[u.ID]; target != nil {
|
|
v.NeedsName = target.PassHash == ""
|
|
v.NeedsTOTP = target.TOTPConfirmed
|
|
}
|
|
out = v
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (s *Server) handleWebDeleteVaultPage(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
|
s.renderDeleteVault(w, sess, r.PathValue("id"), "")
|
|
}
|
|
|
|
func (s *Server) handleWebDeleteVault(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
|
id := r.PathValue("id")
|
|
name, err := s.deleteDatabase(sess.user, id, r.FormValue("password"),
|
|
r.FormValue("totp"), r.FormValue("confirm_name"))
|
|
switch {
|
|
case errors.Is(err, errTOTPRequired):
|
|
s.renderDeleteVault(w, sess, id, "Enter your one-time code as well.")
|
|
case errors.Is(err, errTOTPInvalid):
|
|
s.renderDeleteVault(w, sess, id, "That one-time code was rejected.")
|
|
case errors.Is(err, errBadCredentials):
|
|
s.renderDeleteVault(w, sess, id, "That is not your account password.")
|
|
case errors.Is(err, errConfirmName):
|
|
s.renderDeleteVault(w, sess, id, "Type the vault's name exactly as it is shown.")
|
|
case err != nil:
|
|
s.renderDeleteVault(w, sess, id, humanError(err))
|
|
default:
|
|
s.renderVaults(w, sess, "", "Deleted "+name+" and every version of it. "+
|
|
"Copies already on people's computers stay where they are.")
|
|
}
|
|
}
|
|
|
|
// humanError keeps internal wording out of the browser.
|
|
func humanError(err error) string {
|
|
if errors.Is(err, errForbidden) {
|
|
return "You do not administer that."
|
|
}
|
|
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
|
|
}
|