changing layout
This commit is contained in:
parent
17b5f751e2
commit
b1c6aa659b
37 changed files with 695 additions and 1018 deletions
|
|
@ -1,172 +1,55 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/example/cms/internal/domain"
|
||||
"github.com/example/cms/internal/identity"
|
||||
"github.com/example/cms/internal/service"
|
||||
"github.com/example/cms/internal/web/mapping"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/example/cms/internal/domain"
|
||||
"github.com/example/cms/internal/security"
|
||||
"github.com/example/cms/internal/service"
|
||||
)
|
||||
|
||||
// Handler serves the CMS API. Reading content is anonymous; every mutation
|
||||
// requires the identity asserted by the upstream SSO proxy.
|
||||
type Handler struct {
|
||||
auth *service.AuthService
|
||||
google identity.GoogleProvider
|
||||
content service.IContentService
|
||||
sso security.ITokenVerifier
|
||||
}
|
||||
|
||||
func NewHandler(a *service.AuthService, google identity.GoogleProvider) *Handler {
|
||||
return &Handler{auth: a, google: google}
|
||||
}
|
||||
func NewHandlerWithContent(a *service.AuthService, google identity.GoogleProvider, content service.IContentService) *Handler {
|
||||
return &Handler{auth: a, google: google, content: content}
|
||||
func NewHandler(content service.IContentService, sso security.ITokenVerifier) *Handler {
|
||||
return &Handler{content: content, sso: sso}
|
||||
}
|
||||
func (h *Handler) Routes(origins string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 200, map[string]string{"status": "ok"}) })
|
||||
mux.HandleFunc("/api/login", h.login)
|
||||
mux.HandleFunc("/api/auth/google", h.googleLogin)
|
||||
mux.HandleFunc("/api/auth/google/callback", h.googleCallback)
|
||||
mux.HandleFunc("/api/auth/session", h.session)
|
||||
mux.HandleFunc("/admin/login", h.adminLogin)
|
||||
mux.HandleFunc("/api/test/protected", h.protected)
|
||||
mux.HandleFunc("/api/protected", h.protected)
|
||||
mux.HandleFunc("/api/test/protected", h.protected)
|
||||
mux.HandleFunc("/api/content", h.contentList)
|
||||
mux.HandleFunc("/api/content/categories", h.contentCategories)
|
||||
mux.HandleFunc("/api/content/badges", h.contentBadges)
|
||||
mux.HandleFunc("/api/content/", h.contentDetail)
|
||||
return cors(mux, origins)
|
||||
}
|
||||
func (h *Handler) googleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if h.google.ClientID == "" {
|
||||
jsonWrite(w, http.StatusServiceUnavailable, map[string]string{"error": "google identity provider is not configured"})
|
||||
return
|
||||
}
|
||||
stateBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(stateBytes); err != nil {
|
||||
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": "unable to create oauth state"})
|
||||
return
|
||||
}
|
||||
state := base64.RawURLEncoding.EncodeToString(stateBytes)
|
||||
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Path: "/", MaxAge: 600})
|
||||
http.Redirect(w, r, h.google.OAuthConfig().AuthCodeURL(state), http.StatusFound)
|
||||
}
|
||||
func (h *Handler) googleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
stateCookie, err := r.Cookie("oauth_state")
|
||||
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
|
||||
http.Error(w, "invalid oauth state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, err := h.google.FetchIdentity(r.Context(), r.URL.Query().Get("code"))
|
||||
if err != nil {
|
||||
log.Printf("Google OAuth callback failed: %v", err)
|
||||
http.Error(w, "google authentication failed; start a new sign-in attempt", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user, token, err := h.auth.Login(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, token, r.TLS != nil)
|
||||
_ = user
|
||||
http.Redirect(w, r, "/test", http.StatusFound)
|
||||
return cors(mux, origins, h.sso.HeaderName())
|
||||
}
|
||||
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearerOrCookie(r)
|
||||
if token == "" {
|
||||
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
|
||||
return
|
||||
}
|
||||
s, err := h.auth.Authenticate(r.Context(), token)
|
||||
if err != nil || s.UserID == nil {
|
||||
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
|
||||
return
|
||||
}
|
||||
u, err := h.auth.UserByID(r.Context(), *s.UserID)
|
||||
if err != nil {
|
||||
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": true, "user": u, "canEdit": h.auth.CanEdit(r.Context(), s)})
|
||||
}
|
||||
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonWrite(w, 405, nil)
|
||||
return
|
||||
}
|
||||
var req mapping.LoginRequest
|
||||
if !decode(r, &req) {
|
||||
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
u, t, e := h.auth.Login(r.Context(), req.Identity())
|
||||
if e != nil {
|
||||
jsonWrite(w, 400, map[string]string{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, 200, mapping.LoginResponse{Token: t, User: u})
|
||||
}
|
||||
func (h *Handler) adminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonWrite(w, 405, nil)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if !decode(r, &req) {
|
||||
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
a, t, e := h.auth.AdminLogin(r.Context(), req.Email)
|
||||
if e != nil {
|
||||
jsonWrite(w, 401, map[string]string{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, 200, mapping.LoginResponse{Token: t, Admin: a})
|
||||
authenticated := h.sso.Authenticated(r)
|
||||
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated})
|
||||
}
|
||||
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
|
||||
t := bearerOrCookie(r)
|
||||
if t == "" {
|
||||
jsonWrite(w, 401, map[string]string{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
s, e := h.auth.Authenticate(r.Context(), t)
|
||||
if e != nil || s.UserID == nil {
|
||||
jsonWrite(w, 401, map[string]string{"error": "invalid session"})
|
||||
if !h.sso.Authenticated(r) {
|
||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, 200, map[string]string{"status": "authenticated"})
|
||||
}
|
||||
func (h *Handler) sessionForRequest(r *http.Request) (domain.Session, bool) {
|
||||
t := bearerOrCookie(r)
|
||||
if t == "" {
|
||||
return domain.Session{}, false
|
||||
}
|
||||
if s, e := h.auth.Authenticate(r.Context(), t); e == nil {
|
||||
return s, true
|
||||
}
|
||||
if s, e := h.auth.AdminAuthenticate(r.Context(), t); e == nil {
|
||||
return s, true
|
||||
}
|
||||
return domain.Session{}, false
|
||||
}
|
||||
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
|
||||
if h.content == nil {
|
||||
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
||||
return
|
||||
}
|
||||
if _, ok := h.sessionForRequest(r); !ok {
|
||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
if r.Method != "GET" {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||
return
|
||||
}
|
||||
|
|
@ -199,10 +82,6 @@ func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) {
|
|||
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
||||
return
|
||||
}
|
||||
if _, ok := h.sessionForRequest(r); !ok {
|
||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||
return
|
||||
|
|
@ -219,10 +98,6 @@ func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) {
|
|||
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
||||
return
|
||||
}
|
||||
if _, ok := h.sessionForRequest(r); !ok {
|
||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||
return
|
||||
|
|
@ -240,11 +115,7 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
|
||||
if r.Method == "GET" {
|
||||
if _, ok := h.sessionForRequest(r); !ok {
|
||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
c, e := h.content.Get(r.Context(), slug)
|
||||
if e != nil {
|
||||
jsonWrite(w, 404, map[string]string{"error": "content not found"})
|
||||
|
|
@ -253,46 +124,27 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
|
|||
jsonWrite(w, 200, c)
|
||||
return
|
||||
}
|
||||
if r.Method != "PUT" {
|
||||
if r.Method != http.MethodPut {
|
||||
jsonWrite(w, 405, nil)
|
||||
return
|
||||
}
|
||||
s, ok := h.sessionForRequest(r)
|
||||
if !ok {
|
||||
if !h.sso.Authenticated(r) {
|
||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
if !h.auth.CanEdit(r.Context(), s) {
|
||||
jsonWrite(w, 403, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
var c domain.Content
|
||||
if !decode(r, &c) {
|
||||
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
c.Slug = slug
|
||||
out, e := h.content.Update(r.Context(), c, s)
|
||||
out, e := h.content.Update(r.Context(), c)
|
||||
if e != nil {
|
||||
jsonWrite(w, 400, map[string]string{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, 200, out)
|
||||
}
|
||||
func bearerOrCookie(r *http.Request) string {
|
||||
header := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if header != "" {
|
||||
return header
|
||||
}
|
||||
cookie, err := r.Cookie("cms_session")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
func setSessionCookie(w http.ResponseWriter, token string, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "cms_session", Value: token, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, Path: "/", MaxAge: 86400})
|
||||
}
|
||||
func decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil }
|
||||
func jsonWrite(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -301,13 +153,17 @@ func jsonWrite(w http.ResponseWriter, status int, v any) {
|
|||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
func cors(next http.Handler, origin string) http.Handler {
|
||||
func cors(next http.Handler, origin, ssoHeader string) http.Handler {
|
||||
allowed := "Content-Type, Authorization"
|
||||
if ssoHeader != "" && !strings.EqualFold(ssoHeader, "Authorization") {
|
||||
allowed += ", " + ssoHeader
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Headers", allowed)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||
if r.Method == "OPTIONS" {
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
81
internal/web/handlers_test.go
Normal file
81
internal/web/handlers_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/example/cms/internal/domain"
|
||||
"github.com/example/cms/internal/security"
|
||||
)
|
||||
|
||||
type fakeContent struct{ updated bool }
|
||||
|
||||
func (f *fakeContent) List(context.Context, int, int, string, string, []string) ([]domain.Content, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeContent) ListCategories(context.Context) ([]domain.Category, error) { return nil, nil }
|
||||
func (f *fakeContent) ListBadges(context.Context) ([]domain.Badge, error) { return nil, nil }
|
||||
func (f *fakeContent) Get(context.Context, string) (domain.Content, error) {
|
||||
return domain.Content{Slug: "post", Title: "Post", Body: "body"}, nil
|
||||
}
|
||||
func (f *fakeContent) Update(_ context.Context, c domain.Content) (domain.Content, error) {
|
||||
f.updated = true
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func newTestRoutes(content *fakeContent) http.Handler {
|
||||
return NewHandler(content, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173")
|
||||
}
|
||||
|
||||
func TestAnonymousCanReadButNotEdit(t *testing.T) {
|
||||
content := &fakeContent{}
|
||||
routes := newTestRoutes(content)
|
||||
|
||||
get := httptest.NewRecorder()
|
||||
routes.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/api/content/post", nil))
|
||||
if get.Code != http.StatusOK {
|
||||
t.Fatalf("anonymous read status = %d, want %d", get.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
put := httptest.NewRecorder()
|
||||
routes.ServeHTTP(put, httptest.NewRequest(http.MethodPut, "/api/content/post", strings.NewReader(`{"title":"T","body":"B"}`)))
|
||||
if put.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous update status = %d, want %d", put.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if content.updated {
|
||||
t.Fatal("anonymous request must not reach the content service")
|
||||
}
|
||||
|
||||
session := httptest.NewRecorder()
|
||||
routes.ServeHTTP(session, httptest.NewRequest(http.MethodGet, "/api/auth/session", nil))
|
||||
if !strings.Contains(session.Body.String(), `"authenticated":false`) {
|
||||
t.Fatalf("anonymous session = %s", session.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyIdentityCanEdit(t *testing.T) {
|
||||
content := &fakeContent{}
|
||||
routes := newTestRoutes(content)
|
||||
|
||||
put := httptest.NewRequest(http.MethodPut, "/api/content/post", strings.NewReader(`{"title":"T","body":"B"}`))
|
||||
put.Header.Set("X-SSO-Token", "owner-token")
|
||||
putRec := httptest.NewRecorder()
|
||||
routes.ServeHTTP(putRec, put)
|
||||
if putRec.Code != http.StatusOK {
|
||||
t.Fatalf("owner update status = %d, body = %s", putRec.Code, putRec.Body.String())
|
||||
}
|
||||
if !content.updated {
|
||||
t.Fatal("owner request must reach the content service")
|
||||
}
|
||||
|
||||
session := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
|
||||
session.Header.Set("X-SSO-Token", "owner-token")
|
||||
sessionRec := httptest.NewRecorder()
|
||||
routes.ServeHTTP(sessionRec, session)
|
||||
if !strings.Contains(sessionRec.Body.String(), `"canEdit":true`) {
|
||||
t.Fatalf("owner session = %s", sessionRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package mapping
|
||||
|
||||
import "github.com/example/cms/internal/identity"
|
||||
|
||||
type LoginRequest struct{ Subject, Email, Name string }
|
||||
|
||||
func (r LoginRequest) Identity() identity.Identity {
|
||||
return identity.Identity{Subject: r.Subject, Email: r.Email, Name: r.Name}
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User any `json:"user,omitempty"`
|
||||
Admin any `json:"admin,omitempty"`
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package mapping
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoginRequestIdentity(t *testing.T) {
|
||||
i := (LoginRequest{Subject: "s", Email: "e"}).Identity()
|
||||
if i.Subject != "s" || i.Email != "e" {
|
||||
t.Fatal("identity mapping failed")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue