diff --git a/.gitignore b/.gitignore index 6337cb2..a90b355 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .idea/ /bin/ .envrc +/keys diff --git a/README.md b/README.md index b093535..4c7676a 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ $ echoip Configuration is managed in the `etc/echoip/config.toml` file. This file should be located in the `/etc` folder on your server ( /etc/echoip/config.toml ). If you have the project on your server, you can run `make install-config` to copy it the right location. +***Change location of config file with the `echoip -c /path/to/config.toml` flag*** + ```toml Listen = ":8080" TemplateDir = "html" # The directory of the template files ( eg, index.html ) @@ -166,11 +168,12 @@ ECHOIP_JWT_SECRET="HS256" 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. - +EchoIP validates JWT signing algorithm, `config.SigningMethod` should be one of available from `golang-jwt/jwt` and match your expceted algorithm. `config.SigningMethod string` ``` +# ES256 | ES384 | ES512 +# RS256 | RS384 | RS512 # HS256 | HS384 | HS512 ``` @@ -178,6 +181,8 @@ Requests will be accepted if a valid token is provided in `Authorization: Bearer A `401` will be returned should the token not be valid. +***You can convert a key created with ssh-keygen using something like `ssh-keygen -f id_rsa.pub -e -mpem`*** + ### 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`. diff --git a/cmd/echoip/main.go b/cmd/echoip/main.go index 1dbd4d0..195e518 100644 --- a/cmd/echoip/main.go +++ b/cmd/echoip/main.go @@ -1,6 +1,7 @@ package main import ( + "flag" "io" "log" "strings" @@ -35,20 +36,25 @@ func init() { } func main() { + var configPath string + flag.StringVar(&configPath, "c", "/etc/echoip/config.toml", "Path to config file ( /etc/echoip/config.toml )") + flag.Parse() + runConfig, err := config.GetConfig() if err != nil { log.Fatalf("Error building configuration: %s", err) } - file, err := os.Open("/etc/echoip/config.toml") - defer file.Close() + log.Printf("Using config file %s", configPath) + configFile, err := os.Open(configPath) + defer configFile.Close() if err != nil { log.Printf("Error opening config file (/etc/echoip/config.toml): %s", err) } else { var b []byte - b, err = io.ReadAll(file) + b, err = io.ReadAll(configFile) if err != nil { log.Printf("Error reading config file (/etc/echoip/config.toml): %s", err) } @@ -102,6 +108,18 @@ func main() { serverCache = &cache.Null{} } + if len(runConfig.Jwt.PublicKey) != 0 { + log.Printf("Loading public key from %s", runConfig.Jwt.PublicKey) + + pubKey, err := os.ReadFile(runConfig.Jwt.PublicKey) + + if err != nil { + log.Fatal(err) + } + + runConfig.Jwt.PublicKeyData = pubKey + } + server := http.New(parser, serverCache, &runConfig) server.IPHeaders = runConfig.TrustedHeaders diff --git a/config/config.go b/config/config.go index 9f9a033..1097c38 100644 --- a/config/config.go +++ b/config/config.go @@ -22,6 +22,8 @@ type Jwt struct { Enabled bool SigningMethod string Secret string + PublicKey string + PublicKeyData []byte } type Config struct { diff --git a/go.mod b/go.mod index d667aee..e4550b9 100644 --- a/go.mod +++ b/go.mod @@ -9,17 +9,22 @@ require ( github.com/oschwald/geoip2-golang v1.5.0 github.com/qioalice/ipstack v1.0.1 github.com/redis/go-redis/v9 v9.2.1 + github.com/stretchr/testify v1.8.1 + gopkg.in/stretchr/testify.v1 v1.2.2 ) require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/klauspost/compress v1.13.6 // indirect github.com/oschwald/maxminddb-golang v1.8.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/vmihailenco/go-tinylfu v0.2.2 // indirect github.com/vmihailenco/msgpack/v5 v5.3.4 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/net v0.14.0 // indirect golang.org/x/sync v0.3.0 // indirect golang.org/x/sys v0.11.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 78d0745..a92f4bf 100644 --- a/go.sum +++ b/go.sum @@ -45,9 +45,11 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1: github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -86,6 +88,7 @@ github.com/qioalice/ipstack v1.0.1/go.mod h1:6eB9LdNCUdUoOsfDB8Pn2GpmD2I+f2k3yR3 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= github.com/redis/go-redis/v9 v9.2.1 h1:WlYJg71ODF0dVspZZCpYmoF1+U1Jjk9Rwd7pq6QmlCg= github.com/redis/go-redis/v9 v9.2.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 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= @@ -203,9 +206,12 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.28.0/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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M= +gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/http/http.go b/http/http.go index 6247975..6ea81c6 100644 --- a/http/http.go +++ b/http/http.go @@ -7,13 +7,11 @@ import ( "html/template" "log" "path/filepath" - "reflect" "strings" "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" @@ -426,21 +424,8 @@ func handleAuth(r *http.Request, runConfig *config.Config) error { authorization := r.Header.Get("Authorization") 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 { - log.Printf("Error validating token ( %s ): %s \n", tokenString, err) - } - - return new(InvalidTokenError) + if err := ParseJWT(runConfig, tokenString); err != nil { + return err } return nil diff --git a/http/jwt.go b/http/jwt.go new file mode 100644 index 0000000..a77111f --- /dev/null +++ b/http/jwt.go @@ -0,0 +1,69 @@ +package http + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "reflect" + + "github.com/golang-jwt/jwt" + "github.com/levelsoftware/echoip/config" +) + +func ParseJWT(runConfig *config.Config, tokenString string) error { + if _, err := jwt.Parse(tokenString, GetTokenKey(runConfig)); err != nil { + if runConfig.Debug { + log.Printf("Error validating token ( %s ): %s \n", tokenString, err) + } + + return new(InvalidTokenError) + } + + return nil +} + +func GetTokenKey(runConfig *config.Config) func(token *jwt.Token) (interface{}, error) { + signingMethod := jwt.GetSigningMethod(runConfig.Jwt.SigningMethod) + + var key interface{} + + switch signingMethod.Alg() { + case "ES256", "ES384", "ES512": + pubKey, _ := GetECDSAKey(runConfig.Jwt.PublicKeyData) + key = pubKey + case "RS256", "RS384", "RS512": + pubKey, _ := GetRSAKey(runConfig.Jwt.PublicKeyData) + key = pubKey + default: + key = []byte(runConfig.Jwt.Secret) + } + + return func(token *jwt.Token) (interface{}, error) { + expected := reflect.TypeOf(signingMethod) + got := reflect.TypeOf(token.Method) + if expected != got { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + + return key, nil + } +} + +func GetECDSAKey(data []byte) (*ecdsa.PublicKey, error) { + pkiBlock, _ := pem.Decode(data) + var publicKey *ecdsa.PublicKey + pubInterface, _ := x509.ParsePKIXPublicKey(pkiBlock.Bytes) + publicKey = pubInterface.(*ecdsa.PublicKey) + return publicKey, nil +} + +func GetRSAKey(data []byte) (*rsa.PublicKey, error) { + pkiBlock, _ := pem.Decode(data) + var publicKey *rsa.PublicKey + pubInterface, _ := x509.ParsePKIXPublicKey(pkiBlock.Bytes) + publicKey = pubInterface.(*rsa.PublicKey) + return publicKey, nil +} diff --git a/http/jwt_test.go b/http/jwt_test.go new file mode 100644 index 0000000..1d287ea --- /dev/null +++ b/http/jwt_test.go @@ -0,0 +1,64 @@ +package http + +import ( + "testing" + + "github.com/levelsoftware/echoip/config" + "github.com/stretchr/testify/assert" +) + +const ecdsaPublicKey = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9 +q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg== +-----END PUBLIC KEY-----` +const ecdsaToken = `eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.tyh-VfuzIxCyGYDlkBA7DfyjrqmSHu6pQ2hoZuFqUSLPNY2N0mpHb3nk5K17HWP_3cYHBw7AhHale5wky6-sVA` + +const rsaPublicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo +4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u ++qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh +kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ +0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg +cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc +mwIDAQAB +-----END PUBLIC KEY-----` +const rsaToken = `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ` + +const hmacKey = `supersecretkey` +const hmacToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.1Oi8c8PNZri8BTWg2oWXQVLCFNzI5b8uSeweKhpAIoc` + +func TestGetTokenKeyWithECDSAKey(t *testing.T) { + err := ParseJWT(&config.Config{ + Debug: true, + Jwt: config.Jwt{ + SigningMethod: "ES256", + PublicKeyData: []byte(ecdsaPublicKey), + }, + }, ecdsaToken) + + assert.Nil(t, err) +} + +func TestGetTokenKeyWithRSAKey(t *testing.T) { + err := ParseJWT(&config.Config{ + Debug: true, + Jwt: config.Jwt{ + SigningMethod: "RS256", + PublicKeyData: []byte(rsaPublicKey), + }, + }, rsaToken) + + assert.Nil(t, err) +} + +func TestGetTokenKeyWithHMACKey(t *testing.T) { + err := ParseJWT(&config.Config{ + Debug: true, + Jwt: config.Jwt{ + SigningMethod: "HS256", + Secret: hmacKey, + }, + }, hmacToken) + + assert.Nil(t, err) +}