initial commit

This commit is contained in:
Daniel Noulet 2026-09-19 14:48:42 +02:00
parent b50c054b30
commit fac7588d23
21 changed files with 266 additions and 55 deletions

View file

@ -1,17 +1,60 @@
package config
import (
"encoding/json"
"fmt"
"os"
"time"
)
type Config struct {
HTTPAddr, DatabaseURL, AllowedOrigins, GoogleClientID, GoogleClientSecret, GoogleRedirectURL string
SessionTTL time.Duration
HTTPAddr, AdminDatabaseURL, ApplicationDatabaseURL, 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 Load() (Config, error) {
clientID, clientSecret, err := googleCredentials()
if err != nil {
return Config{}, err
}
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
}
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 != "" {

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"net/http"
@ -37,7 +38,7 @@ func (p GoogleProvider) FetchIdentity(ctx context.Context, code string) (Identit
}
token, err := p.OAuthConfig().Exchange(ctx, code)
if err != nil {
return Identity{}, err
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 {
@ -50,7 +51,7 @@ func (p GoogleProvider) FetchIdentity(ctx context.Context, code string) (Identit
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Identity{}, errors.New("google userinfo request failed")
return Identity{}, fmt.Errorf("Google userinfo request failed with status %s", resp.Status)
}
var profile struct {
Subject string `json:"sub"`

View file

@ -10,12 +10,18 @@ type UserModel struct {
ProviderSubject string `gorm:"uniqueIndex;not null"`
CreatedAt time.Time
}
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"`
@ -24,3 +30,5 @@ type SessionModel struct {
ExpiresAt time.Time
CreatedAt time.Time
}
func (SessionModel) TableName() string { return "sessions" }

View file

@ -0,0 +1,16 @@
package persistence
import "testing"
func TestModelsUseMigrationTableNames(t *testing.T) {
tests := map[string]string{
"users": (UserModel{}).TableName(),
"admins": (AdminModel{}).TableName(),
"sessions": (SessionModel{}).TableName(),
}
for expected, actual := range tests {
if actual != expected {
t.Errorf("table name = %q, want %q", actual, expected)
}
}
}

View file

@ -44,7 +44,8 @@ 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
model := persistence.SessionFromDomain(d)
return r.db.WithContext(ctx).Create(&model).Error
}
func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) {
var m persistence.SessionModel

View file

@ -21,14 +21,19 @@ type IAdminAuthService interface {
Authenticate(context.Context, string) (domain.Session, error)
}
type AuthService struct {
users repository.IUserRepository
admins repository.IAdminRepository
sessions repository.ISessionRepository
ttl time.Duration
users repository.IUserRepository
admins repository.IAdminRepository
userSessions repository.ISessionRepository
adminSessions 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 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) == "" {
@ -42,13 +47,13 @@ func (s *AuthService) Login(ctx context.Context, id identity.Identity) (domain.U
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 {
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.sessions.FindValid(ctx, t)
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)
@ -59,13 +64,13 @@ func (s *AuthService) AdminLogin(ctx context.Context, email string) (domain.Admi
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 {
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.Authenticate(ctx, t)
return s.adminSessions.FindValid(ctx, t)
}
func newToken() (string, error) {
b := make([]byte, 32)

View file

@ -7,6 +7,7 @@ import (
"github.com/example/cms/internal/identity"
"github.com/example/cms/internal/service"
"github.com/example/cms/internal/web/mapping"
"log"
"net/http"
"strings"
)
@ -52,7 +53,8 @@ func (h *Handler) googleCallback(w http.ResponseWriter, r *http.Request) {
}
id, err := h.google.FetchIdentity(r.Context(), r.URL.Query().Get("code"))
if err != nil {
http.Error(w, "google authentication failed", http.StatusUnauthorized)
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)