Phase 1 foundation. Local auth per ADR-0006: Argon2id passwords, opaque hashed session tokens with idle/absolute expiry, CSRF token plus Origin check on cookie sessions, per-IP token bucket and per-account exponential lockout, three roles enforced by middleware, personal API tokens for noemactl. Audit log writer used by every admin mutation. API: request ids, panic recovery, strict CSP and security headers, body limits, single JSON error envelope, health/ready/status, users, sessions, tokens, audit, token-protected /metrics. UI: embedded html/template pages (login, first-run setup, dashboard, system, users, account, audit, interpretation boundary), light/dark theme, dependency-free app.js with CSRF-aware fetch and SSE helper. Binaries: noemad with graceful shutdown, migrations at start, bootstrap admin from environment, supervised background tasks; noemactl with status, migrate, user create and meaningful exit codes.
200 lines
5.8 KiB
Go
200 lines
5.8 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/andydixon/noema/internal/auth"
|
|
"github.com/andydixon/noema/pkg/ids"
|
|
)
|
|
|
|
type userOut struct {
|
|
ID ids.ID `json:"id"`
|
|
Username string `json:"username"`
|
|
Role auth.Role `json:"role"`
|
|
Disabled bool `json:"disabled"`
|
|
TOTPEnabled bool `json:"totp_enabled"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func toUserOut(u auth.User) userOut {
|
|
return userOut{ID: u.ID, Username: u.Username, Role: u.Role, Disabled: u.Disabled, TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt}
|
|
}
|
|
|
|
func (s *Server) usersList(w http.ResponseWriter, r *http.Request) {
|
|
users, err := s.deps.Auth.ListUsers(r.Context())
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
out := make([]userOut, 0, len(users))
|
|
for _, u := range users {
|
|
out = append(out, toUserOut(u))
|
|
}
|
|
writeJSON(w, http.StatusOK, page[userOut]{Items: out})
|
|
}
|
|
|
|
func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
Role auth.Role `json:"role"`
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
u, err := s.deps.Auth.CreateUser(r.Context(), in.Username, in.Password, in.Role)
|
|
if err != nil {
|
|
fail(w, r, wrapInput(err))
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "user.create", "user", u.ID.String(), map[string]any{"username": u.Username, "role": u.Role})
|
|
writeJSON(w, http.StatusCreated, toUserOut(u))
|
|
}
|
|
|
|
func (s *Server) usersPatch(w http.ResponseWriter, r *http.Request) {
|
|
id, err := ids.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, r, ValidationError("invalid id"))
|
|
return
|
|
}
|
|
var in struct {
|
|
Role *auth.Role `json:"role"`
|
|
Disabled *bool `json:"disabled"`
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
p, _ := auth.PrincipalFrom(r.Context())
|
|
if p.User.ID == id && ((in.Disabled != nil && *in.Disabled) || (in.Role != nil && *in.Role != auth.RoleAdministrator)) {
|
|
fail(w, r, ValidationError("you cannot disable or demote your own account"))
|
|
return
|
|
}
|
|
if in.Role != nil {
|
|
if err := s.deps.Auth.SetUserRole(r.Context(), id, *in.Role); err != nil {
|
|
fail(w, r, wrapInput(err))
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "user.role", "user", id.String(), map[string]any{"role": *in.Role})
|
|
}
|
|
if in.Disabled != nil {
|
|
if err := s.deps.Auth.SetUserDisabled(r.Context(), id, *in.Disabled); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "user.disabled", "user", id.String(), map[string]any{"disabled": *in.Disabled})
|
|
}
|
|
u, err := s.deps.Auth.GetUser(r.Context(), id)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toUserOut(u))
|
|
}
|
|
|
|
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
|
p, _ := auth.PrincipalFrom(r.Context())
|
|
writeJSON(w, http.StatusOK, toUserOut(p.User))
|
|
}
|
|
|
|
func (s *Server) mySessions(w http.ResponseWriter, r *http.Request) {
|
|
p, _ := auth.PrincipalFrom(r.Context())
|
|
list, err := s.deps.Auth.ListSessions(r.Context(), p.User.ID)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
type out struct {
|
|
ID ids.ID `json:"id"`
|
|
Current bool `json:"current"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastSeenAt time.Time `json:"last_seen_at"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
IP string `json:"ip"`
|
|
UserAgent string `json:"user_agent"`
|
|
}
|
|
items := make([]out, 0, len(list))
|
|
for _, x := range list {
|
|
items = append(items, out{x.ID, x.ID == p.SessionID, x.CreatedAt, x.LastSeenAt, x.ExpiresAt, x.IP, x.UserAgent})
|
|
}
|
|
writeJSON(w, http.StatusOK, page[out]{Items: items})
|
|
}
|
|
|
|
func (s *Server) mySessionRevoke(w http.ResponseWriter, r *http.Request) {
|
|
p, _ := auth.PrincipalFrom(r.Context())
|
|
id, err := ids.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, r, ValidationError("invalid id"))
|
|
return
|
|
}
|
|
if err := s.deps.Auth.RevokeSession(r.Context(), p.User.ID, id); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "session.revoke", "session", id.String(), nil)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) myTokenCreate(w http.ResponseWriter, r *http.Request) {
|
|
p, _ := auth.PrincipalFrom(r.Context())
|
|
var in struct {
|
|
Name string `json:"name"`
|
|
ExpiresAt *time.Time `json:"expires_at"`
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
if in.Name == "" {
|
|
fail(w, r, ValidationError("name is required"))
|
|
return
|
|
}
|
|
tok, err := s.deps.Auth.CreateAPIToken(r.Context(), p.User.ID, in.Name, in.ExpiresAt)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "token.create", "user", p.User.ID.String(), map[string]any{"name": in.Name})
|
|
writeJSON(w, http.StatusCreated, map[string]string{"token": tok, "note": "store this now; it is not shown again"})
|
|
}
|
|
|
|
func (s *Server) myTokens(w http.ResponseWriter, r *http.Request) {
|
|
p, _ := auth.PrincipalFrom(r.Context())
|
|
list, err := s.deps.Auth.ListAPITokens(r.Context(), p.User.ID)
|
|
if err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": list})
|
|
}
|
|
|
|
func (s *Server) myTokenRevoke(w http.ResponseWriter, r *http.Request) {
|
|
p, _ := auth.PrincipalFrom(r.Context())
|
|
id, err := ids.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, r, ValidationError("invalid id"))
|
|
return
|
|
}
|
|
if err := s.deps.Auth.RevokeAPIToken(r.Context(), p.User.ID, id); err != nil {
|
|
fail(w, r, err)
|
|
return
|
|
}
|
|
s.deps.Audit.Log(r.Context(), "token.revoke", "token", id.String(), nil)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// wrapInput turns validation-style errors from services into 400s. Services
|
|
// return plain errors for bad input; anything wrapping storage sentinels is
|
|
// left alone.
|
|
func wrapInput(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if isStorageErr(err) {
|
|
return err
|
|
}
|
|
return ValidationError(err.Error())
|
|
}
|