Users, with their own settings, and a list of where each is signed in
The login is a user name and a password. The old single password becomes the account named after the OS user on first boot, along with its sessions and the machine-wide look. Settings gains Users (add, remove, reset a password — your own password confirms) and Sessions (device, IP, last seen; revoke one or sign out everywhere else). Theme, accents and the dashboard layout follow the account; the refresh rate, graph history and alerts stay the machine's. Pairing a machine takes a user on it. Log lines name the actor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
db214ebc2b
commit
2b54a06821
+196
-64
@@ -90,12 +90,21 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
if s.user == "" {
|
||||
s.user = "?"
|
||||
}
|
||||
s.migrateUsers()
|
||||
s.sensors.Add(newStatusSensors(s))
|
||||
s.alerts = newAlertEngine(s)
|
||||
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
||||
var raw json.RawMessage
|
||||
var histSec int
|
||||
st.View(func(x *store.State) { raw, histSec = x.Dashboard, x.HistorySec })
|
||||
st.View(func(x *store.State) {
|
||||
raw, histSec = x.Dashboard, x.HistorySec
|
||||
if len(raw) == 0 {
|
||||
for _, u := range x.Users {
|
||||
raw = u.Dashboard
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
s.applyPrefs(raw)
|
||||
if histSec > 0 {
|
||||
col.SetWindow(time.Duration(histSec) * time.Second)
|
||||
@@ -106,7 +115,15 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
// happened to open its Dashboard.
|
||||
var samplerMs int
|
||||
var uiRaw json.RawMessage
|
||||
st.View(func(x *store.State) { samplerMs, uiRaw = x.SamplerMs, x.UI })
|
||||
st.View(func(x *store.State) {
|
||||
samplerMs, uiRaw = x.SamplerMs, x.UI
|
||||
if len(uiRaw) == 0 && len(x.Users) > 0 { // any account's saved rate, before the machine had one of its own
|
||||
for _, u := range x.Users {
|
||||
uiRaw = u.UI
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
if samplerMs > 0 {
|
||||
col.SetInterval(time.Duration(samplerMs) * time.Millisecond)
|
||||
}
|
||||
@@ -114,7 +131,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
var ui struct {
|
||||
RefreshMs float64 `json:"refreshMs"`
|
||||
}
|
||||
if json.Unmarshal(uiRaw, &ui) == nil && ui.RefreshMs >= 100 {
|
||||
if samplerMs > 0 {
|
||||
go s.setSampler(samplerMs, false)
|
||||
} else if json.Unmarshal(uiRaw, &ui) == nil && ui.RefreshMs >= 100 {
|
||||
go s.setSampler(int(ui.RefreshMs), false)
|
||||
}
|
||||
go s.keepFleetInStep()
|
||||
@@ -233,6 +252,13 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
m.HandleFunc("GET /api/procs", s.auth(s.procList))
|
||||
m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal))
|
||||
m.HandleFunc("POST /api/password", s.auth(s.changePassword))
|
||||
m.HandleFunc("GET /api/users", s.auth(s.listUsers))
|
||||
m.HandleFunc("POST /api/users", s.auth(s.addUser))
|
||||
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
|
||||
m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword))
|
||||
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
|
||||
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
|
||||
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
|
||||
m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs))
|
||||
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
|
||||
// Go runtime profiles, behind the login, for chasing memory/CPU (bug 14).
|
||||
@@ -320,21 +346,24 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
|
||||
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) {
|
||||
next(w, r)
|
||||
return
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if ses, ok := s.sessions.Lookup(c.Value, clientKey(r)); ok {
|
||||
next(w, withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}))
|
||||
return
|
||||
}
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
var hashes []string
|
||||
var hashes, names []string
|
||||
s.cfg.View(func(c *config.Config) {
|
||||
for _, t := range c.Tokens {
|
||||
if t.Scope == "" { // a scoped token is not a key to the machine
|
||||
hashes = append(hashes, t.Hash)
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
if auth.TokenMatches(strings.TrimPrefix(h, "Bearer "), hashes) {
|
||||
next(w, r)
|
||||
if i := auth.TokenIndex(strings.TrimPrefix(h, "Bearer "), hashes); i >= 0 {
|
||||
next(w, withActor(r, actor{Token: names[i]}))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -355,9 +384,13 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ok, trusted := false, false
|
||||
var patterns []string
|
||||
if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) {
|
||||
ok, trusted = true, true
|
||||
} else {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid {
|
||||
ok, trusted = true, true
|
||||
r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value})
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
||||
if presented == "" {
|
||||
presented, fromURL = r.URL.Query().Get("token"), true
|
||||
@@ -477,7 +510,7 @@ func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now(), Scope: config.ScopeSensorsRead, Sensors: pats})
|
||||
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now(), Scope: config.ScopeSensorsRead, Sensors: pats, IssuedBy: actorOf(r).User})
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -500,6 +533,7 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -507,13 +541,19 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "password and name required")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
|
||||
user, ok := s.checkLogin(body.Username, body.Password)
|
||||
if !ok {
|
||||
s.limiter.Fail(clientKey(r))
|
||||
log.Printf("token request failed from %s", clientIP(r))
|
||||
jsonErr(w, 401, "wrong password")
|
||||
log.Printf("token request failed for %q from %s", body.Username, clientIP(r))
|
||||
if body.Username == "" {
|
||||
jsonErr(w, 401, "wrong password, or this machine has several users: give a user name")
|
||||
return
|
||||
}
|
||||
jsonErr(w, 401, "wrong user name or password")
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(clientKey(r))
|
||||
s.touchUser(user)
|
||||
tok, hash, err := auth.NewToken()
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "token error")
|
||||
@@ -527,12 +567,12 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
c.Tokens = append(kept, config.Token{Name: name, Hash: hash, Created: time.Now()})
|
||||
c.Tokens = append(kept, config.Token{Name: name, Hash: hash, Created: time.Now(), IssuedBy: user})
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("API token %q issued to %s", name, clientIP(r))
|
||||
log.Printf("API token %q issued to %s by %s", name, clientIP(r), user)
|
||||
jsonOK(w, 200, map[string]string{"token": tok, "name": name})
|
||||
}
|
||||
|
||||
@@ -540,7 +580,7 @@ func (s *Server) listTokens(w http.ResponseWriter, _ *http.Request) {
|
||||
var out []map[string]any
|
||||
s.cfg.View(func(c *config.Config) {
|
||||
for _, t := range c.Tokens {
|
||||
out = append(out, map[string]any{"name": t.Name, "created": t.Created, "scope": t.Scope, "sensors": t.Sensors})
|
||||
out = append(out, map[string]any{"name": t.Name, "created": t.Created, "scope": t.Scope, "sensors": t.Sensors, "issued_by": t.IssuedBy})
|
||||
}
|
||||
})
|
||||
if out == nil {
|
||||
@@ -578,12 +618,12 @@ func (s *Server) listNodes(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) addNode(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Name, URL, Password string }
|
||||
var body struct{ Name, URL, Username, Password string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
st, err := s.nodes.Add(r.Context(), strings.TrimSpace(body.Name), body.URL, body.Password)
|
||||
st, err := s.nodes.Add(r.Context(), strings.TrimSpace(body.Name), body.URL, strings.TrimSpace(body.Username), body.Password)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
@@ -746,27 +786,101 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
|
||||
name, ok := s.checkLogin(body.Username, body.Password)
|
||||
if !ok {
|
||||
s.limiter.Fail(clientKey(r))
|
||||
log.Printf("login failed from %s", clientIP(r))
|
||||
jsonErr(w, 401, "wrong password")
|
||||
log.Printf("login failed for %q from %s", body.Username, clientIP(r))
|
||||
jsonErr(w, 401, "wrong user name or password")
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(clientKey(r))
|
||||
tok, err := s.sessions.Create()
|
||||
tok, err := s.sessions.Create(name, clientKey(r), r.UserAgent())
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds()))
|
||||
log.Printf("login ok from %s", clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
s.touchUser(name)
|
||||
log.Printf("login ok: %s from %s", name, clientIP(r))
|
||||
jsonOK(w, 200, map[string]string{"user": name})
|
||||
}
|
||||
|
||||
// checkLogin verifies a user name and password, returning the account. An empty user
|
||||
// name is allowed only while there is exactly one account — what a client from before
|
||||
// user names sends, and what a hub pairing with a one-user node may leave out.
|
||||
func (s *Server) checkLogin(username, password string) (string, bool) {
|
||||
var users []config.User
|
||||
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" && len(users) == 1 {
|
||||
username = users[0].Name
|
||||
}
|
||||
if username == "" {
|
||||
auth.CheckUser(nil, "", password) // take as long as a real check
|
||||
return "", false
|
||||
}
|
||||
return username, auth.CheckUser(users, username, password)
|
||||
}
|
||||
|
||||
// touchUser notes that an account was just used.
|
||||
func (s *Server) touchUser(name string) {
|
||||
_ = s.cfg.Update(func(c *config.Config) {
|
||||
for i := range c.Users {
|
||||
if c.Users[i].Name == name {
|
||||
c.Users[i].LastSeen = time.Now()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// migrateUsers is the one-time move from a single shared password to accounts: the old
|
||||
// hash becomes the account named after the OS user the console runs as, the sessions that
|
||||
// were signed in with it become that account's, and the machine-wide look becomes theirs.
|
||||
func (s *Server) migrateUsers() {
|
||||
first := ""
|
||||
changed := false
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
changed = c.MigrateUsers(s.user)
|
||||
if len(c.Users) > 0 {
|
||||
first = c.Users[0].Name
|
||||
}
|
||||
}); err != nil {
|
||||
log.Printf("users: %v", err)
|
||||
}
|
||||
if changed {
|
||||
log.Printf("users: the login password is now the account %q", first)
|
||||
}
|
||||
if first == "" {
|
||||
return
|
||||
}
|
||||
_ = s.store.Update(func(st *store.State) {
|
||||
for tok, ses := range st.Sessions {
|
||||
if ses.User == "" {
|
||||
ses.User = first
|
||||
if ses.ID == "" {
|
||||
ses.ID = tok[:12] // an old session has no ID of its own; a slice of the token is not usable as one
|
||||
}
|
||||
st.Sessions[tok] = ses
|
||||
}
|
||||
}
|
||||
if len(st.UI) > 0 || len(st.Dashboard) > 0 {
|
||||
u := st.UserState(first)
|
||||
if len(u.UI) == 0 {
|
||||
u.UI = st.UI
|
||||
}
|
||||
if len(u.Dashboard) == 0 {
|
||||
u.Dashboard = st.Dashboard
|
||||
}
|
||||
st.UI, st.Dashboard = nil, nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -790,11 +904,16 @@ func clientKey(r *http.Request) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// clientIP names the caller for a log line: who (once signed in) and from where.
|
||||
func clientIP(r *http.Request) string {
|
||||
ip := r.RemoteAddr
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
|
||||
ip = strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
|
||||
}
|
||||
return r.RemoteAddr
|
||||
if who := actorOf(r).String(); who != "" {
|
||||
return who + "@" + ip
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// ---- me ----
|
||||
@@ -818,7 +937,7 @@ func (s *Server) hostName() string {
|
||||
return s.host
|
||||
}
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
load := ""
|
||||
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
f := strings.Fields(string(b))
|
||||
@@ -841,7 +960,7 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
})
|
||||
jsonOK(w, 200, map[string]any{
|
||||
"host": s.hostName(), "user": s.user, "version": s.version,
|
||||
"host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version,
|
||||
"load": load, "uptime": uptime, "service_started": s.started,
|
||||
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "demo_url": demoURL, "addr": addr,
|
||||
})
|
||||
@@ -1876,38 +1995,49 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Current) {
|
||||
jsonErr(w, 403, "current password is wrong")
|
||||
if !s.confirmSelf(w, r, body.Current) {
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 12 {
|
||||
if len(body.Password) < auth.MinPasswordLen {
|
||||
jsonErr(w, 400, "password must be at least 12 characters")
|
||||
return
|
||||
}
|
||||
h, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
if !s.setPassword(w, actorOf(r).User, body.Password) {
|
||||
return
|
||||
}
|
||||
if err := s.cfg.Update(func(c *config.Config) { c.PasswordHash = h }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("password changed (from %s)", clientIP(r))
|
||||
log.Printf("password changed (by %s)", clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// ---- ui prefs (theme) ----
|
||||
|
||||
func (s *Server) getUIPrefs(w http.ResponseWriter, _ *http.Request) {
|
||||
var raw json.RawMessage
|
||||
s.store.View(func(st *store.State) { raw = st.UI })
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// The look is the account's: theme and accents follow whoever is signed in. The refresh
|
||||
// rate rides in the same object for the client's convenience but is the machine's — it is
|
||||
// how often every machine samples — so it is kept once, machine-wide, and folded in here.
|
||||
func (s *Server) getUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
prefs := s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.UI })
|
||||
prefs["refreshMs"] = s.metrics.Interval().Milliseconds()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(raw)
|
||||
jsonOK(w, 200, prefs)
|
||||
}
|
||||
|
||||
// userPrefs reads one of the caller's pref blobs as a map (empty when none, or for a token).
|
||||
func (s *Server) userPrefs(r *http.Request, pick func(*store.UserState) json.RawMessage) map[string]any {
|
||||
out := map[string]any{}
|
||||
user := actorOf(r).User
|
||||
if user == "" {
|
||||
return out
|
||||
}
|
||||
var raw json.RawMessage
|
||||
s.store.View(func(st *store.State) {
|
||||
if u := st.Users[user]; u != nil {
|
||||
raw = pick(u)
|
||||
}
|
||||
})
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1929,9 +2059,15 @@ func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "theme must be a short identifier")
|
||||
return
|
||||
}
|
||||
if err := s.store.Update(func(st *store.State) { st.UI = json.RawMessage(b) }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
if user := actorOf(r).User; user != "" {
|
||||
var m map[string]json.RawMessage
|
||||
_ = json.Unmarshal(b, &m)
|
||||
delete(m, "refreshMs")
|
||||
kept, _ := json.Marshal(m)
|
||||
if err := s.store.Update(func(st *store.State) { st.UserState(user).UI = kept }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if p.RefreshMs != nil { // the refresh rate is also how often every machine samples
|
||||
go s.setSampler(int(*p.RefreshMs), true)
|
||||
@@ -2087,15 +2223,9 @@ func (s *Server) pushHistoryWindow(seconds int) {
|
||||
|
||||
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
||||
|
||||
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
|
||||
var raw json.RawMessage
|
||||
s.store.View(func(st *store.State) { raw = st.Dashboard })
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
func (s *Server) getDashPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(raw)
|
||||
jsonOK(w, 200, s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.Dashboard }))
|
||||
}
|
||||
|
||||
func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2104,9 +2234,11 @@ func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "expected a JSON object")
|
||||
return
|
||||
}
|
||||
if err := s.store.Update(func(st *store.State) { st.Dashboard = json.RawMessage(b) }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
if user := actorOf(r).User; user != "" {
|
||||
if err := s.store.Update(func(st *store.State) { st.UserState(user).Dashboard = json.RawMessage(b) }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
s.applyPrefs(b)
|
||||
w.WriteHeader(204)
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package api
|
||||
|
||||
// Accounts and their sessions. Every person has their own login, and the console's look
|
||||
// (theme, accents, dashboard layout) follows the account rather than the machine. Anyone
|
||||
// signed in may add or remove accounts — a household console, not a tenancy — but removing
|
||||
// one, or resetting another's password, asks for the caller's own password first, so a
|
||||
// picked-up unlocked phone is not enough.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/auth"
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
// actor is who a request is from: a signed-in account (with its session), or a token.
|
||||
type actor struct {
|
||||
User string // account name, "" for a token
|
||||
Session string // session ID, "" for a token
|
||||
token string // the raw cookie token, so a handler can spare the current session
|
||||
Token string // token name, "" for a session
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const actorKey ctxKey = 1
|
||||
|
||||
func withActor(r *http.Request, a actor) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), actorKey, a))
|
||||
}
|
||||
|
||||
func actorOf(r *http.Request) actor {
|
||||
a, _ := r.Context().Value(actorKey).(actor)
|
||||
return a
|
||||
}
|
||||
|
||||
// who names the caller for a log line: "core@10.0.0.5" for a person, "token:hub:core@…" for a hub.
|
||||
func (a actor) String() string {
|
||||
switch {
|
||||
case a.User != "":
|
||||
return a.User
|
||||
case a.Token != "":
|
||||
return "token:" + a.Token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- users ----
|
||||
|
||||
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
me := actorOf(r).User
|
||||
counts := s.sessions.Count()
|
||||
var out []map[string]any
|
||||
s.cfg.View(func(c *config.Config) {
|
||||
for _, u := range c.Users {
|
||||
var seen any
|
||||
if !u.LastSeen.IsZero() {
|
||||
seen = u.LastSeen
|
||||
}
|
||||
out = append(out, map[string]any{"name": u.Name, "created": u.Created, "last_seen": seen, "sessions": counts[u.Name], "self": u.Name == me})
|
||||
}
|
||||
})
|
||||
if out == nil {
|
||||
out = []map[string]any{}
|
||||
}
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) addUser(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Name, Password string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if !auth.UserNameRe.MatchString(name) {
|
||||
jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit")
|
||||
return
|
||||
}
|
||||
if len(body.Password) < auth.MinPasswordLen {
|
||||
jsonErr(w, 400, "password must be at least 12 characters")
|
||||
return
|
||||
}
|
||||
h, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
clash := false
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
if _, ok := c.User(name); ok {
|
||||
clash = true
|
||||
return
|
||||
}
|
||||
c.Users = append(c.Users, config.User{Name: name, Hash: h, Created: time.Now()})
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if clash {
|
||||
jsonErr(w, 409, "a user with that name exists")
|
||||
return
|
||||
}
|
||||
log.Printf("user %q added (by %s)", name, clientIP(r))
|
||||
jsonOK(w, 200, map[string]any{"name": name})
|
||||
}
|
||||
|
||||
// confirmSelf checks the caller's own password, the gate on anything done to another account.
|
||||
func (s *Server) confirmSelf(w http.ResponseWriter, r *http.Request, current string) bool {
|
||||
me := actorOf(r).User
|
||||
if me == "" {
|
||||
jsonErr(w, 403, "only a signed-in user may do that")
|
||||
return false
|
||||
}
|
||||
var users []config.User
|
||||
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
|
||||
if !auth.CheckUser(users, me, current) {
|
||||
jsonErr(w, 403, "your password is wrong")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
var body struct{ Current string }
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||
if name == actorOf(r).User {
|
||||
jsonErr(w, 400, "you can't remove the account you are signed in as")
|
||||
return
|
||||
}
|
||||
if !s.confirmSelf(w, r, body.Current) {
|
||||
return
|
||||
}
|
||||
found, last := false, false
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
if len(c.Users) <= 1 {
|
||||
last = true
|
||||
return
|
||||
}
|
||||
kept := c.Users[:0]
|
||||
for _, u := range c.Users {
|
||||
if u.Name == name {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, u)
|
||||
}
|
||||
c.Users = kept
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if last {
|
||||
jsonErr(w, 400, "the last account can't be removed")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
jsonErr(w, 404, "no such user")
|
||||
return
|
||||
}
|
||||
s.sessions.RevokeUser(name)
|
||||
_ = s.store.Update(func(st *store.State) { delete(st.Users, name) })
|
||||
log.Printf("user %q removed (by %s)", name, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// resetPassword sets another user's password, on the caller's own password.
|
||||
func (s *Server) resetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
var body struct{ Current, Password string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if !s.confirmSelf(w, r, body.Current) {
|
||||
return
|
||||
}
|
||||
if len(body.Password) < auth.MinPasswordLen {
|
||||
jsonErr(w, 400, "password must be at least 12 characters")
|
||||
return
|
||||
}
|
||||
if !s.setPassword(w, name, body.Password) {
|
||||
return
|
||||
}
|
||||
if name != actorOf(r).User { // whoever held the old password is signed out
|
||||
s.sessions.RevokeUser(name)
|
||||
}
|
||||
log.Printf("password of %q reset (by %s)", name, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (s *Server) setPassword(w http.ResponseWriter, name, pw string) bool {
|
||||
h, err := auth.HashPassword(pw)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return false
|
||||
}
|
||||
found := false
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
for i := range c.Users {
|
||||
if c.Users[i].Name == name {
|
||||
c.Users[i].Hash = h
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return false
|
||||
}
|
||||
if !found {
|
||||
jsonErr(w, 404, "no such user")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- sessions: where the caller's account is signed in ----
|
||||
|
||||
func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) {
|
||||
a := actorOf(r)
|
||||
if a.User == "" {
|
||||
jsonOK(w, 200, []any{})
|
||||
return
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, ses := range s.sessions.List(a.User) {
|
||||
out = append(out, map[string]any{"id": ses.ID, "created": ses.Created, "last_seen": ses.LastSeen, "expires": ses.Expires, "ip": ses.IP, "agent": ses.Agent, "current": ses.ID == a.Session})
|
||||
}
|
||||
if out == nil {
|
||||
out = []map[string]any{}
|
||||
}
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) revokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
a := actorOf(r)
|
||||
id := r.PathValue("id")
|
||||
if a.User == "" || !s.sessions.Revoke(a.User, id) {
|
||||
jsonErr(w, 404, "no such session")
|
||||
return
|
||||
}
|
||||
log.Printf("session %s of %q revoked (by %s)", id, a.User, clientIP(r))
|
||||
if id == a.Session {
|
||||
s.setCookie(w, r, "", -1)
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (s *Server) revokeOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||
a := actorOf(r)
|
||||
if a.User == "" {
|
||||
jsonErr(w, 403, "only a signed-in user may do that")
|
||||
return
|
||||
}
|
||||
n := s.sessions.RevokeOthers(a.User, a.token)
|
||||
log.Printf("%d other session(s) of %q revoked (by %s)", n, a.User, clientIP(r))
|
||||
jsonOK(w, 200, map[string]int{"revoked": n})
|
||||
}
|
||||
Reference in New Issue
Block a user