Files
DockMV/internal/api/auth.go
T
kawaandClaude Sonnet 5 8d20d9be84
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
Feature 1: Local accounts, replacing shared token
- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup
- Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access
- Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal)
- Login rate limiting (exponential backoff 1s–30s cap) per IP
- Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping)
- Every account can manage every other account (no roles in v1)
- Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check
- Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed
- Account tab for personal token management; sign-out button in topbar
- Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated

Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:13:54 +02:00

228 lines
6.5 KiB
Go

package api
import (
"context"
"net"
"net/http"
"strings"
"github.com/arescom/dockmv/internal/session"
"github.com/arescom/dockmv/internal/store"
)
// Identity is the authenticated caller, attached to the request context by
// auth(). Every handler reached past auth() has one.
type Identity struct {
UserID string
Username string
}
type identityKey struct{}
func identityFrom(r *http.Request) Identity {
id, _ := r.Context().Value(identityKey{}).(Identity)
return id
}
// publicPaths need no authentication: they are how a fresh install creates
// its first account, how a session is established or torn down, and the
// health check the UI's own bootstrap (and Docker's HEALTHCHECK) depend on.
var publicPaths = map[string]bool{
"/api/setup": true,
"/api/login": true,
"/api/logout": true,
"/api/me": true,
"/api/health": true,
}
// auth gates every /api/* request behind a session cookie or a personal API
// token.
//
// Static assets are served without it: see the comment on spaHandler. Nothing
// sensitive lives in the bundle; every piece of data is behind /api.
func (s *Server) auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") || publicPaths[r.URL.Path] {
next.ServeHTTP(w, r)
return
}
if c, err := r.Cookie(session.CookieName); err == nil {
if sess, ok := s.sessions.Get(c.Value); ok {
s.serveAs(w, r, next, Identity{UserID: sess.UserID, Username: sess.Username})
return
}
}
if got := bearerToken(r); got != "" {
if tok, err := s.tokens.Authenticate(got); err == nil {
if usr, err := s.users.Get(tok.UserID); err == nil {
s.serveAs(w, r, next, Identity{UserID: usr.ID, Username: usr.Username})
return
}
}
}
writeError(w, http.StatusUnauthorized, "authentication required")
})
}
func (s *Server) serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, id Identity) {
ctx := context.WithValue(r.Context(), identityKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
}
func bearerToken(r *http.Request) string {
if got := r.Header.Get("X-Auth-Token"); got != "" {
return got
}
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return ""
}
// isHTTPS reports whether the request reached us over TLS, directly or
// through a reverse proxy that sets the standard forwarded-proto header. It
// decides the session cookie's Secure attribute.
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, sess session.Session) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: sess.ID,
Path: "/",
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
Expires: sess.ExpiresAt,
})
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// remoteIP strips the port from RemoteAddr, for the login rate limiter.
func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
type setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// handleSetup creates the first account. It only succeeds while no account
// exists yet; main.go also refuses to bind non-loopback until then, so this
// endpoint being open to anyone is not a standing risk.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.NeedsSetup() {
writeError(w, http.StatusConflict, "setup has already been completed")
return
}
var req setupRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Create(req.Username, req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
s.completeLogin(w, r, usr)
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := remoteIP(r)
if !s.logins.Allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many attempts; try again shortly")
return
}
var req loginRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Verify(req.Username, req.Password)
if err != nil {
s.logins.Fail(ip)
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
s.logins.Reset(ip)
_ = s.users.TouchLastLogin(usr.ID)
s.completeLogin(w, r, usr)
}
func (s *Server) completeLogin(w http.ResponseWriter, r *http.Request, usr store.User) {
sess, err := s.sessions.Create(usr.ID, usr.Username)
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
setSessionCookie(w, r, *sess)
writeJSON(w, http.StatusOK, meResponse{ID: usr.ID, Username: usr.Username, Authenticated: true})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(session.CookieName); err == nil {
s.sessions.Delete(c.Value)
}
clearSessionCookie(w, r)
w.WriteHeader(http.StatusNoContent)
}
// meResponse is also what /api/setup and /api/login return on success.
type meResponse struct {
ID string `json:"id,omitempty"`
Username string `json:"username,omitempty"`
Authenticated bool `json:"authenticated"`
NeedsSetup bool `json:"needsSetup,omitempty"`
}
// handleMe reports the caller's identity, or that setup is still needed. It
// is public so the UI can decide, on load, whether to show the setup screen,
// a login form, or the app itself.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if s.NeedsSetup() {
writeJSON(w, http.StatusOK, meResponse{NeedsSetup: true})
return
}
if c, err := r.Cookie(session.CookieName); err == nil {
if sess, ok := s.sessions.Get(c.Value); ok {
writeJSON(w, http.StatusOK, meResponse{ID: sess.UserID, Username: sess.Username, Authenticated: true})
return
}
}
if got := bearerToken(r); got != "" {
if tok, err := s.tokens.Authenticate(got); err == nil {
if usr, err := s.users.Get(tok.UserID); err == nil {
writeJSON(w, http.StatusOK, meResponse{ID: usr.ID, Username: usr.Username, Authenticated: true})
return
}
}
}
writeJSON(w, http.StatusOK, meResponse{})
}