feat(coordinator): UI login/register via userservice (cookie session)
When JWT_SECRET + USERSERVICE_URL are set, the operator UI authenticates through userservice login/registration instead of the static UI_AUTH_TOKEN: - /ui/login, /ui/register, /ui/logout pages proxy to the userservice - successful login stores the JWT in an httpOnly, /ui-scoped cookie - withUISession verifies the cookie locally and stamps the requester - unset -> falls back to basic auth, so the team's existing flow is unchanged Tests cover the session gate, cookie set/clear, and the login/register proxy.
This commit is contained in:
@@ -110,7 +110,7 @@ func run() error {
|
||||
|
||||
// pool.Ping backs /health: readiness means the database answers, not just
|
||||
// that the process is alive.
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, pool.Ping)
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, pool.Ping)
|
||||
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
|
||||
|
||||
// Shutdown order matters, and defers alone cannot express it (they run
|
||||
|
||||
@@ -32,6 +32,11 @@ type Config struct {
|
||||
// user-JWT auth entirely — the pre-userservice behaviour. Must match the
|
||||
// userservice's JWT_SECRET.
|
||||
JWTSecret string
|
||||
// Base URL of the userservice, e.g. http://userservice:8081. When set
|
||||
// together with JWTSecret, the operator UI authenticates via userservice
|
||||
// login/registration (cookie session) instead of the static UI_AUTH_TOKEN
|
||||
// basic auth. Empty keeps the basic-auth UI.
|
||||
UserserviceURL string
|
||||
|
||||
// Minimum log level: debug, info, warn, error.
|
||||
LogLevel string
|
||||
@@ -87,6 +92,7 @@ func LoadConfig() (Config, error) {
|
||||
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
|
||||
UIToken: os.Getenv("UI_AUTH_TOKEN"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
UserserviceURL: os.Getenv("USERSERVICE_URL"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
LogFile: os.Getenv("LOG_FILE"),
|
||||
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||
@@ -44,13 +45,18 @@ type Server struct {
|
||||
// verifier validates userservice JWTs. nil disables user-JWT auth, leaving
|
||||
// only the shared service token — the pre-userservice behaviour.
|
||||
verifier *tokenpkg.Verifier
|
||||
// userserviceURL is the base URL the UI proxies login/registration to. Empty
|
||||
// keeps the static basic-auth UI.
|
||||
userserviceURL string
|
||||
// httpClient makes the login/register calls to the userservice.
|
||||
httpClient *http.Client
|
||||
// ready probes downstream dependencies (the database) for /health. Kept as
|
||||
// a func so the transport layer never imports pgx.
|
||||
ready func(context.Context) error
|
||||
}
|
||||
|
||||
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
|
||||
maxUploadBytes int64, jwtSecret string, ready func(context.Context) error) *Server {
|
||||
maxUploadBytes int64, jwtSecret, userserviceURL string, ready func(context.Context) error) *Server {
|
||||
return &Server{
|
||||
uc: uc,
|
||||
log: log,
|
||||
@@ -58,10 +64,19 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
maxUploadBytes: maxUploadBytes,
|
||||
verifier: tokenpkg.NewVerifier(jwtSecret),
|
||||
userserviceURL: strings.TrimRight(userserviceURL, "/"),
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
ready: ready,
|
||||
}
|
||||
}
|
||||
|
||||
// uiSessionMode reports whether the operator UI authenticates via userservice
|
||||
// login (cookie session) rather than the static basic-auth token. It needs both
|
||||
// a verifier (to check the JWT locally) and a userservice URL (to issue it).
|
||||
func (s *Server) uiSessionMode() bool {
|
||||
return s.verifier != nil && s.userserviceURL != ""
|
||||
}
|
||||
|
||||
// Handler builds the router. Go 1.22's ServeMux matches on method and path
|
||||
// wildcards, so no third-party router is needed.
|
||||
func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||
@@ -82,19 +97,51 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
if len(uiToken) > 0 && uiToken[0] != "" && s.uc.Dashboard != nil {
|
||||
|
||||
hasBasicAuth := len(uiToken) > 0 && uiToken[0] != ""
|
||||
if s.uc.Dashboard != nil && (s.uiSessionMode() || hasBasicAuth) {
|
||||
ui := http.NewServeMux()
|
||||
ui.HandleFunc("GET /ui", s.handleUIHome)
|
||||
ui.HandleFunc("GET /ui/jobs/new", s.handleUINewJob)
|
||||
ui.HandleFunc("GET /ui/jobs/{job_id}", s.handleUIJob)
|
||||
ui.HandleFunc("GET /ui/api/overview", s.handleUIOverviewJSON)
|
||||
ui.HandleFunc("GET /ui/api/jobs/{job_id}", s.handleUIJobJSON)
|
||||
ui.HandleFunc("POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob)
|
||||
ui.HandleFunc("POST /ui/api/jobs/upload", s.handleUploadDataset)
|
||||
ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload)
|
||||
ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview)
|
||||
mux.Handle("/ui", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
|
||||
mux.Handle("/ui/", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
|
||||
|
||||
// The operator application routes, all requiring an authenticated caller.
|
||||
app := []struct {
|
||||
pattern string
|
||||
handler http.HandlerFunc
|
||||
}{
|
||||
{"GET /ui", s.handleUIHome},
|
||||
{"GET /ui/jobs/new", s.handleUINewJob},
|
||||
{"GET /ui/jobs/{job_id}", s.handleUIJob},
|
||||
{"GET /ui/api/overview", s.handleUIOverviewJSON},
|
||||
{"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON},
|
||||
{"POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob},
|
||||
{"POST /ui/api/jobs/upload", s.handleUploadDataset},
|
||||
{"GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload},
|
||||
{"GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview},
|
||||
}
|
||||
|
||||
if s.uiSessionMode() {
|
||||
// Public auth pages — reachable without a session so a user can log in.
|
||||
ui.HandleFunc("GET /ui/login", s.handleUILoginForm)
|
||||
ui.HandleFunc("POST /ui/login", s.handleUILogin)
|
||||
ui.HandleFunc("GET /ui/register", s.handleUIRegisterForm)
|
||||
ui.HandleFunc("POST /ui/register", s.handleUIRegister)
|
||||
ui.HandleFunc("POST /ui/logout", s.handleUILogout)
|
||||
gate := withUISession(s.verifier)
|
||||
for _, rt := range app {
|
||||
ui.Handle(rt.pattern, gate(rt.handler))
|
||||
}
|
||||
} else {
|
||||
for _, rt := range app {
|
||||
ui.HandleFunc(rt.pattern, rt.handler)
|
||||
}
|
||||
}
|
||||
|
||||
common := []func(http.Handler) http.Handler{withRequestID, withAccessLog(s.log)}
|
||||
if !s.uiSessionMode() {
|
||||
common = append(common, withBasicAuth(uiToken[0]))
|
||||
}
|
||||
common = append(common, withSameOrigin)
|
||||
mux.Handle("/ui", chain(ui, common...))
|
||||
mux.Handle("/ui/", chain(ui, common...))
|
||||
} else {
|
||||
// More specific than the protected catch-all: UI absence is not an auth
|
||||
// failure and does not disclose that a UI feature is configured elsewhere.
|
||||
|
||||
@@ -68,7 +68,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
|
||||
if err != nil {
|
||||
t.Fatalf("register test worker: %v", err)
|
||||
}
|
||||
srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", ready)
|
||||
srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", "", ready)
|
||||
ts := httptest.NewServer(srv.Handler(token, configuredUIToken))
|
||||
t.Cleanup(ts.Close)
|
||||
return &env{ts: ts, blobs: blobs, workerID: worker.ID.String()}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "login.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · SciMesh</title>
|
||||
<style>
|
||||
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}a{color:#94bdff}.card{width:min(92vw,380px);border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:28px}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0 0 18px;color:#f4f8ff;font-size:1.7rem;letter-spacing:-.03em}label{display:block;margin:14px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.button{display:block;width:100%;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.error{margin:14px 0 0;color:#ffacba}.alt{margin:18px 0 0;color:#9fb3cf;font-size:.92rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<p class="eyebrow">SciMesh</p>
|
||||
<h1>Sign in</h1>
|
||||
<form method="post" action="/ui/login">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<button class="button" type="submit">Sign in</button>
|
||||
</form>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<p class="alt">No account? <a href="/ui/register">Register</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "register.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Register · SciMesh</title>
|
||||
<style>
|
||||
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}a{color:#94bdff}.card{width:min(92vw,380px);border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:28px}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0 0 18px;color:#f4f8ff;font-size:1.7rem;letter-spacing:-.03em}label{display:block;margin:14px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.hint{margin:5px 0 0;color:#92a9c6;font-size:.85rem}.button{display:block;width:100%;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.error{margin:14px 0 0;color:#ffacba}.alt{margin:18px 0 0;color:#9fb3cf;font-size:.92rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<p class="eyebrow">SciMesh</p>
|
||||
<h1>Create account</h1>
|
||||
<form method="post" action="/ui/register">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="72" required>
|
||||
<p class="hint">At least 8 characters.</p>
|
||||
<button class="button" type="submit">Register</button>
|
||||
</form>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<p class="alt">Already have an account? <a href="/ui/login">Sign in</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,171 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||
)
|
||||
|
||||
// sessionCookie holds the userservice JWT for the operator UI. It is httpOnly so
|
||||
// page scripts cannot read the token, and scoped to /ui so it never rides along
|
||||
// with worker API calls.
|
||||
const sessionCookie = "scimesh_session"
|
||||
|
||||
// withUISession gates the operator UI on a valid userservice session cookie.
|
||||
// A missing or invalid token redirects to the login page rather than returning
|
||||
// 401, because the caller here is a browser, not an API client. On success it
|
||||
// stamps the requester so downstream handlers can scope views by owner.
|
||||
func withUISession(v tokenVerifier) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
claims, err := v.Verify(c.Value)
|
||||
if err != nil {
|
||||
// Expired or tampered: drop the stale cookie and re-authenticate.
|
||||
clearSessionCookie(w, r)
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
ctx := authctx.With(r.Context(), authctx.Requester{
|
||||
UserID: claims.UserID,
|
||||
Role: claims.Role,
|
||||
Verified: claims.Verified,
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tokenVerifier is satisfied by *token.Verifier; taking an interface keeps the
|
||||
// UI auth testable with a stub.
|
||||
type tokenVerifier interface {
|
||||
Verify(raw string) (tokenpkg.Claims, error)
|
||||
}
|
||||
|
||||
func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")})
|
||||
}
|
||||
|
||||
func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderUI(w, "register.html", map[string]any{"Error": r.URL.Query().Get("error")})
|
||||
}
|
||||
|
||||
// handleUILogin exchanges the submitted credentials for a userservice token and
|
||||
// stores it in the session cookie. The coordinator never sees or stores the
|
||||
// password beyond forwarding it once.
|
||||
func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) {
|
||||
email, password := r.FormValue("email"), r.FormValue("password")
|
||||
|
||||
status, body, err := s.callUserservice(r.Context(), "/login", email, password)
|
||||
if err != nil {
|
||||
s.log.Error("userservice login call", "err", err)
|
||||
http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
http.Redirect(w, r, "/ui/login?error=invalid+email+or+password", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil || resp.Token == "" {
|
||||
http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, resp.Token)
|
||||
http.Redirect(w, r, "/ui", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUIRegister creates an account through the userservice, then sends the
|
||||
// user to the login page. The new account is a plain user until an admin
|
||||
// promotes or verifies it.
|
||||
func (s *Server) handleUIRegister(w http.ResponseWriter, r *http.Request) {
|
||||
email, password := r.FormValue("email"), r.FormValue("password")
|
||||
|
||||
status, _, err := s.callUserservice(r.Context(), "/register", email, password)
|
||||
if err != nil {
|
||||
s.log.Error("userservice register call", "err", err)
|
||||
http.Redirect(w, r, "/ui/register?error=service+unavailable", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
switch status {
|
||||
case http.StatusCreated:
|
||||
http.Redirect(w, r, "/ui/login?error=registered,+please+log+in", http.StatusSeeOther)
|
||||
case http.StatusConflict:
|
||||
http.Redirect(w, r, "/ui/register?error=email+already+registered", http.StatusSeeOther)
|
||||
default:
|
||||
http.Redirect(w, r, "/ui/register?error=invalid+email+or+password", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUILogout(w http.ResponseWriter, r *http.Request) {
|
||||
clearSessionCookie(w, r)
|
||||
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// callUserservice POSTs credentials to the userservice and returns its status
|
||||
// and body. It is the only runtime dependency on the userservice — login and
|
||||
// registration; token verification stays local.
|
||||
func (s *Server) callUserservice(ctx context.Context, path, email, password string) (int, []byte, error) {
|
||||
payload, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.userserviceURL+path, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Cap the response; login/register bodies are tiny.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||
// Secure is set under TLS; a local demo runs plain HTTP, where forcing
|
||||
// Secure would stop the browser from ever sending the cookie back.
|
||||
http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/ui",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/ui",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||
)
|
||||
|
||||
// newReq builds a request carrying a context, which http.NewRequestWithContext
|
||||
// provides on go1.22 (httptest.NewRequestWithContext needs go1.23).
|
||||
func newReq(method, target string, body io.Reader) *http.Request {
|
||||
req, err := http.NewRequestWithContext(context.Background(), method, target, body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
type stubVerifier struct {
|
||||
claims tokenpkg.Claims
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubVerifier) Verify(string) (tokenpkg.Claims, error) { return s.claims, s.err }
|
||||
|
||||
func TestWithUISessionRedirectsWithoutCookie(t *testing.T) {
|
||||
h := withUISession(stubVerifier{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("handler must not run without a session")
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, newReq(http.MethodGet, "/ui", nil))
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("got %d, want 303", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/ui/login" {
|
||||
t.Errorf("redirect = %q, want /ui/login", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithUISessionAcceptsValidCookieAndStampsRequester(t *testing.T) {
|
||||
id := uuid.New()
|
||||
verifier := stubVerifier{claims: tokenpkg.Claims{UserID: id, Role: "admin", Verified: true}}
|
||||
|
||||
var gotReq authctx.Requester
|
||||
var ok bool
|
||||
h := withUISession(verifier)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
gotReq, ok = authctx.From(r.Context())
|
||||
}))
|
||||
|
||||
req := newReq(http.MethodGet, "/ui", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "valid.jwt"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if !ok || gotReq.UserID != id || gotReq.Role != "admin" || !gotReq.Verified {
|
||||
t.Errorf("requester = %+v (ok=%v), want id=%v admin verified", gotReq, ok, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithUISessionClearsInvalidCookie(t *testing.T) {
|
||||
h := withUISession(stubVerifier{err: errors.New("expired")})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("handler must not run with an invalid token")
|
||||
}))
|
||||
req := newReq(http.MethodGet, "/ui", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "stale.jwt"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("got %d, want 303", rec.Code)
|
||||
}
|
||||
if c := rec.Result().Cookies(); len(c) == 0 || c[0].MaxAge >= 0 {
|
||||
t.Error("stale cookie must be cleared (MaxAge < 0)")
|
||||
}
|
||||
}
|
||||
|
||||
// newLoginServer builds a Server whose userservice calls hit stub.
|
||||
func newLoginServer(stub *httptest.Server) *Server {
|
||||
return &Server{
|
||||
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
userserviceURL: strings.TrimRight(stub.URL, "/"),
|
||||
httpClient: stub.Client(),
|
||||
}
|
||||
}
|
||||
|
||||
func postForm(path string, form url.Values) *http.Request {
|
||||
req := newReq(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return req
|
||||
}
|
||||
|
||||
func TestHandleUILoginSetsCookieOnSuccess(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/login" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"token":"issued.jwt.here"}`))
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"password123"}}))
|
||||
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
|
||||
t.Fatalf("got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) == 0 || cookies[0].Name != sessionCookie || cookies[0].Value != "issued.jwt.here" {
|
||||
t.Errorf("session cookie not set: %+v", cookies)
|
||||
}
|
||||
if !cookies[0].HttpOnly {
|
||||
t.Error("session cookie must be httpOnly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUILoginRejectsBadCredentials(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"wrong"}}))
|
||||
|
||||
if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/ui/login?error=") {
|
||||
t.Fatalf("got %d -> %q, want 303 -> /ui/login?error=", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if len(rec.Result().Cookies()) != 0 {
|
||||
t.Error("no cookie must be set on failed login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUIRegisterConflict(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIRegister(rec, postForm("/ui/register", url.Values{"email": {"dup@b.com"}, "password": {"password123"}}))
|
||||
|
||||
if got := rec.Header().Get("Location"); !strings.Contains(got, "already+registered") {
|
||||
t.Errorf("register conflict redirect = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUILogoutClearsCookie(t *testing.T) {
|
||||
s := &Server{log: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUILogout(rec, newReq(http.MethodPost, "/ui/logout", nil))
|
||||
|
||||
if rec.Header().Get("Location") != "/ui/login" {
|
||||
t.Errorf("logout redirect = %q", rec.Header().Get("Location"))
|
||||
}
|
||||
c := rec.Result().Cookies()
|
||||
if len(c) == 0 || c[0].MaxAge >= 0 {
|
||||
t.Error("logout must clear the session cookie")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user