initial commit
This commit is contained in:
parent
b50c054b30
commit
fac7588d23
21 changed files with 266 additions and 55 deletions
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
16
internal/persistence/models_test.go
Normal file
16
internal/persistence/models_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue