From e5412dd578ce8be293000684b8b0c1f1301fd997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Proen=C3=A7a?= Date: Wed, 21 May 2025 01:06:13 +0100 Subject: [PATCH] Re-added keycloak development --- code/docker-compose.yml | 21 ++ code/grpc-gateway/main.go | 105 +++++-- code/grpc-gateway/middleware/auth.go | 163 +++++++++++ code/grpc-gateway/middleware/auth_routes.go | 274 ++++++++++++++++++ code/keycloak/realm-export.json | 53 ++++ code/kubernetes/keycloak/configmap.yaml | 10 + code/kubernetes/keycloak/deployment.yaml | 63 ++++ code/kubernetes/keycloak/realm-configmap.yaml | 46 +++ code/kubernetes/keycloak/secrets.yaml | 11 + code/kubernetes/scripts/deploy.sh | 14 +- code/kubernetes/scripts/keycloak-ops.sh | 65 +++++ code/kubernetes/traefik/ingress-routes.yaml | 54 ++++ code/services/auth/auth.go | 120 ++++++++ code/traefik/traefik.yml | 51 ++++ 14 files changed, 1021 insertions(+), 29 deletions(-) create mode 100644 code/grpc-gateway/middleware/auth.go create mode 100644 code/grpc-gateway/middleware/auth_routes.go create mode 100644 code/keycloak/realm-export.json create mode 100644 code/kubernetes/keycloak/configmap.yaml create mode 100644 code/kubernetes/keycloak/deployment.yaml create mode 100644 code/kubernetes/keycloak/realm-configmap.yaml create mode 100644 code/kubernetes/keycloak/secrets.yaml create mode 100644 code/kubernetes/scripts/keycloak-ops.sh create mode 100644 code/kubernetes/traefik/ingress-routes.yaml create mode 100644 code/services/auth/auth.go diff --git a/code/docker-compose.yml b/code/docker-compose.yml index 261f24f..f512fc1 100644 --- a/code/docker-compose.yml +++ b/code/docker-compose.yml @@ -194,6 +194,27 @@ services: networks: - threadit-network + keycloak: + image: quay.io/keycloak/keycloak:21.1 + container_name: keycloak + restart: always + command: + - start-dev + - --import-realm + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + KC_HOSTNAME_STRICT: false + KC_HOSTNAME_STRICT_HTTPS: false + KC_HTTP_ENABLED: "true" + KC_PROXY: edge + volumes: + - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json:ro + ports: + - "${KEYCLOAK_PORT}:8080" + networks: + - threadit-network + volumes: db_data: driver: local diff --git a/code/grpc-gateway/main.go b/code/grpc-gateway/main.go index 3f5bbfe..b6d9470 100644 --- a/code/grpc-gateway/main.go +++ b/code/grpc-gateway/main.go @@ -18,6 +18,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/types/known/emptypb" + "threadit/grpc-gateway/middleware" ) func getGrpcServerAddress(hostEnvVar string, portEnvVar string) string { @@ -108,8 +109,24 @@ func handleHealthCheck(w http.ResponseWriter, r *http.Request) { } func main() { - gwmux := runtime.NewServeMux() + ctx := context.Background() + mux := runtime.NewServeMux() + + // Initialize auth handler + authHandler := middleware.NewAuthHandler( + os.Getenv("KEYCLOAK_URL"), + os.Getenv("KEYCLOAK_CLIENT_ID"), + os.Getenv("KEYCLOAK_CLIENT_SECRET"), + os.Getenv("KEYCLOAK_REALM"), + ) + + // Create a new ServeMux for both gRPC-Gateway and auth routes + httpMux := http.NewServeMux() + // Register auth routes + authHandler.RegisterRoutes(httpMux) + + // gRPC dial options with message size configurations opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultCallOptions( @@ -118,48 +135,80 @@ func main() { ), } - err := communitypb.RegisterCommunityServiceHandlerFromEndpoint(context.Background(), gwmux, getGrpcServerAddress("COMMUNITY_SERVICE_HOST", "COMMUNITY_SERVICE_PORT"), opts) - if err != nil { - log.Fatalf("Failed to register gRPC gateway: %v", err) + // Register gRPC-Gateway routes with auth middleware + httpMux.Handle("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Auth middleware for API routes + authMiddleware := middleware.NewAuthMiddleware(middleware.KeycloakConfig{ + Realm: os.Getenv("KEYCLOAK_REALM"), + ClientID: os.Getenv("KEYCLOAK_CLIENT_ID"), + ClientSecret: os.Getenv("KEYCLOAK_CLIENT_SECRET"), + KeycloakURL: os.Getenv("KEYCLOAK_URL"), + }) + + authMiddleware.Handler(mux).ServeHTTP(w, r) + })) + + // Register service handlers + if err := registerServices(ctx, mux, opts); err != nil { + log.Fatalf("Failed to register services: %v", err) } - err = threadpb.RegisterThreadServiceHandlerFromEndpoint(context.Background(), gwmux, getGrpcServerAddress("THREAD_SERVICE_HOST", "THREAD_SERVICE_PORT"), opts) - if err != nil { - log.Fatalf("Failed to register gRPC gateway: %v", err) + port := os.Getenv("GRPC_GATEWAY_PORT") + if port == "" { + log.Fatalf("missing GRPC_GATEWAY_PORT env var") } - err = commentpb.RegisterCommentServiceHandlerFromEndpoint(context.Background(), gwmux, getGrpcServerAddress("COMMENT_SERVICE_HOST", "COMMENT_SERVICE_PORT"), opts) - if err != nil { - log.Fatalf("Failed to register gRPC gateway: %v", err) + log.Printf("gRPC Gateway server listening on :%s", port) + if err := http.ListenAndServe(":"+port, httpMux); err != nil { + log.Fatalf("Failed to serve: %v", err) + } +} + +func registerServices(ctx context.Context, mux *runtime.ServeMux, opts []grpc.DialOption) error { + // Register Community Service + if err := communitypb.RegisterCommunityServiceHandlerFromEndpoint( + ctx, mux, getGrpcServerAddress("COMMUNITY_SERVICE_HOST", "COMMUNITY_SERVICE_PORT"), opts, + ); err != nil { + return fmt.Errorf("failed to register community service: %v", err) } - err = votepb.RegisterVoteServiceHandlerFromEndpoint(context.Background(), gwmux, getGrpcServerAddress("VOTE_SERVICE_HOST", "VOTE_SERVICE_PORT"), opts) - if err != nil { - log.Fatalf("Failed to register gRPC gateway: %v", err) + // Register Thread Service + if err := threadpb.RegisterThreadServiceHandlerFromEndpoint( + ctx, mux, getGrpcServerAddress("THREAD_SERVICE_HOST", "THREAD_SERVICE_PORT"), opts, + ); err != nil { + return fmt.Errorf("failed to register thread service: %v", err) } - err = searchpb.RegisterSearchServiceHandlerFromEndpoint(context.Background(), gwmux, getGrpcServerAddress("SEARCH_SERVICE_HOST", "SEARCH_SERVICE_PORT"), opts) - if err != nil { - log.Fatalf("Failed to register gRPC gateway: %v", err) + // Register Comment Service + if err := commentpb.RegisterCommentServiceHandlerFromEndpoint( + ctx, mux, getGrpcServerAddress("COMMENT_SERVICE_HOST", "COMMENT_SERVICE_PORT"), opts, + ); err != nil { + return fmt.Errorf("failed to register comment service: %v", err) } - err = popularpb.RegisterPopularServiceHandlerFromEndpoint(context.Background(), gwmux, getGrpcServerAddress("POPULAR_SERVICE_HOST", "POPULAR_SERVICE_PORT"), opts) - if err != nil { - log.Fatalf("Failed to register gRPC gateway: %v", err) + // Register Vote Service + if err := votepb.RegisterVoteServiceHandlerFromEndpoint( + ctx, mux, getGrpcServerAddress("VOTE_SERVICE_HOST", "VOTE_SERVICE_PORT"), opts, + ); err != nil { + return fmt.Errorf("failed to register vote service: %v", err) } http.HandleFunc("/health", handleHealthCheck) + http.Handle("/", mux) - http.Handle("/", gwmux) - - port := os.Getenv("GRPC_GATEWAY_PORT") - if port == "" { - log.Fatalf("missing GRPC_GATEWAY_PORT env var") + // Register Search Service + if err := searchpb.RegisterSearchServiceHandlerFromEndpoint( + ctx, mux, getGrpcServerAddress("SEARCH_SERVICE_HOST", "SEARCH_SERVICE_PORT"), opts, + ); err != nil { + return fmt.Errorf("failed to register search service: %v", err) } - log.Printf("gRPC Gateway server listening on :%s", port) - err = http.ListenAndServe(fmt.Sprintf(":%s", port), nil) - if err != nil { - log.Fatalf("Failed to start HTTP server: %v", err) + // Register Popular Service + if err := popularpb.RegisterPopularServiceHandlerFromEndpoint( + ctx, mux, getGrpcServerAddress("POPULAR_SERVICE_HOST", "POPULAR_SERVICE_PORT"), opts, + ); err != nil { + return fmt.Errorf("failed to register popular service: %v", err) } + + return nil } diff --git a/code/grpc-gateway/middleware/auth.go b/code/grpc-gateway/middleware/auth.go new file mode 100644 index 0000000..5796953 --- /dev/null +++ b/code/grpc-gateway/middleware/auth.go @@ -0,0 +1,163 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc/metadata" + "your-module/code/services/auth" +) + +type AuthMiddleware struct { + keycloak *auth.KeycloakClient +} + +func NewAuthMiddleware(config auth.KeycloakConfig) (*AuthMiddleware, error) { + kc, err := auth.NewKeycloakClient(config) + if err != nil { + return nil, err + } + return &AuthMiddleware{keycloak: kc}, nil +} + +func (am *AuthMiddleware) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip auth for public endpoints + if isPublicEndpoint(r.URL.Path, r.Method) { + next.ServeHTTP(w, r) + return + } + + // Extract token from Authorization header + token, err := auth.ExtractBearerToken(r.Header.Get("Authorization")) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Validate token + claims, err := am.keycloak.ValidateToken(r.Context(), token) + if err != nil { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + // Check required roles for protected endpoints + if !hasRequiredRole(r.URL.Path, claims) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // Add user info to context + ctx := context.WithValue(r.Context(), "user_claims", claims) + + // Forward token to gRPC services + md := metadata.Pairs("authorization", "Bearer "+token) + ctx = metadata.NewOutgoingContext(ctx, md) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func isPublicEndpoint(path, method string) bool { + // Auth endpoints are always public + authPaths := []string{ + "/auth/login", + "/auth/register", + "/auth/logout", + } + for _, ap := range authPaths { + if path == ap { + return true + } + } + + // Only GET requests can be public for these paths + if method != http.MethodGet { + return false + } + + publicGetPaths := []string{ + "/communities", + "/threads", + "/comments", + "/search", + "/search/thread", + "/search/community", + "/popular/threads", + "/popular/comments", + } + + // Check exact matches for list endpoints + for _, pp := range publicGetPaths { + if path == pp { + return true + } + } + + // Check id based paths + idBasedPaths := []string{ + "/communities/", + "/threads/", + "/comments/", + } + + for _, pp := range idBasedPaths { + if strings.HasPrefix(path, pp) && path != pp { + return true + } + } + + return false +} + +func hasRequiredRole(path string, claims *auth.TokenClaims) bool { + roleRequirements := map[string]string{ + // Communities + "POST /communities": "user", + "PATCH /communities/": "moderator", + "DELETE /communities/": "moderator", + + // Threads + "POST /threads": "user", + "PATCH /threads/": "user", + "DELETE /threads/": "user", + + // Comment sdpoints + "POST /comments": "user", + "PATCH /comments/": "user", + "DELETE /comments/": "user", + + // Votes + "POST /votes/thread/": "user", + "POST /votes/comment/": "user", + + // Admin + "POST /admin/": "admin", + "PUT /admin/": "admin", + "DELETE /admin/": "admin", + } + + // Check each role requirement + for pathPattern, requiredRole := range roleRequirements { + parts := strings.SplitN(pathPattern, " ", 2) + method, pattern := parts[0], parts[1] + if strings.HasPrefix(path, pattern) { + return claims.RealmAccess.Roles != nil && contains(claims.RealmAccess.Roles, requiredRole) + } + } + + // If no specific role requirement, allow access + return true +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/code/grpc-gateway/middleware/auth_routes.go b/code/grpc-gateway/middleware/auth_routes.go new file mode 100644 index 0000000..8eb112d --- /dev/null +++ b/code/grpc-gateway/middleware/auth_routes.go @@ -0,0 +1,274 @@ +package middleware + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +type AuthHandler struct { + keycloakURL string + clientID string + clientSecret string + realm string + adminClientID string +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` +} + +type ErrorResponse struct { + Error string `json:"error"` + Description string `json:"error_description,omitempty"` +} + +func NewAuthHandler(keycloakURL, clientID, clientSecret, realm string) *AuthHandler { + return &AuthHandler{ + keycloakURL: keycloakURL, + clientID: clientID, + clientSecret: clientSecret, + realm: realm, + adminClientID: "admin-cli", + } +} + +func (h *AuthHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/auth/register", h.handleRegister) + mux.HandleFunc("/auth/login", h.handleLogin) + mux.HandleFunc("/auth/logout", h.handleLogout) +} + +func (h *AuthHandler) validateRegister(req *RegisterRequest) error { + // Username validation + if len(req.Username) < 3 || len(req.Username) > 30 { + return fmt.Errorf("username must be between 3 and 30 characters") + } + if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(req.Username) { + return fmt.Errorf("username can only contain letters, numbers, underscores, and hyphens") + } + + // Email validation + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + if !emailRegex.MatchString(req.Email) { + return fmt.Errorf("invalid email format") + } + + // Password validation + if len(req.Password) < 8 { + return fmt.Errorf("password must be at least 8 characters long") + } + if !regexp.MustCompile(`[A-Z]`).MatchString(req.Password) { + return fmt.Errorf("password must contain at least one uppercase letter") + } + if !regexp.MustCompile(`[a-z]`).MatchString(req.Password) { + return fmt.Errorf("password must contain at least one lowercase letter") + } + if !regexp.MustCompile(`[0-9]`).MatchString(req.Password) { + return fmt.Errorf("password must contain at least one number") + } + + return nil +} + +func (h *AuthHandler) handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + h.sendError(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.sendError(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate registration data + if err := h.validateRegister(&req); err != nil { + h.sendError(w, err.Error(), http.StatusBadRequest) + return + } + + // Create user in Keycloak + keycloakURL := fmt.Sprintf("%s/auth/admin/realms/%s/users", h.keycloakURL, h.realm) + userData := map[string]interface{}{ + "username": req.Username, + "email": req.Email, + "enabled": true, + "credentials": []map[string]interface{}{ + { + "type": "password", + "value": req.Password, + "temporary": false, + }, + }, + } + + jsonData, err := json.Marshal(userData) + if err != nil { + h.sendError(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Get admin token + adminToken, err := h.getAdminToken() + if err != nil { + h.sendError(w, "Failed to authenticate with Keycloak", http.StatusInternalServerError) + return + } + + request, err := http.NewRequest(http.MethodPost, keycloakURL, strings.NewReader(string(jsonData))) + if err != nil { + h.sendError(w, "Internal server error", http.StatusInternalServerError) + return + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+adminToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(request) + if err != nil { + h.sendError(w, "Failed to register user", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + h.sendError(w, fmt.Sprintf("Failed to register user: %s", string(body)), resp.StatusCode) + return + } + + // Auto login after registration + h.performLogin(w, req.Username, req.Password) +} + +func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + h.sendError(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.sendError(w, "Invalid request body", http.StatusBadRequest) + return + } + + h.performLogin(w, req.Username, req.Password) +} + +func (h *AuthHandler) performLogin(w http.ResponseWriter, username, password string) { + tokenURL := fmt.Sprintf("%s/auth/realms/%s/protocol/openid-connect/token", h.keycloakURL, h.realm) + data := url.Values{} + data.Set("grant_type", "password") + data.Set("client_id", h.clientID) + data.Set("client_secret", h.clientSecret) + data.Set("username", username) + data.Set("password", password) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.PostForm(tokenURL, data) + if err != nil { + h.sendError(w, "Failed to login", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + h.sendError(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + // Forward Keycloak response (tokens) to client + w.Header().Set("Content-Type", "application/json") + io.Copy(w, resp.Body) +} + +func (h *AuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + h.sendError(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + token := r.Header.Get("Authorization") + if token == "" { + h.sendError(w, "No token provided", http.StatusBadRequest) + return + } + + logoutURL := fmt.Sprintf("%s/auth/realms/%s/protocol/openid-connect/logout", h.keycloakURL, h.realm) + request, err := http.NewRequest(http.MethodPost, logoutURL, nil) + if err != nil { + h.sendError(w, "Internal server error", http.StatusInternalServerError) + return + } + + request.Header.Set("Authorization", token) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(request) + if err != nil { + h.sendError(w, "Failed to logout", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + h.sendError(w, "Failed to logout", resp.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"}) +} + +func (h *AuthHandler) getAdminToken() (string, error) { + tokenURL := fmt.Sprintf("%s/auth/realms/master/protocol/openid-connect/token", h.keycloakURL) + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("client_id", h.adminClientID) + data.Set("client_secret", h.clientSecret) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.PostForm(tokenURL, data) + if err != nil { + return "", fmt.Errorf("failed to get admin token: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get admin token: status %d", resp.StatusCode) + } + + var result struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode admin token response: %v", err) + } + + return result.AccessToken, nil +} + +func (h *AuthHandler) sendError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(ErrorResponse{ + Error: http.StatusText(status), + Description: message, + }) +} diff --git a/code/keycloak/realm-export.json b/code/keycloak/realm-export.json new file mode 100644 index 0000000..e21a533 --- /dev/null +++ b/code/keycloak/realm-export.json @@ -0,0 +1,53 @@ +{ + "id": "threadit", + "realm": "threadit", + "enabled": true, + "roles": { + "realm": [ + { + "name": "user", + "description": "User role" + }, + { + "name": "moderator", + "description": "Community moderator role" + }, + { + "name": "admin", + "description": "Admin role" + } + ] + }, + "defaultRoles": ["user"], + "clients": [ + { + "clientId": "threadit-api", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "${CLIENT_SECRET}", + "redirectUris": ["*"], + "webOrigins": ["*"], + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true + } + ], + "users": [ + { + "username": "admin", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "${ADMIN_PASSWORD}", + "temporary": false + } + ], + "realmRoles": ["admin"] + } + ] +} \ No newline at end of file diff --git a/code/kubernetes/keycloak/configmap.yaml b/code/kubernetes/keycloak/configmap.yaml new file mode 100644 index 0000000..d1c6865 --- /dev/null +++ b/code/kubernetes/keycloak/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: keycloak-config + namespace: threadit +data: + KC_HOSTNAME_STRICT: "false" + KC_HOSTNAME_STRICT_HTTPS: "false" + KC_HTTP_ENABLED: "true" + KC_PROXY: "edge" \ No newline at end of file diff --git a/code/kubernetes/keycloak/deployment.yaml b/code/kubernetes/keycloak/deployment.yaml new file mode 100644 index 0000000..d4715b8 --- /dev/null +++ b/code/kubernetes/keycloak/deployment.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: Service +metadata: + name: keycloak + namespace: threadit + labels: + app: keycloak +spec: + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + selector: + app: keycloak +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak + namespace: threadit + labels: + app: keycloak +spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:21.1 + args: ["start-dev", "--import-realm"] + ports: + - containerPort: 8080 + envFrom: + - configMapRef: + name: keycloak-config + - secretRef: + name: keycloak-secrets + volumeMounts: + - name: realm-config + mountPath: /opt/keycloak/data/import + readOnly: true + readinessProbe: + httpGet: + path: /auth/realms/master + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /auth/realms/master + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 15 + volumes: + - name: realm-config + configMap: + name: keycloak-realm-config \ No newline at end of file diff --git a/code/kubernetes/keycloak/realm-configmap.yaml b/code/kubernetes/keycloak/realm-configmap.yaml new file mode 100644 index 0000000..68c737d --- /dev/null +++ b/code/kubernetes/keycloak/realm-configmap.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: keycloak-realm-config + namespace: threadit +data: + realm.json: | + { + "id": "threadit", + "realm": "threadit", + "enabled": true, + "roles": { + "realm": [ + { + "name": "user", + "description": "User role" + }, + { + "name": "moderator", + "description": "Community moderator role" + }, + { + "name": "admin", + "description": "Admin role" + } + ] + }, + "defaultRoles": ["user"], + "clients": [ + { + "clientId": "threadit-api", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "${CLIENT_SECRET}", + "redirectUris": ["*"], + "webOrigins": ["*"], + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true + } + ] + } \ No newline at end of file diff --git a/code/kubernetes/keycloak/secrets.yaml b/code/kubernetes/keycloak/secrets.yaml new file mode 100644 index 0000000..322821f --- /dev/null +++ b/code/kubernetes/keycloak/secrets.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-secrets + namespace: threadit +type: Opaque +data: + KC_DB_PASSWORD: a2V5Y2xvYWtfcGFzc3dvcmQ= + KEYCLOAK_ADMIN: YWRtaW4= + KEYCLOAK_ADMIN_PASSWORD: YWRtaW5fcGFzc3dvcmQ= + CLIENT_SECRET: eW91ci1jbGllbnQtc2VjcmV0 \ No newline at end of file diff --git a/code/kubernetes/scripts/deploy.sh b/code/kubernetes/scripts/deploy.sh index 854279f..b9c58ee 100644 --- a/code/kubernetes/scripts/deploy.sh +++ b/code/kubernetes/scripts/deploy.sh @@ -54,6 +54,7 @@ helm upgrade --install traefik traefik/traefik -n $CLUSTER_NAME -f traefik/value kubectl apply -n $CLUSTER_NAME -f traefik/cors.yaml kubectl apply -n $CLUSTER_NAME -f traefik/strip-prefix.yaml +kubectl apply -n $CLUSTER_NAME -f traefik/ingress-routes.yaml # Deploy threadit application kubectl create secret generic "bucket-secret" \ @@ -68,8 +69,19 @@ kubectl create secret generic "mongo-secret" \ kubectl apply -n $CLUSTER_NAME -f config.yaml kubectl apply -n $CLUSTER_NAME -f mongo/ +echo "Deploying Keycloak..." +kubectl apply -n $CLUSTER_NAME -f keycloak/configmap.yaml +kubectl apply -n $CLUSTER_NAME -f keycloak/secrets.yaml +kubectl apply -n $CLUSTER_NAME -f keycloak/realm-configmap.yaml +kubectl apply -n $CLUSTER_NAME -f keycloak/deployment.yaml + for SERVICE in "${SERVICES[@]}"; do kubectl apply -n $CLUSTER_NAME -f services/"$SERVICE-service"/ done -kubectl apply -n $CLUSTER_NAME -f grpc-gateway/ \ No newline at end of file +kubectl apply -n $CLUSTER_NAME -f grpc-gateway/ + +echo "Waiting for Keycloak to be ready..." +kubectl wait --for=condition=ready pod -l app=keycloak -n $CLUSTER_NAME --timeout=300s + +echo "Deployment complete!" \ No newline at end of file diff --git a/code/kubernetes/scripts/keycloak-ops.sh b/code/kubernetes/scripts/keycloak-ops.sh new file mode 100644 index 0000000..170a5f4 --- /dev/null +++ b/code/kubernetes/scripts/keycloak-ops.sh @@ -0,0 +1,65 @@ +#!/bin/bash +set -e + +CLUSTER_NAME="threadit-cluster" + +function help() { + echo "Usage: $0 " + echo "Commands:" + echo " status - Check Keycloak status" + echo " logs - Show Keycloak logs" + echo " restart - Restart Keycloak deployment" + echo " reload - Reload realm configuration" + echo " port-forward - Start port forwarding to access Keycloak locally" +} + +function check_status() { + echo "Checking Keycloak status..." + kubectl get pods -n $CLUSTER_NAME -l app=keycloak +} + +function show_logs() { + echo "Fetching Keycloak logs..." + kubectl logs -n $CLUSTER_NAME -l app=keycloak --tail=100 -f +} + +function restart_keycloak() { + echo "Restarting Keycloak..." + kubectl rollout restart deployment/keycloak -n $CLUSTER_NAME + kubectl rollout status deployment/keycloak -n $CLUSTER_NAME +} + +function reload_realm() { + echo "Reloading realm configuration..." + # Delete the existing pod to force a reload of the realm config + kubectl delete pod -n $CLUSTER_NAME -l app=keycloak + echo "Waiting for new pod to be ready..." + kubectl wait --for=condition=ready pod -l app=keycloak -n $CLUSTER_NAME --timeout=300s +} + +function port_forward() { + echo "Starting port forward to Keycloak on localhost:8080..." + kubectl port-forward -n $CLUSTER_NAME svc/keycloak 8080:8080 +} + +case "$1" in + "status") + check_status + ;; + "logs") + show_logs + ;; + "restart") + restart_keycloak + ;; + "reload") + reload_realm + ;; + "port-forward") + port_forward + ;; + *) + help + exit 1 + ;; +esac \ No newline at end of file diff --git a/code/kubernetes/traefik/ingress-routes.yaml b/code/kubernetes/traefik/ingress-routes.yaml new file mode 100644 index 0000000..626a069 --- /dev/null +++ b/code/kubernetes/traefik/ingress-routes.yaml @@ -0,0 +1,54 @@ +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: cors-headers + namespace: threadit +spec: + headers: + accessControlAllowMethods: + - GET + - POST + - PUT + - DELETE + - PATCH + accessControlAllowHeaders: + - "Authorization" + - "Content-Type" + accessControlAllowOriginList: + - "*" + accessControlMaxAge: 100 + addVaryHeader: true +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: api + namespace: threadit +spec: + entryPoints: + - web + routes: + - match: PathPrefix(`/api/v1`) + kind: Rule + services: + - name: grpc-gateway + port: 8080 + middlewares: + - name: cors-headers +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: keycloak + namespace: threadit +spec: + entryPoints: + - web + routes: + - match: PathPrefix(`/auth`) + kind: Rule + services: + - name: keycloak + port: 8080 + middlewares: + - name: cors-headers \ No newline at end of file diff --git a/code/services/auth/auth.go b/code/services/auth/auth.go new file mode 100644 index 0000000..ab1add2 --- /dev/null +++ b/code/services/auth/auth.go @@ -0,0 +1,120 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/golang-jwt/jwt/v4" + "net/http" + "strings" + "time" +) + +var ( + ErrNoToken = errors.New("no token provided") + ErrInvalidToken = errors.New("invalid token") + ErrInsufficientRole = errors.New("insufficient role") +) + +type KeycloakConfig struct { + Realm string + ClientID string + ClientSecret string + KeycloakURL string +} + +type TokenClaims struct { + jwt.StandardClaims + RealmAccess struct { + Roles []string `json:"roles"` + } `json:"realm_access"` +} + +type KeycloakClient struct { + config KeycloakConfig + keys map[string]interface{} +} + +func NewKeycloakClient(config KeycloakConfig) (*KeycloakClient, error) { + kc := &KeycloakClient{ + config: config, + keys: make(map[string]interface{}), + } + if err := kc.fetchKeys(); err != nil { + return nil, err + } + return kc, nil +} + +func (kc *KeycloakClient) fetchKeys() error { + resp, err := http.Get(fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", kc.config.KeycloakURL, kc.config.Realm)) + if err != nil { + return err + } + defer resp.Body.Close() + + var jwks struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Alg string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return err + } + + for _, key := range jwks.Keys { + kc.keys[key.Kid] = key + } + + return nil +} + +func (kc *KeycloakClient) ValidateToken(ctx context.Context, tokenString string) (*TokenClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) { + if kid, ok := token.Header["kid"].(string); ok { + if key, exists := kc.keys[kid]; exists { + return key, nil + } + } + return nil, ErrInvalidToken + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + if claims, ok := token.Claims.(*TokenClaims); ok && token.Valid { + return claims, nil + } + + return nil, ErrInvalidToken +} + +func (kc *KeycloakClient) HasRole(claims *TokenClaims, requiredRole string) bool { + for _, role := range claims.RealmAccess.Roles { + if role == requiredRole { + return true + } + } + return false +} + +func ExtractBearerToken(header string) (string, error) { + if header == "" { + return "", ErrNoToken + } + + parts := strings.Split(header, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return "", ErrInvalidToken + } + + return parts[1], nil +} diff --git a/code/traefik/traefik.yml b/code/traefik/traefik.yml index 58366a4..0b4376d 100644 --- a/code/traefik/traefik.yml +++ b/code/traefik/traefik.yml @@ -3,12 +3,63 @@ global: sendAnonymousUsage: false api: + dashboard: true insecure: true entryPoints: web: address: ":80" + forwardedHeaders: + insecure: true providers: + docker: + exposedByDefault: false file: filename: "/etc/traefik/dynamic.yml" + +http: + middlewares: + cors-headers: + headers: + accessControlAllowMethods: + - GET + - POST + - PUT + - DELETE + - PATCH + accessControlAllowHeaders: + - "Authorization" + - "Content-Type" + accessControlAllowOriginList: + - "*" + accessControlMaxAge: 100 + addVaryHeader: true + + routers: + api: + rule: "PathPrefix(`/api/v1`)" + service: "grpc-gateway" + middlewares: + - "cors-headers" + entryPoints: + - "web" + + keycloak: + rule: "PathPrefix(`/auth`)" + service: "keycloak" + middlewares: + - "cors-headers" + entryPoints: + - "web" + + services: + grpc-gateway: + loadBalancer: + servers: + - url: "http://grpc-gateway:8080" + + keycloak: + loadBalancer: + servers: + - url: "http://keycloak:8080" \ No newline at end of file