diff --git a/cmd/main.go b/cmd/main.go index 0ee6e43..130cd3e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,7 +17,7 @@ import ( ) // @title httPod -// @version 0.0.1 +// @version 0.0.4 // @description A simple HTTP Request & HTTPResponse Service, shamelessly stolen from httpbin.org. // @tag.name HTTP Methods // @tag.description Testing different HTTP methods @@ -28,10 +28,10 @@ import ( func main() { const ( - SWAGGER_PATH = "/swagger" - API_PATH = "/api" - BASE_PATH_ENV = "BASE_PATH" - PORT = "PORT" + SwaggerPath = "/swagger" + ApiPath = "/api" + BasePathEnv = "BASE_PATH" + PORT = "PORT" ) server := echo.New() server.HideBanner = true @@ -40,7 +40,7 @@ func main() { if port == "" { port = "8080" } - basePath := os.Getenv(BASE_PATH_ENV) + basePath := os.Getenv(BasePathEnv) if basePath != "" { basePath = "/" + basePath } @@ -50,10 +50,10 @@ func main() { // api will be available on /basePath/api // swagger info will use X-Forwarded headers if available; // e.g.: X-Forwarded-Host=my.domain.com X-Forwarded-Prefix=myPrefix swagger ui show api on url http://my.domain.com/myPrefix/basePath/api - apiPath := basePath + API_PATH - endpoints.GET(SWAGGER_PATH+"/*", echoSwagger.WrapHandler, swaggerMiddleware(apiPath)) + apiPath := basePath + ApiPath + endpoints.GET(SwaggerPath+"/*", echoSwagger.WrapHandler, swaggerMiddleware(apiPath)) - api := endpoints.Group(API_PATH) + api := endpoints.Group(ApiPath) api.GET("/get", http.GetHandler) api.DELETE("/delete", http.DeleteHandler) api.PATCH("/patch", http.PatchHandler) @@ -72,14 +72,14 @@ func main() { api.GET("/jwt", jwt.GetHandler) - println(banner("http://localhost:" + port + SWAGGER_PATH + "/index.html")) + println(banner("http://localhost:" + port + SwaggerPath + "/index.html")) server.Logger.Fatal(server.Start(":" + port)) } -func banner(localUrl string) string { +func banner(localURL string) string { const BANNER = `/ˌeɪtʃ tiː tiː ˈpɒd/ %s trapping on %s` honeyPod := html.UnescapeString("&#" + strconv.Itoa(0x1f36f) + ";") - return fmt.Sprintf(BANNER, honeyPod, localUrl) + return fmt.Sprintf(BANNER, honeyPod, localURL) } func swaggerMiddleware(path string) echo.MiddlewareFunc { diff --git a/docs/swagger-ui.png b/docs/swagger-ui.png index d814a6b..a324eae 100644 Binary files a/docs/swagger-ui.png and b/docs/swagger-ui.png differ diff --git a/go.mod b/go.mod index 7d04bcf..600cb82 100644 --- a/go.mod +++ b/go.mod @@ -4,21 +4,24 @@ go 1.16 require ( github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/go-openapi/spec v0.20.3 // indirect github.com/go-openapi/swag v0.19.15 // indirect github.com/goccy/go-json v0.4.13 // indirect + github.com/jarcoal/httpmock v1.0.8 github.com/labstack/echo/v4 v4.2.2 github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect - github.com/lestrrat-go/jwx v1.1.7 + github.com/lestrrat-go/jwx v1.2.0 github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.8 // indirect github.com/nxadm/tail v1.4.6 // indirect github.com/onsi/ginkgo v1.14.2 github.com/onsi/gomega v1.10.1 + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/swaggo/echo-swagger v1.1.0 github.com/swaggo/swag v1.7.0 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect - golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 // indirect - golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 // indirect + golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 // indirect + golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324 // indirect golang.org/x/tools v0.1.0 // indirect ) diff --git a/go.sum b/go.sum index 312f284..8b4faed 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= @@ -20,6 +22,7 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= @@ -52,6 +55,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jarcoal/httpmock v1.0.8 h1:8kI16SoO6LQKgPE7PvQuV+YuD/inwHd7fOOe2zMbo4k= +github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -75,8 +80,8 @@ github.com/lestrrat-go/httpcc v1.0.0 h1:FszVC6cKfDvBKcJv646+lkh4GydQg2Z29scgUfkO github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE= github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A= github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= -github.com/lestrrat-go/jwx v1.1.7 h1:+PNt2U7FfrK4xn+ZCG+9jPRq5eqyG30gwpVwcekrCjA= -github.com/lestrrat-go/jwx v1.1.7/go.mod h1:Tg2uP7bpxEHUDtuWjap/PxroJ4okxGzkQznXiG+a5Dc= +github.com/lestrrat-go/jwx v1.2.0 h1:n08WEu8cJy3uzuQ39KWAOIhM4XfeozgaEGA8mTiioZ8= +github.com/lestrrat-go/jwx v1.2.0/go.mod h1:Tg2uP7bpxEHUDtuWjap/PxroJ4okxGzkQznXiG+a5Dc= github.com/lestrrat-go/option v0.0.0-20210103042652-6f1ecfceda35/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4= github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= @@ -115,6 +120,9 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE 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/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -129,6 +137,7 @@ github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 h1:PyYN9JH5jY9j6av01S github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E= github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E= github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= @@ -159,8 +168,8 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 h1:0PC75Fz/kyMGhL0e1QnypqK2kQMqKt9csD1GnMJR+Zk= -golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 h1:a8jGStKg0XqKDlKqjLrXn0ioF5MH36pT7Z0BRTqLhbk= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -184,8 +193,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 h1:iGu644GcxtEcrInvDsQRCwJjtCIOlT2V7IRt6ah2Whw= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324 h1:pAwJxDByZctfPwzlNGrDN2BQLsdPb9NkhoTJtUkAO28= +golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/cookies/handler.go b/internal/cookies/handler.go index ebe58d1..c6cf4df 100644 --- a/internal/cookies/handler.go +++ b/internal/cookies/handler.go @@ -7,7 +7,8 @@ import ( "net/http" ) -// @Summary Get all cookies of the request. +// GetHandler for GET cookies +// @Summary Get all cookies of the request. // @Tags Cookies // @Description Requests using GET should only retrieve data. // @Accept json @@ -18,7 +19,7 @@ func GetHandler(context echo.Context) error { cookies := context.Cookies() getCookies := make([]GetCookies, len(cookies)) for i, cookie := range cookies { - getCookies[i] = toJsonCookie(cookie) + getCookies[i] = toJSONCookie(cookie) } prettyJSON, err := json.MarshalIndent(getCookies, "", " ") if err != nil { @@ -27,6 +28,7 @@ func GetHandler(context echo.Context) error { return context.String(http.StatusOK, string(prettyJSON)) } +// DeleteHandler for DELETE cookies // @Summary Delete a cookie. // @Tags Cookies // @Description Delete a specific cookie. @@ -43,7 +45,7 @@ func DeleteHandler(context echo.Context) error { Path: "/", } context.SetCookie(cookie) - getCookie := toJsonCookie(cookie) + getCookie := toJSONCookie(cookie) prettyJSON, err := json.MarshalIndent(getCookie, "", " ") if err != nil { return context.String(http.StatusBadRequest, fmt.Sprintf("Error parsing cookies: %v", err.Error())) @@ -51,6 +53,7 @@ func DeleteHandler(context echo.Context) error { return context.String(http.StatusOK, string(prettyJSON)) } +// PostHandler for creating new cookies // @Summary Create a new cookie. // @Tags Cookies // @Description @@ -68,12 +71,12 @@ func PostHandler(context echo.Context) error { return context.String(http.StatusBadRequest, fmt.Sprintf("Cookie %s already exists", name)) } - cookie, err := toHttpCookie(context) + cookie, err := toHTTPCookie(context) if err != nil { return context.String(http.StatusBadRequest, fmt.Sprintf("Oops: %v", err)) } context.SetCookie(cookie) - jsonCookie := toJsonCookie(cookie) + jsonCookie := toJSONCookie(cookie) prettyJSON, err := json.MarshalIndent(jsonCookie, "", " ") if err != nil { return context.String(http.StatusBadRequest, fmt.Sprintf("Error parsing cookies: %v", err.Error())) diff --git a/internal/cookies/post_test.go b/internal/cookies/post_test.go index 21a0b46..d6e7dae 100644 --- a/internal/cookies/post_test.go +++ b/internal/cookies/post_test.go @@ -55,7 +55,7 @@ var _ = Describe("PostHandler", func() { expireString := expireTime.UTC().Format(http.TimeFormat) Expect(setCookieHeader).Should(Equal("testCookie=testValue; Path=/blubb; Domain=myapp.com; Expires=" + expireString + "; HttpOnly; Secure; SameSite=Strict")) // response body contains json cookie - expireJson := expireTime.Format(cookies.TIME_FORMAT) + expireJson := expireTime.Format(cookies.TimeFormat) Expect(responseRecorder.Body.String()).To(MatchJSON(`{ "name": "testCookie", "value": "testValue", diff --git a/internal/cookies/types.go b/internal/cookies/types.go index e4db834..36254e5 100644 --- a/internal/cookies/types.go +++ b/internal/cookies/types.go @@ -9,12 +9,15 @@ import ( "time" ) -const TIME_FORMAT = "2006-01-02T15:04:05Z07:00" +// TimeFormat standard time format for response +const TimeFormat = "2006-01-02T15:04:05Z07:00" +// JSONTime for serializing time in TimeFormat type JSONTime struct { time.Time } +// GetCookies response for GET cookies type GetCookies struct { Name string `json:"name"` Value string `json:"value"` @@ -25,21 +28,22 @@ type GetCookies struct { RawExpires string `json:"rawExpires,omitempty"` MaxAge int `json:"maxAge,omitempty"` Secure bool `json:"secure,omitempty"` - HttpOnly bool `json:"httpOnly,omitempty"` + HTTPOnly bool `json:"httpOnly,omitempty"` SameSite string `json:"sameSite,omitempty"` } +// SetCookie request body for creating or updating cookies type SetCookie struct { Value string `json:"value" example:"Test"` Path string `json:"path,omitempty" example:"/"` ExpiresSeconds int `json:"expiresSeconds,omitempty" example:"3600"` MaxAge int `json:"maxAge,omitempty" example:"0"` Secure bool `json:"secure,omitempty" example:"true"` - HttpOnly bool `json:"httpOnly,omitempty" example:"true"` + HTTPOnly bool `json:"httpOnly,omitempty" example:"true"` SameSite string `json:"sameSite,omitempty" example:"Strict"` } -func toJsonCookie(cookie *http.Cookie) GetCookies { +func toJSONCookie(cookie *http.Cookie) GetCookies { var expires *JSONTime = nil if cookie.Expires.After(time.Time{}) { expires = &JSONTime{cookie.Expires} @@ -53,12 +57,12 @@ func toJsonCookie(cookie *http.Cookie) GetCookies { RawExpires: cookie.RawExpires, MaxAge: cookie.MaxAge, Secure: cookie.Secure, - HttpOnly: cookie.HttpOnly, + HTTPOnly: cookie.HttpOnly, SameSite: sameSiteString(cookie.SameSite), } } -func toHttpCookie(context echo.Context) (*http.Cookie, error) { +func toHTTPCookie(context echo.Context) (*http.Cookie, error) { cookie := new(SetCookie) if err := context.Bind(cookie); err != nil { return nil, err @@ -83,7 +87,7 @@ func toHttpCookie(context echo.Context) (*http.Cookie, error) { Expires: expires, MaxAge: maxAge, Secure: cookie.Secure, - HttpOnly: cookie.HttpOnly, + HttpOnly: cookie.HTTPOnly, SameSite: sameSite(cookie.SameSite), }, nil } @@ -116,8 +120,8 @@ func sameSite(s string) http.SameSite { } } +// MarshalJSON marshals a JSONTime in standard TimeFormat func (t JSONTime) MarshalJSON() ([]byte, error) { - //do your serializing here - stamp := fmt.Sprintf("\"%s\"", t.Format(TIME_FORMAT)) + stamp := fmt.Sprintf("\"%s\"", t.Format(TimeFormat)) return []byte(stamp), nil } diff --git a/internal/docs/docs.go b/internal/docs/docs.go index 695a438..b55cfb9 100644 --- a/internal/docs/docs.go +++ b/internal/docs/docs.go @@ -176,14 +176,22 @@ var doc = `{ "tags": [ "JWT" ], - "summary": "Get jwt of the request.", + "summary": "Get jwt passed as authorization bearer token of the request.", + "parameters": [ + { + "type": "string", + "description": "if set, the jwt is verified with the key received from jwks endpoint", + "name": "jwksUri", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { - "$ref": "#/definitions/cookies.GetCookies" + "$ref": "#/definitions/jwt.Response" } } } @@ -659,6 +667,25 @@ var doc = `{ "type": "string" } } + }, + "jwt.Response": { + "type": "object", + "properties": { + "header": { + "type": "object", + "additionalProperties": true + }, + "payload": { + "type": "object", + "additionalProperties": true + }, + "raw": { + "type": "string" + }, + "valid": { + "type": "boolean" + } + } } }, "tags": [ @@ -688,7 +715,7 @@ type swaggerInfo struct { // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = swaggerInfo{ - Version: "0.0.1", + Version: "0.0.4", Host: "", BasePath: "", Schemes: []string{}, diff --git a/internal/docs/swagger.json b/internal/docs/swagger.json index 6c1d99f..c06b648 100644 --- a/internal/docs/swagger.json +++ b/internal/docs/swagger.json @@ -4,7 +4,7 @@ "description": "A simple HTTP Request \u0026 HTTPResponse Service, shamelessly stolen from httpbin.org.", "title": "httPod", "contact": {}, - "version": "0.0.1" + "version": "0.0.4" }, "paths": { "/cookies": { @@ -159,14 +159,22 @@ "tags": [ "JWT" ], - "summary": "Get jwt of the request.", + "summary": "Get jwt passed as authorization bearer token of the request.", + "parameters": [ + { + "type": "string", + "description": "if set, the jwt is verified with the key received from jwks endpoint", + "name": "jwksUri", + "in": "query" + } + ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { - "$ref": "#/definitions/cookies.GetCookies" + "$ref": "#/definitions/jwt.Response" } } } @@ -642,6 +650,25 @@ "type": "string" } } + }, + "jwt.Response": { + "type": "object", + "properties": { + "header": { + "type": "object", + "additionalProperties": true + }, + "payload": { + "type": "object", + "additionalProperties": true + }, + "raw": { + "type": "string" + }, + "valid": { + "type": "boolean" + } + } } }, "tags": [ diff --git a/internal/docs/swagger.yaml b/internal/docs/swagger.yaml index 4f173a9..2c85a19 100644 --- a/internal/docs/swagger.yaml +++ b/internal/docs/swagger.yaml @@ -68,12 +68,25 @@ definitions: url: type: string type: object + jwt.Response: + properties: + header: + additionalProperties: true + type: object + payload: + additionalProperties: true + type: object + raw: + type: string + valid: + type: boolean + type: object info: contact: {} description: A simple HTTP Request & HTTPResponse Service, shamelessly stolen from httpbin.org. title: httPod - version: 0.0.1 + version: 0.0.4 paths: /cookies: get: @@ -171,6 +184,11 @@ paths: consumes: - application/json description: Requests using GET should only retrieve data. + parameters: + - description: if set, the jwt is verified with the key received from jwks endpoint + in: query + name: jwksUri + type: string produces: - application/json responses: @@ -178,9 +196,9 @@ paths: description: OK schema: items: - $ref: '#/definitions/cookies.GetCookies' + $ref: '#/definitions/jwt.Response' type: array - summary: Get jwt of the request. + summary: Get jwt passed as authorization bearer token of the request. tags: - JWT /patch: diff --git a/internal/http/types.go b/internal/http/types.go index cf83726..65b9f78 100644 --- a/internal/http/types.go +++ b/internal/http/types.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "github.com/labstack/echo/v4" - "net/http" "strings" ) @@ -47,16 +46,3 @@ func getParams(c echo.Context) map[string]string { } return parameters } - -// getHost tries its best to return the request host. -func getHost(r *http.Request) string { - if r.URL.IsAbs() { - host := r.Host - // Slice off any port information. - if i := strings.Index(host, ":"); i != -1 { - host = host[:i] - } - return host - } - return r.URL.Host -} diff --git a/internal/jwt/get_test.go b/internal/jwt/get_test.go index 0551922..f81c0cf 100644 --- a/internal/jwt/get_test.go +++ b/internal/jwt/get_test.go @@ -2,18 +2,24 @@ package jwt_test import ( "fmt" + "github.com/jarcoal/httpmock" "github.com/labstack/echo/v4" + "github.com/lestrrat-go/jwx/jwa" + "github.com/lestrrat-go/jwx/jwk" + "github.com/lestrrat-go/jwx/jws" + jwt2 "github.com/lestrrat-go/jwx/jwt" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/tgunsch/httpod/internal/jwt" "net/http" "net/http/httptest" + "time" ) var _ = Describe("GetHandler", func() { It("return a jwt", func() { - ctx, _, responseRecorder := mockGetContext("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c") + ctx, _, responseRecorder := mockGetContext("http://myapp.com/api/jwt", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImp0aSI6Ijg4MmY3MWEzLWRiM2EtNGE2Ny05NTllLTZmZDE3MmFhYWNhMCIsImlhdCI6MTYxOTM0NjE2MywiZXhwIjoxNjE5MzQ5NzYzfQ.3GRfe59wu2KuXJyZV0uGqxpX6WWdeQTEsARbwow_ZG4") err := jwt.GetHandler(ctx) Expect(err).Should(BeNil()) @@ -23,20 +29,101 @@ var _ = Describe("GetHandler", func() { // response body contains json cookie Expect(responseRecorder.Body.String()).To(MatchJSON(`{ - "iat": 1516239022, - "name": "John Doe", - "sub": "1234567890" + "raw": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImp0aSI6Ijg4MmY3MWEzLWRiM2EtNGE2Ny05NTllLTZmZDE3MmFhYWNhMCIsImlhdCI6MTYxOTM0NjE2MywiZXhwIjoxNjE5MzQ5NzYzfQ.3GRfe59wu2KuXJyZV0uGqxpX6WWdeQTEsARbwow_ZG4", + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "payload": { + "admin": true, + "exp": "2021-04-25T11:22:43Z", + "iat": "2021-04-25T10:22:43Z", + "jti": "882f71a3-db3a-4a67-959e-6fd172aaaca0", + "name": "John Doe", + "sub": "1234567890" + } + }`)) + }) + It("return a validated jwt", func() { + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + key := createSymmetricKey("your-256-bit-secret", "12345") + + token := createToken(key) + + mockJWKSEndpoint(key, "http://my.jwks.com/jwks") + + ctx, _, responseRecorder := mockGetContext("http://myapp.com/api/jwt?jwksUri=http%3A%2F%2Fmy.jwks.com%2Fjwks", token) + + err := jwt.GetHandler(ctx) + Expect(err).Should(BeNil()) + + // return 200 + Expect(responseRecorder.Code).Should(Equal(200)) + + // response body contains json cookie + Expect(responseRecorder.Body.String()).To(MatchJSON(`{ + "raw": "eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQ1IiwidHlwIjoiSldUIn0.eyJleHAiOjg3ODQ1NDg0MCwiaWF0Ijo0NTMyNzkxMjAsImlzcyI6InNreW5ldCIsInN1YiI6IlQtODAwIn0.GWYi_xOOQG3xzH6zhRFbomaIZ4xra6Accn0FhaZ_87A", + "header": { + "alg": "HS256", + "kid": "12345", + "typ": "JWT" + }, + "payload": { + "exp": "1997-11-02T07:14:00Z", + "iat": "1984-05-13T06:52:00Z", + "iss": "skynet", + "sub": "T-800" + }, + "valid": true }`)) }) }) -func mockGetContext(token string) (echo.Context, *http.Request, *httptest.ResponseRecorder) { +func createSymmetricKey(value string, kid string) jwk.Key { + raw := []byte(value) + key, err := jwk.New(raw) + Expect(err).Should(BeNil()) + _ = key.Set("kid", kid) + _ = key.Set("alg", jwa.HS256) + return key +} + +func createToken(key jwk.Key) string { + loc, _ := time.LoadLocation("EST") + token := jwt2.New() + _ = token.Set(jwt2.IssuerKey, "skynet") + _ = token.Set(jwt2.ExpirationKey, time.Date(1997, 8, 94, 2, 14, 0, 0, loc).Unix()) + _ = token.Set(jwt2.IssuedAtKey, time.Date(1984, 05, 13, 1, 52, 0, 0, loc).Unix()) + _ = token.Set(jwt2.SubjectKey, "T-800") + headers := jws.NewHeaders() + _ = headers.Set(jws.KeyIDKey, key.KeyID()) + signedToken, _ := jwt2.Sign(token, jwa.HS256, key, jwt2.WithHeaders(headers)) + return string(signedToken) +} + +func mockGetContext(uri string, token string) (echo.Context, *http.Request, *httptest.ResponseRecorder) { e := echo.New() - req := httptest.NewRequest(http.MethodGet, "http://myapp.com/api/jwt", nil) + req := httptest.NewRequest(http.MethodGet, uri, nil) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) req.Header.Set(echo.HeaderAuthorization, fmt.Sprintf("Bearer %s", token)) res := httptest.NewRecorder() c := e.NewContext(req, res) return c, req, res } + +func mockJWKSEndpoint(key jwk.Key, jwksUrl string) { + + set := jwk.NewSet() + set.Add(key) + + responder := func(req *http.Request) (*http.Response, error) { + resp, err := httpmock.NewJsonResponse(200, set) + return resp, err + } + httpmock.RegisterResponder("GET", jwksUrl, responder) + +} diff --git a/internal/jwt/handler.go b/internal/jwt/handler.go index 5122a22..bc69482 100644 --- a/internal/jwt/handler.go +++ b/internal/jwt/handler.go @@ -1,12 +1,12 @@ package jwt import ( + "context" "encoding/json" "fmt" "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/jwt" + "github.com/lestrrat-go/jwx/jwk" "net/http" - "strings" ) // @Summary Get jwt passed as authorization bearer token of the request. @@ -14,25 +14,39 @@ import ( // @Description Requests using GET should only retrieve data. // @Accept json // @Produce json -// @Success 200 {array} jwt.Token +// @Param jwksUri query string false "if set, the jwt is verified with the key received from jwks endpoint" +// @Success 200 {array} jwt.Response // @Router /jwt [get] -func GetHandler(context echo.Context) error { +func GetHandler(ctx echo.Context) error { + var ( + auth string + keys jwk.Set + err error + response *Response + prettyJSON []byte + ) - auth := context.Request().Header.Get(echo.HeaderAuthorization) + auth = ctx.Request().Header.Get(echo.HeaderAuthorization) l := len("Bearer") if auth[:l] == "Bearer" { rawToken := auth[l+1:] - token, err := jwt.ParseReader(strings.NewReader(rawToken)) - if err != nil { - return context.String(http.StatusBadRequest, fmt.Sprintf("failed to parse payload: %s\n", err)) + + jwksUri := ctx.QueryParam("jwksUri") + if jwksUri != "" { + if keys, err = jwk.Fetch(context.Background(), jwksUri); err != nil { + return ctx.String(http.StatusBadRequest, fmt.Sprintf("failed to validate token: %s\n", err)) + } + } + if response, err = NewResponse(rawToken, keys); err != nil { + return ctx.String(http.StatusBadRequest, fmt.Sprintf("failed to parse payload: %s\n", err)) } - prettyJSON, err := json.MarshalIndent(token, "", " ") - if err != nil { - return context.String(http.StatusBadRequest, fmt.Sprintf("Error parsing cookies: %v", err.Error())) + if prettyJSON, err = json.MarshalIndent(response, "", " "); err != nil { + return ctx.String(http.StatusBadRequest, fmt.Sprintf("Error parsing cookies: %v", err.Error())) } - return context.String(http.StatusOK, string(prettyJSON)) + return ctx.String(http.StatusOK, string(prettyJSON)) + } - return context.String(http.StatusBadRequest, "No JWT in request header") + return ctx.String(http.StatusBadRequest, "No JWT in request header") } diff --git a/internal/jwt/jwt_test.go b/internal/jwt/jwt_test.go new file mode 100644 index 0000000..34a40e2 --- /dev/null +++ b/internal/jwt/jwt_test.go @@ -0,0 +1,32 @@ +package jwt_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "strings" +) + +var _ = Describe("JWT", func() { + It("validate jwt", func() { + header := `{"alg":"HS256","typ":"JWT"}` + payload := `{"sub":"1234567890","name":"John Doe","iat":1516239022}` + unsignedToken := Base64Encode(header) + "." + Base64Encode(payload) + Expect(unsignedToken).To(Equal("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ")) + + key := `your-256-bit-secret` + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(unsignedToken)) + signature := mac.Sum(nil) + + token := unsignedToken + "." + Base64Encode(string(signature)) + Expect(token).To(Equal("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")) + + }) +}) + +func Base64Encode(src string) string { + return strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(src)), "=") +} diff --git a/internal/jwt/types.go b/internal/jwt/types.go new file mode 100644 index 0000000..344e58b --- /dev/null +++ b/internal/jwt/types.go @@ -0,0 +1,63 @@ +package jwt + +import ( + "context" + "fmt" + "github.com/lestrrat-go/jwx/jwk" + "github.com/lestrrat-go/jwx/jws" + "github.com/lestrrat-go/jwx/jwt" +) + +type Response struct { + Raw string `json:"raw"` + Header map[string]interface{} `json:"header"` + Payload map[string]interface{} `json:"payload"` + Valid *bool `json:"valid,omitempty"` +} + +func NewResponse(rawToken string, keys jwk.Set) (*Response, error) { + var ( + payload map[string]interface{} + header map[string]interface{} + err error + msg *jws.Message + token jwt.Token + valid *bool + ) + + // payload + if token, err = jwt.ParseString(rawToken); err != nil { + return nil, fmt.Errorf("failed to parse payload: %s\n", err) + } + if payload, err = token.AsMap(context.TODO()); err != nil { + return nil, err + } + + // header + if msg, err = jws.ParseString(rawToken); err != nil { + return nil, fmt.Errorf("failed to parse token data: %v", err) + } + if header, err = msg.Signatures()[0].ProtectedHeaders().AsMap(context.TODO()); err != nil { + return nil, fmt.Errorf(`failed to parse token data: %v`, err) + } + + // verify + if keys != nil { + if _, err = jws.VerifySet([]byte(rawToken), keys); err != nil { + valid = newOptionalBool(false) + } else { + valid = newOptionalBool(true) + } + } + + return &Response{ + Raw: rawToken, + Payload: payload, + Header: header, + Valid: valid, + }, nil +} + +func newOptionalBool(b bool) *bool { + return &b +}