fix installer
This commit is contained in:
parent
74b3a382ab
commit
46030a5b14
7 changed files with 113 additions and 5 deletions
|
|
@ -2,4 +2,5 @@
|
||||||
.env
|
.env
|
||||||
bin
|
bin
|
||||||
coverage.out
|
coverage.out
|
||||||
|
google_secrets.json
|
||||||
|
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,4 +3,5 @@
|
||||||
.vscode/
|
.vscode/
|
||||||
bin/
|
bin/
|
||||||
coverage.out
|
coverage.out
|
||||||
|
google_secrets.json
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ type authorizationResponse struct {
|
||||||
Principal
|
Principal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const XTenantId = "X-Tenant-Id"
|
||||||
|
|
||||||
type contextKey struct{}
|
type contextKey struct{}
|
||||||
|
|
||||||
func New(cfg Config) (*Client, error) {
|
func New(cfg Config) (*Client, error) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ services:
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- go_loose_network
|
||||||
go-loose:
|
go-loose:
|
||||||
build: .
|
build: .
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -29,13 +31,17 @@ services:
|
||||||
GO_LOOSE_LOCAL_LOGIN: "true"
|
GO_LOOSE_LOCAL_LOGIN: "true"
|
||||||
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true"
|
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true"
|
||||||
ports:
|
ports:
|
||||||
- "${GO_LOOSE_PORT:-8080}:8080"
|
- "${GO_LOOSE_PORT:-8081}:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "/go-loose", "-healthcheck"]
|
test: ["CMD", "/go-loose", "-healthcheck"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- go_loose_network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data2:
|
postgres-data2:
|
||||||
|
networks:
|
||||||
|
go_loose_network:
|
||||||
|
|
@ -24,7 +24,7 @@ func (s *Server) installPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "installer unavailable", http.StatusInternalServerError)
|
http.Error(w, "installer unavailable", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
csrfToken, err := s.installCSRFToken(w)
|
csrfToken, err := s.installCSRFToken(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "could not prepare installer", http.StatusInternalServerError)
|
http.Error(w, "could not prepare installer", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -118,16 +118,27 @@ func buildDemoUsers() ([]store.DemoUser, error) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) installCSRFToken(w http.ResponseWriter) (string, error) {
|
func (s *Server) installCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||||
|
if cookie, err := r.Cookie("go_loose_install_csrf"); err == nil {
|
||||||
|
payload, valid := s.sessions.VerifySigned(cookie.Value)
|
||||||
|
if valid && strings.HasPrefix(payload, "install|") {
|
||||||
|
s.setInstallCSRFCookie(w, cookie.Value)
|
||||||
|
return cookie.Value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
state, err := s.sessions.NewState()
|
state, err := s.sessions.NewState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
token := s.sessions.SignedState("install|" + state)
|
token := s.sessions.SignedState("install|" + state)
|
||||||
|
s.setInstallCSRFCookie(w, token)
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setInstallCSRFCookie(w http.ResponseWriter, token string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "go_loose_install_csrf", Value: token, Path: "/install",
|
Name: "go_loose_install_csrf", Value: token, Path: "/install",
|
||||||
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
|
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
|
||||||
SameSite: http.SameSiteStrictMode, MaxAge: 900,
|
SameSite: http.SameSiteStrictMode, MaxAge: 900,
|
||||||
})
|
})
|
||||||
return token, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
85
internal/server/installer_test.go
Normal file
85
internal/server/installer_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/internal/config"
|
||||||
|
"github.com/danyel/go-loose/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newInstallerTestServer() *Server {
|
||||||
|
secret := strings.Repeat("s", 32)
|
||||||
|
return &Server{
|
||||||
|
cfg: config.Config{BaseURL: "http://localhost:8080", SessionSecret: secret},
|
||||||
|
sessions: session.New(secret, false),
|
||||||
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFaviconDoesNotRedirectToInstaller(t *testing.T) {
|
||||||
|
server := newInstallerTestServer()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("favicon status = %d, want 404; location %q", recorder.Code, recorder.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
if recorder.Header().Get("Set-Cookie") != "" {
|
||||||
|
t.Fatal("favicon response set a cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUninstalledRootRedirectsToInstaller(t *testing.T) {
|
||||||
|
server := newInstallerTestServer()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusSeeOther || recorder.Header().Get("Location") != "/install" {
|
||||||
|
t.Fatalf("status %d location %q", recorder.Code, recorder.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerCSRFTokenSurvivesRepeatedPageLoad(t *testing.T) {
|
||||||
|
server := newInstallerTestServer()
|
||||||
|
first := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/install", nil))
|
||||||
|
if first.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first install page status = %d", first.Code)
|
||||||
|
}
|
||||||
|
cookie := first.Result().Cookies()[0]
|
||||||
|
token := csrfTokenFromInstaller(t, first.Body.String())
|
||||||
|
if cookie.Value != token {
|
||||||
|
t.Fatalf("cookie %q does not match form token %q", cookie.Value, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondRequest := httptest.NewRequest(http.MethodGet, "/install", nil)
|
||||||
|
secondRequest.AddCookie(cookie)
|
||||||
|
second := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(second, secondRequest)
|
||||||
|
if got := csrfTokenFromInstaller(t, second.Body.String()); got != token {
|
||||||
|
t.Fatalf("second page rotated CSRF token to %q", got)
|
||||||
|
}
|
||||||
|
if second.Result().Cookies()[0].Value != token {
|
||||||
|
t.Fatal("second response rotated CSRF cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func csrfTokenFromInstaller(t *testing.T, page string) string {
|
||||||
|
t.Helper()
|
||||||
|
const marker = `name="csrf_token" value="`
|
||||||
|
start := strings.Index(page, marker)
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatal("installer page has no csrf token")
|
||||||
|
}
|
||||||
|
start += len(marker)
|
||||||
|
end := strings.Index(page[start:], `"`)
|
||||||
|
if end < 0 {
|
||||||
|
t.Fatal("installer csrf token is unterminated")
|
||||||
|
}
|
||||||
|
return page[start : start+end]
|
||||||
|
}
|
||||||
|
|
@ -114,7 +114,9 @@ func (s *Server) Handler() http.Handler {
|
||||||
mux.HandleFunc("POST /connect/logout", s.clientLogout)
|
mux.HandleFunc("POST /connect/logout", s.clientLogout)
|
||||||
mux.HandleFunc("GET /client/login", s.clientLoginPage)
|
mux.HandleFunc("GET /client/login", s.clientLoginPage)
|
||||||
|
|
||||||
mux.Handle("GET /", s.requireSession(http.HandlerFunc(s.index)))
|
// {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a
|
||||||
|
// browser favicon request would redirect to /install and rotate its CSRF cookie.
|
||||||
|
mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index)))
|
||||||
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
|
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
|
||||||
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
|
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
|
||||||
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
|
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue