Files
hamlab/internal/api/users.go
Mitchell-JohnsonandClaude Fable 5.1 f3ca3d8f73 Idle sessions sign themselves out; a KVM's login is remembered; the compose editor knows YAML
A session unused for seven days is dropped, the Sessions list marks a day's idleness and
can sign all of those out at once. The hub keeps a KVM's own cookies from any browser that
has them and lends them to one that hasn't, on the wire and back to the page. The stack
editor and the new-stack dialog use CodeEditor: line numbers, YAML and .env colour under a
real textarea, Tab/Shift+Tab/Enter indentation, and a key bar on touch screens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 01:20:27 -07:00

301 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}