Initialize CMS application scaffold
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
b50c054b30
39 changed files with 3040 additions and 0 deletions
29
internal/config/config.go
Normal file
29
internal/config/config.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr, DatabaseURL, AllowedOrigins, GoogleClientID, GoogleClientSecret, GoogleRedirectURL string
|
||||
SessionTTL time.Duration
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{HTTPAddr: get("CMS_HTTP_ADDR", ":8080"), DatabaseURL: get("CMS_DATABASE_URL", "./cms.db"), AllowedOrigins: get("CMS_ALLOWED_ORIGINS", "http://localhost:5173"), SessionTTL: duration("CMS_SESSION_TTL", 24*time.Hour), GoogleClientID: os.Getenv("CMS_GOOGLE_CLIENT_ID"), GoogleClientSecret: os.Getenv("CMS_GOOGLE_CLIENT_SECRET"), GoogleRedirectURL: os.Getenv("CMS_GOOGLE_REDIRECT_URL")}
|
||||
}
|
||||
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
|
||||
}
|
||||
22
internal/domain/models.go
Normal file
22
internal/domain/models.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID uint
|
||||
Email, Name string
|
||||
Provider, ProviderSubject string
|
||||
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
|
||||
}
|
||||
64
internal/identity/provider.go
Normal file
64
internal/identity/provider.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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{}, 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{}, errors.New("google userinfo request failed")
|
||||
}
|
||||
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
|
||||
}
|
||||
13
internal/identity/provider_test.go
Normal file
13
internal/identity/provider_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGoogleProviderRequiresConfiguration(t *testing.T) {
|
||||
_, e := (GoogleProvider{}).Authenticate(context.Background(), "")
|
||||
if e == nil {
|
||||
t.Fatal("expected configuration error")
|
||||
}
|
||||
}
|
||||
19
internal/persistence/mapping.go
Normal file
19
internal/persistence/mapping.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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, 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, CreatedAt: d.CreatedAt}
|
||||
}
|
||||
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}
|
||||
}
|
||||
14
internal/persistence/mapping_test.go
Normal file
14
internal/persistence/mapping_test.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
26
internal/persistence/models.go
Normal file
26
internal/persistence/models.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
type AdminModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
type SessionModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Token string `gorm:"uniqueIndex;not null"`
|
||||
UserID *uint
|
||||
AdminID *uint
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
56
internal/repository/gorm.go
Normal file
56
internal/repository/gorm.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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
|
||||
}
|
||||
|
||||
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
|
||||
e := r.db.WithContext(ctx).Where("email = ?", email).First(&m).Error
|
||||
if e != nil {
|
||||
return domain.Admin{}, e
|
||||
}
|
||||
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.SessionFromDomain(d)).Error
|
||||
}
|
||||
func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) {
|
||||
var m persistence.SessionModel
|
||||
e := r.db.WithContext(ctx).Where("token = ? AND expires_at > ?", t, time.Now()).First(&m).Error
|
||||
if e != nil {
|
||||
return domain.Session{}, e
|
||||
}
|
||||
return persistence.SessionToDomain(m), nil
|
||||
}
|
||||
17
internal/repository/interfaces.go
Normal file
17
internal/repository/interfaces.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/example/cms/internal/domain"
|
||||
)
|
||||
|
||||
type IUserRepository interface {
|
||||
FindOrCreateByIdentity(context.Context, domain.User) (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)
|
||||
}
|
||||
76
internal/service/auth.go
Normal file
76
internal/service/auth.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
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
|
||||
sessions repository.ISessionRepository
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewAuthService(u repository.IUserRepository, a repository.IAdminRepository, s repository.ISessionRepository, ttl time.Duration) *AuthService {
|
||||
return &AuthService{u, a, s, 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.sessions.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.sessions.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.sessions.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.Authenticate(ctx, t)
|
||||
}
|
||||
func newToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, e := rand.Read(b); e != nil {
|
||||
return "", e
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
39
internal/service/auth_test.go
Normal file
39
internal/service/auth_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
163
internal/web/handlers.go
Normal file
163
internal/web/handlers.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/example/cms/internal/identity"
|
||||
"github.com/example/cms/internal/service"
|
||||
"github.com/example/cms/internal/web/mapping"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
auth *service.AuthService
|
||||
google identity.GoogleProvider
|
||||
}
|
||||
|
||||
func NewHandler(a *service.AuthService, google identity.GoogleProvider) *Handler {
|
||||
return &Handler{auth: a, google: google}
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
http.Error(w, "google authentication failed", 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
jsonWrite(w, http.StatusOK, map[string]bool{"authenticated": true})
|
||||
}
|
||||
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})
|
||||
}
|
||||
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"})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, 200, map[string]string{"status": "authenticated"})
|
||||
}
|
||||
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")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
func cors(next http.Handler, origin string) http.Handler {
|
||||
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-Methods", "GET, POST, OPTIONS")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
15
internal/web/mapping/auth.go
Normal file
15
internal/web/mapping/auth.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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"`
|
||||
}
|
||||
10
internal/web/mapping/auth_test.go
Normal file
10
internal/web/mapping/auth_test.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
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