Skip to content

Commit

Permalink
add support for metrics token management and validation
Browse files Browse the repository at this point in the history
  • Loading branch information
tillkuhn committed Feb 8, 2024
1 parent b60d29f commit b1eae65
Show file tree
Hide file tree
Showing 26 changed files with 217 additions and 52 deletions.
25 changes: 14 additions & 11 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
# Inspired by https://github.com/pgporada/terraform-makefile
# Quick Reference: https://www.gnu.org/software/make/manual/html_node/Quick-Reference.html

# https://unix.stackexchange.com/questions/269077/tput-setaf-color-table-how-to-determine-color-codes
# DISABLE; will generate Makefile:11: not recursively expanding RESET to export to shell function
#BOLD=$(shell tput bold)
#RED=$(shell tput setaf 1)
#GREEN=$(shell tput setaf 2)
#YELLOW=$(shell tput setaf 3)
#CYAN=$(shell tput setaf 6)
#RESET=$(shell tput sgr0)
#STARTED=$(shell date +%s)

.DEFAULT_GOAL := help # default target when launched without arguments
.EXPORT_ALL_VARIABLES: # especially important for sub-make calls
.ONESHELL:
Expand All @@ -14,14 +24,7 @@ AWS_CMD ?= aws
# Our fingerprint changes rather often
SSH_OPTIONS ?= -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null

# https://unix.stackexchange.com/questions/269077/tput-setaf-color-table-how-to-determine-color-codes
BOLD=$(shell tput bold)
RED=$(shell tput setaf 1)
GREEN=$(shell tput setaf 2)
YELLOW=$(shell tput setaf 3)
CYAN=$(shell tput setaf 6)
RESET=$(shell tput sgr0)
STARTED=$(shell date +%s)


############################
# self documenting makefile, recipe:
Expand All @@ -47,15 +50,15 @@ help:
############################
tf-init: ## Runs terraform init on working directory ./terraform, switch to
@$(MAKE) -C terraform init;
@echo "🏗️ $(GREEN)Terraform Infrastructure successfully initialized $(RESET)[$$(($$(date +%s)-$(STARTED)))s] "
@echo "🏗️ Terraform Infrastructure successfully initialized[$$(($$(date +%s)-$(STARTED)))s] "

tf-plan: ## Runs terraform plan with implicit init and fmt (alias: plan)
@$(MAKE) -C terraform plan;
@echo "🏗️ $(GREEN)Terraform Infrastructure successfully planned $(RESET)[$$(($$(date +%s)-$(STARTED)))s]"
@echo "🏗️ Terraform Infrastructure successfully planned[$$(($$(date +%s)-$(STARTED)))s]"

tf-apply: ## Runs terraform apply with auto-approval (alias: apply)
@$(MAKE) -C terraform deploy;
@echo "🏗️ $(GREEN)Terraform Infrastructure succcessfully deployed $(RESET)[$$(($$(date +%s)-$(STARTED)))s]"
@echo "🏗️ Terraform Infrastructure succcessfully deployed[$$(($$(date +%s)-$(STARTED)))s]"

