Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
20 changes: 20 additions & 0 deletions backend/config/config.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
40 changes: 40 additions & 0 deletions backend/handlers/reports.go
Original file line number Diff line number Diff line change
@@ -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)
}
55 changes: 55 additions & 0 deletions backend/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
56 changes: 56 additions & 0 deletions backend/middleware/auth.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
28 changes: 25 additions & 3 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
27 changes: 17 additions & 10 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ReactKeycloakProvider authClient={keycloak}>
<div className="App">
<ReportPage />
</div>
</ReactKeycloakProvider>
);
return (
<ReactKeycloakProvider
authClient={keycloak}
initOptions={{
flow: 'standard',
pkceMethod: 'S256',
checkLoginIframe: false
}}
>
<div className="App">
<ReportPage />
</div>
</ReactKeycloakProvider>
);
};

export default App;