Introduction
From Microservices with Clojure. In the previous article, we discussed how to build a database for microservices using the next generation DB Datomic. In this article, we will discuss about security for microservices using Clojure’s Auth and Pedestal APIs.
Microservices should be deployed in isolation and monitored for usage. Monitoring current workloads and processing times can also help determine when to scale them up and when to scale them down. Another important aspect of a microservice-based architecture will be security. One way to secure microservices would be to ensure that each service has its own authentication and authorization module. But this approach quickly becomes problematic. Because each microservice is deployed in isolation, it becomes incredibly difficult to agree on common criteria for authenticating users. Also, in this case, ownership of users and their roles would be distributed among the services. This chapter addresses these issues and describes solutions for securing, monitoring, and extending microservice-based applications.
Activate Authentication and Authorization
Authentication is the process of identifying who the user is, while authorization is the process of verifying what the authenticated user can access. The most common way to perform authentication is to ask the user to enter a username and password, which is then verified in the back-end database.
Passwords should never be stored in plain text in the back-end database. It is recommended that a one-way hash of the password be computed and stored instead. To reset a password, the system could simply generate a random password, store the hash, and share the random password with the user in plain text. Alternatively, the system could send the user a unique URL to reset the password and verify the user’s identity by means of a preset question and answer or one-time password (OTP).
If an application has multiple security perimeters, authenticating the user is not sufficient. For example, an application may require only certain users to send notifications through the system and prevent others from doing so. In such a case, the application would need to create a security boundary for that resource. This boundary is often defined using roles with one or more permissions that are validated by the application before granting access to the resource, notification, or other functionality. Roles and permissions are key elements of authorization, allowing an application to create multiple security boundaries for its resources.
Token and JWT implementation
In a monolithic environment, authentication and authorization are handled within the same application using a module that validates requests to receive the necessary authentication and authorization information, as shown in the following figure. This module also allows authorized users to define roles and permissions and assign them to other users in the system to allow them access to protected resources.
A monolithic application may also maintain a session store that allows each instance of the monolithic application to validate requests received and determine a valid session for the user. In many cases, such session information is stored in a cookie that is sent to the client as a token once the user has successfully authenticated. This cookie is attached to each request by the client, and a valid session and associated roles can be verified by the server to determine whether to allow or deny access to the requested resource, as shown in the preceding figure.
In microservice-based applications, each microservice is deployed in isolation and is not responsible for maintaining a separate user or session database. Furthermore, there must be a standardized method for authenticating and authorizing users across microservices. It will be common to separate the responsibility for authentication and authorization as a separate Auth service that can own the user database and authenticate and authorize users. This would also help in authenticating users once through the Auth service and then authorizing them to access resources and related services through other microservices.
Since each microservice uses its own technology stack and may not have prior knowledge of Auth services, a common standard is needed to validate authenticated users across microservices. JSON Web Tokens (JWT) is one such standard, which consists of a header, a It consists of a header, payload, and signature, and can be issued as a token to the user upon successful authentication. The user sends this token to the microservice with each request, and the microservice can verify the token and grant access to the requested resource.
JWT can either encrypt content or protect it with digital signatures or message authentication codes; JSON Web Signature (JWS) represents content protected with digital signatures or message authentication codes (MACs), and JSON Web Encryption (JWE) represents content encrypted using JSON-based data structures. If the token is encrypted, it can only be read with the key used to encrypt the token; to read a JWE token, the service must possess the key used to encrypt the token. Instead of sharing keys among microservices, it will be common practice to send the token directly to the Auth service, which will decrypt the token and approve the request on behalf of the service. This can create performance bottlenecks and single point-of-failure as each service attempts to get to the Auth service first for authentication. This can be prevented by caching pre-verified tokens at each microservice level for a configurable time based on the token’s expiration date.
The expiration date is an important criterion when dealing with JWTs. JWTs with very long expiration times should be avoided, as there is no way for the application to log out the user or invalidate the token. Issued tokens remain valid as long as they do not expire. As long as the user possesses a valid token, he/she can use the issued token to access the service. One way to prevent logout problems would be to have the microservice always validate the token with an Auth service that keeps a cache of user authentication details synchronized with the user’s role and privileges The Auth service would validate the token each time it receives a token against this It can validate against the cache, and if the user’s role or other properties change, it can invalidate the token and force the user to request a new token.
Create an Auth service for Helping Hands
Helping Hands’ Auth service can be built using the same pedestal project template as Helping Hands’ other microservices. In this example, we will use JWE to create a JWT token for the user. To begin, create a new project with the directory structure shown in the following example. This project contains a new namespace helping-hands.auth.jwt that contains the implementation associated with JWT, and the remaining namespaces are used as described in the previous chapter.
├── Capstanfile
├── config
│ ├── conf.edn
│ └── logback.xml ├── Dockerfile
├── project.clj ├── README.md
├── resources
├── src
│ ├── clj
│ │ └── helping_hands
│ │ └── auth
│ │ ├── config.clj
│ │ ├── core.clj
│ │ ├── jwt.clj
│ │ ├── persistence.clj
│ │ ├── server.clj
│ │ ├── service.clj
│ │ └── state.clj
│ └── jvm
└── test
├── clj
│ └── helping_hands
│ └── auth
│ ├── core_test.clj
│ └── service_test.clj
└── jvm
12 directories, 14 files
Using the Nimbus JOSE JWT library for tokens
The Auth service project additionally uses the Nimbus-JOSE-JWT library to create and validate JSON Web Tokens and uses permissions to authorize users for a set of roles and privileges. We have added a dependency between Nimbus-JOSE-JWT and the permissions library as shown in the project.clj file below.
(defproject helping-hands-auth "0.0.1-SNAPSHOT"
:description "Helping Hands Auth Service"
:url
"https://www.packtpub.com/application-development/microservices-clojure"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.8.0"]
[io.pedestal/pedestal.service "0.5.3"]
[io.pedestal/pedestal.jetty "0.5.3"]
[com.datomic/datomic-free "0.9.5561.62"] ;; Omniconf
[com.grammarly/omniconf "0.2.7"]
;; Mount
[mount "0.1.11"]
;; nimbus-jose for JWT [com.nimbusds/nimbus-jose-jwt "5.4"]
;; used for roles and permissions [agynamix/permissions "0.2.2-SNAPSHOT"] ;; logger
[org.clojure/tools.logging "0.4.0"] [ch.qos.logback/logback-classic "1.1.8"
:exclusions [org.slf4j/slf4j-api]]
[org.slf4j/jul-to-slf4j "1.7.22"]
[org.slf4j/jcl-over-slf4j "1.7.22"]
[org.slf4j/log4j-over-slf4j "1.7.22"]]
:min-lein-version "2.0.0"
:source-paths ["src/clj"]
:java-source-paths ["src/jvm"]
:test-paths ["test/clj" "test/jvm"]
:resource-paths ["config", "resources"]
:plugins [[:lein-codox "0.10.3"]
;; Code Coverage
[:lein-cloverage "1.0.9"]
;; Unit test docs
[test2junit "1.2.2"]]
:codox {:namespaces :all}
:test2junit-output-dir "target/test-reports"
:profiles {:provided {:dependencies [[org.clojure/tools.reader "0.10.0"]
[org.clojure/tools.nrepl "0.2.12"]]}
:dev {:aliases
{"run-dev" ["trampoline" "run" "-m"
"helping-hands.auth.server/run-dev"]}
:dependencies
[[io.pedestal/pedestal.service-tools "0.5.3"]]
:resource-paths ["config", "resources"]
:jvm-opts ["-Dconf=config/conf.edn"]}
:uberjar {:aot [helping-hands.auth.server]}
:doc {:dependencies [[codox-theme-rdash "0.1.1"]]
:codox {:metadata {:doc/format :markdown}
:themes [:rdash]}}
:debug {:jvm-opts
["-server" (str "-agentlib:jdwp=transport=dt_socket,"
"server=y,address=8000,suspend=n")]}}
:main ^{:skip-aot true} helping-hands.auth.server)
Creating a Secret Key for JSON Web Encryption
First, to implement JWT with claims encrypted, create a get-secret function to generate a secret key for encryption. Also, add the get-secret-jwk function to create a JSON Web Key using the secret key generated by the get-secret function, as shown in the code below.
(ns helping-hands.auth.jwt
"JWT Implementation for Auth Service"
(:require [cheshire.core :as jp])
(:import [com.nimbusds.jose EncryptionMethod
JWEAlgorithm JWSAlgorithm
JWEDecrypter JWEEncrypter
JWEHeader$Builder JWEObject Payload]
[com.nimbusds.jose.crypto
AESDecrypter AESEncrypter]
[com.nimbusds.jose.jwk KeyOperation KeyUse
OctetSequenceKey OctetSequenceKey$Builder]
[com.nimbusds.jwt JWTClaimsSet JWTClaimsSet$Builder]
[com.nimbusds.jwt.proc DefaultJWTClaimsVerifier]
[com.nimbusds.jose.util Base64URL]
[java.util Date]
[javax.crypto KeyGenerator]
[javax.crypto.spec SecretKeySpec]))
(def ^:cons khash-256 "SHA-256")
(defonce ^:private kgen-aes-128
(let [keygen (KeyGenerator/getInstance "AES")
_ (.init keygen 128)]
keygen))
(defonce ^:private alg-a128kw
(JWEAlgorithm/A128KW))
(defonce ^:private enc-a128cbc_hs256
(EncryptionMethod/A128CBC_HS256))
(defn get-secret
"Gets the secret key"
([] (get-secret kgen-aes-128))
([kgen]
;; must be created iff the key hasn't
;; been creaed earlier. Create once and
;; persist in an external database
(.generateKey kgen)))
(defn get-secret-jwk
"Generates a new JSON Web Key (JWK)"
[{:keys [khash kgen alg] :as enc-impl} secret]
;; must be created iff the key hasn't
;; been creaed earlier. Create once and
;; persist in an external database
(.. (OctetSequenceKey$Builder. secret)
(keyIDFromThumbprint (or khash khash-256))
(algorithm (or alg alg-a128kw))
(keyUse (KeyUse/ENCRYPTION))
(build)))
The implementation shown previously uses the AES 128-bit algorithm to generate keys; the secret key generated by the get-secret function needs to be generated only once during the lifetime of the application. Therefore, when the Auth service is extended to multiple instances, it shall be stored in an external database that can be shared among instances.
Nimbus-JOSE-JWT also supports the 256-bit algorithm; to make the 256-bit algorithm work, the JRE must explicitly set the Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files.
The get-secret-jwk function takes a private key as one of the input parameters and generates a JWK, as shown in the REPL session below; the JWK consists of the key type (kty), public key usage (use), key ID (kid), and key value (k) defined in JWK RFC-7517, Algorithm (alg) parameter.
Since JWK only represents the private key in JSON format, the utility function enckey->secret can be used to retrieve the private key from JWK, as in the following implementation.
(defn enckey->secret
"Converts JSON Web Key (JWK) to the secret key"
[{:keys [k kid alg] :as enc-key}]
(.. (OctetSequenceKey$Builder. k)
(keyID kid)
(algorithm (or alg alg-a128kw))
(keyUse (KeyUse/ENCRYPTION))
(build)
(toSecretKey "AES")))
The enckey->secret function takes a key ID (kid) and key value (k) as input and creates a private key identical to the one used to create the source JSON Web Key. the alg parameter is optional and falls back to the default AES-128 algorithm if not specified If not specified, fall back to the default AES-128 algorithm. The next REPL session creates the secret key from the previously generated JWK and verifies that it always generates the same JWK.
;; JSON Web Key (JWK) generated earlier
helping-hands.auth.server> (.toJSONObject jwk)
{"kty" "oct", "use" "enc", "kid" "F5UNJYT4A-
GpngZwRMYfs8ZuCKsmRGt08Xo_dMQrY5w", "k" "CvTaCBfdEkAlXfuOnW7pnw", "alg"
"A128KW"}
;; extract the secret key
helping-hands.auth.server> (def secret-extracted (jwt/enckey->secret {:k
(.getKeyValue jwk) :kid (.getKeyID jwk)}))
#'helping-hands.auth.server/secret-extracted
;; generate JSON Web Key that is exactly same as source
helping-hands.auth.server> (.toJSONObject (jwt/get-secret-jwk {} secret-
extracted))
{"kty" "oct", "use" "enc", "kid" "F5UNJYT4A-
GpngZwRMYfs8ZuCKsmRGt08Xo_dMQrY5w", "k" "CvTaCBfdEkAlXfuOnW7pnw", "alg"
"A128KW"}
helping-hands.auth.server> (.toJSONObject jwk)
{"kty" "oct", "use" "enc", "kid" "F5UNJYT4A-
GpngZwRMYfs8ZuCKsmRGt08Xo_dMQrY5w", "k" "CvTaCBfdEkAlXfuOnW7pnw", "alg"
"A128KW"}
Create Token
The next step would be to create the JWT and define a function to read it; since the JWT used in the Helping Hands application uses JWE to encrypt claims, it is acceptable to add both user ID and role information in the payload so that it can later be retrieved from a valid token to It is acceptable to allow users to be authenticated.
The create-token and read-token functions shown in the following examples provide a way to create a JSON Web Token and read an existing one, respectively. the create-token function uses the utility function create-payload to create a JWT create-claimsets and payloads. The claim set used in this example is: issueTime, which defines the epoch time when the token was created; expirationTime, which sets the time when the token is considered expired; user and roles custom claims, which store the authenticated user name and the roles assigned to the user at the time the token was issued. See JWT RFC-7519 for more information on the available claim set options.
(defn- create-payload
"Creates a payload as JWT Claims"
[{:keys [user roles] :as params}]
(let [ts (System/currentTimeMillis)
claims (.. (JWTClaimsSet$Builder.)
(issuer "Packt")
(subject "HelpingHands")
(audience "https://www.packtpub.com")
(issueTime (Date. ts))
(expirationTime (Date. (+ ts 120000)))
(claim "user" user)
(claim "roles" roles)
(build))]
(.toJSONObject claims)))
(defn create-token
"Creates a new token with the given payload"
[{:keys [user roles alg enc] :as params} secret]
(let [enckey (get-secret-jwk params secret)
payload (create-payload {:user user :roles roles})
passphrase (JWEObject.
(.. (JWEHeader$Builder.
(or alg alg-a128kw)
(or enc enc-a128cbc_hs256))
(build))
(Payload. payload))
encrypter (AESEncrypter. enckey)
_ (.encrypt passphrase encrypter)]
(.serialize passphrase)))
(defn read-token
"Decrypts the given token with the said algorithm
Throws BadJWTException is token is invalid or expired"
[token secret]
(let [passphrase (JWEObject/parse token)
decrypter (AESDecrypter. secret)
_ (.decrypt passphrase decrypter)
payload (.. passphrase getPayload toString)
claims (JWTClaimsSet/parse payload)
;; throws exception if the token is invalid
_ (.verify (DefaultJWTClaimsVerifier.) claims)]
(jp/parse-string payload)))
The following REPL session shows the steps to create and read a token, then wait for the token to expire. It will be noted that the exception thrown by the library can be captured to indicate that the token has expired.
;; generate a new token with the user and roles
helping-hands.auth.server> (def token (jwt/create-token {:user "hhuser"
:roles #{"hh/notify"}} secret))
#'helping-hands.auth.server/token
;; dump the compact serialization string
helping-hands.auth.server> token
"eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTEyOEtXIn0.FiAelEg_R8We8xEF2xRxcC
908BCoH1nRYvY3nV_jkqYO8JPp-QukBw.86-
JKq6cYFH2rtFBOXiA6A.Pxz3ZzBGKX2Cd_sjtYdEwKDltzKQiolWSvrjPbLLGL8NlShcWWEIqkd
7NL2WcXHukDa6zS4ANIWnee2hNWUraItqZFEY6N_RhXZVVXQvZJsqzeiueBxvxc1fj1LFUKsyR6
3oOwLd5ZIIT99ItrqaYPM88enMsjchsXYBJ_Tcb-
WR6R_KirmDBxCVjqFcg7OdWjjcKTP4FcUNIQU9G8fSnQ.pfLyW8ggXV8vQnidytJmMw"
;; read the token back
helping-hands.auth.server> (pprint (jwt/read-token token secret))
{"sub" "HelpingHands",
"aud" "https://www.packtpub.com",
"roles" ["hh/notify"],
"iss" "Packt",
"exp" 1515959756,
"iat" 1515959636,
"user" "hhuser"}
nil
;; wait for 2 mins (expiry time as per implementation)
;; token is now expired
helping-hands.auth.server> (pprint (jwt/read-token token secret))
BadJWTException Expired JWT
com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier.
(DefaultJWTClaimsVerifier.java:62)
Allow authorization of users and roles
Ideally, the Auth service should be backed by a persistent store, holding the private keys of users, roles, and applications. To simplify this example, a sample in-memory database is created in the help-hands.auth.persistence namespace as follows
(ns helping-hands.auth.persistence
"Persistence Implementation for Auth Service"
(:require [agynamix.roles :as r]
[cheshire.core :as jp])
(:import [java.security MessageDigest]))
(defn get-hash
"Creates a MD5 hash of the password"
[creds]
(.. (MessageDigest/getInstance "MD5")
(digest (.getBytes creds "UTF-8"))))
(def userdb
;; Used ony for demonstration
;; TODO Persist in an external database
(atom
{:secret nil
:roles {"hh/superadmin" "*"
"hh/admin" "hh:*"
"hh/notify" #{"hh:notify" "notify/alert"}
"notify/alert" #{"notify:email" "notify:sms"}}
:users {"hhuser" {:pwd (get-hash "hhuser")
:roles #{"hh/notify"}}
"hhadmin" {:pwd (get-hash "hhadmin")
:roles #{"hh/admin"}}
"superadmin" {:pwd (get-hash "superadmin")
:roles #{"hh/superadmin"}}}}))
(defn has-access?
"Checks for relevant permission"
[uid perms]
(r/has-permission?
(-> @userdb :users (get uid))
:roles :permissions perms))
(defn init-db
"Initializes the roles for permission framework"
[]
(r/init-roles (:roles @userdb))
userdb)
The userdb contains a sample in-memory database consisting of :secret key initialized to nil, and :users and :roles that store user and role information, respectively. Roles are defined according to the guidelines of the permission library, and roles and permissions are defined according to the usage of the library. Role names are followed by a slash “/” and permission names are followed by a colon “:” as defined in the previous role definition. Role definitions are recursive, and a single role can encapsulate both roles and permissions.
The init-db function is used to initialize the database and role definitions. has-access? is a utility function that can be used to verify whether a user contains a given set of permissions. The following REPL session illustrates the use of the has-access? function with an example.
;; require the persistence namespace
helping-hands.auth.server> (require '[helping-hands.auth.persistence :as
p])
nil
;; since there is no secret key define,
;; initialize the database with a secret-key
;; if it does not exist
helping-hands.auth.server> (let [db (p/init-db)]
;; if key does not exist, initialize one
;; and update the database with :secret key
(if-not (:secret @db)
(swap! db #(assoc % :secret (jwt/get-
secret))) @db))
{:secret #object[javax.crypto.spec.SecretKeySpec 0xebc150b
"javax.crypto.spec.SecretKeySpec@17ce8"],
:roles {"hh/superadmin" "*", "hh/admin" "hh:*", "hh/notify" #{"notify/alert" "hh:notify"},
"notify/alert" #{"notify:email" "notify:sms"}},
:users {"hhuser" {:pwd #object["[B" 0x1b46ced7 "[B@1b46ced7"],
:roles #{"hh/notify"}}, "hhadmin" {:pwd #object["[B" 0x7b9083e6 "[B@7b9083e6"],
:roles #{"hh/admin"}}, "superadmin" {:pwd #object["[B" 0x64083ac1 "[B@64083ac1"],
:roles #{"hh/superadmin"}}}}
;; validate that `hhuser` has the ``hh:notify`` permission helping-hands.auth.server>
(p/has-access? "hhuser" #{"hh:notify"}) true
;; validate permissions that are not defined
helping-hands.auth.server> (p/has-access? "hhuser" #{"hh:admin"}) false
;; validate permissions that are obtained by other role references helping-hands.auth.server>
(p/has-access? "hhuser" #{"hh:notify" "notify:email"})
true
In the previous example, the REPL explicitly initializes the database and sets the secret key. Instead of explicitly initializing the database, this can be done at startup itself.
to do it at startup. to initialize the database state with the private key in mount and make it available in other namespaces, define the database state in the helping-hands.auth.state namespace as follows.
(ns helping-hands.auth.state
"Initializes State for Auth Service"
(:require [mount.core :refer [defstate] :as mount]
[helping-hands.auth.jwt :as jwt]
[helping-hands.auth.persistence :as p]))
(defstate auth-db
:start (let [db (p/init-db)]
;; if key does not exist, initialize one
;; and update the database with :secret key
(if-not (:secret @db)
(swap! db #(assoc % :secret (jwt/get-secret))) @db))
:stop nil)
Next, add mount/start and mount/stop functions to the server startup functions in the helping-hands.auth.server namespace to enable startup and stop events, as shown in the example below.
(ns helping-hands.auth.server
(:gen-class) ; for -main method in uberjar
(:require [io.pedestal.http :as server]
[io.pedestal.http.route :as route]
[mount.core :as mount]
[helping-hands.auth.config :as cfg]
[helping-hands.auth.service :as service]))
;; This is an adapted service map, that can be started and stopped
;; From the REPL you can call server/start and server/stop on this service
(defonce runnable-service (server/create-server service/service))
(defn run-dev
"The entry-point for 'lein run-dev'"
[& args]
(println "nCreating your [DEV] server...")
;; initialize configuration
(cfg/init-config {:cli-args args :quit-on-error true})
;; initialize state
(mount/start)
;; Add shutdown-hook
(.addShutdownHook
(Runtime/getRuntime)
(Thread. mount/stop))
(-> service/service ;; start with production configuration
...
;; Wire up interceptor chains
server/default-interceptors
server/dev-interceptors
server/create-server
server/start))
(defn -main
"The entry-point for 'lein run'"
[& args]
(println "nCreating your server...")
;; initialize configuration
(cfg/init-config {:cli-args args :quit-on-error true}) ;; initialize state
(mount/start)
;; Add shutdown-hook
(.addShutdownHook
(Runtime/getRuntime)
(Thread. mount/stop))
(server/start runnable-service))
Creating an Auth API with Pedestal
Next, define an API for the Auth service to authenticate and authorize users. Add the /tokens and /tokens/validate routes to the helping-hands.auth.service namespace as follows
(ns helping-hands.auth.service
(:require [helping-hands.auth.core :as core]
[cheshire.core :as jp]
[io.pedestal.http :as http]
[io.pedestal.http.route :as route]
[io.pedestal.http.body-params :as body-params]
[io.pedestal.interceptor.chain :as chain]
[ring.util.response :as ring-resp]))
;; Defines "/" and "/about" routes with their associated :get handlers.
;; The interceptors defined after the verb map (e.g., {:get home-page}
;; apply to / and its children (/about).
(def common-interceptors [(body-params/body-params) http/html-body])
;; Tabular routes
(def routes #{["/tokens"
:get (conj common-interceptors
`core/validate `core/get-token)
:route-name :token-get]
["/tokens/validate"
:post (conj common-interceptors
`core/validate `core/validate-token)
:route-name :token-validate]})
;; See http/default-interceptors for additional options you can configure
(def service {:env :prod
::http/routes routes
::http/resource-path "/public"
::http/type :jetty
::http/port 8080
;; Options to pass to the container (Jetty)
::http/container-options {:h2c? true
:h2? false
:ssl? false}})
The GET /tokens route looks for uid and pwd parameters or valid authorization headers to process the request. uid and pwd parameters are specified, and if they are valid, a JWT token is issued as part of the authorization header. If an existing JWT is specified as part of the authorization header for the request, the Auth service will return a username and its associated role.
The POST /tokens/validate route expects a valid authorization header containing the form parameter perms and a JWT to authenticate the user for the given authorization. This endpoint is used by other microservices in the Helping Hands application to authenticate users against the permissions required to provide access to the resources managed by the microservice. Because permissions and roles are defined as strings, an administrator can initialize the Auth database with all expected roles and permissions, assign them to users, and grant or deny access to the application’s services.
The interceptor used for the route defined in the previous code is implemented in the help-hands.auth.core namespace, as shown in the following example: The validate interceptor prepares all available request parameters for the :tx-data parameter, and initializes the uid and pwd or authorization headers are present, and returns an HTTP 400 Bad Request response if not present.
(ns helping-hands.auth.core
"Initializes Helping Hands Auth Service"
(:require [cheshire.core :as jp]
[clojure.string :as s]
[helping-hands.auth.jwt :as jwt]
[helping-hands.auth.persistence :as p]
[helping-hands.auth.state :refer [auth-db]]
[io.pedestal.interceptor.chain :as chain])
(:import [com.nimbusds.jwt.proc BadJWTException]
[java.io IOException]
[java.text ParseException]
[java.util Arrays UUID]))
;; --------------------------------
;; Validation Interceptors
;; --------------------------------
(defn- prepare-valid-context
"Applies validation logic and returns the resulting context"
[context]
(let [params (merge (-> context :request :form-params)
(-> context :request :query-params)
(-> context :request :headers)
(if-let [pparams (-> context :request :path-params)]
(if (empty? pparams) {} pparams)))]
(if (or (and (params :uid) (params :pwd))
(params "authorization"))
(assoc context :tx-data params)
(chain/terminate
(assoc context
:response {:status 400
:body "Invalid Creds/Token"})))))
(def validate
{:name ::validate
:enter
(fn [context]
(prepare-valid-context context))
:error
(fn [context ex-info]
(assoc context
:response {:status 500
:body (.getMessage ex-info)}))})
The get-token interceptor looks for a valid uid and pwd and issues a JWT if the authentication is successful; if the uid and pwd are not present, it looks for a valid authorization header of type Bearer and, if the token is valid, returns the authenticated user ID and assigned role is returned.
(defn- extract-token
"Extracts user and roles map from the auth header"
[auth]
(select-keys
(jwt/read-token
(second (s/split auth #"s+")) (auth-db :secret))
["user" "roles"]))
(def get-token
{:name ::token-get
:enter
(fn [context]
(let [tx-data (:tx-data context)
uid (:uid tx-data)
pwd (:pwd tx-data)
auth (tx-data "authorization")]
(cond
(and uid pwd (Arrays/equals
(-> auth-db :users (get uid) :pwd)
(p/get-hash pwd)))
(let [token (jwt/create-token
{:roles (-> auth-db :users (get uid) :roles)
:user uid} (auth-db :secret))]
(assoc context :response
{:status 200
:headers {"authorization" (str "Bearer " token)}}))
(and auth (= "Bearer" (-> (s/split auth #"s+") first)))
(try
(assoc context :response
{:status 200
:body (jp/generate-string (extract-token auth))})
(catch BadJWTException e
(assoc context :response
{:status 401 :body "Token expired"})))
:else (assoc context :response {:status 401}))))
:error
(fn [context ex-info]
(assoc context
:response {:status 500
:body (.getMessage ex-info)}))})
The implementation of the validate-token interceptor shown in the example below authorizes the user associated with the CSV of the JWT sent as the authorization header and the permissions specified as perms form parameters.
(def validate-token
{:name ::token-validate
:enter
(fn [context]
(let [tx-data (:tx-data context)
auth (tx-data "authorization")
perms (if-let [p (tx-data :perms)]
(into #{} (map s/trim (s/split p #","))))]
(if (and auth (= "Bearer" (-> (s/split auth #"s+") first)))
(try
(if (p/has-access? ((extract-token auth) "user") perms)
(assoc context :response {:status 200 :body "true"})
(assoc context :response {:status 200 :body "false"}))
(catch BadJWTException e
(assoc context :response
{:status 401 :body "Token expired"}))
(catch ParseException e
(assoc context :response
{:status 401 :body "Invalid JWT"})))
(assoc context :response {:status 401}))))
:error
(fn [context ex-info]
(assoc context
:response {:status 500
:body (.getMessage ex-info)}))})
To test the route, start the Auth service using the lein run command, or start it within the REPL as shown in the following example. As soon as the application starts, mount is invoked and the private key used to issue the token or read it for authentication is initialized.
helping-hands.auth.server> (def server (run-dev))
Creating your [DEV] server...
Omniconf configuration:
{:conf #object[java.io.File 0x979c2d2 "config/conf.edn"]}
#'helping-hands.auth.server/server
helping-hands.auth.server>
Once the server is up and running, try different scenarios with cURL, as in the following example. If no authentication headers or valid credentials are specified, the Validate interceptor is invoked and the request is marked as invalid, as shown below.
> curl -i "http://localhost:8080/tokens"
HTTP/1.1 400 Bad Request
Date: Sun, 14 Jan 2018 20:49:48 GMT
...
Invalid Creds/Token
> curl -i -XPOST -d "perms=notify:email"
"http://localhost:8080/tokens/validate"
HTTP/1.1 400 Bad Request
Date: Sun, 14 Jan 2018 20:50:21 GMT
...
Invalid Creds/Token
If the specified authentication information is invalid, a response with HTTP 401 Unauthorized status is thrown, as shown in the following example.
> curl -i "http://localhost:8080/tokens?uid=hhuser&pwd=hello"
HTTP/1.1 401 Unauthorized
Date: Sun, 14 Jan 2018 20:53:16 GMT
...
> curl -i -H "Authorization: Bearer abc" -XPOST -d "perms=notify:email" "http://localhost:8080/tokens/validate"
HTTP/1.1 401 Unauthorized
Date: Sun, 14 Jan 2018 20:55:35 GMT
...
Invalid JWT
If the parameter is valid, the endpoint will behave as expected, which is the case for the hhuser user.
> curl -i "http://localhost:8080/tokens?uid=hhuser&pwd=hhuser" HTTP/1.1 200 OK
Date: Sun, 14 Jan 2018 20:59:48 GMT
...
Authorization: Bearer
eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTEyOEtXIn0.YY_dMY8qoqfTHeZwGacsFY7
0tCaUvCjjPKYNFhuA2ppOD-
Deaj5zzw.BbK36SSYyuUeVVS9jpyIXw.6UNkLFMVF5Foj5qFX5vLdKcyOoU2G2eeVtHskSWBoZu
BnnAwI1NGrPc3PvQqKF4QkzlrbFfOYD2Vxd4YqYmj8Hcb1qVUQD1QgtYKiStIMujH--
ZRltPfy7m8VW1D31ToeqAYU1LLlXYSC1W3kSjZQiMFMU1LXkMqZVdmJyfQIL_SvizfWbYuZQPcy
DCxG5-XtVeG2r09vnvUybw8tKdafg.WML7xCZ-lZur1GXpNFNKrw
Transfer-Encoding: chunked
> curl -i -H "Authorization: Bearer eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTEyOEtXIn0.YY_dMY8qoqfTHeZwGacsFY7 0tCaUvCjjPKYNFhuA2ppOD- Deaj5zzw.BbK36SSYyuUeVVS9jpyIXw.6UNkLFMVF5Foj5qFX5vLdKcyOoU2G2eeVtHskSWBoZu BnnAwI1NGrPc3PvQqKF4QkzlrbFfOYD2Vxd4YqYmj8Hcb1qVUQD1QgtYKiStIMujH-- ZRltPfy7m8VW1D31ToeqAYU1LLlXYSC1W3kSjZQiMFMU1LXkMqZVdmJyfQIL_SvizfWbYuZQPcy DCxG5-XtVeG2r09vnvUybw8tKdafg.WML7xCZ-lZur1GXpNFNKrw" "http://localhost:8080/tokens"
HTTP/1.1 200 OK
Date: Sun, 14 Jan 2018 21:00:11 GMT
...
{"user":"hhuser","roles":["hh/notify"]}
> curl -XPOST -i -H "Authorization: Bearer eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTEyOEtXIn0.YY_dMY8qoqfTHeZwGacsFY7 0tCaUvCjjPKYNFhuA2ppOD- Deaj5zzw.BbK36SSYyuUeVVS9jpyIXw.6UNkLFMVF5Foj5qFX5vLdKcyOoU2G2eeVtHskSWBoZu BnnAwI1NGrPc3PvQqKF4QkzlrbFfOYD2Vxd4YqYmj8Hcb1qVUQD1QgtYKiStIMujH-- ZRltPfy7m8VW1D31ToeqAYU1LLlXYSC1W3kSjZQiMFMU1LXkMqZVdmJyfQIL_SvizfWbYuZQPcy DCxG5-XtVeG2r09vnvUybw8tKdafg.WML7xCZ-lZur1GXpNFNKrw" -d "perms=notify:email" "http://localhost:8080/tokens/validate"
HTTP/1.1 200 OK
Date: Sun, 14 Jan 2018 21:00:38 GMT
...
true%
> curl -XPOST -i -H "Authorization: Bearer eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTEyOEtXIn0.YY_dMY8qoqfTHeZwGacsFY7 0tCaUvCjjPKYNFhuA2ppOD- Deaj5zzw.BbK36SSYyuUeVVS9jpyIXw.6UNkLFMVF5Foj5qFX5vLdKcyOoU2G2eeVtHskSWBoZu BnnAwI1NGrPc3PvQqKF4QkzlrbFfOYD2Vxd4YqYmj8Hcb1qVUQD1QgtYKiStIMujH-- ZRltPfy7m8VW1D31ToeqAYU1LLlXYSC1W3kSjZQiMFMU1LXkMqZVdmJyfQIL_SvizfWbYuZQPcy DCxG5-XtVeG2r09vnvUybw8tKdafg.WML7xCZ-lZur1GXpNFNKrw" -d "perms=notify:random" "http://localhost:8080/tokens/validate"
HTTP/1.1 200 OK
Date: Sun, 14 Jan 2018 21:00:49 GMT
...
The Auth service can be deployed alone to authenticate users by connecting through the Auth interceptor of the rest of the services in the Helping Hands application. The user can call the /tokens endpoint of the Auth service directly to obtain a token, and use the same token to authenticate and authorize themselves with other services.
Buddy is another Clojure library, and the Buddy Sign library can also be used to generate JSON Web Tokens.
In the next article, we will discuss the use of ElasticStash for monitoring microservice system operations.
コメント