ssh: ## Runs terraform init on working directory ./terraform, switch to
@$(MAKE) -C terraform ec2-login;
Expand Down
6 changes: 4 additions & 2 deletions go/imagine/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ BASE_URL ?= http://localhost:8090
# this is super ugly (share kafka dev credentials with api project), we need this in ~/.angkor/.env!
KAFKA_SASL_USERNAME_DEV ?= $(shell grep sasl.jaas.config ../../kotlin/config/application.properties | cut -d\" -f2)
KAFKA_SASL_PASSWORD_DEV ?= $(shell grep sasl.jaas.config ../../kotlin/config/application.properties | cut -d\" -f4)
IMAGINE_JWKS_ENDPOINT=$(shell grep "^IMAGINE_JWKS_ENDPOINT" ~/.angkor/.env |cut -d= -f2-)
IMAGINE_JWKS_ENDPOINT = $(shell grep "^IMAGINE_JWKS_ENDPOINT" ~/.angkor/.env |cut -d= -f2-)
IMAGINE_API_TOKEN_METRICS = $(shell grep "^APP_API_TOKEN_METRICS" ~/.angkor/.env |cut -d= -f2-)

.ONESHELL:
.PHONY: format fmt lint run build build-linux clean test
Expand Down Expand Up @@ -48,6 +49,7 @@ run: ## run app
IMAGINE_FORCE_GC=true \
IMAGINE_ENABLE_AUTH=true \
IMAGINE_JWKS_ENDPOINT=$(IMAGINE_JWKS_ENDPOINT) \
IMAGINE_API_TOKEN_METRICS=$(IMAGINE_API_TOKEN_METRICS) \
IMAGINE_RESIZE_MODES=small:150,medium:300,large:600 IMAGINE_CONTEXTPATH=/imagine \
go run $(GO_FILES); \
else echo "AWS_SESSION_TOKEN is present, pls open a fresh terminal"; exit 1; fi
Expand Down Expand Up @@ -86,7 +88,7 @@ get: ## test get files for id

# X-Authorization and Authorization should work both
get-metrics: ## get prometheus metrics
curl -isSH "Authorization: Bearer $(JWT_TOKEN)" $(BASE_URL)/imagine/metrics
curl -isSH "Authorization: Bearer $(IMAGINE_API_TOKEN_METRICS)" $(BASE_URL)/imagine/metrics

get-presign-url: ## test get files for id
curl -i -Ss $(BASE_URL)/imagine/places/$(TEST_ID)/hase2.jpeg
Expand Down
5 changes: 3 additions & 2 deletions go/imagine/audio/audio_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package audio

import (
"os"
"testing"

"github.com/dhowden/tag"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"os"
"testing"

"github.com/stretchr/testify/assert"
)
Expand Down
11 changes: 6 additions & 5 deletions go/imagine/auth/jwtauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,11 @@ func (t *TokenInfo) Scope() interface{} {
}

// Roles returns the Cognito specific roles claim "cognito:roles"
// "cognito:roles": [
// "arn:aws:iam::06**********:role/*******-cognito-role-user",
// "arn:aws:iam::06**********:role/*******-cognito-role-admin"
// ]
//
// "cognito:roles": [
// "arn:aws:iam::06**********:role/*******-cognito-role-user",
// "arn:aws:iam::06**********:role/*******-cognito-role-admin"
// ]
func (t *TokenInfo) Roles() []interface{} {
if roles, ok := t.claims["cognito:roles"].([]interface{}); ok {
return roles
Expand All @@ -87,5 +88,5 @@ func (t *TokenInfo) Subject() interface{} {

// extractToken returns the part after "Bearer " from the auth header
func extractToken(authHeader string) string {
return strings.Split(authHeader, "Bearer ")[1]
return strings.Fields(authHeader)[1]
}
40 changes: 34 additions & 6 deletions go/imagine/auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"reflect"
"strings"

"github.com/cdfmlr/ellipsis"

"github.com/gorilla/context"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
Expand All @@ -24,6 +26,8 @@ type ContextKey string
// ContextAuthKey used to store auth info such as claims in request context
const ContextAuthKey ContextKey = "auth"

var authError = errors.New("auth error")

// New constructs a new Handler Context
func New(enabled bool, jwkUrl string, account string) *Handler {
ctxLogger := log.With().Str("logger", "auth").Logger()
Expand All @@ -40,6 +44,25 @@ func New(enabled bool, jwkUrl string, account string) *Handler {
}
}

// CheckTokenMiddleware simple validation of static tokens
func (ah *Handler) CheckTokenMiddleware(token string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
authHeader := getAuthHeader(req)
if strings.Contains(getAuthHeader(req), "Bearer") {
hToken := strings.Fields(authHeader)[1]
if hToken != token {
handleError(w, fmt.Sprintf("token %s does not match expected token %s",
ellipsis.Ending(hToken, 7), ellipsis.Ending(token, 7)), authError, http.StatusForbidden)
return
}
} else {
handleError(w, fmt.Sprintf("Invalid Auth Header %v", req.Header), authError, http.StatusForbidden)
return
}
next(w, req)
}
}

// ValidationMiddleware a wrapper around the actual request
// to validate the Authorization header and either stop processing (invalid / no token)
// or continue with the next HandlerFunc
Expand All @@ -54,12 +77,9 @@ func (ah *Handler) ValidationMiddleware(next http.HandlerFunc) http.HandlerFunc
// TODO Delegate to middleware, e.g. like this
// https://hackernoon.com/creating-a-middleware-in-golang-for-jwt-based-authentication-cx3f32z8
if ah.enabled {
authHeader := req.Header.Get("X-Authorization") // case-insensitive
if authHeader == "" {
authHeader = req.Header.Get("Authorization") // fallback (e.g. for Grafana metrics scraping)
}
authHeader := getAuthHeader(req)
if strings.Contains(authHeader, "Bearer") {
jwtB64 := strings.Split(authHeader, "Bearer ")[1]
jwtB64 := strings.Fields(authHeader)[1] // bearer part is first field
claims, err := ah.jwtAuth.ParseClaims(authHeader)
if err != nil {
handleError(w, fmt.Sprintf("Failed to parse jwtb64 %v: %v", jwtB64, err), err, http.StatusForbidden)
Expand All @@ -77,7 +97,7 @@ func (ah *Handler) ValidationMiddleware(next http.HandlerFunc) http.HandlerFunc
claims.Subject(), claims.Scope(), claims.Roles(), claims.Name(), reflect.TypeOf(claims.Roles()))
context.Set(req, ContextAuthKey, claims)
} else {
handleError(w, fmt.Sprintf("Cannot find/validate (X-)Authorization header in %v", req.Header), errors.New("oops"), http.StatusForbidden)
handleError(w, fmt.Sprintf("Cannot find (X-)Authorization with Bearer Token in %v", req.Header), authError, http.StatusForbidden)
return
}
} else {
Expand All @@ -92,3 +112,11 @@ func handleError(writer http.ResponseWriter, msg string, err error, code int) {
log.Error().Msgf("Error %s - %v", msg, err)
http.Error(writer, fmt.Sprintf("%s - %v", msg, err), code)
}

func getAuthHeader(req *http.Request) string {
authHeader := req.Header.Get("X-Authorization") // case-insensitive
if authHeader == "" {
authHeader = req.Header.Get("Authorization") // fallback (e.g. for Grafana metrics scraping)
}
return authHeader
}
20 changes: 19 additions & 1 deletion go/imagine/auth/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,25 @@ func TestValidTokenInvalidMiddleware(t *testing.T) {
for _, ah := range []string{"X-Authorization", "Authorization"} {
req.Header.Set(ah, "Bearer invalid-string")
authContextEnabled.ValidationMiddleware(testHandler).ServeHTTP(rr, req)
assert.Equal(t, rr.Code, http.StatusForbidden, rr.Body.String())
assert.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
assert.Contains(t, strings.ToLower(rr.Body.String()), "invalid number of segments")
}
}

func TestSimpleTokenInvalidMiddleware(t *testing.T) {
rr := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/metrics", nil)
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
// encToken, _ := issueToken("extra-protected")
// both headers should be checked
validToken := "hello-bear"
for _, ah := range []string{"X-Authorization"} {
req.Header.Set(ah, "Bearer "+validToken)
authContextEnabled.CheckTokenMiddleware(validToken, testHandler).ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
req.Header.Set(ah, "Bearer invalidToken")
authContextEnabled.CheckTokenMiddleware(validToken, testHandler).ServeHTTP(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String())
assert.Contains(t, strings.ToLower(rr.Body.String()), "does not match expected t")
}
}
12 changes: 12 additions & 0 deletions go/imagine/auth/sample-cognito-token-resource.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"sub": "4unkt2ome*****",
"token_use": "access",
"scope": "a*****-resources/read a*****r-resources/write",
"auth_time": 1607340912,
"iss": "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_*****",
"exp": 1607344512,
"iat": 1607340912,
"version": 2,
"jti": "e3570b50-3f93-*****-*****-**********",
"client_id": "4unkt2ome*****"
}
1 change: 1 addition & 0 deletions go/imagine/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/IBM/sarama v1.42.1 // indirect
github.com/MicahParks/keyfunc v1.9.0
github.com/aws/aws-sdk-go v1.50.12
github.com/cdfmlr/ellipsis v0.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dhowden/tag v0.0.0-20240122214204-713ab0e94639
github.com/disintegration/imaging v1.6.2
Expand Down
2 changes: 2 additions & 0 deletions go/imagine/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/cdfmlr/ellipsis v0.0.1 h1:4pwrPbKPMd4mXSdJA4CSRjgEzCbXyRiFBkmgg2KclBI=
github.com/cdfmlr/ellipsis v0.0.1/go.mod h1:hulYx9m/7Edoo2AkRzkJ/YPDlLB45BgjitI3z0sMVFI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
Expand Down
33 changes: 22 additions & 11 deletions go/imagine/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ package main
import (
"flag"
"fmt"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"net/http"
"os"
"os/signal"
"runtime"
"syscall"

"github.com/aws/aws-sdk-go/service/sts"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"

"github.com/rs/zerolog"
"github.com/tillkuhn/angkor/tools/imagine/s3"
"github.com/tillkuhn/angkor/tools/imagine/server"
Expand Down Expand Up @@ -105,11 +106,6 @@ func main() {
cp := config.ContextPath
router := mux.NewRouter()

// Prometheus Preparation
// reduce noise (default init https://github.com/prometheus/client_golang/blob/main/prometheus/registry.go#L60)
prometheus.Unregister(collectors.NewGoCollector())
prometheus.Unregister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))

// Setup AWS and init S3 Upload Worker
mainLogger.Info().Msgf("[AWS] Establish session target bucket=%s prefix=%s", config.S3Bucket, config.S3Prefix)
awsSession, err := session.NewSession(&aws.Config{Region: aws.String(config.AWSRegion)})
Expand Down Expand Up @@ -139,9 +135,20 @@ func main() {
// return metrics such as
// # TYPE promhttp_metric_handler_requests_total counter
// promhttp_metric_handler_requests_total{code="200"} 3
// Prometheus Preparation
// reduce noise (default init https://github.com/prometheus/client_golang/blob/main/prometheus/registry.go#L60)
// reg := prometheus.NewRegistry()
prometheus.Unregister(collectors.NewGoCollector())
prometheus.Unregister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
ph := http.HandlerFunc(promhttp.Handler().ServeHTTP) // need to wrap from http.Handler to HandlerFunc
router.Handle(cp+"/metrics", authHandler.ValidationMiddleware(ph)).Methods(http.MethodGet)
// ph := http.HandlerFunc(promhttp.HandlerFor(reg, promhttp.HandlerOpts{Registry: reg}).ServeHTTP) // need to wrap from http.Handler to HandlerFunc

metricsToken := os.Getenv("IMAGINE_API_TOKEN_METRICS")
if metricsToken != "" {
router.Handle(cp+"/metrics", authHandler.CheckTokenMiddleware(metricsToken, ph)).Methods(http.MethodGet)
} else {
log.Warn().Msgf("IMAGINE_API_TOKEN_METRICS not set, cannot expose prometheus metrics handler")
}
// Redirect to presigned url for a particular song (protected)
// router.HandleFunc(cp+"/songs/{item}", authHandler.ValidationMiddleware(GetSongPresignUrl)).Methods(http.MethodGet)
router.HandleFunc(cp+"/songs/{folder}/{item}", authHandler.ValidationMiddleware(httpHandler.GetSongPresignUrl)).Methods(http.MethodGet)
Expand Down Expand Up @@ -177,7 +184,11 @@ func main() {
dumpRoutes(router)

// Launch re-tagger in separate go routine
go s3Handler.Retag()
if config.MP3Retag {
go s3Handler.Retag()
} else {
log.Warn().Msg("MP3 Retag is separate goroutine is disabled")
}

// Launch the HTTP Server and block
mainLogger.Info().Msgf("[HTTP] Start HTTPServer http://localhost:%d%s", config.Port, config.ContextPath)
Expand Down
2 changes: 1 addition & 1 deletion go/imagine/server/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func TestShouldRejectPostIfUnauthenticated(t *testing.T) {
fmt.Println(targetUrl)
filename := "../README.md"
err = postFile(filename, targetUrl)
assert.Contains(t, err.Error(), "Authorization header")
assert.Contains(t, err.Error(), "auth error")
assert.Contains(t, err.Error(), "403")
}

Expand Down
1 change: 1 addition & 0 deletions go/imagine/types/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Config struct {
JwksEndpoint string `split_words:"true" desc:"Endpoint to download JWKS"`
KafkaSupport bool `default:"true" desc:"Send important events to Kafka Topic(s)" split_words:"true"`
KafkaTopic string `default:"app" desc:"Default Kafka Topic for published Events" split_words:"true"`
MP3Retag bool `default:"false" desc:"MP3 retag on start enabled" split_words:"true"`
Port int `default:"8090" desc:"http server port"`
PresignExpiry time.Duration `default:"30m" desc:"how long presigned urls are valid"`
QueueSize int `default:"10" split_words:"true" desc:"max capacity of s3 upload worker queue"`
Expand Down
10 changes: 7 additions & 3 deletions terraform/files/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ services:
angkor-api:
image: ${DOCKER_USER}/${APPID}-api:${API_VERSION}
container_name: angkor-api
# https://stackoverflow.com/a/58048994/4292075
# existing .env file will be loaded implicitly by docker-compose to adjust the environment of the docker-compose
# command itself, whereas defining an "env_file: ..." inside the yaml will take environment variables from the file
# and inject them into the container (i.e. they cannot be used for variables inside your yaml)
# env_file: "./.env" # KEEP DISABLED
# make sure all vars in environment: block below (right side) are defined in .env
# which is loaded implicitly by docker.compose
# 'docker-compose config' will show the resolved application config
# make sure all vars in environment: block below (right side) are defined in .env so they can be expanded
# TIP: 'docker-compose config' will show the resolved application config
environment:

## JDBC config
Expand Down Expand Up @@ -95,6 +98,7 @@ services:
IMAGINE_S3PREFIX: "imagine/" # todo this is already new default in recent version
IMAGINE_DUMPDIR: "/tmp" # use /tmp root in container, no volume mount necessary
IMAGINE_FORCE_GC: "true" # call freeMemory / force gc after expensive operations
IMAGINE_API_TOKEN_METRICS: "${APP_API_TOKEN_METRICS}"
ports:
- "8090:8090"
restart: always
Expand Down
11 changes: 9 additions & 2 deletions terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,13 @@ module "grafana" {
cloud_api_key = data.hcp_vault_secrets_app.rt_secrets_manual.secrets["GRAFANA_CLOUD_API_KEY"]
}

output "grafana_stack" {
value = module.grafana.cloud_stack

#output "grafana_stack" {
# value = module.grafana.cloud_stack
#}

module "tokens" {
source = "./modules/tokens"
app_id = var.appid
keeper = module.ec2.ami_id
}
3 changes: 2 additions & 1 deletion terraform/main_objects.tf
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
locals {
dotenv_content = templatefile("${path.module}/templates/.env_config", {
account_id = module.vpcinfo.account_id
aws_region = module.vpcinfo.aws_region
api_version = var.api_version
api_token_metrics = module.tokens.api_token_metrics
appid = var.appid
aws_region = module.vpcinfo.aws_region
bucket_name = module.s3.bucket_name
certbot_domain_name = var.certbot_domain_name
certbot_domain_str = format("-d %s", join(" -d ", concat([
Expand Down
Loading

0 comments on commit b1eae65

Please sign in to comment.