diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..ec272629c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.23-alpine AS builder +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o prothetic-server . + +FROM alpine:latest +WORKDIR /root/ + +COPY --from=builder /app/prothetic-server . + +EXPOSE 8000 + +CMD ["./prothetic-server"] \ No newline at end of file diff --git a/backend/config/config.go b/backend/config/config.go new file mode 100644 index 000000000..0ddeb41ab --- /dev/null +++ b/backend/config/config.go @@ -0,0 +1,20 @@ +package config + +import ( + "github.com/caarlos0/env/v6" +) + +type Config struct { + Issuer string `env:"ISSUER"` + ServerAddress string `env:"SERVER_ADDRESS" envDefault:":8000"` + AllowedOrigins string `env:"ALLOWED_ORIGINS" envDefault:"*"` +} + +func Load() (*Config, error) { + cfg := &Config{} + err := env.Parse(cfg) + if err != nil { + return nil, err + } + return cfg, nil +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 000000000..945cd2a76 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,17 @@ +module backend + +go 1.23.0 + +toolchain go1.23.8 + +require ( + github.com/caarlos0/env/v6 v6.10.1 + github.com/coreos/go-oidc/v3 v3.14.1 + github.com/rs/cors v1.11.1 +) + +require ( + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect +) diff --git a/backend/handlers/reports.go b/backend/handlers/reports.go new file mode 100644 index 000000000..5acae1efb --- /dev/null +++ b/backend/handlers/reports.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "encoding/json" + "math/rand" + "net/http" + "time" +) + +type Reading struct { + Timestamp time.Time `json:"timestamp"` + Sensor string `json:"sensor"` + Value float64 `json:"value"` +} + +type Report struct { + GeneratedAt time.Time `json:"generated_at"` + Readings []Reading `json:"readings"` +} + +var sensors = []string{"EMG1", "EMG2", "IMU1", "IMU2", "Battery"} + +func ReportsHandler(w http.ResponseWriter, _ *http.Request) { + now := time.Now() + readings := make([]Reading, len(sensors)) + for i, sensor := range sensors { + readings[i] = Reading{ + Timestamp: now.Add(-time.Duration(rand.Intn(1000)) * time.Millisecond), + Sensor: sensor, + Value: rand.Float64() * 100, + } + } + report := Report{ + GeneratedAt: now, + Readings: readings, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(report) +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 000000000..3cad83558 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "log" + "net/http" + "time" + + "backend/config" + "backend/handlers" + "backend/middleware" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/rs/cors" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + ctx := context.Background() + provider, err := oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + log.Fatalf("failed to discover issuer: %v", err) + } + verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true}) + + mux := http.NewServeMux() + mux.Handle( + "/reports", + middleware.AuthMiddleware(verifier, "prothetic_user")(http.HandlerFunc(handlers.ReportsHandler)), + ) + + c := cors.New(cors.Options{ + AllowedOrigins: []string{"http://localhost:3000"}, + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, + AllowedHeaders: []string{"Authorization", "Content-Type"}, + AllowCredentials: true, + }) + handler := c.Handler(mux) + + srv := &http.Server{ + Addr: cfg.ServerAddress, + Handler: handler, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + } + + log.Printf("server listening on %s", cfg.ServerAddress) + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("server failed: %v", err) + } +} diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go new file mode 100644 index 000000000..766fad4e2 --- /dev/null +++ b/backend/middleware/auth.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "log" + "net/http" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" +) + +func AuthMiddleware(verifier *oidc.IDTokenVerifier, requiredRole string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth == "" { + log.Printf("auth error: Authorization header missing") + http.Error(w, "Authorization header missing", http.StatusUnauthorized) + return + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + log.Printf("auth error: invalid header format: %q", auth) + http.Error(w, "Authorization header format must be Bearer {token}", http.StatusUnauthorized) + return + } + rawToken := parts[1] + + idToken, err := verifier.Verify(r.Context(), rawToken) + if err != nil { + log.Printf("auth error: token verification failed: %v", err) + http.Error(w, "Invalid or expired token", http.StatusUnauthorized) + return + } + + var claims struct { + RealmAccess struct { + Roles []string `json:"roles"` + } `json:"realm_access"` + } + if err = idToken.Claims(&claims); err != nil { + log.Printf("auth error: claims parsing failed: %v", err) + http.Error(w, "Invalid token claims", http.StatusUnauthorized) + return + } + + for _, role := range claims.RealmAccess.Roles { + if role == requiredRole { + next.ServeHTTP(w, r) + return + } + } + log.Printf("auth error: missing required role %q, roles: %v", requiredRole, claims.RealmAccess.Roles) + http.Error(w, "Forbidden: missing required role", http.StatusForbidden) + }) + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml index f21d8cf27..740dbccdc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,11 +20,20 @@ services: KC_DB_URL: jdbc:postgresql://keycloak_db:5432/keycloak_db KC_DB_USERNAME: keycloak_user KC_DB_PASSWORD: keycloak_password - command: + KC_HEALTH_ENABLED: "true" + command: - start-dev - --import-realm - volumes: - - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json + + healthcheck: + test: [ "CMD-SHELL", + "bash -c 'exec 3<>/dev/tcp/localhost/8080 && \ + printf \"GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n\" >&3 && \ + head -n1 <&3 | grep -q \" 200 \"'" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s ports: - "8080:8080" depends_on: @@ -40,3 +49,16 @@ services: REACT_APP_KEYCLOAK_URL: http://localhost:8080 REACT_APP_KEYCLOAK_REALM: reports-realm REACT_APP_KEYCLOAK_CLIENT_ID: reports-frontend + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + extra_hosts: + - "localhost:host-gateway" + environment: + ISSUER: http://localhost:8080/realms/reports-realm + depends_on: + keycloak: + condition: service_healthy \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c5aaaf0e3..95ffc8e1a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,21 +4,28 @@ import Keycloak, { KeycloakConfig } from 'keycloak-js'; import ReportPage from './components/ReportPage'; const keycloakConfig: KeycloakConfig = { - url: process.env.REACT_APP_KEYCLOAK_URL, - realm: process.env.REACT_APP_KEYCLOAK_REALM||"", - clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID||"" + url: process.env.REACT_APP_KEYCLOAK_URL, + realm: process.env.REACT_APP_KEYCLOAK_REALM||"", + clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID||"" }; const keycloak = new Keycloak(keycloakConfig); const App: React.FC = () => { - return ( - -
- -
-
- ); + return ( + +
+ +
+
+ ); }; export default App; \ No newline at end of file