changing layout
This commit is contained in:
parent
17b5f751e2
commit
b1c6aa659b
37 changed files with 695 additions and 1018 deletions
|
|
@ -1,72 +1,30 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config carries the runtime settings for the CMS. The application database is
|
||||
// the only datastore: identities and sessions belong to the upstream SSO proxy.
|
||||
type Config struct {
|
||||
HTTPAddr, AdminDatabaseURL, ApplicationDatabaseURL, AllowedOrigins, GoogleClientID, GoogleClientSecret, GoogleRedirectURL string
|
||||
SessionTTL time.Duration
|
||||
HTTPAddr, ApplicationDatabaseURL string
|
||||
AllowedOrigins, SSOHeader, SSOToken, SSOCookie string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
clientID, clientSecret, err := googleCredentials()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
func Load() Config {
|
||||
return Config{
|
||||
HTTPAddr: get("CMS_HTTP_ADDR", ":8080"),
|
||||
AdminDatabaseURL: get("CMS_ADMIN_DATABASE_URL", "./cms-admin.db"),
|
||||
ApplicationDatabaseURL: get("CMS_APPLICATION_DATABASE_URL", "host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable"),
|
||||
AllowedOrigins: get("CMS_ALLOWED_ORIGINS", "http://localhost:5173"),
|
||||
SessionTTL: duration("CMS_SESSION_TTL", 24*time.Hour),
|
||||
GoogleClientID: get("CMS_GOOGLE_CLIENT_ID", clientID),
|
||||
GoogleClientSecret: get("CMS_GOOGLE_CLIENT_SECRET", clientSecret),
|
||||
GoogleRedirectURL: get("CMS_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/auth/google/callback"),
|
||||
}, nil
|
||||
SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"),
|
||||
SSOToken: get("CMS_SSO_TOKEN", ""),
|
||||
SSOCookie: get("CMS_SSO_COOKIE", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func googleCredentials() (string, string, error) {
|
||||
path := os.Getenv("CMS_GOOGLE_CLIENT_FILE")
|
||||
if path == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read Google OAuth client file: %w", err)
|
||||
}
|
||||
var credentials struct {
|
||||
Web struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
} `json:"web"`
|
||||
Installed struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
} `json:"installed"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &credentials); err != nil {
|
||||
return "", "", fmt.Errorf("parse Google OAuth client file: %w", err)
|
||||
}
|
||||
if credentials.Web.ClientID != "" || credentials.Web.ClientSecret != "" {
|
||||
return credentials.Web.ClientID, credentials.Web.ClientSecret, nil
|
||||
}
|
||||
return credentials.Installed.ClientID, credentials.Installed.ClientSecret, nil
|
||||
}
|
||||
func get(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func duration(k string, d time.Duration) time.Duration {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
if n, e := time.ParseDuration(v); e == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,27 +2,6 @@ package domain
|
|||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID uint
|
||||
Email, Name string
|
||||
Provider, ProviderSubject string
|
||||
Role string
|
||||
Editor bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
type Admin struct {
|
||||
ID uint
|
||||
Email, Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
type Session struct {
|
||||
ID uint
|
||||
Token string
|
||||
UserID *uint
|
||||
AdminID *uint
|
||||
ExpiresAt, CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
ID uint
|
||||
Slug, Title, Summary, Body string
|
||||
|
|
@ -32,7 +11,6 @@ type Content struct {
|
|||
Category string
|
||||
Badges []string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
CreatedBy, UpdatedBy uint
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
|
|
@ -47,11 +25,9 @@ type Badge struct {
|
|||
}
|
||||
|
||||
type ContentHistory struct {
|
||||
ID uint
|
||||
ContentID uint
|
||||
Operation string
|
||||
ActorID *uint
|
||||
ActorAdminID *uint
|
||||
CreatedAt time.Time
|
||||
Snapshot string
|
||||
ID uint
|
||||
ContentID uint
|
||||
Operation string
|
||||
CreatedAt time.Time
|
||||
Snapshot string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Identity struct{ Subject, Email, Name string }
|
||||
type IProvider interface {
|
||||
Name() string
|
||||
Authenticate(context.Context, string) (Identity, error)
|
||||
}
|
||||
type GoogleProvider struct{ ClientID, ClientSecret, RedirectURL string }
|
||||
|
||||
func (GoogleProvider) Name() string { return "google" }
|
||||
func (p GoogleProvider) Authenticate(context.Context, string) (Identity, error) {
|
||||
if p.ClientID == "" {
|
||||
return Identity{}, errors.New("google identity provider is not configured")
|
||||
}
|
||||
return Identity{}, errors.New("google authentication requires the OAuth callback")
|
||||
}
|
||||
|
||||
func (p GoogleProvider) OAuthConfig() *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: p.ClientID, ClientSecret: p.ClientSecret, RedirectURL: p.RedirectURL,
|
||||
Endpoint: google.Endpoint, Scopes: []string{"openid", "email", "profile"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p GoogleProvider) FetchIdentity(ctx context.Context, code string) (Identity, error) {
|
||||
if p.ClientID == "" || p.ClientSecret == "" || p.RedirectURL == "" {
|
||||
return Identity{}, errors.New("google identity provider is not configured")
|
||||
}
|
||||
token, err := p.OAuthConfig().Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("exchange authorization code: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://openidconnect.googleapis.com/v1/userinfo", nil)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Identity{}, fmt.Errorf("Google userinfo request failed with status %s", resp.Status)
|
||||
}
|
||||
var profile struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
return Identity{Subject: profile.Subject, Email: profile.Email, Name: profile.Name}, nil
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGoogleProviderRequiresConfiguration(t *testing.T) {
|
||||
_, e := (GoogleProvider{}).Authenticate(context.Background(), "")
|
||||
if e == nil {
|
||||
t.Fatal("expected configuration error")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,28 +2,14 @@ package persistence
|
|||
|
||||
import "github.com/example/cms/internal/domain"
|
||||
|
||||
func UserToDomain(m UserModel) domain.User {
|
||||
return domain.User{ID: m.ID, Email: m.Email, Name: m.Name, Provider: m.Provider, ProviderSubject: m.ProviderSubject, Role: m.Role, Editor: m.Editor, CreatedAt: m.CreatedAt}
|
||||
}
|
||||
func UserFromDomain(d domain.User) UserModel {
|
||||
return UserModel{ID: d.ID, Email: d.Email, Name: d.Name, Provider: d.Provider, ProviderSubject: d.ProviderSubject, Role: d.Role, Editor: d.Editor, CreatedAt: d.CreatedAt}
|
||||
}
|
||||
func ContentToDomain(m ContentModel) domain.Content {
|
||||
badges := make([]string, len(m.Badges))
|
||||
for i := range m.Badges {
|
||||
badges[i] = m.Badges[i].Name
|
||||
}
|
||||
return domain.Content{ID: m.ID, Slug: m.Slug, Title: m.Title, Summary: m.Summary, Body: m.Body, Status: m.Status, Published: m.Published, CategoryID: m.CategoryID, Category: m.Category.Name, Badges: badges, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, CreatedBy: m.CreatedBy, UpdatedBy: m.UpdatedBy}
|
||||
return domain.Content{ID: m.ID, Slug: m.Slug, Title: m.Title, Summary: m.Summary, Body: m.Body, Status: m.Status, Published: m.Published, CategoryID: m.CategoryID, Category: m.Category.Name, Badges: badges, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
|
||||
}
|
||||
|
||||
func ContentFromDomain(d domain.Content) ContentModel {
|
||||
return ContentModel{ID: d.ID, Slug: d.Slug, Title: d.Title, Summary: d.Summary, Body: d.Body, Status: d.Status, Published: d.Published, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, CreatedBy: d.CreatedBy, UpdatedBy: d.UpdatedBy}
|
||||
}
|
||||
func AdminToDomain(m AdminModel) domain.Admin {
|
||||
return domain.Admin{ID: m.ID, Email: m.Email, Name: m.Name, CreatedAt: m.CreatedAt}
|
||||
}
|
||||
func SessionToDomain(m SessionModel) domain.Session {
|
||||
return domain.Session{ID: m.ID, Token: m.Token, UserID: m.UserID, AdminID: m.AdminID, ExpiresAt: m.ExpiresAt, CreatedAt: m.CreatedAt}
|
||||
}
|
||||
func SessionFromDomain(d domain.Session) SessionModel {
|
||||
return SessionModel{ID: d.ID, Token: d.Token, UserID: d.UserID, AdminID: d.AdminID, ExpiresAt: d.ExpiresAt, CreatedAt: d.CreatedAt}
|
||||
return ContentModel{ID: d.ID, Slug: d.Slug, Title: d.Title, Summary: d.Summary, Body: d.Body, Status: d.Status, Published: d.Published, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/example/cms/internal/domain"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUserMappingRoundTrip(t *testing.T) {
|
||||
d := domain.User{ID: 2, Email: "a@b", Name: "A", Provider: "google", ProviderSubject: "s", CreatedAt: time.Now()}
|
||||
if got := UserToDomain(UserFromDomain(d)); got.Email != d.Email || got.ProviderSubject != d.ProviderSubject {
|
||||
t.Fatalf("round trip mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,17 +2,6 @@ package persistence
|
|||
|
||||
import "time"
|
||||
|
||||
type UserModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
Name string
|
||||
Provider string `gorm:"not null"`
|
||||
ProviderSubject string `gorm:"uniqueIndex;not null"`
|
||||
Role string `gorm:"not null;default:''"`
|
||||
Editor bool `gorm:"not null;default:false"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ContentModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Slug string `gorm:"uniqueIndex;not null"`
|
||||
|
|
@ -25,9 +14,10 @@ type ContentModel struct {
|
|||
Category CategoryModel `gorm:"foreignKey:CategoryID"`
|
||||
Badges []BadgeModel `gorm:"many2many:content_badges;joinForeignKey:ContentID;joinReferences:BadgeID"`
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
CreatedBy, UpdatedBy uint
|
||||
}
|
||||
|
||||
func (ContentModel) TableName() string { return "content" }
|
||||
|
||||
type CategoryModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Slug string `gorm:"uniqueIndex;not null"`
|
||||
|
|
@ -43,38 +33,12 @@ type BadgeModel struct {
|
|||
|
||||
func (BadgeModel) TableName() string { return "badges" }
|
||||
|
||||
func (ContentModel) TableName() string { return "content" }
|
||||
|
||||
type ContentHistoryModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ContentID uint `gorm:"index;not null"`
|
||||
Operation string `gorm:"not null"`
|
||||
ActorID *uint
|
||||
ActorAdminID *uint
|
||||
CreatedAt time.Time
|
||||
Snapshot string `gorm:"type:jsonb;not null"`
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ContentID uint `gorm:"index;not null"`
|
||||
Operation string `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
Snapshot string `gorm:"type:jsonb;not null"`
|
||||
}
|
||||
|
||||
func (ContentHistoryModel) TableName() string { return "content_history" }
|
||||
|
||||
func (UserModel) TableName() string { return "users" }
|
||||
|
||||
type AdminModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (AdminModel) TableName() string { return "admins" }
|
||||
|
||||
type SessionModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Token string `gorm:"uniqueIndex;not null"`
|
||||
UserID *uint
|
||||
AdminID *uint
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (SessionModel) TableName() string { return "sessions" }
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
package persistence
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/example/cms/internal/domain"
|
||||
)
|
||||
|
||||
func TestModelsUseMigrationTableNames(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"users": (UserModel{}).TableName(),
|
||||
"admins": (AdminModel{}).TableName(),
|
||||
"sessions": (SessionModel{}).TableName(),
|
||||
"content": (ContentModel{}).TableName(),
|
||||
"content_history": (ContentHistoryModel{}).TableName(),
|
||||
"categories": (CategoryModel{}).TableName(),
|
||||
"badges": (BadgeModel{}).TableName(),
|
||||
}
|
||||
for expected, actual := range tests {
|
||||
if actual != expected {
|
||||
|
|
@ -14,3 +20,11 @@ func TestModelsUseMigrationTableNames(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentMappingRoundTrip(t *testing.T) {
|
||||
d := domain.Content{ID: 2, Slug: "hello-world", Title: "Hello", Body: "body", Status: "draft", Badges: []string{"go"}, CreatedAt: time.Now()}
|
||||
got := ContentToDomain(ContentFromDomain(d))
|
||||
if got.Slug != d.Slug || got.Title != d.Title || got.Status != d.Status {
|
||||
t.Fatalf("round trip mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,64 +2,11 @@ package repository
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/example/cms/internal/domain"
|
||||
"github.com/example/cms/internal/persistence"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GORMUserRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewUserRepository(db *gorm.DB) IUserRepository { return &GORMUserRepository{db} }
|
||||
func (r *GORMUserRepository) FindOrCreateByIdentity(ctx context.Context, d domain.User) (domain.User, error) {
|
||||
var m persistence.UserModel
|
||||
err := r.db.WithContext(ctx).Where("provider = ? AND provider_subject = ?", d.Provider, d.ProviderSubject).First(&m).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
m = persistence.UserFromDomain(d)
|
||||
if err = r.db.WithContext(ctx).Create(&m).Error; err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return persistence.UserToDomain(m), nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return persistence.UserToDomain(m), nil
|
||||
}
|
||||
func (r *GORMUserRepository) FindByID(ctx context.Context, id uint) (domain.User, error) {
|
||||
var m persistence.UserModel
|
||||
if err := r.db.WithContext(ctx).First(&m, id).Error; err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return persistence.UserToDomain(m), nil
|
||||
}
|
||||
|
||||
type GORMAdminRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewAdminRepository(db *gorm.DB) IAdminRepository { return &GORMAdminRepository{db} }
|
||||
func (r *GORMAdminRepository) FindByEmail(ctx context.Context, email string) (domain.Admin, error) {
|
||||
var m persistence.AdminModel
|
||||
if err := r.db.WithContext(ctx).Where("email = ?", email).First(&m).Error; err != nil {
|
||||
return domain.Admin{}, err
|
||||
}
|
||||
return persistence.AdminToDomain(m), nil
|
||||
}
|
||||
|
||||
type GORMSessionRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewSessionRepository(db *gorm.DB) ISessionRepository { return &GORMSessionRepository{db} }
|
||||
func (r *GORMSessionRepository) Create(ctx context.Context, d domain.Session) error {
|
||||
return r.db.WithContext(ctx).Create(&persistence.SessionModel{ID: d.ID, Token: d.Token, UserID: d.UserID, AdminID: d.AdminID, ExpiresAt: d.ExpiresAt, CreatedAt: d.CreatedAt}).Error
|
||||
}
|
||||
func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) {
|
||||
var m persistence.SessionModel
|
||||
if err := r.db.WithContext(ctx).Where("token = ? AND expires_at > ?", t, time.Now()).First(&m).Error; err != nil {
|
||||
return domain.Session{}, err
|
||||
}
|
||||
return persistence.SessionToDomain(m), nil
|
||||
}
|
||||
|
||||
type GORMContentRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewContentRepository(db *gorm.DB) IContentRepository { return &GORMContentRepository{db} }
|
||||
|
|
@ -113,7 +60,7 @@ func (r *GORMContentRepository) FindBySlug(ctx context.Context, slug string) (do
|
|||
}
|
||||
func (r *GORMContentRepository) Update(ctx context.Context, d domain.Content, h domain.ContentHistory) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&persistence.ContentModel{}).Where("id = ?", d.ID).Updates(map[string]any{"slug": d.Slug, "title": d.Title, "summary": d.Summary, "body": d.Body, "status": d.Status, "published": d.Published, "updated_at": d.UpdatedAt, "updated_by": d.UpdatedBy}).Error; err != nil {
|
||||
if err := tx.Model(&persistence.ContentModel{}).Where("id = ?", d.ID).Updates(map[string]any{"slug": d.Slug, "title": d.Title, "summary": d.Summary, "body": d.Body, "status": d.Status, "published": d.Published, "updated_at": d.UpdatedAt}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM content_badges WHERE content_id = ?", d.ID).Error; err != nil {
|
||||
|
|
@ -127,6 +74,6 @@ func (r *GORMContentRepository) Update(ctx context.Context, d domain.Content, h
|
|||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&persistence.ContentHistoryModel{ContentID: h.ContentID, Operation: h.Operation, ActorID: h.ActorID, ActorAdminID: h.ActorAdminID, CreatedAt: h.CreatedAt, Snapshot: h.Snapshot}).Error
|
||||
return tx.Create(&persistence.ContentHistoryModel{ContentID: h.ContentID, Operation: h.Operation, CreatedAt: h.CreatedAt, Snapshot: h.Snapshot}).Error
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,19 +5,6 @@ import (
|
|||
"github.com/example/cms/internal/domain"
|
||||
)
|
||||
|
||||
type IUserRepository interface {
|
||||
FindOrCreateByIdentity(context.Context, domain.User) (domain.User, error)
|
||||
}
|
||||
type IUserLookup interface {
|
||||
FindByID(context.Context, uint) (domain.User, error)
|
||||
}
|
||||
type IAdminRepository interface {
|
||||
FindByEmail(context.Context, string) (domain.Admin, error)
|
||||
}
|
||||
type ISessionRepository interface {
|
||||
Create(context.Context, domain.Session) error
|
||||
FindValid(context.Context, string) (domain.Session, error)
|
||||
}
|
||||
type IContentRepository interface {
|
||||
List(context.Context, int, int, string, string, []string) ([]domain.Content, error)
|
||||
ListCategories(context.Context) ([]domain.Category, error)
|
||||
|
|
|
|||
73
internal/security/token.go
Normal file
73
internal/security/token.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// Package security decides whether a request carries the identity asserted by
|
||||
// the upstream single-sign-on proxy. The CMS never signs users in itself: the
|
||||
// proxy authenticates the owner and forwards a header on every accepted
|
||||
// request. Requests without that header are anonymous.
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ITokenVerifier reports whether a request was authenticated by the SSO proxy.
|
||||
type ITokenVerifier interface {
|
||||
Authenticated(*http.Request) bool
|
||||
HeaderName() string
|
||||
}
|
||||
|
||||
// SSOTokenVerifier trusts the header injected by the identity-aware proxy. An
|
||||
// empty Header disables header lookups; Cookie optionally names a cookie that
|
||||
// carries the same value. When Token is non-empty the forwarded value must
|
||||
// match it exactly, otherwise the presence of any non-empty value is accepted.
|
||||
type SSOTokenVerifier struct {
|
||||
Header string
|
||||
Token string
|
||||
Cookie string
|
||||
}
|
||||
|
||||
// NewSSOTokenVerifier builds a verifier for the given header, optional expected
|
||||
// token, and optional cookie fallback.
|
||||
func NewSSOTokenVerifier(header, token, cookie string) SSOTokenVerifier {
|
||||
return SSOTokenVerifier{Header: header, Token: token, Cookie: cookie}
|
||||
}
|
||||
|
||||
// HeaderName returns the proxy header carrying the SSO token.
|
||||
func (v SSOTokenVerifier) HeaderName() string { return v.Header }
|
||||
|
||||
// Authenticated reports whether the request carries an accepted SSO token.
|
||||
func (v SSOTokenVerifier) Authenticated(r *http.Request) bool {
|
||||
return v.accepted(v.presented(r))
|
||||
}
|
||||
|
||||
func (v SSOTokenVerifier) presented(r *http.Request) string {
|
||||
if v.Header != "" {
|
||||
if value := trimBearer(r.Header.Get(v.Header)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if v.Cookie != "" {
|
||||
if c, err := r.Cookie(v.Cookie); err == nil {
|
||||
return trimBearer(c.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v SSOTokenVerifier) accepted(presented string) bool {
|
||||
if presented == "" {
|
||||
return false
|
||||
}
|
||||
if v.Token == "" {
|
||||
return true
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(presented), []byte(v.Token)) == 1
|
||||
}
|
||||
|
||||
func trimBearer(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= len("bearer ") && strings.EqualFold(value[:len("bearer ")], "bearer ") {
|
||||
return strings.TrimSpace(value[len("bearer "):])
|
||||
}
|
||||
return value
|
||||
}
|
||||
42
internal/security/token_test.go
Normal file
42
internal/security/token_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func requestWithHeader(name, value string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set(name, value)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSSOTokenVerifierRequiresProxyHeader(t *testing.T) {
|
||||
v := NewSSOTokenVerifier("X-SSO-Token", "", "")
|
||||
if v.Authenticated(httptest.NewRequest(http.MethodGet, "/", nil)) {
|
||||
t.Fatal("request without proxy header must stay anonymous")
|
||||
}
|
||||
if !v.Authenticated(requestWithHeader("X-SSO-Token", "oauth2-access-token")) {
|
||||
t.Fatal("request with proxy header must be authenticated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSOTokenVerifierChecksExpectedToken(t *testing.T) {
|
||||
v := NewSSOTokenVerifier("X-SSO-Token", "secret", "")
|
||||
if v.Authenticated(requestWithHeader("X-SSO-Token", "other")) {
|
||||
t.Fatal("mismatching token must stay anonymous")
|
||||
}
|
||||
if !v.Authenticated(requestWithHeader("X-SSO-Token", "Bearer secret")) {
|
||||
t.Fatal("bearer-prefixed token must be authenticated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSOTokenVerifierCookieFallback(t *testing.T) {
|
||||
v := NewSSOTokenVerifier("X-SSO-Token", "", "cms_session")
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "cms_session", Value: "cookie-token"})
|
||||
if !v.Authenticated(r) {
|
||||
t.Fatal("cookie token must be authenticated")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"github.com/example/cms/internal/domain"
|
||||
"github.com/example/cms/internal/identity"
|
||||
"github.com/example/cms/internal/repository"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IUserService interface {
|
||||
Login(context.Context, identity.Identity) (domain.User, string, error)
|
||||
Authenticate(context.Context, string) (domain.Session, error)
|
||||
}
|
||||
type IAdminAuthService interface {
|
||||
Login(context.Context, string) (domain.Admin, string, error)
|
||||
Authenticate(context.Context, string) (domain.Session, error)
|
||||
}
|
||||
type AuthService struct {
|
||||
users repository.IUserRepository
|
||||
admins repository.IAdminRepository
|
||||
userSessions repository.ISessionRepository
|
||||
adminSessions repository.ISessionRepository
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewAuthService(u repository.IUserRepository, a repository.IAdminRepository, sessions repository.ISessionRepository, ttl time.Duration) *AuthService {
|
||||
return NewAuthServiceWithSeparateSessions(u, a, sessions, sessions, ttl)
|
||||
}
|
||||
|
||||
func NewAuthServiceWithSeparateSessions(u repository.IUserRepository, a repository.IAdminRepository, userSessions, adminSessions repository.ISessionRepository, ttl time.Duration) *AuthService {
|
||||
return &AuthService{users: u, admins: a, userSessions: userSessions, adminSessions: adminSessions, ttl: ttl}
|
||||
}
|
||||
func (s *AuthService) Login(ctx context.Context, id identity.Identity) (domain.User, string, error) {
|
||||
if strings.TrimSpace(id.Email) == "" || strings.TrimSpace(id.Subject) == "" {
|
||||
return domain.User{}, "", errors.New("identity email and subject are required")
|
||||
}
|
||||
u, e := s.users.FindOrCreateByIdentity(ctx, domain.User{Email: id.Email, Name: id.Name, Provider: "google", ProviderSubject: id.Subject, CreatedAt: time.Now()})
|
||||
if e != nil {
|
||||
return domain.User{}, "", e
|
||||
}
|
||||
t, e := newToken()
|
||||
if e != nil {
|
||||
return domain.User{}, "", e
|
||||
}
|
||||
if e = s.userSessions.Create(ctx, domain.Session{Token: t, UserID: &u.ID, ExpiresAt: time.Now().Add(s.ttl), CreatedAt: time.Now()}); e != nil {
|
||||
return domain.User{}, "", e
|
||||
}
|
||||
return u, t, nil
|
||||
}
|
||||
func (s *AuthService) Authenticate(ctx context.Context, t string) (domain.Session, error) {
|
||||
return s.userSessions.FindValid(ctx, t)
|
||||
}
|
||||
func (s *AuthService) AdminLogin(ctx context.Context, email string) (domain.Admin, string, error) {
|
||||
a, e := s.admins.FindByEmail(ctx, email)
|
||||
if e != nil {
|
||||
return domain.Admin{}, "", errors.New("invalid admin credentials")
|
||||
}
|
||||
t, e := newToken()
|
||||
if e != nil {
|
||||
return domain.Admin{}, "", e
|
||||
}
|
||||
if e = s.adminSessions.Create(ctx, domain.Session{Token: t, AdminID: &a.ID, ExpiresAt: time.Now().Add(s.ttl), CreatedAt: time.Now()}); e != nil {
|
||||
return domain.Admin{}, "", e
|
||||
}
|
||||
return a, t, nil
|
||||
}
|
||||
func (s *AuthService) AdminAuthenticate(ctx context.Context, t string) (domain.Session, error) {
|
||||
return s.adminSessions.FindValid(ctx, t)
|
||||
}
|
||||
func (s *AuthService) UserByID(ctx context.Context, id uint) (domain.User, error) {
|
||||
if lookup, ok := s.users.(repository.IUserLookup); ok {
|
||||
return lookup.FindByID(ctx, id)
|
||||
}
|
||||
return domain.User{}, errors.New("user lookup unavailable")
|
||||
}
|
||||
func (s *AuthService) CanEdit(ctx context.Context, session domain.Session) bool {
|
||||
if session.AdminID != nil {
|
||||
return true
|
||||
}
|
||||
if session.UserID == nil {
|
||||
return false
|
||||
}
|
||||
u, err := s.UserByID(ctx, *session.UserID)
|
||||
return err == nil && (u.Editor || u.Role == "editor" || u.Role == "admin")
|
||||
}
|
||||
func newToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, e := rand.Read(b); e != nil {
|
||||
return "", e
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/example/cms/internal/domain"
|
||||
"github.com/example/cms/internal/identity"
|
||||
)
|
||||
|
||||
type fakeUsers struct{}
|
||||
|
||||
func (fakeUsers) FindOrCreateByIdentity(_ context.Context, u domain.User) (domain.User, error) {
|
||||
u.ID = 7
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type fakeAdmins struct{}
|
||||
|
||||
func (fakeAdmins) FindByEmail(context.Context, string) (domain.Admin, error) {
|
||||
return domain.Admin{ID: 2, Email: "admin@example.com"}, nil
|
||||
}
|
||||
|
||||
type fakeSessions struct{ created domain.Session }
|
||||
|
||||
func (f *fakeSessions) Create(_ context.Context, s domain.Session) error { f.created = s; return nil }
|
||||
func (f *fakeSessions) FindValid(context.Context, string) (domain.Session, error) {
|
||||
return f.created, nil
|
||||
}
|
||||
|
||||
func TestAuthServiceCreatesUserSession(t *testing.T) {
|
||||
sessions := &fakeSessions{}
|
||||
s := NewAuthService(fakeUsers{}, fakeAdmins{}, sessions, time.Hour)
|
||||
u, token, err := s.Login(context.Background(), identity.Identity{Subject: "sub", Email: "user@example.com"})
|
||||
if err != nil || token == "" || u.ID != 7 || sessions.created.UserID == nil {
|
||||
t.Fatalf("login failed: user=%+v token=%q session=%+v err=%v", u, token, sessions.created, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,20 +11,21 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
// IContentService exposes the content use cases. Read operations are anonymous;
|
||||
// Update is only reachable once the web layer verified the SSO proxy identity.
|
||||
type IContentService interface {
|
||||
List(context.Context, int, int, string, string, []string) ([]domain.Content, error)
|
||||
ListCategories(context.Context) ([]domain.Category, error)
|
||||
ListBadges(context.Context) ([]domain.Badge, error)
|
||||
Get(context.Context, string) (domain.Content, error)
|
||||
Update(context.Context, domain.Content, domain.Session) (domain.Content, error)
|
||||
Update(context.Context, domain.Content) (domain.Content, error)
|
||||
}
|
||||
type ContentService struct {
|
||||
repo repository.IContentRepository
|
||||
auth *AuthService
|
||||
}
|
||||
|
||||
func NewContentService(r repository.IContentRepository, a *AuthService) *ContentService {
|
||||
return &ContentService{repo: r, auth: a}
|
||||
func NewContentService(r repository.IContentRepository) *ContentService {
|
||||
return &ContentService{repo: r}
|
||||
}
|
||||
func (s *ContentService) List(ctx context.Context, limit, offset int, category, status string, badges []string) ([]domain.Content, error) {
|
||||
if limit < 1 {
|
||||
|
|
@ -50,10 +51,7 @@ func (s *ContentService) Get(ctx context.Context, slug string) (domain.Content,
|
|||
}
|
||||
return s.repo.FindBySlug(ctx, slug)
|
||||
}
|
||||
func (s *ContentService) Update(ctx context.Context, c domain.Content, session domain.Session) (domain.Content, error) {
|
||||
if s.auth == nil || !s.auth.CanEdit(ctx, session) {
|
||||
return domain.Content{}, errors.New("forbidden")
|
||||
}
|
||||
func (s *ContentService) Update(ctx context.Context, c domain.Content) (domain.Content, error) {
|
||||
if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
|
||||
return domain.Content{}, errors.New("invalid content")
|
||||
}
|
||||
|
|
@ -63,15 +61,11 @@ func (s *ContentService) Update(ctx context.Context, c domain.Content, session d
|
|||
}
|
||||
c.ID = old.ID
|
||||
c.CreatedAt = old.CreatedAt
|
||||
c.CreatedBy = old.CreatedBy
|
||||
c.UpdatedAt = time.Now()
|
||||
c.Badges = normalizeBadges(c.Badges)
|
||||
if session.UserID != nil {
|
||||
c.UpdatedBy = *session.UserID
|
||||
}
|
||||
|
||||
snapshot, _ := json.Marshal(c)
|
||||
h := domain.ContentHistory{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot), ActorID: session.UserID, ActorAdminID: session.AdminID}
|
||||
h := domain.ContentHistory{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot)}
|
||||
if err = s.repo.Update(ctx, c, h); err != nil {
|
||||
return domain.Content{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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