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" "strconv" "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 // Guest marks a temporary look-around login, and Expires is when it stops working. What // a guest may reach is decided in the middleware, not here; see internal/api/guests.go. Guest bool Expires time.Time } 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 != "" && a.Guest: return "guest:" + a.User 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.Accounts() { // guest logins are listed on their own, see listGuests 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 strings.HasPrefix(name, guestPrefix) { jsonErr(w, 400, "names starting with "+guestPrefix+" belong to guest logins") 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.Accounts()) <= 1 { // guest logins don't keep the last real account alive 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) } // revokeIdleSessions drops the caller's sessions that have not been used for ?hours (24 by // default): the scripts and headless browsers that signed in once and never came back, // without touching the phone that was used this morning. func (s *Server) revokeIdleSessions(w http.ResponseWriter, r *http.Request) { a := actorOf(r) if a.User == "" { jsonErr(w, 403, "only a signed-in user may do that") return } hours := 24 if h := r.URL.Query().Get("hours"); h != "" { n, err := strconv.Atoi(h) if err != nil || n < 1 || n > 24*365 { jsonErr(w, 400, "hours is a whole number from 1 to 8760") return } hours = n } n := s.sessions.RevokeIdle(a.User, a.token, time.Duration(hours)*time.Hour) log.Printf("%d idle session(s) of %q revoked (idle > %dh, by %s)", n, a.User, hours, clientIP(r)) jsonOK(w, 200, map[string]int{"revoked": n}) } 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}) }