Add an Account page for sign-in methods and signed-in devices
/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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
318bd68908
commit
f8f634fbf7
@@ -177,6 +177,10 @@ func (s *Server) routes() {
|
||||
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("DELETE /api/v1/auth/totp", s.authed(s.handleTOTPDisable))
|
||||
m.HandleFunc("GET /api/v1/auth/sessions", s.authed(s.handleListSessions))
|
||||
m.HandleFunc("DELETE /api/v1/auth/sessions", s.authed(s.handleRevokeOtherSessions))
|
||||
m.HandleFunc("DELETE /api/v1/auth/sessions/{id}", s.authed(s.handleRevokeSession))
|
||||
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
|
||||
m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll)
|
||||
|
||||
@@ -191,7 +195,8 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST /web/signout", s.webForm(s.handleWebSignOut))
|
||||
m.HandleFunc("GET /web/vaults", s.webPage(s.handleWebVaults))
|
||||
m.HandleFunc("POST /web/vaults", s.webForm(s.handleWebCreateVault))
|
||||
m.HandleFunc("POST /web/password", s.webForm(s.handleWebChangePassword))
|
||||
m.HandleFunc("GET /web/account", s.webPage(s.handleWebAccount))
|
||||
m.HandleFunc("POST /web/account", s.webForm(s.handleWebAccountAction))
|
||||
m.HandleFunc("GET /web/vaults/{id}/delete", s.webPage(s.handleWebDeleteVaultPage))
|
||||
m.HandleFunc("POST /web/vaults/{id}/delete", s.webForm(s.handleWebDeleteVault))
|
||||
m.HandleFunc("GET /web/groups", s.webPage(s.handleWebGroups))
|
||||
@@ -293,7 +298,10 @@ func (s *Server) userFromRequest(r *http.Request) (*store.User, error) {
|
||||
if tok == "" {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
var out *store.User
|
||||
var (
|
||||
out *store.User
|
||||
seen time.Time
|
||||
)
|
||||
s.st.Read(func(st *store.State) {
|
||||
sess := st.Sessions[tok]
|
||||
if sess == nil || time.Now().After(sess.ExpiresAt) {
|
||||
@@ -301,15 +309,35 @@ func (s *Server) userFromRequest(r *http.Request) (*store.User, error) {
|
||||
}
|
||||
if u := st.Users[sess.UserID]; u != nil && !u.Disabled {
|
||||
cp := *u
|
||||
out = &cp
|
||||
out, seen = &cp, sess.LastSeenAt
|
||||
}
|
||||
})
|
||||
if out == nil {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
s.markSeen(tok, seen)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seenResolution is how stale a session's LastSeenAt may get before a
|
||||
// request refreshes it. Each refresh rewrites the state document, so it is
|
||||
// minutes, not seconds; the devices list only needs to tell today from last
|
||||
// month.
|
||||
const seenResolution = 5 * time.Minute
|
||||
|
||||
// markSeen records that a session was used, when its record is stale.
|
||||
func (s *Server) markSeen(token string, last time.Time) {
|
||||
if time.Since(last) < seenResolution {
|
||||
return
|
||||
}
|
||||
_ = s.st.Update(func(st *store.State) error {
|
||||
if sess := st.Sessions[token]; sess != nil {
|
||||
sess.LastSeenAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// bearer extracts a token from the Authorization header, or from the
|
||||
// access_token query parameter, which EventSource clients need because they
|
||||
// cannot set headers.
|
||||
@@ -322,18 +350,28 @@ func bearer(r *http.Request) string {
|
||||
}
|
||||
|
||||
func (s *Server) newSession(st *store.State, userID, label string) *store.Session {
|
||||
now := time.Now()
|
||||
sess := &store.Session{
|
||||
Token: secret.Token(),
|
||||
UserID: userID,
|
||||
Label: label,
|
||||
CSRF: secret.Token(),
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(s.cfg.SessionTTL),
|
||||
Token: secret.Token(),
|
||||
UserID: userID,
|
||||
Label: label,
|
||||
CSRF: secret.Token(),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(s.cfg.SessionTTL),
|
||||
LastSeenAt: now,
|
||||
}
|
||||
st.Sessions[sess.Token] = sess
|
||||
return sess
|
||||
}
|
||||
|
||||
// newBrowserSession is a session for a web cookie, named after the browser
|
||||
// so that a list of them can be told apart.
|
||||
func (s *Server) newBrowserSession(st *store.State, userID string, r *http.Request) *store.Session {
|
||||
sess := s.newSession(st, userID, browserLabel(r.UserAgent()))
|
||||
sess.Browser = true
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
var users, dbs int
|
||||
s.st.Read(func(st *store.State) { users, dbs = len(st.Users), len(st.Databases) })
|
||||
|
||||
Reference in New Issue
Block a user