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{}) }