go first commit

This commit is contained in:
Leon Bösche
2025-12-17 22:57:57 +01:00
parent e5a4de7aab
commit 7749ebfd08
22 changed files with 1044 additions and 0 deletions

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 + `"}`))
}

View File

@@ -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,
},
}
}

View File

@@ -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)
})
}
}

View File

@@ -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
}

View File

@@ -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
}