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>
301 lines
8.4 KiB
Go
301 lines
8.4 KiB
Go
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})
|
||
}
|