Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
- 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>
228 lines
6.5 KiB
Go
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{})
|
|
}
|