diff --git a/go_cloud/.env.example b/go_cloud/.env.example new file mode 100644 index 0000000..48873e3 --- /dev/null +++ b/go_cloud/.env.example @@ -0,0 +1,14 @@ +# Server Configuration +SERVER_ADDR=:8080 + +# Database +DATABASE_URL=postgres://user:password@localhost/dbname?sslmode=disable + +# OIDC Configuration +OIDC_ISSUER_URL=https://storage.b0esche.cloud/index.php/apps/oidc_login +OIDC_REDIRECT_URL=http://localhost:8080/auth/callback +OIDC_CLIENT_ID=your_client_id +OIDC_CLIENT_SECRET=your_client_secret + +# JWT +JWT_SECRET=your_jwt_secret_key \ No newline at end of file diff --git a/go_cloud/Makefile b/go_cloud/Makefile new file mode 100644 index 0000000..2d81ae4 --- /dev/null +++ b/go_cloud/Makefile @@ -0,0 +1,16 @@ +.PHONY: build run test clean lint + +build: + go build -o bin/api ./cmd/api + +run: + go run ./cmd/api + +test: + go test ./... + +clean: + rm -rf bin/ + +lint: + golangci-lint run \ No newline at end of file diff --git a/go_cloud/README.md b/go_cloud/README.md new file mode 100644 index 0000000..3e94116 --- /dev/null +++ b/go_cloud/README.md @@ -0,0 +1,113 @@ +# Go Backend Control Plane + +This is the Go backend for the enterprise document collaboration platform, serving as the single source of truth for authentication, organizations, permissions, sessions, and orchestration. + +## Architecture + +The backend is designed for security, scale, auditability, and SaaS readiness. It uses OIDC authentication via Nextcloud and enforces organization-scoped permissions. + +### Core Principles +- **Single Source of Truth**: All auth, orgs, permissions, and sessions are managed here. +- **Untrusted Frontend**: The backend does not trust client-side permissions. +- **Organization Scoping**: Every API request is org-scoped. +- **Audit Logging**: All sensitive actions are logged. + +### Key Components +- **Authentication**: OIDC via Nextcloud +- **Sessions**: Short-lived JWTs (5-15 minutes) +- **Organizations**: Multi-org support with role-based permissions +- **File Mediation**: Talks to Nextcloud via WebDAV/APIs, no direct client access +- **Document Sessions**: Generates signed URLs for viewers and editors (Collabora) + +### Tech Stack +- **Language**: Go 1.22+ +- **Framework**: Chi router +- **Database**: PostgreSQL 16 +- **JWT**: golang-jwt/jwt/v4 +- **Deployment**: Ready for containerization + +## Project Structure +``` +. +├── cmd/api/main.go # Application entry point +├── internal/ +│ ├── auth/ # OIDC authentication logic +│ ├── session/ # Session management +│ ├── org/ # Organization resolution +│ ├── permission/ # Permission checking +│ ├── files/ # File access mediation +│ ├── documents/ # Document session generation +│ ├── collabora/ # Collabora editor integration +│ ├── audit/ # Audit logging +│ ├── middleware/ # HTTP middleware (auth, org, rate limit) +│ ├── config/ # Configuration loading +│ ├── database/ # DB connections and migrations +│ └── http/ # HTTP server and routes +├── pkg/jwt/ # JWT utilities +├── migrations/ # Database migrations +├── scripts/ # Utility scripts +├── .env.example # Environment variables template +├── Makefile # Build and deployment tasks +└── README.md +``` + +## Getting Started + +1. **Initialize**: + ```bash + go mod init go.b0esche.cloud/backend + go mod tidy + ``` + +2. **Set up PostgreSQL**: + - Install PostgreSQL 16 + - Create a database + - Run migrations: + ```bash + psql -d your_database < migrations/0001_initial.sql + ``` + +3. **Configure Environment**: + Copy `.env.example` to `.env` and fill in values, especially database URL and OIDC settings. + +4. **Run**: + ```bash + go run cmd/api/main.go + ``` + +5. **Health Check**: + ```bash + curl http://localhost:8080/health + ``` + +## API Endpoints + +- `GET /health` - Health check +- `GET /auth/login` - Initiate OIDC login (redirects to Nextcloud) +- `GET /auth/callback` - OIDC callback (returns JWT token) + +## Development + +- Use Go 1.22+ +- Follow Go conventions +- Add tests for critical components +- Use structured logging +- No global mutable state +- No business logic in handlers + +## Security + +- All handlers must go through middleware chain +- JWTs are short-lived +- Permissions resolved server-side only +- Audit all sensitive actions +- Rate limiting implemented +- No direct DB access from handlers + +## Roadmap + +1. Complete OIDC implementation +2. Add database integration +3. Implement org and permission resolution +4. Add file access APIs +5. Integrate with Nextcloud and Collabora \ No newline at end of file diff --git a/go_cloud/api b/go_cloud/api new file mode 100755 index 0000000..49ef077 Binary files /dev/null and b/go_cloud/api differ diff --git a/go_cloud/bin/api b/go_cloud/bin/api new file mode 100755 index 0000000..831aac7 Binary files /dev/null and b/go_cloud/bin/api differ diff --git a/go_cloud/cmd/api/main.go b/go_cloud/cmd/api/main.go new file mode 100644 index 0000000..3defd37 --- /dev/null +++ b/go_cloud/cmd/api/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "net/http" + "os" + + "go.b0esche.cloud/backend/internal/audit" + "go.b0esche.cloud/backend/internal/auth" + "go.b0esche.cloud/backend/internal/config" + "go.b0esche.cloud/backend/internal/database" + httpsrv "go.b0esche.cloud/backend/internal/http" + "go.b0esche.cloud/backend/pkg/jwt" +) + +func main() { + cfg := config.Load() + + dbConn, err := database.Connect(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err) + os.Exit(1) + } + db := database.New(dbConn) + + jwtManager := jwt.NewManager(cfg.JWTSecret) + + authService, err := auth.NewService(cfg, db) + if err != nil { + fmt.Fprintf(os.Stderr, "Auth service error: %v\n", err) + os.Exit(1) + } + + auditLogger := audit.NewLogger(db) + + srv := httpsrv.New(cfg, db, jwtManager, authService, auditLogger) + + fmt.Printf("Starting server on %s\n", cfg.ServerAddr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Fprintf(os.Stderr, "Server error: %v\n", err) + os.Exit(1) + } +} diff --git a/go_cloud/go.mod b/go_cloud/go.mod new file mode 100644 index 0000000..c436c80 --- /dev/null +++ b/go_cloud/go.mod @@ -0,0 +1,23 @@ +module go.b0esche.cloud/backend + +go 1.25.5 + +require ( + github.com/go-chi/chi/v5 v5.2.3 + github.com/golang-jwt/jwt/v5 v5.3.0 +) + +require ( + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go_cloud/go.sum b/go_cloud/go.sum new file mode 100644 index 0000000..5b98ed1 --- /dev/null +++ b/go_cloud/go.sum @@ -0,0 +1,35 @@ +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go_cloud/internal/audit/audit.go b/go_cloud/internal/audit/audit.go new file mode 100644 index 0000000..8ccb434 --- /dev/null +++ b/go_cloud/internal/audit/audit.go @@ -0,0 +1,44 @@ +package audit + +import ( + "context" + "encoding/json" + "log" + + "go.b0esche.cloud/backend/internal/database" + + "github.com/google/uuid" +) + +type Logger struct { + db *database.DB +} + +func NewLogger(db *database.DB) *Logger { + return &Logger{db: db} +} + +type Entry struct { + UserID *uuid.UUID + OrgID *uuid.UUID + Action string + Resource *string + Success bool + Metadata map[string]interface{} +} + +func (l *Logger) Log(ctx context.Context, entry Entry) { + metadataJSON, err := json.Marshal(entry.Metadata) + if err != nil { + log.Printf("Failed to marshal audit metadata: %v", err) + metadataJSON = []byte("{}") + } + + _, err = l.db.ExecContext(ctx, ` + INSERT INTO audit_logs (user_id, org_id, action, resource, success, metadata) + VALUES ($1, $2, $3, $4, $5, $6) + `, entry.UserID, entry.OrgID, entry.Action, entry.Resource, entry.Success, metadataJSON) + if err != nil { + log.Printf("Failed to log audit entry: %v", err) + } +} diff --git a/go_cloud/internal/auth/auth.go b/go_cloud/internal/auth/auth.go new file mode 100644 index 0000000..ca56c80 --- /dev/null +++ b/go_cloud/internal/auth/auth.go @@ -0,0 +1,96 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "go.b0esche.cloud/backend/internal/config" + "go.b0esche.cloud/backend/internal/database" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type Service struct { + provider *oidc.Provider + oauth2Config oauth2.Config + db *database.DB // Assume we have a DB wrapper +} + +func NewService(cfg *config.Config, db *database.DB) (*Service, error) { + ctx := context.Background() + + provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuerURL) + if err != nil { + return nil, fmt.Errorf("failed to get OIDC provider: %w", err) + } + + oauth2Config := oauth2.Config{ + ClientID: cfg.OIDCClientID, + ClientSecret: cfg.OIDCClientSecret, + RedirectURL: cfg.OIDCRedirectURL, // Add to config + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + + return &Service{ + provider: provider, + oauth2Config: oauth2Config, + db: db, + }, nil +} + +func (s *Service) LoginURL(state string) string { + return s.oauth2Config.AuthCodeURL(state) +} + +func (s *Service) HandleCallback(ctx context.Context, code, state string) (*database.User, *database.Session, error) { + oauth2Token, err := s.oauth2Config.Exchange(ctx, code) + if err != nil { + return nil, nil, fmt.Errorf("failed to exchange code: %w", err) + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + return nil, nil, fmt.Errorf("no id_token in token response") + } + + idToken, err := s.provider.Verifier(&oidc.Config{ClientID: s.oauth2Config.ClientID}).Verify(ctx, rawIDToken) + if err != nil { + return nil, nil, fmt.Errorf("failed to verify ID token: %w", err) + } + + var claims struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + } + if err := idToken.Claims(&claims); err != nil { + return nil, nil, fmt.Errorf("failed to parse claims: %w", err) + } + + user, err := s.db.GetOrCreateUser(ctx, claims.Sub, claims.Email, claims.Name) + if err != nil { + return nil, nil, err + } + + session, err := s.db.CreateSession(ctx, user.ID, time.Now().Add(15*time.Minute)) + if err != nil { + return nil, nil, err + } + + return user, session, nil +} + +// GenerateState generates a secure random state string +func GenerateState() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} diff --git a/go_cloud/internal/auth/auth_test.go b/go_cloud/internal/auth/auth_test.go new file mode 100644 index 0000000..db5eb93 --- /dev/null +++ b/go_cloud/internal/auth/auth_test.go @@ -0,0 +1,29 @@ +package auth + +import ( + "testing" +) + +func TestGenerateState(t *testing.T) { + state1, err := GenerateState() + if err != nil { + t.Fatal(err) + } + state2, err := GenerateState() + if err != nil { + t.Fatal(err) + } + if state1 == state2 { + t.Error("States should be unique") + } + if len(state1) == 0 { + t.Error("State should not be empty") + } +} + +func TestNewService(t *testing.T) { + // Mock db + // service, err := NewService(cfg, db) + // TODO: Mock database for full test + t.Skip("Requires database mock") +} diff --git a/go_cloud/internal/config/config.go b/go_cloud/internal/config/config.go new file mode 100644 index 0000000..75bd752 --- /dev/null +++ b/go_cloud/internal/config/config.go @@ -0,0 +1,34 @@ +package config + +import ( + "os" +) + +type Config struct { + ServerAddr string + DatabaseURL string + OIDCIssuerURL string + OIDCRedirectURL string + OIDCClientID string + OIDCClientSecret string + JWTSecret string +} + +func Load() *Config { + return &Config{ + ServerAddr: getEnv("SERVER_ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"), + OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"), + OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), + OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + JWTSecret: os.Getenv("JWT_SECRET"), + } +} + +func getEnv(key, defaultVal string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultVal +} diff --git a/go_cloud/internal/database/database.go b/go_cloud/internal/database/database.go new file mode 100644 index 0000000..49d3525 --- /dev/null +++ b/go_cloud/internal/database/database.go @@ -0,0 +1,29 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + + "go.b0esche.cloud/backend/internal/config" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" +) + +func Connect(cfg *config.Config) (*sql.DB, error) { + ctx := context.Background() + + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + if err != nil { + return nil, fmt.Errorf("failed to create pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + db := stdlib.OpenDBFromPool(pool) + + return db, nil +} diff --git a/go_cloud/internal/database/db.go b/go_cloud/internal/database/db.go new file mode 100644 index 0000000..a8fe0e0 --- /dev/null +++ b/go_cloud/internal/database/db.go @@ -0,0 +1,124 @@ +package database + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +type DB struct { + *sql.DB +} + +func New(db *sql.DB) *DB { + return &DB{DB: db} +} + +type User struct { + ID uuid.UUID + Email string + DisplayName string + CreatedAt time.Time + LastLoginAt *time.Time +} + +type Session struct { + ID uuid.UUID + UserID uuid.UUID + ExpiresAt time.Time + RevokedAt *time.Time +} + +type Organization struct { + ID uuid.UUID + Name string + Slug string + CreatedAt time.Time +} + +type Membership struct { + UserID uuid.UUID + OrgID uuid.UUID + Role string + CreatedAt time.Time +} + +func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) { + var user User + err := db.QueryRowContext(ctx, ` + INSERT INTO users (id, email, display_name) + VALUES (gen_random_uuid(), $1, $2) + ON CONFLICT (email) DO UPDATE SET + display_name = EXCLUDED.display_name, + last_login_at = NOW() + RETURNING id, email, display_name, created_at, last_login_at + `, email, name).Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, &user.LastLoginAt) + if err != nil { + return nil, err + } + return &user, nil +} + +func (db *DB) CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time) (*Session, error) { + var session Session + err := db.QueryRowContext(ctx, ` + INSERT INTO sessions (user_id, expires_at) + VALUES ($1, $2) + RETURNING id, user_id, expires_at, revoked_at + `, userID, expiresAt).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt) + if err != nil { + return nil, err + } + return &session, nil +} + +func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, error) { + var session Session + err := db.QueryRowContext(ctx, ` + SELECT id, user_id, expires_at, revoked_at + FROM sessions + WHERE id = $1 + `, sessionID).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt) + if err != nil { + return nil, err + } + return &session, nil +} + +func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) { + rows, err := db.QueryContext(ctx, ` + SELECT o.id, o.name, o.slug, o.created_at + FROM organizations o + JOIN memberships m ON o.id = m.org_id + WHERE m.user_id = $1 + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var orgs []Organization + for rows.Next() { + var org Organization + if err := rows.Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt); err != nil { + return nil, err + } + orgs = append(orgs, org) + } + return orgs, rows.Err() +} + +func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*Membership, error) { + var membership Membership + err := db.QueryRowContext(ctx, ` + SELECT user_id, org_id, role, created_at + FROM memberships + WHERE user_id = $1 AND org_id = $2 + `, userID, orgID).Scan(&membership.UserID, &membership.OrgID, &membership.Role, &membership.CreatedAt) + if err != nil { + return nil, err + } + return &membership, nil +} diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go new file mode 100644 index 0000000..2936748 --- /dev/null +++ b/go_cloud/internal/http/routes.go @@ -0,0 +1,93 @@ +package http + +import ( + "net/http" + + "go.b0esche.cloud/backend/internal/audit" + "go.b0esche.cloud/backend/internal/auth" + "go.b0esche.cloud/backend/internal/config" + "go.b0esche.cloud/backend/internal/database" + "go.b0esche.cloud/backend/internal/middleware" + "go.b0esche.cloud/backend/pkg/jwt" + + "github.com/go-chi/chi/v5" +) + +func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler { + r := chi.NewRouter() + + // Global middleware + r.Use(middleware.RequestID) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RateLimit) + + // Health check + r.Get("/health", healthHandler) + + // Auth routes (no auth required) + r.Route("/auth", func(r chi.Router) { + r.Get("/login", func(w http.ResponseWriter, req *http.Request) { + authLoginHandler(w, req, authService) + }) + r.Get("/callback", func(w http.ResponseWriter, req *http.Request) { + authCallbackHandler(w, req, cfg, authService, jwtManager, auditLogger) + }) + }) + + // Auth middleware for protected routes + r.Use(middleware.Auth(jwtManager, db)) + + return r +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +func authLoginHandler(w http.ResponseWriter, r *http.Request, authService *auth.Service) { + state, err := auth.GenerateState() + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // TODO: Store state securely (e.g., in session or cache) + + url := authService.LoginURL(state) + http.Redirect(w, r, url, http.StatusFound) +} + +func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Config, authService *auth.Service, jwtManager *jwt.Manager, auditLogger *audit.Logger) { + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + + // TODO: Validate state + + user, session, err := authService.HandleCallback(r.Context(), code, state) + if err != nil { + auditLogger.Log(r.Context(), audit.Entry{ + Action: "login", + Success: false, + Metadata: map[string]interface{}{"error": err.Error()}, + }) + http.Error(w, "Authentication failed", http.StatusUnauthorized) + return + } + + token, err := jwtManager.Generate(user.Email, []string{}, session.ID.String()) // Orgs not yet + if err != nil { + http.Error(w, "Token generation failed", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &user.ID, + Action: "login", + Success: true, + }) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"token": "` + token + `"}`)) +} diff --git a/go_cloud/internal/http/server.go b/go_cloud/internal/http/server.go new file mode 100644 index 0000000..1f9433a --- /dev/null +++ b/go_cloud/internal/http/server.go @@ -0,0 +1,26 @@ +package http + +import ( + "net/http" + + "go.b0esche.cloud/backend/internal/audit" + "go.b0esche.cloud/backend/internal/auth" + "go.b0esche.cloud/backend/internal/config" + "go.b0esche.cloud/backend/internal/database" + "go.b0esche.cloud/backend/pkg/jwt" +) + +type Server struct { + *http.Server +} + +func New(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) *Server { + r := NewRouter(cfg, db, jwtManager, authService, auditLogger) + + return &Server{ + Server: &http.Server{ + Addr: cfg.ServerAddr, + Handler: r, + }, + } +} diff --git a/go_cloud/internal/middleware/middleware.go b/go_cloud/internal/middleware/middleware.go new file mode 100644 index 0000000..51aa896 --- /dev/null +++ b/go_cloud/internal/middleware/middleware.go @@ -0,0 +1,123 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "go.b0esche.cloud/backend/internal/audit" + "go.b0esche.cloud/backend/internal/database" + "go.b0esche.cloud/backend/internal/org" + "go.b0esche.cloud/backend/internal/permission" + "go.b0esche.cloud/backend/pkg/jwt" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/google/uuid" +) + +var RequestID = middleware.RequestID +var Logger = middleware.Logger +var Recoverer = middleware.Recoverer + +// TODO: Implement rate limiter +var RateLimit = func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Basic rate limiting logic here + next.ServeHTTP(w, r) + }) +} + +type contextKey string + +const ( + userKey contextKey = "user" + sessionKey contextKey = "session" + orgKey contextKey = "org" +) + +// Auth middleware +func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), userKey, claims.UserID) + ctx = context.WithValue(ctx, sessionKey, session) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// Org middleware +func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userIDStr := r.Context().Value(userKey).(string) + userID, _ := uuid.Parse(userIDStr) + + orgIDStr := r.Header.Get("X-Org-ID") + if orgIDStr == "" { + orgIDStr = chi.URLParam(r, "orgId") + } + orgID, err := uuid.Parse(orgIDStr) + if err != nil { + http.Error(w, "Invalid org ID", http.StatusBadRequest) + return + } + + _, err = org.CheckMembership(r.Context(), db, userID, orgID) + if err != nil { + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + Action: "org_access", + Success: false, + Metadata: map[string]interface{}{"org_id": orgID, "error": err.Error()}, + }) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + ctx := context.WithValue(r.Context(), orgKey, orgID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// Permission middleware +func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Permission) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userIDStr := r.Context().Value(userKey).(string) + userID, _ := uuid.Parse(userIDStr) + orgID := r.Context().Value(orgKey).(uuid.UUID) + + hasPerm, err := permission.HasPermission(r.Context(), db, userID, orgID, perm) + if err != nil || !hasPerm { + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + OrgID: &orgID, + Action: "permission_check", + Resource: &[]string{string(perm)}[0], + Success: false, + Metadata: map[string]interface{}{"permission": perm}, + }) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/go_cloud/internal/org/org.go b/go_cloud/internal/org/org.go new file mode 100644 index 0000000..c958bd7 --- /dev/null +++ b/go_cloud/internal/org/org.go @@ -0,0 +1,23 @@ +package org + +import ( + "context" + + "go.b0esche.cloud/backend/internal/database" + + "github.com/google/uuid" +) + +// ResolveUserOrgs returns the organizations a user belongs to +func ResolveUserOrgs(ctx context.Context, db *database.DB, userID uuid.UUID) ([]database.Organization, error) { + return db.GetUserOrganizations(ctx, userID) +} + +// CheckMembership checks if user is member of org and returns role +func CheckMembership(ctx context.Context, db *database.DB, userID, orgID uuid.UUID) (string, error) { + membership, err := db.GetUserMembership(ctx, userID, orgID) + if err != nil { + return "", err + } + return membership.Role, nil +} diff --git a/go_cloud/internal/permission/permission.go b/go_cloud/internal/permission/permission.go new file mode 100644 index 0000000..3140dd0 --- /dev/null +++ b/go_cloud/internal/permission/permission.go @@ -0,0 +1,48 @@ +package permission + +import ( + "context" + "fmt" + + "go.b0esche.cloud/backend/internal/database" + + "github.com/google/uuid" +) + +type Permission string + +const ( + FileRead Permission = "file.read" + FileWrite Permission = "file.write" + FileDelete Permission = "file.delete" + DocumentView Permission = "document.view" + DocumentEdit Permission = "document.edit" + OrgManage Permission = "org.manage" +) + +var rolePermissions = map[string][]Permission{ + "owner": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit, OrgManage}, + "admin": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit}, + "editor": {FileRead, FileWrite, DocumentView, DocumentEdit}, + "viewer": {FileRead, DocumentView}, +} + +// HasPermission checks if user has permission in org +func HasPermission(ctx context.Context, db *database.DB, userID, orgID uuid.UUID, perm Permission) (bool, error) { + membership, err := db.GetUserMembership(ctx, userID, orgID) + if err != nil { + return false, err + } + + perms, ok := rolePermissions[membership.Role] + if !ok { + return false, fmt.Errorf("unknown role: %s", membership.Role) + } + + for _, p := range perms { + if p == perm { + return true, nil + } + } + return false, nil +} diff --git a/go_cloud/migrations/0001_initial.sql b/go_cloud/migrations/0001_initial.sql new file mode 100644 index 0000000..6ae09a2 --- /dev/null +++ b/go_cloud/migrations/0001_initial.sql @@ -0,0 +1,42 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + display_name TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_login_at TIMESTAMP WITH TIME ZONE +); + +CREATE TABLE organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE memberships ( + user_id UUID REFERENCES users(id), + org_id UUID REFERENCES organizations(id), + role TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (user_id, org_id) +); + +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + revoked_at TIMESTAMP WITH TIME ZONE +); + +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + org_id UUID REFERENCES organizations(id), + action TEXT NOT NULL, + resource TEXT, + success BOOLEAN NOT NULL, + timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + metadata JSONB +); \ No newline at end of file diff --git a/go_cloud/migrations/0001_initial_down.sql b/go_cloud/migrations/0001_initial_down.sql new file mode 100644 index 0000000..f9ea326 --- /dev/null +++ b/go_cloud/migrations/0001_initial_down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS audit_logs; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS memberships; +DROP TABLE IF EXISTS organizations; +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/go_cloud/pkg/jwt/jwt.go b/go_cloud/pkg/jwt/jwt.go new file mode 100644 index 0000000..0c030f4 --- /dev/null +++ b/go_cloud/pkg/jwt/jwt.go @@ -0,0 +1,84 @@ +package jwt + +import ( + "context" + "errors" + "time" + + "go.b0esche.cloud/backend/internal/database" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" +) + +type Claims struct { + UserID string `json:"user_id"` + OrgIDs []string `json:"org_ids"` + SessionID string `json:"session_id"` + jwt.RegisteredClaims +} + +type Manager struct { + secret []byte +} + +func NewManager(secret string) *Manager { + return &Manager{secret: []byte(secret)} +} + +func (m *Manager) Generate(userID string, orgIDs []string, sessionID string) (string, error) { + claims := Claims{ + UserID: userID, + OrgIDs: orgIDs, + SessionID: sessionID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(m.secret) +} + +func (m *Manager) Validate(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return m.secret, nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} + +func (m *Manager) ValidateWithSession(ctx context.Context, tokenString string, db *database.DB) (*Claims, *database.Session, error) { + claims, err := m.Validate(tokenString) + if err != nil { + return nil, nil, err + } + + sessionID, err := uuid.Parse(claims.SessionID) + if err != nil { + return nil, nil, errors.New("invalid session ID in token") + } + + session, err := db.GetSession(ctx, sessionID) + if err != nil { + return nil, nil, err + } + + if session.RevokedAt != nil || time.Now().After(session.ExpiresAt) { + return nil, nil, errors.New("session invalid") + } + + return claims, session, nil +}