From d9bf3259e08441ac5a8fe2f2d3cb8e18827b09d5 Mon Sep 17 00:00:00 2001 From: Ethan Knowlton Date: Tue, 10 Oct 2023 15:52:05 -0400 Subject: [PATCH 1/4] Added JWT Authentication --- CHANGELOG.md | 13 ++++++++++ README.md | 2 +- cmd/echoip/main.go | 16 ++++++++---- config/config.go | 22 ++++++++++++++++ etc/echoip/config.toml | 7 ++++- go.mod | 1 + go.sum | 2 ++ http/error.go | 10 +++++++- http/http.go | 58 ++++++++++++++++++++++++++++++++++-------- 9 files changed, 112 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e91f0..b960b92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.2.0 (2023-10-06) + +### Features + +- JWT Authentication + +## 1.1.0 (2023-10-06) + +### Features + +- Environment Variable Configuration [712e216](https://github.com/levelsoftware/echoip/commit/712e2166d51fdb85229f52caa380743245f31dfa) + + ## 1.0.0 (2023-10-06) ### Features diff --git a/README.md b/README.md index a7fe331..741d77f 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ AsnFile = "" ``` ### Environment Variables for Configuration -You can also use environment variables for configuration, most likely used for Docker. +You can also use environment variables for configuration, most likely used for Docker. Configuration file takes precedence first, and then environment variables. Remove the value from the config file if you wish to use the environment variable. ``` ECHOIP_LISTEN=":8080" diff --git a/cmd/echoip/main.go b/cmd/echoip/main.go index 166e7f9..1dbd4d0 100644 --- a/cmd/echoip/main.go +++ b/cmd/echoip/main.go @@ -102,14 +102,20 @@ func main() { serverCache = &cache.Null{} } - server := http.New(parser, serverCache, runConfig.CacheTtl, runConfig.Profile) + server := http.New(parser, serverCache, &runConfig) server.IPHeaders = runConfig.TrustedHeaders - if _, err := os.Stat(runConfig.TemplateDir); err == nil { - server.Template = runConfig.TemplateDir - } else { + if _, err := os.Stat(runConfig.TemplateDir); err != nil { + runConfig.TemplateDir = "" log.Printf("Not configuring default handler: Template not found: %s", runConfig.TemplateDir) } + if runConfig.Jwt.Enabled { + log.Println("Enabling JWT Auth") + + if len(runConfig.Jwt.Secret) == 0 { + log.Fatal("Please provide a JWT Token secret when JWT is enabled") + } + } if runConfig.ReverseLookup { log.Println("Enabling reverse lookup") server.LookupAddr = iputil.LookupAddr @@ -118,9 +124,9 @@ func main() { log.Println("Enabling port lookup") server.LookupPort = iputil.LookupPort } + if runConfig.ShowSponsor { log.Println("Enabling sponsor logo") - server.Sponsor = runConfig.ShowSponsor } if len(runConfig.TrustedHeaders) > 0 { log.Printf("Trusting remote IP from header(s): %s", runConfig.TrustedHeaders) diff --git a/config/config.go b/config/config.go index 93d5c6e..188ea2a 100644 --- a/config/config.go +++ b/config/config.go @@ -18,6 +18,11 @@ type GeoIP struct { AsnFile string } +type Jwt struct { + Enabled bool + Secret string +} + type Config struct { Listen string TemplateDir string @@ -30,9 +35,11 @@ type Config struct { Database string Profile bool + Debug bool IPStack IPStack GeoIP GeoIP + Jwt Jwt } func GetConfig() (Config, error) { @@ -41,6 +48,9 @@ func GetConfig() (Config, error) { TemplateDir: getenv_string("ECHOIP_TEMPLATE_DIR", "html/"), RedisUrl: getenv_string("ECHOIP_REDIS_URL", ""), Database: getenv_string("ECHOIP_DATABASE", "geoip"), + Jwt: Jwt{ + Secret: getenv_string("ECHOIP_JWT_SECRET", ""), + }, IPStack: IPStack{ ApiKey: getenv_string("ECHOIP_IPSTACK_API_KEY", ""), }, @@ -51,6 +61,18 @@ func GetConfig() (Config, error) { }, } + jwtAuthEnabled, err := getenv_bool("ECHOIP_JWT_AUTH", false) + if err != nil { + return Config{}, err + } + defaultConfig.Jwt.Enabled = jwtAuthEnabled + + debug, err := getenv_bool("ECHOIP_DEBUG", false) + if err != nil { + return Config{}, err + } + defaultConfig.Debug = debug + cacheTtl, err := getenv_int("ECHOIP_CACHE_TTL", 3600) if err != nil { return Config{}, err diff --git a/etc/echoip/config.toml b/etc/echoip/config.toml index a4ff37d..8079850 100644 --- a/etc/echoip/config.toml +++ b/etc/echoip/config.toml @@ -7,7 +7,12 @@ PortLookup = true ShowSponsor = true Database = "ipstack" # use "IP Stack" or "GeoIP" TrustedHeaders = [] -Profile = false # enable debug / profiling +Profile = false # enable profiling +Debug = false # enable debugging, ex print jwt token errors + +[Jwt] +Enabled = false +Secret = "" [IPStack] ApiKey = "HelloWorld" diff --git a/go.mod b/go.mod index dd74956..d667aee 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.18 require ( github.com/BurntSushi/toml v1.3.2 github.com/go-redis/cache/v9 v9.0.0 + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/oschwald/geoip2-golang v1.5.0 github.com/qioalice/ipstack v1.0.1 github.com/redis/go-redis/v9 v9.2.1 diff --git a/go.sum b/go.sum index 11a4dd1..78d0745 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbV github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= diff --git a/http/error.go b/http/error.go index 72c6fce..0c494db 100644 --- a/http/error.go +++ b/http/error.go @@ -1,6 +1,9 @@ package http -import "net/http" +import ( + "errors" + "net/http" +) type appError struct { Error error @@ -22,6 +25,11 @@ func notFound(err error) *appError { } func badRequest(err error) *appError { + badAuth := new(InvalidTokenError) + if errors.As(err, &badAuth) { + return &appError{Error: err, Code: http.StatusUnauthorized} + } + return &appError{Error: err, Code: http.StatusBadRequest} } diff --git a/http/http.go b/http/http.go index 4581e72..d18b15f 100644 --- a/http/http.go +++ b/http/http.go @@ -12,7 +12,9 @@ import ( "net/http/pprof" rcache "github.com/go-redis/cache/v9" + "github.com/golang-jwt/jwt" "github.com/levelsoftware/echoip/cache" + "github.com/levelsoftware/echoip/config" parser "github.com/levelsoftware/echoip/iputil/paser" "github.com/levelsoftware/echoip/useragent" @@ -27,15 +29,12 @@ const ( ) type Server struct { - Template string IPHeaders []string LookupAddr func(net.IP) (string, error) LookupPort func(net.IP, uint64) error cache cache.Cache - cacheTtl int + runConfig *config.Config parser parser.Parser - profile bool - Sponsor bool } type PortResponse struct { @@ -44,8 +43,8 @@ type PortResponse struct { Reachable bool `json:"reachable"` } -func New(parser parser.Parser, cache cache.Cache, cacheTtl int, profile bool) *Server { - return &Server{cache: cache, cacheTtl: cacheTtl, parser: parser, profile: profile} +func New(parser parser.Parser, cache cache.Cache, runConfig *config.Config) *Server { + return &Server{cache: cache, parser: parser, runConfig: runConfig} } func ipFromForwardedForHeader(v string) string { @@ -104,6 +103,10 @@ func userAgentFromRequest(r *http.Request) *useragent.UserAgent { } func (s *Server) newResponse(r *http.Request) (parser.Response, error) { + if err := handleAuth(r, s.runConfig); err != nil { + return parser.Response{}, err + } + ctx := context.Background() ip, err := ipFromRequest(s.IPHeaders, r, true) @@ -130,7 +133,7 @@ func (s *Server) newResponse(r *http.Request) (parser.Response, error) { response, err = s.parser.Parse(ip, hostname) log.Printf("Caching response for %s", ip.String()) - if err := s.cache.Set(ctx, ip.String(), cachedResponse.Build(response), s.cacheTtl); err != nil { + if err := s.cache.Set(ctx, ip.String(), cachedResponse.Build(response), s.runConfig.CacheTtl); err != nil { return parser.Response{}, err } @@ -158,6 +161,10 @@ func (s *Server) newPortResponse(r *http.Request) (PortResponse, error) { } func (s *Server) CLIHandler(w http.ResponseWriter, r *http.Request) *appError { + if err := handleAuth(r, s.runConfig); err != nil { + return badRequest(err).WithMessage(err.Error()).AsJSON() + } + ip, err := ipFromRequest(s.IPHeaders, r, true) if err != nil { return badRequest(err).WithMessage(err.Error()).AsJSON() @@ -259,10 +266,12 @@ func (s *Server) DefaultHandler(w http.ResponseWriter, r *http.Request) *appErro if err != nil { return badRequest(err).WithMessage(err.Error()) } - t, err := template.ParseGlob(s.Template + "/*") + + t, err := template.ParseGlob(s.runConfig.TemplateDir + "/*") if err != nil { return internalServerError(err) } + json, err := json.MarshalIndent(response, "", " ") if err != nil { return internalServerError(err) @@ -287,7 +296,7 @@ func (s *Server) DefaultHandler(w http.ResponseWriter, r *http.Request) *appErro response.Longitude + 0.05, string(json), s.LookupPort != nil, - s.Sponsor, + s.runConfig.ShowSponsor, } if err := t.Execute(w, &data); err != nil { @@ -373,7 +382,7 @@ func (s *Server) Handler() http.Handler { } // Browser - if s.Template != "" { + if s.runConfig.TemplateDir != "" { r.Route("GET", "/", s.DefaultHandler) } @@ -383,7 +392,7 @@ func (s *Server) Handler() http.Handler { } // Profiling - if s.profile { + if s.runConfig.Profile { r.Route("GET", "/debug/pprof/cmdline", wrapHandlerFunc(pprof.Cmdline)) r.Route("GET", "/debug/pprof/profile", wrapHandlerFunc(pprof.Profile)) r.Route("GET", "/debug/pprof/symbol", wrapHandlerFunc(pprof.Symbol)) @@ -401,3 +410,30 @@ func (s *Server) ListenAndServe(addr string) error { func formatCoordinate(c float64) string { return strconv.FormatFloat(c, 'f', 6, 64) } + +type InvalidTokenError struct{} + +func (m *InvalidTokenError) Error() string { + return "invalid_token" +} + +func handleAuth(r *http.Request, runConfig *config.Config) error { + if !runConfig.Jwt.Enabled { + return nil + } + + authorization := r.Header.Get("Authorization") + tokenString := strings.ReplaceAll(authorization, "Bearer ", "") + + if _, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(runConfig.Jwt.Secret), nil + }); err != nil { + if runConfig.Debug { + log.Printf("Error validating token ( %s ): %s \n", tokenString, err) + } + + return new(InvalidTokenError) + } + + return nil +} From 7d2aab397c94ffb2aab1b4c9ab9c055bc2369ee5 Mon Sep 17 00:00:00 2001 From: Ethan Knowlton Date: Tue, 10 Oct 2023 16:31:22 -0400 Subject: [PATCH 2/4] fix test --- http/http_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/http/http_test.go b/http/http_test.go index 5e5d20a..68098ef 100644 --- a/http/http_test.go +++ b/http/http_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/levelsoftware/echoip/cache" + "github.com/levelsoftware/echoip/config" "github.com/levelsoftware/echoip/iputil" "github.com/levelsoftware/echoip/iputil/geo" parser "github.com/levelsoftware/echoip/iputil/paser" @@ -84,7 +85,13 @@ func (fc *FakeCache) Set(ctx context.Context, ip string, response cache.CachedRe func testServer() *Server { fakeCache := FakeCache{} - return &Server{cache: &fakeCache, cacheTtl: 100, parser: &testDb{}, LookupAddr: lookupAddr, LookupPort: lookupPort} + return &Server{ + cache: &fakeCache, + parser: &testDb{}, + LookupAddr: lookupAddr, + LookupPort: lookupPort, + runConfig: &config.Config{}, + } } func httpGet(url string, acceptMediaType string, userAgent string) (string, int, error) { From 4fc865d36c2dc1fa8a1240723cde33dbd12b3061 Mon Sep 17 00:00:00 2001 From: Ethan Knowlton Date: Tue, 10 Oct 2023 16:40:50 -0400 Subject: [PATCH 3/4] readme updates --- README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 741d77f..6707437 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,7 @@ $ curl -H 'Accept: application/json' ip.level.io # or curl ip.level.io/json "ip_decimal": 2130706433, "asn": "AS59795", "asn_org": "Hosting4Real" -} -``` +} ``` Port testing: @@ -85,6 +84,7 @@ between IPv4 and IPv6 lookup. - All endpoints (except `/port`) can return information about a custom IP address specified via `?ip=` query parameter - Open source under the [BSD 3-Clause license](https://opensource.org/licenses/BSD-3-Clause) - Supports IP Stack API or GeoIP +- JWT Authentication ### Installation from Release @@ -121,6 +121,10 @@ Database = "ipstack" # use "IP Stack" or "GeoIP" TrustedHeaders = [] # Which header to trust, eg, `["X-Real-IP"]` Profile = false # enable debug / profiling +[Jwt] +Enabled = false +Secret = "" + [IPStack] ApiKey = "" UseHttps = true @@ -152,8 +156,19 @@ ECHOIP_SHOW_SPONSOR=true ECHOIP_PROFILE=false ECHOIP_IPSTACK_USE_HTTPS=true ECHOIP_IPSTACK_ENABLE_SECURITY=true +ECHOIP_JWT_AUTH=false +ECHOIP_JWT_SECRET="" ``` +### Authenticate each API request with JWT + +You can authenticate each API request with JWT token. +Just enable `config.Jwt.Enabled` and add your JWT secret to `config.Jwt.Secret`. + +Requests will be accepted if a valid token is provided in `Authorization: Bearer $token` header. + +A `401` will be returned should the token not be valid. + ### Caching with Redis You can connect EchoIP to a Redis client to cache each request per IP. You can configure the life of the key in `config.CacheTtl`. From bc03347fec8ce12f72b089d82bf555c8f5b6ad8d Mon Sep 17 00:00:00 2001 From: Ethan Knowlton Date: Tue, 10 Oct 2023 23:46:49 -0400 Subject: [PATCH 4/4] probably should verify signing method -- and this only supports HMAC --- README.md | 11 ++++++++++- config/config.go | 8 +++++--- etc/echoip/config.toml | 1 + http/http.go | 8 ++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6707437..b093535 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,8 @@ ECHOIP_PROFILE=false ECHOIP_IPSTACK_USE_HTTPS=true ECHOIP_IPSTACK_ENABLE_SECURITY=true ECHOIP_JWT_AUTH=false -ECHOIP_JWT_SECRET="" +ECHOIP_JWT_SIGNING_METHOD=HS256 +ECHOIP_JWT_SECRET="HS256" ``` ### Authenticate each API request with JWT @@ -165,6 +166,14 @@ ECHOIP_JWT_SECRET="" You can authenticate each API request with JWT token. Just enable `config.Jwt.Enabled` and add your JWT secret to `config.Jwt.Secret`. +EchoIP validates JWT signing algorithm, `config.SigningMethod` should be one of available from `golang-jwt/jwt` and match your expceted algorithm. Currently only supporting HMAC signing. + +`config.SigningMethod string` + +``` +# HS256 | HS384 | HS512 +``` + Requests will be accepted if a valid token is provided in `Authorization: Bearer $token` header. A `401` will be returned should the token not be valid. diff --git a/config/config.go b/config/config.go index 188ea2a..9f9a033 100644 --- a/config/config.go +++ b/config/config.go @@ -19,8 +19,9 @@ type GeoIP struct { } type Jwt struct { - Enabled bool - Secret string + Enabled bool + SigningMethod string + Secret string } type Config struct { @@ -49,7 +50,8 @@ func GetConfig() (Config, error) { RedisUrl: getenv_string("ECHOIP_REDIS_URL", ""), Database: getenv_string("ECHOIP_DATABASE", "geoip"), Jwt: Jwt{ - Secret: getenv_string("ECHOIP_JWT_SECRET", ""), + Secret: getenv_string("ECHOIP_JWT_SECRET", ""), + SigningMethod: getenv_string("ECHOIP_JWT_SIGNING_METHOD", "HS256"), }, IPStack: IPStack{ ApiKey: getenv_string("ECHOIP_IPSTACK_API_KEY", ""), diff --git a/etc/echoip/config.toml b/etc/echoip/config.toml index 8079850..6560a5e 100644 --- a/etc/echoip/config.toml +++ b/etc/echoip/config.toml @@ -12,6 +12,7 @@ Debug = false # enable debugging, ex print jwt token errors [Jwt] Enabled = false +SigningMethod = "HS256" # HS256 | HS384 | HS512 Secret = "" [IPStack] diff --git a/http/http.go b/http/http.go index d18b15f..6247975 100644 --- a/http/http.go +++ b/http/http.go @@ -7,6 +7,7 @@ import ( "html/template" "log" "path/filepath" + "reflect" "strings" "net/http/pprof" @@ -426,6 +427,13 @@ func handleAuth(r *http.Request, runConfig *config.Config) error { tokenString := strings.ReplaceAll(authorization, "Bearer ", "") if _, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + expected := reflect.TypeOf(jwt.GetSigningMethod(runConfig.Jwt.SigningMethod)) + got := reflect.TypeOf(token.Method) + if expected != got { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + + // Only support SigningMethodHMAC ( Others will be quite a bit more complicated ) return []byte(runConfig.Jwt.Secret), nil }); err != nil { if runConfig.Debug {