diff --git a/README.md b/README.md index ca41fb0..1eab200 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ # realtime-messaging Real time pubsub messaging with Golang and Websockets + +### Build and run the project +``` +docker compose up --build +``` + +### Obtain a JWT token +``` +docker compose run auth-cli /app/auth-cli-bin -action create -username demo-user +``` + +### Access Grafana dashboard: localhost:3002/d/realtime-metrics-dashboard diff --git a/auth-cli/main.go b/auth-cli/main.go new file mode 100644 index 0000000..15e1d66 --- /dev/null +++ b/auth-cli/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/iulian509/realtime-messaging/config" +) + +func main() { + action := flag.String("action", "", "Action to perform (create)") + username := flag.String("username", "", "Username for the token") + + flag.Parse() + + if *action != "create" { + flag.Usage() + os.Exit(1) + } + + if *username == "" { + log.Fatal("username is required for create action") + } + + token, err := generateJWT(*username) + if err != nil { + log.Fatalf("failed to generate JWT: %v", err) + } + + fmt.Printf("generated JWT for user %s: %s\n", *username, token) +} + +func generateJWT(username string) (string, error) { + config, err := config.LoadConfig() + if err != nil { + log.Fatalf("failed to load YAML configuration") + } + + claims := jwt.MapClaims{ + "username": username, + "exp": time.Now().Add(time.Hour * 1).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(config.JWT.SecretKey)) +} diff --git a/config/config-dev.yml b/config/config-dev.yml index 7d48752..5d8ba94 100644 --- a/config/config-dev.yml +++ b/config/config-dev.yml @@ -1,2 +1,5 @@ nats: host: 'nats://realtime-messaging-nats:4222' + +jwt: + secretKey: "your-very-secure-secret-key" # Do not store real keys here diff --git a/config/config.go b/config/config.go index 192423f..71b8de1 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,9 @@ type Config struct { Nats struct { Host string `yaml:"host"` } + JWT struct { + SecretKey string `yaml:"secretKey"` + } } func LoadConfig() (*Config, error) { diff --git a/docker-compose.yml b/docker-compose.yml index 8ebe091..50d8a1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,3 +42,7 @@ services: volumes: - ./grafana/provisioning:/etc/grafana/provisioning - ./grafana/dashboards:/var/lib/grafana/dashboards + auth-cli: + build: + context: . + dockerfile: docker/auth-cli/Dockerfile diff --git a/docker/auth-cli/Dockerfile b/docker/auth-cli/Dockerfile new file mode 100644 index 0000000..e342428 --- /dev/null +++ b/docker/auth-cli/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.24.2-alpine + +WORKDIR /app + +COPY config/ ./config/ +COPY auth-cli/ ./auth-cli +COPY go.mod go.sum ./ + +RUN go mod download + +RUN CGO_ENABLED=0 GOOS=linux go build -o auth-cli-bin -ldflags "-s -w" ./auth-cli/ diff --git a/go.mod b/go.mod index c62dd62..880ee2d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.2 require ( github.com/goccy/go-yaml v1.17.1 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/gorilla/websocket v1.5.3 github.com/nats-io/nats.go v1.40.1 github.com/prometheus/client_golang v1.21.1 diff --git a/go.sum b/go.sum index b286c29..45179d9 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..e1d49b3 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,46 @@ +package auth + +import ( + "fmt" + "log" + "net/http" + + "github.com/golang-jwt/jwt/v5" + "github.com/iulian509/realtime-messaging/config" +) + +func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "missing Authorization header", http.StatusUnauthorized) + return + } + + const bearerPrefix = "Bearer " + if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix { + http.Error(w, "invalid Authorization header format", http.StatusUnauthorized) + return + } + + tokenString := authHeader[len(bearerPrefix):] + config, err := config.LoadConfig() + if err != nil { + log.Fatalf("failed to load YAML configuration") + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(config.JWT.SecretKey), nil + }) + + if err != nil || !token.Valid { + http.Error(w, "invalid or expired token", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + } +} diff --git a/publisher/cmd/publisher/main.go b/publisher/cmd/publisher/main.go index 98af4ff..5ab4a00 100644 --- a/publisher/cmd/publisher/main.go +++ b/publisher/cmd/publisher/main.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/iulian509/realtime-messaging/config" + "github.com/iulian509/realtime-messaging/internal/auth" "github.com/iulian509/realtime-messaging/internal/metrics" "github.com/iulian509/realtime-messaging/publisher/internal/handlers" "github.com/iulian509/realtime-messaging/publisher/internal/mq" @@ -28,7 +29,7 @@ func main() { } http.HandleFunc("/metrics", metrics.PromHandler()) - http.HandleFunc("/publish", metrics.PrometheusMiddleware(deps.PublisherHandler)) + http.HandleFunc("/publish", metrics.PrometheusMiddleware(auth.AuthMiddleware(deps.PublisherHandler))) err = http.ListenAndServe(":3000", nil) log.Println("publisher service running on :3000") diff --git a/subscriber/cmd/subscriber/main.go b/subscriber/cmd/subscriber/main.go index e004528..248b415 100644 --- a/subscriber/cmd/subscriber/main.go +++ b/subscriber/cmd/subscriber/main.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/iulian509/realtime-messaging/config" + "github.com/iulian509/realtime-messaging/internal/auth" "github.com/iulian509/realtime-messaging/internal/metrics" "github.com/iulian509/realtime-messaging/subscriber/internal/handlers" "github.com/iulian509/realtime-messaging/subscriber/internal/mq" @@ -28,7 +29,7 @@ func main() { } http.HandleFunc("/metrics", metrics.PromHandler()) - http.HandleFunc("/subscribe", metrics.PrometheusMiddleware(deps.SubscriberHandler)) + http.HandleFunc("/subscribe", metrics.PrometheusMiddleware(auth.AuthMiddleware(deps.SubscriberHandler))) err = http.ListenAndServe(":3001", nil) log.Println("subscriber service running on :3001")