Users -> Guest logins makes a throwaway user name and password, good for half an hour by default, to hand to somebody who wants to see the console. It is a normal account with `guest: true` and an expiry, so login, sessions and the saved look need no special case, and it is left out of the account list, out of "the last account can't be removed", and out of the one account an empty user name means. What a guest may do is one allow-list, checked in the auth middleware, rather than a flag ~190 handlers have to remember. The list is matched by asking the router which handler a request would reach, so it cannot drift from the routes it names, and it is checked against the scoped path — /api/nodes/grid/procs is /api/procs on grid — because the hub proxies to a node with a token that is full control of that node. The node cannot tell a guest from its owner, so the only place it can be stopped is here, before the request leaves. A guest gets the dashboard, containers, stacks, disks, hardware, ZFS and usage, on this machine and on any managed one, and nothing else: no terminal (attaching is a GET, and it is a root shell), no files, no container logs or environments, no stack files, no alert channels, no cron, no tokens, no settings, no power. Expiry is enforced on every request and swept once a minute, so a console left open on a visitor's phone stops within the minute rather than at their next click; revoking signs them out at once. The console hides what a guest cannot use and says how long is left where the power button would be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
7.5 KiB
Go
277 lines
7.5 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"
|
||
"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)
|
||
}
|
||
|
||
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})
|
||
}
|