From 0e8d108156e100c4708347707782520518296b06 Mon Sep 17 00:00:00 2001 From: Kristjan Komlosi Date: Sun, 28 Dec 2025 13:24:16 +0100 Subject: [PATCH] initial core commit --- .dockerignore | 11 ++++ .gitignore | 14 +++++ Dockerfile | 17 ++++++ Makefile | 23 ++++++++ cmd/server/main.go | 64 ++++++++++++++++++++++ go.mod | 34 ++++++++++++ go.sum | 88 +++++++++++++++++++++++++++++++ internal/handler/handlers.go | 60 +++++++++++++++++++++ internal/handler/handlers_test.go | 87 ++++++++++++++++++++++++++++++ internal/middleware/auth.go | 42 +++++++++++++++ internal/middleware/logging.go | 23 ++++++++ readme.md | 56 ++++++++++++++++++++ 12 files changed, 519 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/handler/handlers.go create mode 100644 internal/handler/handlers_test.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/logging.go create mode 100644 readme.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..82459cf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Binaries +/bin + +# VCS +.git + +# Local env +.env + +# Build +vendor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..005eeaa --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Binaries +/bin + +# OS files +*.exe + +# Vendor +/vendor + +# Local env +.env + +# Editor +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3b7d806 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Build stage +FROM golang:1.21-alpine AS builder +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags "-s -w" -o /app/server ./cmd/server + +# Final image +FROM alpine:3.18 +RUN apk add --no-cache ca-certificates +COPY --from=builder /app/server /usr/local/bin/server +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/server"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4cef402 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +BINARY := bin/server + +.PHONY: all build run docker-build test fmt clean + +all: build + +build: + go build -o $(BINARY) ./cmd/server + +run: build + ./$(BINARY) + +docker-build: + docker build -t lambda-iot-core:latest . + +test: + go test ./... -v + +fmt: + go fmt ./... + +clean: + rm -rf bin diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..81deabd --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + + "github.com/kristjank/lambda-iot/core/internal/handler" + "github.com/kristjank/lambda-iot/core/internal/middleware" +) + +func main() { + addr := ":8080" + + // Gin setup + r := gin.New() + r.Use(gin.Recovery()) + r.Use(middleware.GinLogger()) + + // Public routes + r.GET("/health", handler.Health) + r.GET("/hello", handler.Hello) + r.POST("/login", handler.Login) + + // Protected routes + auth := r.Group("/") + auth.Use(middleware.AuthMiddleware(getJWTSecret())) + auth.GET("/protected", handler.Protected) + + srv := &http.Server{ + Addr: addr, + Handler: r, + } + + go func() { + log.Printf("starting server on %s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + <-quit + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("server forced to shutdown: %v", err) + } + log.Println("server exiting") +} + +func getJWTSecret() string { + if s := os.Getenv("JWT_SECRET"); s != "" { + return s + } + return "secret" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5db9a56 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/kristjank/lambda-iot/core + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.0 + github.com/golang-jwt/jwt/v5 v5.0.0 +) + +require ( + github.com/bytedance/sonic v1.8.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.11.2 // indirect + github.com/goccy/go-json v0.10.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.6 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.9 // indirect + golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect + golang.org/x/crypto v0.5.0 // indirect + golang.org/x/net v0.7.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fdbad1a --- /dev/null +++ b/go.sum @@ -0,0 +1,88 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA= +github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8= +github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU= +github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= +github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA= +github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= +github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU= +github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= +golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/handler/handlers.go b/internal/handler/handlers.go new file mode 100644 index 0000000..4ad5989 --- /dev/null +++ b/internal/handler/handlers.go @@ -0,0 +1,60 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// Health returns basic service health +func Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// Hello returns a simple greeting +func Hello(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "hello from lambda-iot core"}) +} + +// Login issues a JWT token for demo purposes +func Login(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + // demo credentials + if req.Username != "admin" || req.Password != "password" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + // create token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": req.Username, + "exp": time.Now().Add(time.Hour).Unix(), + }) + secret := "secret" + if s := c.GetHeader("X-JWT-SECRET"); s != "" { + secret = s + } + signed, err := token.SignedString([]byte(secret)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not sign token"}) + return + } + c.JSON(http.StatusOK, gin.H{"token": signed}) +} + +// Protected requires a valid JWT and returns the token claims +func Protected(c *gin.Context) { + if v, ok := c.Get("claims"); ok { + c.JSON(http.StatusOK, gin.H{"claims": v}) + return + } + c.JSON(http.StatusUnauthorized, gin.H{"error": "no claims"}) +} diff --git a/internal/handler/handlers_test.go b/internal/handler/handlers_test.go new file mode 100644 index 0000000..a1354da --- /dev/null +++ b/internal/handler/handlers_test.go @@ -0,0 +1,87 @@ +package handler + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +func TestHealth(t *testing.T) { + r := gin.New() + r.GET("/health", Health) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200 got %d", w.Code) + } + var body map[string]string + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["status"] != "ok" { + t.Fatalf("unexpected body: %#v", body) + } +} + +func TestHello(t *testing.T) { + r := gin.New() + r.GET("/hello", Hello) + + req := httptest.NewRequest("GET", "/hello", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200 got %d", w.Code) + } + var body map[string]string + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["message"] == "" { + t.Fatalf("unexpected body: %#v", body) + } +} + +func TestLoginAndProtected(t *testing.T) { + secret := "testsecret" + // create token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "tester", + "exp": time.Now().Add(time.Hour).Unix(), + }) + signed, err := token.SignedString([]byte(secret)) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + // test protected route + r := gin.New() + // use middleware inline for test + r.Use(func(c *gin.Context) { + c.Request.Header.Set("Authorization", "Bearer "+signed) + }) + r.GET("/protected", func(c *gin.Context) { + c.Set("claims", jwt.MapClaims{"sub": "tester"}) + Protected(c) + }) + + req := httptest.NewRequest("GET", "/protected", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("expected 200 got %d", w.Code) + } + var body map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["claims"] == nil { + t.Fatalf("expected claims in response") + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..478615d --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// AuthMiddleware validates a JWT token from the Authorization header +func AuthMiddleware(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + auth := c.GetHeader("Authorization") + if auth == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) + return + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"}) + return + } + tokenStr := parts[1] + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + // ensure signing method is HMAC + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrTokenUnverifiable + } + return []byte(secret), nil + }) + if err != nil || !token.Valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + // expose token claims to handlers + if claims, ok := token.Claims.(jwt.MapClaims); ok { + c.Set("claims", claims) + } + c.Next() + } +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go new file mode 100644 index 0000000..20d6543 --- /dev/null +++ b/internal/middleware/logging.go @@ -0,0 +1,23 @@ +package middleware + +import ( + "log" + "time" + + "github.com/gin-gonic/gin" +) + +// GinLogger returns a middleware that logs requests using the standard logger +func GinLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + latency := time.Since(start) + status := c.Writer.Status() + if len(c.Errors) > 0 { + log.Printf("error: method=%s path=%s status=%d latency=%s errs=%s", c.Request.Method, c.Request.URL.Path, status, latency.String(), c.Errors.String()) + return + } + log.Printf("method=%s path=%s status=%d latency=%s", c.Request.Method, c.Request.URL.Path, status, latency.String()) + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..39dd3df --- /dev/null +++ b/readme.md @@ -0,0 +1,56 @@ +# Lambda-IoT Core + +This repository contains a minimal Go REST service used as the backend for the Lambda-IoT project. + +## Features ✅ + +- Simple HTTP server with two endpoints: + - `GET /health` — basic health check + - `GET /hello` — example greeting endpoint +- Tests for handlers using `httptest` +- Multi-stage `Dockerfile` for small production images +- `Makefile` with common tasks (build, run, test, docker-build) + +--- + +## Quickstart 🔧 + +Build and run locally: + +```bash +make build +./bin/server +``` + +Run tests: + +```bash +make test +``` + +Build Docker image: + +```bash +make docker-build +``` + +Then run it: + +```bash +docker run -p 8080:8080 lambda-iot-core:latest +``` + +### Endpoints + +- `http://localhost:8080/health` — returns `{ "status": "ok" }` +- `http://localhost:8080/hello` — returns a small greeting JSON +- `POST http://localhost:8080/login` — demo login, JSON body: `{ "username": "admin", "password": "password" }`, returns `{ "token": "..." }` +- `GET http://localhost:8080/protected` — protected endpoint requiring `Authorization: Bearer ` header + +### Environment + +- `JWT_SECRET` — (optional) secret used to sign tokens; defaults to `secret` for local/dev use + +--- + +If you want I can wire more features (structured logging, config, middleware, or dependency injection). Just tell me which direction to take. 🎯