diff --git a/cmd/commands/serve.go b/cmd/commands/serve.go index 16378065..5511e299 100644 --- a/cmd/commands/serve.go +++ b/cmd/commands/serve.go @@ -2,10 +2,12 @@ package commands import ( "context" + "fmt" "net/http" "time" "github.com/ecadlabs/signatory/pkg/auth" + "github.com/ecadlabs/signatory/pkg/middlewares" "github.com/ecadlabs/signatory/pkg/server" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -24,6 +26,17 @@ func NewServeCommand(c *Context) *cobra.Command { Signer: c.signatory, } + if c.config.Server.JWTConfig != nil { + if c.config.Server.AuthorizedKeys != nil { + return fmt.Errorf("cannot use both JWT and static authorized keys") + } + mw := middlewares.NewMiddleware(c.config.Server.JWTConfig) + if err := c.config.Server.JWTConfig.CheckUpdateNewCred(); err != nil { + return err + } + srvConf.MidWare = mw + } + if c.config.Server.AuthorizedKeys != nil { ak, err := auth.StaticAuthorizedKeys(c.config.Server.AuthorizedKeys.List()...) if err != nil { diff --git a/docs/jwt_auth.md b/docs/jwt_auth.md new file mode 100644 index 00000000..647cefe2 --- /dev/null +++ b/docs/jwt_auth.md @@ -0,0 +1,163 @@ +--- +id: jwt +title: JWT +--- + +# JWT - Signatory interaction + +The diagram represents a sequence of interactions between a client and a Signatory service, which appears to be a service that handles cryptographic signing operations. Here is a breakdown of the sequence of interactions: + +```mermaid +sequenceDiagram + participant Client + participant Signatory + Client->>Signatory: Provide credentials + Signatory->>Client: Verify credentials + Note over Signatory: Create JWT + Signatory->>Client: Send JWT + Note over Client: Store JWT + Client->>Signatory: Request signing operation (include JWT) + Signatory->>Signatory: Validate JWT signature + Note over Signatory: Check claims (public key, roles, permissions) + alt JWT is valid and client is authorized + Signatory->>Signatory: Sign operation with private key + Signatory->>Client: Send signed operation + else JWT is invalid or client is unauthorized + Signatory->>Client: Access denied (error message) + end +``` + +1. The client provides its credentials to the Signatory service. +2. The Signatory service verifies the credentials provided by the client. +3. If the credentials are valid, the Signatory service creates a JSON Web Token (JWT) and sends it to the client. +4. The client stores the JWT for later use. +5. The client requests a signing operation from the Signatory service, and includes the JWT in the request. +6. The Signatory service validates the JWT signature and checks the claims in the JWT (such as the public key, roles, and permissions). +7. If the JWT is valid and the client is authorized, the Signatory service signs the operation with its private key and sends the signed operation back to the client. +8. If the JWT is invalid or the client is unauthorized, the Signatory service sends an access denied error message to the client. +9. When the token expires or any other token authentication failures happen, the client can start again from 1. + +## Sample Signatory JWT configuration + +`jwt_exp` is the time duration (in minutes) for which the token is valid and it is optional. When not provided the token expiry is 60 minutes, otherwise it is `current time + jwt_exp`. + +`secret` is the secret used to sign the JWT token. + +```yaml +server: + address: :6732 + utility_address: :9583 + jwt: + users: + user_name1: + password: password1 + secret: secret1 + jwt_exp: 234 + user_name2: + password: password2 + secret: secret2 + jwt_exp: 73 +``` + +## Sample client code which used in the integration test. + +```go + //Send user credentials to receive a new token + url := "http://localhost:6732/login" + pub.Hash().String() + client := &http.Client{} + + req, err := http.NewRequest("POST", url, nil) + require.NoError(t, err) + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("username", "user1") + req.Header.Add("password", "pass123") + time.Sleep(2 * time.Second) + + res, err := client.Do(req) + require.NoError(t, err) + require.Equal(t, 201, res.StatusCode) + + body, err := ioutil.ReadAll(res.Body) + require.NoError(t, err) + require.NotEmpty(t, body) + + res.Body.Close() + + // Send request using received token + + client = &http.Client{} + + req, err = http.NewRequest("GET", url, nil) + require.NoError(t, err) + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Authorization", "Bearer "+string(body)) + req.Header.Add("username", "user1") + time.Sleep(2 * time.Second) + + res, err = client.Do(req) + require.NoError(t, err) + require.Equal(t, 200, res.StatusCode) + + defer res.Body.Close() + body, err = ioutil.ReadAll(res.Body) + require.NoError(t, err) + require.NotEmpty(t, body) + + fmt.Println("Response-GET-PKH: ", string(body)) +``` + +## Credentials rotation + +The credentials can be rotated by updating the configuration file and restarting the Signatory service. + +Older credentials can be removed from the configuration file after the new credentials are added and signatory is up and serving. The Signatory service will continue to accept the older credentials until the `old_cred_exp` time expires. If any error occurs with expiry time, the Signatory service will stop accepting the older credentials immediately. The `old_cred_exp` field is `GMT` expressed in `YYYY-MM-DD hh:mm:ss` format. + +### sample configuration file: + +```yaml +server: + address: :6732 + utility_address: :9583 + jwt: + users: + user_name1: + password: password1 + secret: secret1 + jwt_exp: 234 + old_cred_exp: "2006-01-02 15:04:05" + new_data: + password: password1 + secret: secret1 + jwt_exp: 35 +``` + +## JWT users for each PKH + +The JWT users can be configured for each PKH in the configuration file. Even if the JWT client provides a valid token, the request will be rejected if the user is not configured for that PKH requested for signing. +If no JWT users are configured for a PKH, then any JWT token will be accepted for that PKH. + +### sample configuration file: + +```yaml +tezos: + tz3cbDCwrqFqfx1dBhHoXTwZ9FG3MDrtyMs6: + jwt_users: + - user_name1 + - user_name2 + log_payloads: true + allow: + block: + endorsement: + preendorsement: + generic: + - transaction + - reveal +``` + +## Important security note: + +TLS should be taken care by the user who configures JWT as the authentication mechanism in Signatory for the clients. + +The configuration file also contains sensitive information when using JWT with Signatory, so that file must also be secure. \ No newline at end of file diff --git a/go.mod b/go.mod index 388f9e74..ea657a82 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/enceve/crypto v0.0.0-20160707101852-34d48bb93815 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v5 v5.0.0-rc.1 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect diff --git a/go.sum b/go.sum index 5fb26156..0fe6a8f5 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn 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/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-jwt/jwt/v5 v5.0.0-rc.1 h1:tDQ1LjKga657layZ4JLsRdxgvupebc0xuPwRNuTfUgs= github.com/golang-jwt/jwt/v5 v5.0.0-rc.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= diff --git a/integration_test/.env.current b/integration_test/.env.current index 3077d96a..9b9d806e 100644 --- a/integration_test/.env.current +++ b/integration_test/.env.current @@ -1,2 +1,2 @@ -export OCTEZ_VERSION=${ARCH}_v16.0-rc3 +export OCTEZ_VERSION=${ARCH}_v16.1 export PROTOCOL=Mumbai diff --git a/integration_test/.env.next b/integration_test/.env.next index 21752423..d956aa39 100644 --- a/integration_test/.env.next +++ b/integration_test/.env.next @@ -1,2 +1,2 @@ -export OCTEZ_VERSION=${ARCH}_v17.0-beta1 +export OCTEZ_VERSION=${ARCH}_v17.0 export PROTOCOL=Nairobi diff --git a/integration_test/config.go b/integration_test/config.go index f74baa0f..317d9ecd 100644 --- a/integration_test/config.go +++ b/integration_test/config.go @@ -16,10 +16,29 @@ type Config struct { Tezos TezosConfig `yaml:"tezos"` } +type JwtConfig struct { + Users map[string]*JwtUserData `yaml:"users"` +} + +type JwtUserData struct { + Password string `yaml:"password"` + Secret string `yaml:"secret"` + Exp uint64 `yaml:"jwt_exp"` + CredExp string `yaml:"old_cred_exp,omitempty"` + NewCred *JwtNewCred `yaml:"new_data,omitempty"` +} + +type JwtNewCred struct { + Password string `yaml:"password"` + Secret string `yaml:"secret"` + Exp uint64 `yaml:"jwt_exp"` +} + type ServerConfig struct { - Address string `yaml:"address"` - UtilityAddress string `yaml:"utility_address"` - Keys []string `yaml:"authorized_keys,omitempty"` + Address string `yaml:"address"` + UtilityAddress string `yaml:"utility_address"` + Keys []string `yaml:"authorized_keys,omitempty"` + Jwt JwtConfig `yaml:"jwt,omitempty"` } type TezosConfig = map[string]*TezosPolicy @@ -27,6 +46,7 @@ type TezosConfig = map[string]*TezosPolicy type TezosPolicy struct { Allow map[string][]string `yaml:"allow"` LogPayloads bool `yaml:"log_payloads"` + JwtUsers []string `yaml:"jwt_users,omitempty"` } type VaultConfig struct { diff --git a/integration_test/jwt_test.go b/integration_test/jwt_test.go new file mode 100644 index 00000000..0f1760ff --- /dev/null +++ b/integration_test/jwt_test.go @@ -0,0 +1,387 @@ +package integrationtest + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + baseUrl = "http://localhost:6732/" + secret = "!sEtcU5RwLQYsA5qQ1c6zpo3FljQxfAKP" + secret2 = "*sEtcU5RwLQYsA5qQ1c6zpo3FljQxfAKP" + endpoint = baseUrl + "keys/tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb" + login = baseUrl + "login" + message = "\"03c8e312c61a5fd8e9d6ff1d5dccf900e10b5769e55738eb365e99636e3c3fd1d76c006b82198cb179e8306c1bedd08f12dc863f328886df0202e90700c0843d0000a26828841890d3f3a2a1d4083839c7a882fe050100\"" + username1 = "username1" + password1 = "87GridNEG3gKZ3I!" + username2 = "username2" + password2 = "RkB143NUCmok2f4!" +) + +func TestJWTHappyPath(t *testing.T) { + //sanity check: request a signature without JWT configured and see it succeed + code, bytes := request(endpoint, message, nil) + require.Equal(t, 200, code) + require.Contains(t, string(bytes), "signature") + + //configure JWT and make the same request, and see it fail because you have no token + var c Config + c.Read() + c.Server.Jwt = JwtConfig{Users: map[string]*JwtUserData{username1: {Password: password1, Secret: secret, Exp: 60}}} + backup_then_update_config(c) + defer restore_config() + restart_signatory() + code, bytes = request(endpoint, message, nil) + require.Equal(t, 401, code) + assert.Equal(t, "token required", string(bytes)) + + //get a bearer token from the login endpoint + var h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"password", password1}} + code, bytes = request(login, "", h) + require.Equal(t, 201, code) + token := string(bytes) + require.Greater(t, len(token), 1) + require.NotContains(t, token, "signature") + require.Equal(t, 2, strings.Count(token, ".")) + + //request with a token is successful + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"Authorization", "Bearer " + token}} + code, bytes = request(endpoint, message, h) + require.Equal(t, 200, code) + require.Contains(t, string(bytes), "signature") +} + +func TestJWTCredentialFailure(t *testing.T) { + var c Config + c.Read() + c.Server.Jwt = JwtConfig{Users: map[string]*JwtUserData{username1: {Password: password1, Secret: secret, Exp: 60}}} + backup_then_update_config(c) + defer restore_config() + restart_signatory() + + //wrong username + var h = [][]string{{"Content-Type", "application/json"}, {"username", "username3"}, {"password", password1}} + code, bytes := request(login, "", h) + require.Equal(t, 401, code) + assert.Equal(t, "Access denied", string(bytes)) + + //wrong password + h = [][]string{{"Content-Type", "application/json"}, {"username", username2}, {"password", password1}} + code, _ = request(login, "", h) + require.Equal(t, 401, code) + require.Equal(t, "Access denied", string(bytes)) +} + +func TestJWTExpiry(t *testing.T) { + var c Config + c.Read() + //configure a 1 minute expiry + c.Server.Jwt = JwtConfig{Users: map[string]*JwtUserData{username1: {Password: password1, Secret: secret, Exp: 1}}} + backup_then_update_config(c) + defer restore_config() + restart_signatory() + + //get a token + var h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"password", password1}} + code, bytes := request(login, "", h) + require.Equal(t, 201, code) + token := string(bytes) + + //allow token to expire + time.Sleep(time.Second * 61) + + //request a signature with an expired token + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"Authorization", "Bearer " + token}} + code, bytes = request(endpoint, message, h) + require.Equal(t, 401, code) + require.Equal(t, string(bytes), "Token is expired") +} + +func request(url string, body string, headers [][]string) (int, []byte) { + reqbody := strings.NewReader(body) + client := &http.Client{} + req, err := http.NewRequest(http.MethodPost, url, reqbody) + if err != nil { + panic(err) + } + for _, h := range headers { + req.Header.Add(h[0], h[1]) + } + resp, err := client.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + bytes, err := io.ReadAll(resp.Body) + if err != nil { + panic(err) + } + return resp.StatusCode, bytes +} + +func TestAlgNoneAttack(t *testing.T) { + var c Config + c.Read() + c.Server.Jwt = JwtConfig{Users: map[string]*JwtUserData{username1: {Password: password1, Secret: secret, Exp: 60}}} + backup_then_update_config(c) + defer restore_config() + restart_signatory() + + user := "username1" + token := createUnsignedToken(user) + var h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"Authorization", "Bearer " + token}} + code, bytes := request(endpoint, message, h) + require.Equal(t, 401, code) + require.Equal(t, "'none' signature type is not allowed", string(bytes)) +} + +func createUnsignedToken(username string) string { + h := jwtHeader{Type: "JWT", Algorithm: "none"} + p := jwtPayload{Expires: 9999999999, User: username} + + hb, _ := json.Marshal(h) + pb, _ := json.Marshal(p) + + he := base64.RawURLEncoding.EncodeToString(hb) + pe := base64.RawURLEncoding.EncodeToString(pb) + + return he + "." + pe + ".8x-WJhWP7IXiyeFYTaxNg6IlJrXB9_2xvgUS3O_3aeE" +} + +type jwtHeader struct { + Type string `json:"typ"` + Algorithm string `json:"alg"` +} + +type jwtPayload struct { + Expires uint64 `json:"exp"` + User string `json:"user"` +} + +func TestSignatureIsVerified(t *testing.T) { + var c Config + c.Read() + c.Server.Jwt = JwtConfig{Users: map[string]*JwtUserData{username1: {Password: password1, Secret: secret, Exp: 60}, + username2: {Password: password2, Secret: secret, Exp: 60}}} + backup_then_update_config(c) + defer restore_config() + restart_signatory() + + //get a token + var h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"password", password1}} + code, bytes := request(login, "", h) + require.Equal(t, 201, code) + token := string(bytes) + parts := strings.Split(token, ".") + + //write a slightly different payload, but, keep the same header and signature + p := jwtPayload{Expires: 9999999999, User: username2} + pb, _ := json.Marshal(p) + pe := base64.RawURLEncoding.EncodeToString(pb) + + newtoken := parts[0] + "." + pe + "." + parts[2] + + //request a signature with a token whose signature is not valid + h = [][]string{{"Content-Type", "application/json"}, {"username", username2}, {"Authorization", "Bearer " + newtoken}} + code, bytes = request(endpoint, message, h) + assert.Equal(t, 401, code) + require.Contains(t, string(bytes), "signature is invalid") +} + +func TestBadInputs(t *testing.T) { + //configure JWT and make the same request, and see it fail + var c Config + c.Read() + c.Server.Jwt = JwtConfig{Users: map[string]*JwtUserData{username1: {Password: password1, Secret: secret, Exp: 60}}} + backup_then_update_config(c) + defer restore_config() + restart_signatory() + code, bytes := request(endpoint, message, nil) + assert.Equal(t, 401, code) + assert.Equal(t, "token required", string(bytes)) + + //provide credentials in the header of the same request to fetch a bearer token + var h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"password", password1}} + code, bytes = request(login, "", h) + assert.Equal(t, 201, code) + token := string(bytes) + assert.Greater(t, len(token), 1) + assert.NotContains(t, token, "signature") + assert.Equal(t, 2, strings.Count(token, ".")) + + //there is no whitespace after "Bearer" in the Authorization header, on purpose + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"Authorization", "Bearer" + token}} + code, bytes = request(endpoint, message, h) + assert.Equal(t, 401, code) + assert.Contains(t, string(bytes), "looking for beginning of value") + + //missing username header + h = [][]string{{"Content-Type", "application/json"}, {"Authorization", "Bearer " + token}} + code, bytes = request(endpoint, message, h) + assert.Equal(t, 401, code) + assert.Contains(t, string(bytes), "user not found") + + //missing token header + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}} + code, bytes = request(endpoint, message, h) + assert.Equal(t, 401, code) + assert.Contains(t, string(bytes), "token required") + + //empty string headers + h = [][]string{{"Content-Type", ""}, {"username", ""}, {"Authorization", ""}} + code, bytes = request(endpoint, message, h) + assert.Equal(t, 401, code) + assert.Contains(t, string(bytes), "token required") + + //login no username + h = [][]string{{"Content-Type", "application/json"}, {"password", password1}} + code, bytes = request(login, "", h) + assert.Equal(t, 401, code) + assert.Contains(t, string(bytes), "username and password required") + + //login no password + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}} + code, bytes = request(login, "", h) + assert.Equal(t, 401, code) + assert.Contains(t, string(bytes), "username and password required") +} + +func TestPasswordRotation(t *testing.T) { + var c Config + c.Read() + expiry, _, _ := strings.Cut(time.Now().Add(time.Minute).UTC().String(), ".") + c.Server.Jwt = JwtConfig{Users: map[string]*JwtUserData{username1: {Password: password1, Secret: secret, Exp: 60, CredExp: expiry, NewCred: &JwtNewCred{Password: password2, Secret: secret2, Exp: 60}}}} + backup_then_update_config(c) + defer restore_config() + restart_signatory() + + //use old password + var h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"password", password1}} + code, bytes := request(login, "", h) + require.Equal(t, 201, code) + token := string(bytes) + assert.NotContains(t, token, "signature") + assert.Equal(t, 2, strings.Count(token, ".")) + + //use new password + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"password", password2}} + code, bytes = request(login, "", h) + assert.Equal(t, 201, code) + token = string(bytes) + assert.NotContains(t, token, "signature") + assert.Equal(t, 2, strings.Count(token, ".")) + + //wait for old password to expire + time.Sleep(time.Minute + time.Second) + + //old password doesn't work now + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"password", password1}} + code, bytes = request(login, "", h) + assert.Equal(t, 401, code) + assert.Contains(t, string(bytes), "Access denied") + + //new password still works + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"password", password2}} + code, bytes = request(login, "", h) + assert.Equal(t, 201, code) + token = string(bytes) + assert.NotContains(t, token, "signature") + assert.Equal(t, 2, strings.Count(token, ".")) +} + +func TestPerPkh(t *testing.T) { + var pkh1 = "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb" + var pkh2 = "tz1aSkwEot3L2kmUvcoxzjMomb9mvBNuzFK6" + var pkh3 = "tz1RKGhRF4TZNCXEfwyqZshGsVfrZeVU446B" + var pkh4 = "tz1R8HJMzVdZ9RqLCknxeq9w5rSbiqJ41szi" + var base = baseUrl + "keys/" + var url1 = base + pkh1 + var url2 = base + pkh2 + var url3 = base + pkh3 + var url4 = base + pkh4 + + var c Config + c.Read() + c.Server.Jwt = JwtConfig{Users: map[string]*JwtUserData{username1: {Password: password1, Secret: secret}, + username2: {Password: password2, Secret: secret}}} + + c.Tezos[pkh1].JwtUsers = []string{username1} + c.Tezos[pkh2].JwtUsers = []string{username2} + c.Tezos[pkh3].JwtUsers = []string{username1, username2} + c.Tezos[pkh3].Allow = map[string][]string{"generic": {"transaction"}} + c.Tezos[pkh4].Allow = map[string][]string{"generic": {"transaction"}} + + backup_then_update_config(c) + defer restore_config() + restart_signatory() + + //user1 login + var h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"password", password1}} + code, bytes := request(login, "", h) + require.Equal(t, 201, code) + token1 := string(bytes) + + //user2 login + h = [][]string{{"Content-Type", "application/json"}, {"username", username2}, {"password", password2}} + code, bytes = request(login, "", h) + require.Equal(t, 201, code) + token2 := string(bytes) + + //pkh1 signs for user1 + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"Authorization", "Bearer " + token1}} + code, bytes = request(url1, message, h) + assert.Equal(t, 200, code) + assert.Contains(t, string(bytes), "signature") + + //pkh1 does not sign for user2 + h = [][]string{{"Content-Type", "application/json"}, {"username", username2}, {"Authorization", "Bearer " + token2}} + code, bytes = request(url1, message, h) + assert.Equal(t, 403, code) + assert.Contains(t, string(bytes), "user `username2' is not authorized to access "+pkh1) + + //pkh1 does not sign for malicious user2 who tries to be user1 + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"Authorization", "Bearer " + token2}} + code, bytes = request(url1, message, h) + assert.Equal(t, 401, code) + assert.Contains(t, string(bytes), "JWT: invalid token") + + //pkh2 signs for user2 + h = [][]string{{"Content-Type", "application/json"}, {"username", username2}, {"Authorization", "Bearer " + token2}} + code, bytes = request(url2, message, h) + assert.Equal(t, 200, code) + assert.Contains(t, string(bytes), "signature") + + //pkh2 does not sign for user1 + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"Authorization", "Bearer " + token1}} + code, bytes = request(url2, message, h) + assert.Equal(t, 403, code) + assert.Contains(t, string(bytes), "user `username1' is not authorized to access "+pkh2) + + //pkh3 signs for both user1 and user2 because both are configured + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"Authorization", "Bearer " + token1}} + code, bytes = request(url3, message, h) + assert.Equal(t, 200, code) + assert.Contains(t, string(bytes), "signature") + h = [][]string{{"Content-Type", "application/json"}, {"username", username2}, {"Authorization", "Bearer " + token2}} + code, bytes = request(url3, message, h) + assert.Equal(t, 200, code) + assert.Contains(t, string(bytes), "signature") + + //pkh4 signs for both user1 and user2 because nobody is configured + h = [][]string{{"Content-Type", "application/json"}, {"username", username1}, {"Authorization", "Bearer " + token1}} + code, bytes = request(url4, message, h) + assert.Equal(t, 200, code) + assert.Contains(t, string(bytes), "signature") + h = [][]string{{"Content-Type", "application/json"}, {"username", "username2"}, {"Authorization", "Bearer " + token2}} + code, bytes = request(url4, message, h) + assert.Equal(t, 200, code) + assert.Contains(t, string(bytes), "signature") +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 8f8939d0..8bd7f77e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,6 +6,7 @@ import ( "github.com/ecadlabs/signatory/pkg/crypt" "github.com/ecadlabs/signatory/pkg/hashmap" + "github.com/ecadlabs/signatory/pkg/middlewares" "github.com/go-playground/validator/v10" yaml "gopkg.in/yaml.v3" ) @@ -18,9 +19,10 @@ type PolicyHook struct { // ServerConfig contains the information necessary to the tezos signing server type ServerConfig struct { - Address string `yaml:"address" validate:"hostname_port"` - UtilityAddress string `yaml:"utility_address" validate:"hostname_port"` - AuthorizedKeys *AuthorizedKeys `yaml:"authorized_keys"` + Address string `yaml:"address" validate:"hostname_port"` + UtilityAddress string `yaml:"utility_address" validate:"hostname_port"` + AuthorizedKeys *AuthorizedKeys `yaml:"authorized_keys"` + JWTConfig *middlewares.JWT `yaml:"jwt"` } // TezosConfig contains the configuration related to tezos network @@ -33,6 +35,7 @@ type TezosPolicy struct { AllowedKinds []string `yaml:"allowed_kinds"` LogPayloads bool `yaml:"log_payloads"` AuthorizedKeys *AuthorizedKeys `yaml:"authorized_keys"` + JwtUsers []string `yaml:"jwt_users"` } // VaultConfig represents single vault instance @@ -67,7 +70,6 @@ func (c *Config) Read(file string) error { if err = yaml.Unmarshal(yamlFile, c); err != nil { return err } - return nil } diff --git a/pkg/middlewares/jwt.go b/pkg/middlewares/jwt.go new file mode 100644 index 00000000..20aa2ab4 --- /dev/null +++ b/pkg/middlewares/jwt.go @@ -0,0 +1,299 @@ +package middlewares + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt" +) + +// AuthGen is an interface that generates token authenticates the same +type AuthGen interface { + GetUserData(user string) (*UserData, bool) + Authenticate(user string, token string) (string, error) + GenerateToken(user string, pass string) (string, error) +} + +// JWTMiddleware is an AuthGen implementation that uses JWT tokens +type JWTMiddleware struct { + AuthGen AuthGen +} + +// NewMiddleware creates a new JWTMiddleware +func NewMiddleware(a AuthGen) *JWTMiddleware { + return &JWTMiddleware{a} +} + +// Handler is a middleware handler +func (m *JWTMiddleware) LoginHandler(w http.ResponseWriter, r *http.Request) { + user := r.Header.Get("username") + pass := r.Header.Get("password") + if user == "" || pass == "" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("username and password required")) + return + } + + ud, ok := m.AuthGen.GetUserData(user) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Access denied")) + return + } + if ud.Password != pass { + if ud.NewData != nil { + if ud.NewData.Password != pass { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Access denied")) + return + } + } else { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Access denied")) + return + } + } + + token, err := m.AuthGen.GenerateToken(user, pass) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.WriteHeader(http.StatusCreated) + w.Write([]byte(token)) +} + +// Handler is a middleware handler +func (m *JWTMiddleware) AuthHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var u string + var err error + if r.URL.Path == "/login" { + next.ServeHTTP(w, r) + return + } + token := r.Header.Get("Authorization") + user := r.Header.Get("username") + + if token != "" { + token = strings.TrimPrefix(token, "Bearer ") + u, err = m.AuthGen.Authenticate(user, token) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(err.Error())) + return + } + } else { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("token required")) + return + } + ctx := context.WithValue(r.Context(), "user", u) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// JWT contains the configuration for JWT tokens +type JWT struct { + Users map[string]UserData `yaml:"users"` +} + +type UserData struct { + Password string `yaml:"password"` + Exp uint64 `yaml:"jwt_exp"` + Secret string `yaml:"secret"` + OldCredExp string `yaml:"old_cred_exp,omitempty"` + NewData *UserData `yaml:"new_data"` +} + +func (j *JWT) SetNewCred(user string) error { + if u, ok := j.Users[user]; ok { + if u.NewData != nil { + u.Password = u.NewData.Password + u.Secret = u.NewData.Secret + u.Exp = u.NewData.Exp + u.NewData = nil + j.Users[user] = u + } + return nil + } + return fmt.Errorf("JWT: user not found") +} + +func (j *JWT) GetUserData(user string) (*UserData, bool) { + if u, ok := j.Users[user]; ok { + return &u, true + } + return nil, false +} + +// GenerateToken generates a new token for the given user +func (j *JWT) GenerateToken(user string, pass string) (string, error) { + token := jwt.New(jwt.SigningMethodHS256) + claims := token.Claims.(jwt.MapClaims) + claims["user"] = user + ud, ok := j.GetUserData(user) + if pass != ud.Password { + ud = ud.NewData + } + if ok { + if ud.Exp == 0 { + ud.Exp = 60 + } + claims["exp"] = time.Now().Add(time.Minute * time.Duration(ud.Exp)).Unix() + } else { + return "", fmt.Errorf("JWT: user not found") + } + token.Claims = claims + return token.SignedString([]byte(ud.Secret)) +} + +// Authenticate authenticates the given token +func (j *JWT) Authenticate(user string, token string) (string, error) { + var tok *jwt.Token + var err error + ud, ok := j.GetUserData(user) + if ok { + tok, err = jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + return []byte(ud.Secret), nil + }) + if err != nil { + if ud.NewData != nil { + tok, err = jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + return []byte(ud.NewData.Secret), nil + }) + } + return "", err + } + if tu := tok.Claims.(jwt.MapClaims)["user"]; tu != nil { + if tu.(string) != user { + fmt.Println("JWT_Warning: Suspicious activity detected, token user is not the same as the user in the request") + return "", fmt.Errorf("JWT: invalid token") + } + } else { + return "", fmt.Errorf("JWT: invalid token") + } + if _, ok := tok.Claims.(jwt.MapClaims); ok && tok.Valid { + return tok.Claims.(jwt.MapClaims)["user"].(string), nil + } + } else { + return "", fmt.Errorf("JWT: user not found") + } + return "", fmt.Errorf("JWT: invalid token") +} + +func (j *JWT) CheckUpdateNewCred() error { + for user, data := range j.Users { + if data.NewData != nil { + if data.NewData.Password == data.Password || data.NewData.Secret == data.Secret { + return fmt.Errorf("JWT: new credentials are same as old for user %s", user) + } + if e := validateSecretAndPass([]string{data.NewData.Password, data.NewData.Secret}); e != nil { + return fmt.Errorf("JWT:config validation failed for user %s: %e", user, e) + } + go func(u string, exp string) error { + if exp == "" { + err := j.SetNewCred(u) + if err != nil { + return fmt.Errorf("JWT: Failed to set new user config for %s: %e", u, err) + } + return nil + } + + layout := "2006-01-02 15:04:05" + t, err := time.Parse(layout, exp) + if err != nil { + e := j.SetNewCred(u) + if e != nil { + return fmt.Errorf("JWT: Failed to set new user config for %s: %e", u, e) + } + return fmt.Errorf("JWT: Failed to parse time for user %s: %e", u, err) + } + + duration := t.UTC().Unix() - time.Now().Unix() + if duration < 0 { + err := j.SetNewCred(u) + if err != nil { + return fmt.Errorf("JWT: Failed to set new user config for %s: %e", u, err) + } + return nil + } + time.Sleep(1 * time.Second * time.Duration(duration)) + err = j.SetNewCred(u) + if err != nil { + return fmt.Errorf("JWT: Failed to set new user config for %s: %e", u, err) + } + return nil + }(user, data.OldCredExp) + } + if e := validateSecretAndPass([]string{data.Password, data.Secret}); e != nil { + return fmt.Errorf("JWT:config validation failed for user %s: %e", user, e) + } + } + return nil +} + +func validateSecretAndPass(secret []string) error { + var length int = 16 + var stype string = "password" + for _, s := range secret { + // Check length + if len(s) < length { + return fmt.Errorf("%s should be at least %d characters", stype, length) + } + // Check if secret contains uppercase characters + hasUppercase := false + for _, c := range s { + if c >= 'A' && c <= 'Z' { + hasUppercase = true + break + } + } + if !hasUppercase { + return fmt.Errorf("%s should contain at least one uppercase character", stype) + } + + // Check if secret contains lowercase characters + hasLowercase := false + for _, c := range s { + if c >= 'a' && c <= 'z' { + hasLowercase = true + break + } + } + if !hasLowercase { + return fmt.Errorf("%s should contain at least one lowercase character", stype) + } + + // Check if secret contains digits + hasDigit := false + for _, c := range s { + if c >= '0' && c <= '9' { + hasDigit = true + break + } + } + if !hasDigit { + return fmt.Errorf("%s should contain at least one digit", stype) + } + + // Check if secret contains special characters + hasSpecialChar := false + for _, c := range s { + if c >= 32 && c <= 126 && !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) { + hasSpecialChar = true + break + } + } + if !hasSpecialChar { + return fmt.Errorf("%s should contain at least one special character", stype) + } + length = 32 + stype = "secret" + } + return nil +} diff --git a/pkg/middlewares/jwt_test.go b/pkg/middlewares/jwt_test.go new file mode 100644 index 00000000..05a4c39d --- /dev/null +++ b/pkg/middlewares/jwt_test.go @@ -0,0 +1,449 @@ +package middlewares + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + secret = "!_?z$Tf$o}iDcJQ4Yk|&H87dm5#ZO'hv" +) + +// MockAuthGen is a mock implementation of the AuthGen interface +type MockAuthGen struct { + fails bool + Users map[string]UserData +} + +func (m *MockAuthGen) SetNewCred(user string) error { + return nil +} + +func (m *MockAuthGen) GetUserData(user string) (*UserData, bool) { + ud, ok := m.Users[user] + return &ud, ok +} + +func (m *MockAuthGen) Authenticate(user string, token string) (string, error) { + + return "", nil +} + +func (m *MockAuthGen) GenerateToken(user string, pass string) (string, error) { + if m.fails { + return "", fmt.Errorf("Generate test error") + } + return "Generate-Token Success", nil +} + +func TestLoginHandler(t *testing.T) { + type testCases []struct { + title string + user string + password string + code int + expected string + ma AuthGen + } + + cases := testCases{ + { + title: "Empty username password", + user: "", + password: "", + code: http.StatusUnauthorized, + ma: &MockAuthGen{ + Users: map[string]UserData{ + "user": { + Password: "pass", + Secret: secret, + }, + }, + }, + }, + { + title: "Empty username", + user: "", + password: "pass", + code: http.StatusUnauthorized, + ma: &MockAuthGen{ + Users: map[string]UserData{ + "user": { + Password: "pass", + Secret: secret, + }, + }, + }, + }, + { + title: "Empty password", + user: "user", + password: "", + code: http.StatusUnauthorized, + ma: &MockAuthGen{ + Users: map[string]UserData{ + "user": { + Password: "pass", + Secret: secret, + }, + }, + }, + }, + { + title: "Invalid user", + user: "user1", + password: "pass", + code: http.StatusUnauthorized, + expected: "Access denied", + ma: &MockAuthGen{ + Users: map[string]UserData{ + "user": { + Password: "pass", + Secret: secret, + }, + }, + }, + }, + { + title: "Invalid password", + user: "user", + password: "pass1", + code: http.StatusUnauthorized, + expected: "Access denied", + ma: &MockAuthGen{ + Users: map[string]UserData{ + "user": { + Password: "pass", + Secret: secret, + }, + }, + }, + }, + { + title: "Valid user and password but error generating token", + user: "user", + password: "pass", + code: http.StatusInternalServerError, + expected: "Generate test error", + ma: &MockAuthGen{ + fails: true, + Users: map[string]UserData{ + "user": { + Password: "pass", + Secret: secret, + }, + }, + }, + }, + { + title: "Valid user and password", + user: "user", + password: "pass", + code: http.StatusCreated, + expected: "Generate-Token Success", + ma: &MockAuthGen{ + fails: false, + Users: map[string]UserData{ + "user": { + Password: "pass", + Secret: secret, + }, + }, + }, + }, + { + title: "Generator coverage case", + user: "user", + password: "pass", + code: http.StatusCreated, + ma: &JWT{ + Users: map[string]UserData{ + "user": { + Password: "pass", + Secret: "secret", + }, + }, + }, + }, + { + title: "Generator coverage case", + user: "user", + password: "pass", + code: http.StatusCreated, + ma: &JWT{ + Users: map[string]UserData{ + "user": { + Password: "pass", + Secret: secret, + Exp: 23, + }, + }, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.title, func(t *testing.T) { + jwtMiddleware := NewMiddleware(tc.ma) + + req, err := http.NewRequest("GET", "/login", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("username", tc.user) + req.Header.Set("password", tc.password) + recorder := httptest.NewRecorder() + + handler := http.HandlerFunc(jwtMiddleware.LoginHandler) + handler.ServeHTTP(recorder, req) + + require.Equal(t, tc.code, recorder.Code) + if tc.expected != "" { + require.Equal(t, tc.expected, recorder.Body.String()) + } + }) + } +} +func TestAuthHandlerValidToken(t *testing.T) { + user := "user" + authGen := &JWT{ + Users: map[string]UserData{ + user: { + Password: "pass", + Secret: secret, + Exp: 23, + }, + }, + } + jwtMiddleware := NewMiddleware(authGen) + + req, err := http.NewRequest("GET", "/protected", nil) + if err != nil { + t.Fatal(err) + } + tok, err := getToken(user, jwtMiddleware) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("username", user) + + recorder := httptest.NewRecorder() + + handler := jwtMiddleware.AuthHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := r.Context().Value("user") + require.Equal(t, user, u.(string)) + })) + + handler.ServeHTTP(recorder, req) + require.Equal(t, http.StatusOK, recorder.Code) +} + +func TestAuthHandlerInValidToken(t *testing.T) { + user := "user" + authGen := &JWT{ + Users: map[string]UserData{ + user: { + Password: "pass", + Secret: secret, + Exp: 23, + }, + }, + } + jwtMiddleware := NewMiddleware(authGen) + + req, err := http.NewRequest("GET", "/protected", nil) + if err != nil { + t.Fatal(err) + } + tok := "invalid-token" + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("username", user) + + recorder := httptest.NewRecorder() + + handler := jwtMiddleware.AuthHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Fail(t, "should not be called") + })) + + handler.ServeHTTP(recorder, req) + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.Equal(t, "token contains an invalid number of segments", recorder.Body.String()) +} + +func TestAuthHandlerEmptyToken(t *testing.T) { + user := "user" + authGen := &JWT{ + Users: map[string]UserData{ + user: { + Password: "pass", + Secret: secret, + Exp: 23, + }, + }, + } + jwtMiddleware := NewMiddleware(authGen) + + req, err := http.NewRequest("GET", "/protected", nil) + if err != nil { + t.Fatal(err) + } + require.NoError(t, err) + req.Header.Set("username", user) + + recorder := httptest.NewRecorder() + + handler := jwtMiddleware.AuthHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Fail(t, "should not be called") + })) + + handler.ServeHTTP(recorder, req) + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.Equal(t, "token required", recorder.Body.String()) +} + +func getToken(user string, au *JWTMiddleware) (string, error) { + + req, err := http.NewRequest("GET", "/login", nil) + if err != nil { + return "", err + } + req.Header.Set("username", user) + ud, b := au.AuthGen.GetUserData(user) + if !b { + return "", fmt.Errorf("error getting user data") + } + req.Header.Set("password", ud.Password) + + recorder := httptest.NewRecorder() + + handler := http.HandlerFunc(au.LoginHandler) + handler.ServeHTTP(recorder, req) + + if http.StatusCreated != recorder.Code { + return "", fmt.Errorf("error generating token") + } + return recorder.Body.String(), nil +} + +func TestValidateSecret(t *testing.T) { + type args struct { + pass string + secret string + } + tests := []struct { + name string + args args + expect string + }{ + // Invalid length + { + name: "Invalid length", + args: args{ + pass: "SecretSecretSecretSecretSecretS1#$", + secret: "Secret1!Secret1!Secret1!Secret1", + }, + expect: "secret should be at least 32 characters", + }, + // No uppercase + { + name: "No uppercase", + args: args{ + pass: "SecretSecretSecretSecretSecretS1#$", + secret: "secretsecretsecretsecretsecrets1", + }, + expect: "secret should contain at least one uppercase character", + }, + // No lowercase + { + name: "No lowercase", + args: args{ + pass: "SecretSecretSecretSecretSecretS1#$", + secret: "SECRETSECRETSECRETSECRETSECRETS1", + }, + expect: "secret should contain at least one lowercase character", + }, + // No number + { + name: "No number", + args: args{ + pass: "SecretSecretSecretSecretSecretS1#$", + secret: "SecretSecretSecretSecretSecretSe", + }, + expect: "secret should contain at least one digit", + }, + // No special character + { + name: "No special character", + args: args{ + pass: "SecretSecretSecretSecretSecretS1#$", + secret: "SecretSecretSecretSecretSecretS1", + }, + expect: "secret should contain at least one special character", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := validateSecretAndPass([]string{tt.args.pass, tt.args.secret}) + require.Equal(t, tt.expect, actual.Error()) + }) + } +} + +func TestJWT_CheckUpdatenewCred(t *testing.T) { + now := time.Now().UTC() + desiredTime := now.Add(time.Minute * 1) + desiredTimeStr := desiredTime.Format("2006-01-02 15:04:05") + fmt.Println(desiredTimeStr) + type fields struct { + Users map[string]UserData + } + tests := []struct { + name string + fields fields + }{ + { + name: "CheckUpdatenewCred", + fields: fields{ + Users: map[string]UserData{ + "user": { + Password: "SecretSecretSecretSecretSecretS1#$", + Secret: secret, + Exp: 1, + OldCredExp: desiredTimeStr, + NewData: &UserData{ + Password: "SecretSecretSecretSecretSecretS1#$x", + Secret: secret + "1", + Exp: 33, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &JWT{ + Users: tt.fields.Users, + } + err := a.CheckUpdateNewCred() + require.NoError(t, err) + d, ret := a.GetUserData("user") + require.True(t, ret) + require.Equal(t, tt.fields.Users["user"].Password, d.Password) + require.Equal(t, secret, d.Secret) + + time.Sleep(time.Minute + time.Second) + + d, ret = a.GetUserData("user") + require.True(t, ret) + require.True(t, ret) + require.Equal(t, "SecretSecretSecretSecretSecretS1#$x", d.Password) + require.Equal(t, secret+"1", d.Secret) + }) + } +} diff --git a/pkg/server/middleware.go b/pkg/middlewares/logger.go similarity index 98% rename from pkg/server/middleware.go rename to pkg/middlewares/logger.go index b13abfba..6a909698 100644 --- a/pkg/server/middleware.go +++ b/pkg/middlewares/logger.go @@ -1,4 +1,4 @@ -package server +package middlewares // Logging middleware inspired by github.com/urfave/negroni diff --git a/pkg/server/server.go b/pkg/server/server.go index 1813c8f6..38c6fbe0 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -13,6 +13,7 @@ import ( "github.com/ecadlabs/signatory/pkg/auth" "github.com/ecadlabs/signatory/pkg/crypt" "github.com/ecadlabs/signatory/pkg/errors" + "github.com/ecadlabs/signatory/pkg/middlewares" "github.com/ecadlabs/signatory/pkg/signatory" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" @@ -29,6 +30,7 @@ type Signer interface { // Server struct containing the information necessary to run a tezos remote signers type Server struct { Auth auth.AuthorizedKeysStorage + MidWare *middlewares.JWTMiddleware Signer Signer Address string Logger log.FieldLogger @@ -183,8 +185,12 @@ func (s *Server) Handler() (http.Handler, error) { } r := mux.NewRouter() - r.Use((&Logging{}).Handler) + r.Use((&middlewares.Logging{}).Handler) + if s.MidWare != nil { + r.Use(s.MidWare.AuthHandler) + } + r.Methods("POST").Path("/login").HandlerFunc(s.MidWare.LoginHandler) r.Methods("POST").Path("/keys/{key}").HandlerFunc(s.signHandler) r.Methods("GET").Path("/keys/{key}").HandlerFunc(s.getKeyHandler) r.Methods("GET").Path("/authorized_keys").HandlerFunc(s.authorizedKeysHandler) diff --git a/pkg/signatory/signatory.go b/pkg/signatory/signatory.go index ba750c80..0c9e1fc1 100644 --- a/pkg/signatory/signatory.go +++ b/pkg/signatory/signatory.go @@ -65,6 +65,7 @@ type PublicKeyPolicy struct { AllowedOps []string LogPayloads bool AuthorizedKeyHashes []crypt.PublicKeyHash + AuthorizedJwtUsers []string } // PublicKey contains public key with its hash @@ -204,6 +205,25 @@ func matchFilter(policy *PublicKeyPolicy, req *SignRequest, msg request.SignRequ return nil } +func jwtVerifyUser(user string, policy *PublicKeyPolicy, req *SignRequest) error { + fmt.Println("jwtVerifyUser", user, policy.AuthorizedJwtUsers) + authorized := false + if policy.AuthorizedJwtUsers != nil { + fmt.Println("AuthorizedJwtUsers", policy.AuthorizedJwtUsers) + for _, u := range policy.AuthorizedJwtUsers { + if u == user { + authorized = true + break + } + } + if !authorized { + return fmt.Errorf("user `%s' is not authorized to access %s", user, req.PublicKeyHash) + } + } + fmt.Println("AuthorizedJwtUsers nil: ", authorized) + return nil +} + func (s *Signatory) callPolicyHook(ctx context.Context, req *SignRequest) error { if s.config.PolicyHook == nil { return nil @@ -311,6 +331,14 @@ func (s *Signatory) Sign(ctx context.Context, req *SignRequest) (crypt.Signature return nil, errors.Wrap(err, http.StatusForbidden) } + u := ctx.Value("user") + if u != nil { + if err := jwtVerifyUser(u.(string), policy, req); err != nil { + l.WithField(logRaw, hex.EncodeToString(req.Message)).Error(err) + return nil, errors.Wrap(err, http.StatusForbidden) + } + } + var msg request.SignRequest _, err := encoding.Decode(req.Message, &msg) if err != nil { @@ -474,6 +502,7 @@ func (s *Signatory) getPublicKey(ctx context.Context, keyHash crypt.PublicKeyHas // GetPublicKey retrieve the public key from a vault func (s *Signatory) GetPublicKey(ctx context.Context, keyHash crypt.PublicKeyHash) (*PublicKey, error) { + p, err := s.getPublicKey(ctx, keyHash) if err != nil { return nil, err @@ -630,6 +659,11 @@ func PreparePolicy(src config.TezosConfig) (out Policy, err error) { pol.AuthorizedKeyHashes[i] = k.Hash() } } + + if v.JwtUsers != nil { + pol.AuthorizedJwtUsers = v.JwtUsers + } + policy.Insert(k, &pol) return true }) diff --git a/signatory.yaml b/signatory.yaml index 93bf1aaf..d9d07528 100644 --- a/signatory.yaml +++ b/signatory.yaml @@ -3,6 +3,18 @@ server: address: :6732 # Address for the utility HTTP server to listen on utility_address: :9583 + jwt: + # Secret used to sign JWT tokens + users: + user_name1: + password: password1 + secret: secret1 + jwt_exp: 35 + user_name2: + password: password2 + secret: secret2 + jwt_exp: 30 + vaults: # Name is used to identify backend during import process diff --git a/website/sidebars.js b/website/sidebars.js index cacac801..cda70bd5 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -22,7 +22,22 @@ const sidebars = { className: 'sidebarHeader', collapsed: false, collapsible: false, - items: ['start', 'file_based', 'yubihsm', 'azure_kms', 'gcp_kms', 'aws_kms', 'ledger', `cli`, 'remote_policy','authorized_keys', 'architecture', 'bakers'], + items: ['start', + 'file_based', + 'yubihsm', + 'azure_kms', + 'gcp_kms', + 'aws_kms', + 'ledger', + 'cli', + 'remote_policy', + 'architecture', + 'bakers', + { + type: 'category', + label: 'Client Authorization', + items: [`authorized_keys`, `jwt`] + }], }, ], };