State often gets in the way when it comes to cacheability. In HTTP we
usually pass information about state via a Cookie header or an
Authorization header.
The built-in VCL is very explicit about this:
if (req.http.Authorization || req.http.Cookie) {
/* Not cacheable by default */
return (pass);
}
When the Authorization or Cookie request header are present,
Varnish will not cache by default.
In earlier chapters we already explained how you can maintain
cacheability without getting rid of all your cookies. In this section
we’ll do the same for the Authorization header.
This section is all about offloading authentication and how to turn Varnish into an authentication gateway.
Basic authentication is pretty basic, as the name indicates: the username and password are sent as a base64 encoded string. Within that string, the username and password are separated by a colon.
The example below will force basic authentication before the page is displayed:
vcl 4.1;
sub vcl_recv {
if(req.http.Authorization != "Basic YWRtaW46c2VjcmV0") {
return (synth(401, "Restricted"));
}
}
sub vcl_synth {
if (resp.status == 401) {
set resp.http.WWW-Authenticate = {"Basic realm="Restricted area""};
}
}
In this case the username is admin and the password is secret; this
corresponds to the following Authorization header:
Authorization: Basic YWRtaW46c2VjcmV0
If the credentials don’t match, an HTTP 401 error is returned. To trigger web browsers to present a login screen when invalid credentials are received, the following response header is returned:
WWW-Authenticate: Basic realm="Restricted area"
This is a very static authentication mechanism that doesn’t offer lots of flexibility, and where passwords are stored in the VCL file. We can do better.
It is important to know that even though we offload the authentication from the origin, the built-in VCL will still not allow the corresponding response to be served from cache.
We tackle this issue by stripping off the Authorization header when
we’re done offloading the authentication layer.
This is the VCL code you add at the end of your authentication logic:
unset req.http.Authorization;
As mentioned in chapter 5, there’s vmod_basicauth, which loads a
typical .htpasswd file from disk. The value of the Authorization
header can be matched against the loaded values.
The following example has already been featured but illustrates nicely how the logic is abstracted into a VMOD:
vcl 4.1;
import basicauth;
sub vcl_recv {
if (!basicauth.match("/var/www/.htpasswd",req.http.Authorization)) {
return (synth(401, "Restricted"));
}
}
sub vcl_synth {
if (resp.status == 401) {
set resp.http.WWW-Authenticate = {"Basic realm="Restricted area""};
}
}
Not only does this VMOD make offloading authentication a lot cleaner, it also supports hashed passwords.
The easiest way to generate the .htpasswd file is by using the
htpasswd program, which is part of a typical Apache setup.
The example below shows how to generate a new .htpasswd file with
credentials for the admin user:
$ htpasswd -c -s .htpasswd admin
New password:
Re-type new password:
Adding password for user admin
The -c flag will make sure the file is created, and the -s flag
ensures SHA hashing.
We can then add more users to the file:
$ htpasswd .htpasswd thijs
New password:
Re-type new password:
Adding password for user thijs
This command will add the user thijs to the existing .htpasswd file
using MD5 hashing.
Although not advised, it is also possible to add clear text passwords
using the -p flag, as illustrated below:
$ htpasswd -p .htpasswd varnish
New password:
Re-type new password:
Adding password for user varnish
If we look inside the .htpasswd file, we can see the various users and
corresponding password hashes:
admin:{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=
thijs:$apr1$7VbVkafq$rx9KxPEMy4bOkb61HNeY4.
varnish:cache
Unless the password is in clear text, the hashing algorithm is attached
to the password. All these hash formats are supported by
vmod_basicauth, which makes this a safe way to interact with
passwords.
See http://man.gnu.org.ua/manpage/?3+vmod-basicauth for more information about this VMOD.
Instead of relying on vmod_basicauth, we can write our own logic, and
we can choose our own password storage. However, we also need a way to
hash passwords.
vmod_digest is an open source VMOD that can be used to create hashes
in VCL. You can download the source from
https://github.com/varnish/libvmod-digest.
This VMOD is also packaged in Varnish Enterprise. vmod_crypto is a
competing VMOD that is only available in Varnish Enterprise.
However, in this section, I’ll only focus on vmod_digest.
By storing these hashed passwords inside vmod_kvstore, we have quick
access to the credentials. Because vmod_kvstore stores data in memory,
one would think that the hashed passwords cannot be persisted on disk.
Luckily the .init_file() method allows us to preload the key-value
store with that coming from a file.
Here’s the VCL code:
vcl 4.1;
import kvstore;
import digest;
sub vcl_init {
new auth = kvstore.init();
auth.init_file("/etc/varnish/auth",":");
}
sub vcl_recv {
if(req.http.Authorization !~ "^Basic .+$") {
return(synth(401,"Authentication required"));
}
set req.http.userpassword = digest.base64url_decode(
regsub(req.http.Authorization,"^Basic (.+)$","\1")
);
set req.http.user = regsub(req.http.userpassword,"^([^:]+):([^:]+)$","\1");
set req.http.password = regsub(req.http.userpassword,"^([^:]+):([^:]+)$","\2");
if(digest.hash_sha256(req.http.password) != auth.get(req.http.user,"0")) {
return(synth(401,"Authentication required"));
}
unset req.http.user;
unset req.http.password;
}
sub vcl_synth {
if (resp.status == 401) {
set resp.http.WWW-Authenticate = {"Basic realm="Restricted area""};
}
}
When the configuration is loaded, vmod_kvstore will load its contents
from /etc/varnish/auth. Here is what this file could look like:
admin:5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
thijs:4e5d73505c74a4d6c80d7fe4c7b53ddb9563488ee9f2e91200a78413f86e2597
The usernames, which are the keys, appear first on each line. The passwords are hashed using the SHA256 hashing algorithm and are delimited from the key via a colon.
The regsub() function helps us extract the username and password from
the Authorization header, and the digest.hash_sha256() function will
create the right hash.
The username is temporarily stored in req.http.user, and the password
in req.http.password. In the end the value of
auth.get(req.http.user,"0") is compared to the hashed password. If the
values match, access is granted.
In this example we use
vmod_kvstore, butvmod_redis, orvmod_memcached, or evenvmod_sqlite3could also be viable candidates.
Although basic authentication is one of the most common forms of authentication, there are some concerns: even if the stored passwords are hashed, the user does send the username and password over the wire unencrypted.
Base64 encoding is not human-readable, but it is very easy to decode. This concern is usually mitigated by TLS because the request is encrypted.
Digest authentication does not send clear-text passwords, but instead hashes the response. There are even more security mechanisms in place to avoid replay attacks.
It all starts when a server requests authentication by sending the following response:
HTTP/1.1 401 Authentication required
WWW-Authenticate: Digest realm="varnish",
qop="auth",
nonce="5f9e162f49a7811049b2d4bdf2d30196",
opaque="c23bd2a0047189e89aa9bea67adbc1f0"
The HTTP 401 indicates that authentication is required before access
is allowed to the resource. The HTTP response provides more context by
issuing a WWW-Authenticate header containing the digest information.
It’s not as simple as requesting that the client sends a username and a
password. The following information is presented by the
WWW-Authenticate header:
Digest indicates that digest authentication is required.realm="varnish" indicates that varnish is the realm for which
valid access credentials should be provided.qop="auth" is the quality of protection and is set to auth.nonce is a unique value that changes for every request. It is used
to avoid replay attacks.opaque is a unique value that is static.These fields are used by the client to compose the right Authorization
header.
Here’s an example of the corresponding Authorization header:
Authorization: Digest username="admin",
realm="varnish",
nonce="f378c7d8a10a8ade3213fd5877b0c47d", uri="/",
response="5bb85448beebdc6ec83c2e5712b5fdd0",
opaque="c23bd2a0047189e89aa9bea67adbc1f0",
qop=auth,
nc=00000002,
cnonce="fdd97488004e64a7"
As you can see, the password is not sent in clear text, but instead is
part of the response hash.
Let’s break down the entire header:
Digest to confirm that the authentication type is
indeed digest authentication.username field, which is sent in clear text.
The same applies to the realm field.nonce and the opaque fields are sent back to the server
unchanged.qop field is still set to auth, which confirms the quality of
protection.response field contains a hashed version of the password, along
with some other data.nc field is a counter that is incremented for every
authentication attempt.cnonce field is a client nonceThe password that is stored on the server is an MD5 hash of the username, the realm, and the password. This is how it is composed:
md5(username:realm:password)
The response that is received from the client should be matched to a server-generated response that is composed as follows:
hash1 = md5(username:realm:password)
hash2 = md5(request method:uri)
response = md5(hash1:nonce:nc:cnonce:qop:hash2)
If the response field sent by the client as a part of the
Authorization header matches the response generated on the server, the
user is allowed to access the content.
The following example features digest authentication offloading in Varnish. The hashed passwords are stored in Redis.
Admittedly, it’s quite a lengthy example, but there are a lot things to check when performing digest authentication!
vcl 4.1;
import redis;
import digest;
import std;
sub vcl_init {
new redis_client = redis.db(
location="redis:6379",
shared_connections=false,
max_connections=1);
}
sub vcl_recv {
set req.http.auth-user = regsub(req.http.Authorization,{"^Digest username="(\w+)",.*$"},"\1");
set req.http.auth-realm = regsub(req.http.Authorization,{".*, realm="(varnish)",.*$"},"\1");
set req.http.auth-opaque = regsub(req.http.Authorization,{".*, opaque="(c23bd2a0047189e89aa9bea67adbc1f0)",.*$"},"\1");
set req.http.auth-nonce = regsub(req.http.Authorization,{".*, nonce="(\w+)",.*$"},"\1");
set req.http.auth-nc = regsub(req.http.Authorization,{".*, nc=([0-9]+),.*$"},"\1");
set req.http.auth-qop = regsub(req.http.Authorization,{".*, qop=(auth),.*$"},"\1");
set req.http.auth-response = regsub(req.http.Authorization,{".*, response="(\w+)",.*$"},"\1");
set req.http.auth-cnonce = regsub(req.http.Authorization,{".*, cnonce="(\w+)"$"},"\1");
if(req.http.Authorization !~ "^Digest .+$" ||
req.http.auth-realm != "varnish" ||
req.http.auth-opaque != "c23bd2a0047189e89aa9bea67adbc1f0") {
return(synth(401,"Authentication required"));
}
redis_client.command("GET");
redis_client.push("user:" + req.http.auth-user);
redis_client.execute();
if(redis_client.reply_is_nil()){
return(synth(401,"Authentication required"));
}
set req.http.auth-password = redis_client.get_string_reply();
set req.http.response = digest.hash_md5(
req.http.auth-password + ":" +
req.http.auth-nonce + ":" +
req.http.auth-nc + ":" +
req.http.auth-cnonce + ":" +
req.http.auth-qop + ":" +
digest.hash_md5(req.method + ":" + req.url)
);
if(req.http.auth-response != req.http.response) {
return(synth(401,"Authentication required"));
}
}
sub vcl_synth {
if (resp.status == 401) {
set resp.http.WWW-Authenticate = {"Digest realm="varnish",
qop="auth",
nonce=""} + digest.hash_md5(std.random(1, 90000000)) + {"",
opaque="c23bd2a0047189e89aa9bea67adbc1f0""};
}
}
Whenever access is not granted, we return
return``(synth(401,"Authentication required"));, which triggers
vcl_synth. Inside vcl_synth, we return the WWW-Authenticate header
containing the necessary fields.
The nonce is different for every request. A uuid would be suitable
for this, but only Varnish Enterprise has a uuid generator. Since
this is an example that also works in Varnish Cache, we generated a
random number and hashed it via MD5.
digest.hash_md5(std.random(1, 90000000)) is what we use to get that
done.
In vcl_recv we use regsub() to extract the value of every field in
the Authorization header. In the first if-statement we check whether
the Authorization header starts with Digest. If not, we request
reauthentication by returning the HTTP 401 status that includes the
WWW-Authenticate header.
The same HTTP 401 is returned when the realm or opaque field
doesn’t match the expected values.
The next step involves checking if the supplied username exists in the
database. In the case of the admin user, we perform a GET user:admin
command in Redis. If Redis responds with a nil value, we can
conclude that the user doesn’t exist.
If Redis returns a string value, the value corresponds to the hashed password. This value is stored in VCL for later use.
Despite all these earlier checks, we still need to match the response
field to the response that was generated. As explained earlier, we need
to create a series of MD5 hashes:
In the authentication exchange subsection, we illustrated this using the following formula:
hash1 = md5(username:realm:password)
hash2 = md5(request method:uri)
response = md5(hash1:nonce:nc:cnonce:qop:hash2)
In the VCL example, the following code is responsible for creating the response hash:
set req.http.response = digest.hash_md5(
req.http.auth-password + ":" +
req.http.auth-nonce + ":" +
req.http.auth-nc + ":" +
req.http.auth-cnonce + ":" +
req.http.auth-qop + ":" +
digest.hash_md5(req.method + ":" + req.url)
);
And eventually the value of req.http.response is matched with the
response field from the Authorization header. If these values match,
we know the user supplied the correct credentials.
We often associate authentication with usernames and passwords. While these sorts of credentials are prevalent, there are also other means of authentication. Token-based authentication is one of them.
JSON web tokens (JWT) is an implementation of token-based authentication where the token contains a collection of public claims, and where security is guaranteed through a cryptographic signature.
Here’s an example JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0aGlqcyIsImV4cCI6MTYxNDI2NDI3MSwiaWF0IjoxNjE0MjU3MDcxLCJuYmYiOjE2MTQyNTcwNzF9.vuJEQOqS3uTeKFihFehiqzLVOjT7F0J8ZpIeOvEOgZc
It might look like gibberish, but it does make perfect sense: a JWT is a group of base64 URL encoded JSON strings that are separated by dots.
This is the composition of a JWT:
JSON web tokens are mostly used for API authentication and are
transported as a bearer authentication token via the Authorization
request header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0aGlqcyIsImV4cCI6MTYxNDI2NDI3MSwiaWF0IjoxNjE0MjU3MDcxLCJuYmYiOjE2MTQyNTcwNzF9.vuJEQOqS3uTeKFihFehiqzLVOjT7F0J8ZpIeOvEOgZc
Not only does this token serve as an authentication mechanism, it also serves as client-side session storage because the relevant client data is part of the token.
Here’s the decoded version of the header:
{
"alg": "HS256",
"typ": "JWT"
}
The alg property refers to the algorithm that is used to sign the
JWT. In this case this is a SHA256-encoded HMAC signature. The typ
property refers to the token type. In this case it’s a JWT.
HS256 involves symmetric encryption. This means that both the issuer
of the token and the validator of token use the same private key.
Asymmetric encryption is also supported: by using RS256 as the value
of the alg field. When using RS256, the JWT will be signed using a
private key, and the JWT can later be verified using the public
key.
When the application that is processing the JWT is the same as the one
issuing the JWT, HS256 is a good option. When the JWT is issued by
a third-party application, RS256 makes more sense. The key information
would in that case meet the JSON Web Key (JWK) specification, which is
beyond the scope of this book.
This is the JSON representation of the decoded payload:
{
"sub": "thijs",
"exp": 1614264271,
"iat": 1614257071,
"nbf": 1614257071
}
The payload’s properties deliberately have short names: the bigger the property names and values, the bigger the size of the JWT, and the bigger the data transfer. This payload example features some reserved claims:
sub: the subject of the JWT. This claim contains the username.exp: the expiration time of the token. The 1614264271 value is a
Unix timestamp.iat: the issued at time of the token. This token was issued at
1614257071, which is also a Unix timestamp.nbf: the not before time of the token is a Unix timestamp that
dictates when the JWT becomes valid. This is also 1614257071.If you subtract the iat value from the exp value, you get 7200.
This represents the TTL of the token, which is two hours. The iat
and nbf values are identical. This means the token is valid
immediately after issuing.
You can also add your own claims to the payload. Just remember: the more content, the bigger the token, the bigger the transfer.
Remember that the payload is not encrypted: it’s just base64 URL encoded JSON that can easily be decoded by the client. This means that a JWT should not contain sensitive data that the user is not privy to.
The third part of the JWT is the signature. This signature is based on the header and payload, which means it ensures that the data is not tampered with.
When the HS256 algorithm is used, an HMAC signature is generated
using the SHA256-hashing algorithm. This signature is based on a
secret key.
In the example below, the signature is generated for
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0aGlqcyIsImV4cCI6MTYxNDI2NDI3MSwiaWF0IjoxNjE0MjU3MDcxLCJuYmYiOjE2MTQyNTcwNzF9
with supersecret as the secret key:
#!/usr/bin/env bash
JWT_HEADER="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
JWT_PAYLOAD="eyJzdWIiOiJ0aGlqcyIsImV4cCI6MTYxNDI2NDI3MSwiaWF0IjoxNjE0MjU3MDcxLCJuYmYiOjE2MTQyNTcwNzF9"
SECRET="supersecret"
echo -n "${JWT_HEADER}.${JWT_PAYLOAD}" \
| openssl dgst -sha256 -hmac $SECRET -binary \
| base64 | sed s/\+/-/g | sed 's/\//_/g' | sed -E s/=+$//
When you run this script, the output would be
vuJEQOqS3uTeKFihFehiqzLVOjT7F0J8ZpIeOvEOgZc, which matches the JWT
mentioned above. Don’t forget that this signature is base64 URL
encoded.
Issuing processing HS256 tokens is done using the same HMAC
signature, but when we use RS256 tokens, things are slightly
different.
For RS256 tokens, the token is issued with a signature that was signed
using the private key. When processing this token, the public key must
be used for verification.
Here’s how to create and verify a RS256 JWT.
The first step is to generate the key pair:
#!/usr/bin/env bash
ssh-keygen -q -t rsa -b 4096 -m PEM -f private.key -P "" <<<y 2>&1 >/dev/null
openssl rsa -in private.key -pubout -outform PEM -out public.key
The following script will use the private.key file to create the
signature.
Although the JWT header looks the same as in the previous example, it differs. Because the
algproperty was changed fromHS256toRS256for this example, we have a different header.
#!/usr/bin/env bash
JWT_HEADER="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
JWT_PAYLOAD="eyJzdWIiOiJ0aGlqcyIsImV4cCI6MTYxNDI2NDI3MSwiaWF0IjoxNjE0MjU3MDcxLCJuYmYiOjE2MTQyNTcwNzF9"
echo -n "${JWT_HEADER}.${JWT_PAYLOAD}" \
| openssl dgst -sha256 -sign private.key -binary \
| base64 | sed s/\+/-/g | sed 's/\//_/g' | sed -E s/=+$//
The signature is a lot bigger for RS256 as you can see:
oAVLTpK3BRny_kA40h8asQlSNENvo-xrx6_6EooM6co812AUC_agTaVQb9KIjnlVl9jMXdGZGFfL6pnMI4tXmZvukMonZKoEcrT8XilNRq0LUutnymObmYWY3eiQTwuQ6D1QPy_ykLtw78e8zig1ihLAcXp2QGwTOsc5ndMYiovCs-_zWDJoAyzy6RtbnGo7BAO8fu_XTYKLHZAeB2ZPiVCr3mMn6H3PTJvW3PhwPyrpHQRAPX21zXP-hYDcrly-UnKIpR9qStPIhPAUznrdDzZJIGvBeN_6BaShXXsze2XOE8JO-M8RUMUQ4OS8ufNo8wDxYH-C9hVslVlAmVqcNpc23Dtu3-k4K30ZLmINrBVFcdOHEliz93msZVIcdNDJVLZZia-JsQLCeNEkouiH1wLHkZYmaJLuv-dvIqOBzjMPDGbti2p1vfAjPJHAIZIRyZnfM461L01WbWZ7xr4hIHmQ0X5xR7_jv5rsjl2kfRlQa_JqKr9PgXPqiQ1UTvzT0O0249hjbZ7N5oo6UEPd-Bi1wO9PeEjJXg75ZLBsXdoSBmvgYkceMgxvK0Lq1STw3I9HTbk26ygvqqKDo-CGCv1N95ebsl3v9TTaSb4y6QLqgH7Sr3VvrdAa7NtcsSL5bVR23oJSW1P7atWBJNuC0HAxG5h-GffkNFSJOvml-ss
The end result is the following JWT :
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0aGlqcyIsImV4cCI6MTYxNDI2NDI3MSwiaWF0IjoxNjE0MjU3MDcxLCJuYmYiOjE2MTQyNTcwNzF9.oAVLTpK3BRny_kA40h8asQlSNENvo-xrx6_6EooM6co812AUC_agTaVQb9KIjnlVl9jMXdGZGFfL6pnMI4tXmZvukMonZKoEcrT8XilNRq0LUutnymObmYWY3eiQTwuQ6D1QPy_ykLtw78e8zig1ihLAcXp2QGwTOsc5ndMYiovCs-_zWDJoAyzy6RtbnGo7BAO8fu_XTYKLHZAeB2ZPiVCr3mMn6H3PTJvW3PhwPyrpHQRAPX21zXP-hYDcrly-UnKIpR9qStPIhPAUznrdDzZJIGvBeN_6BaShXXsze2XOE8JO-M8RUMUQ4OS8ufNo8wDxYH-C9hVslVlAmVqcNpc23Dtu3-k4K30ZLmINrBVFcdOHEliz93msZVIcdNDJVLZZia-JsQLCeNEkouiH1wLHkZYmaJLuv-dvIqOBzjMPDGbti2p1vfAjPJHAIZIRyZnfM461L01WbWZ7xr4hIHmQ0X5xR7_jv5rsjl2kfRlQa_JqKr9PgXPqiQ1UTvzT0O0249hjbZ7N5oo6UEPd-Bi1wO9PeEjJXg75ZLBsXdoSBmvgYkceMgxvK0Lq1STw3I9HTbk26ygvqqKDo-CGCv1N95ebsl3v9TTaSb4y6QLqgH7Sr3VvrdAa7NtcsSL5bVR23oJSW1P7atWBJNuC0HAxG5h-GffkNFSJOvml-ss
Verifying the RS256 signature requires using the public.key file, as
illustrated in the script below:
#!/usr/bin/env bash
JWT_HEADER="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
JWT_PAYLOAD="eyJzdWIiOiJ0aGlqcyIsImV4cCI6MTYxNDI2NDI3MSwiaWF0IjoxNjE0MjU3MDcxLCJuYmYiOjE2MTQyNTcwNzF9"
JWT_SIGNATURE="oAVLTpK3BRny_kA40h8asQlSNENvo-xrx6_6EooM6co812AUC_agTaVQb9KIjnlVl9jMXdGZGFfL6pnMI4tXmZvukMonZKoEcrT8XilNRq0LUutnymObmYWY3eiQTwuQ6D1QPy_ykLtw78e8zig1ihLAcXp2QGwTOsc5ndMYiovCs-_zWDJoAyzy6RtbnGo7BAO8fu_XTYKLHZAeB2ZPiVCr3mMn6H3PTJvW3PhwPyrpHQRAPX21zXP-hYDcrly-UnKIpR9qStPIhPAUznrdDzZJIGvBeN_6BaShXXsze2XOE8JO-M8RUMUQ4OS8ufNo8wDxYH-C9hVslVlAmVqcNpc23Dtu3-k4K30ZLmINrBVFcdOHEliz93msZVIcdNDJVLZZia-JsQLCeNEkouiH1wLHkZYmaJLuv-dvIqOBzjMPDGbti2p1vfAjPJHAIZIRyZnfM461L01WbWZ7xr4hIHmQ0X5xR7_jv5rsjl2kfRlQa_JqKr9PgXPqiQ1UTvzT0O0249hjbZ7N5oo6UEPd-Bi1wO9PeEjJXg75ZLBsXdoSBmvgYkceMgxvK0Lq1STw3I9HTbk26ygvqqKDo-CGCv1N95ebsl3v9TTaSb4y6QLqgH7Sr3VvrdAa7NtcsSL5bVR23oJSW1P7atWBJNuC0HAxG5h-GffkNFSJOvml-ss"
MOD=$(($(echo -n "$JWT_SIGNATURE" | wc -c) % 4))
PADDING=$(if [ $MOD -eq 2 ]; then echo -n '=='; elif [ $MOD -eq 3 ]; then echo -n '=' ; fi)
echo -n "${JWT_SIGNATURE}${PADDING}" | sed s/\-/+/g | sed 's/_/\//g' | base64 -d > signature.rsa
echo -n "${JWT_HEADER}.${JWT_PAYLOAD}" | openssl dgst -sha256 -verify public.key -signature signature.rsa
If the script ran successfully, the output will be Verified OK. If
not, you’ll get Verification Failure.
This script will store the base64 URL decoded signature in the
signature.rsa file, and will be loaded along with the private.key
file to perform the verification.
Long story short: the signature ensures the integrity of the payload and prevents users from getting unauthorized access because of manipulated payload.
Enough about issuing and verifying JWT in Bash, time to bring Varnish back into the picture.
Varnish Enterprise has a VMOD for reading and writing JWTs. It’s
called vmod_jwt, and here’s an example of how it is used to verify the
validity of a bearer authentication token:
vcl 4.1;
import jwt;
sub vcl_init {
new jwt_reader = jwt.reader();
sub vcl_recv {
if (!jwt_reader.parse(regsub(req.http.Authorization,"^Bearer (.+)$","\1")) ||
!jwt_reader.set_key("supersecret") ||
!jwt_reader.verify("HS256")) {
return (synth(401, "Invalid token"));
}
}
First we check if the Authorization header contains the Bearer type
and the payload. The next step involves setting the secret key, which is
supersecret in this case. And finally we verify the content of the
token.
The verification involves multiple steps:
alg property that is set to HS256?nbf claim allow us to already use the token?iat and exp claims, can we conclude that the
token has expired?If any of these criteria doesn’t apply, the VCL example will return an HTTP 401 error.
This example assumes that the JWT was issued by the origin, which is a common use case. The next example will completely offload authentication and will also issue JSON web tokens:
vcl 4.1;
import jwt;
import json;
import xbody;
import kvstore;
import std;
import crypto;
sub vcl_init {
new jwt_reader = jwt.reader();
new jwt_writer = jwt.writer();
new auth = kvstore.init();
auth.init_file("/etc/varnish/auth",":");
new keys = kvstore.init();
}
sub vcl_recv {
if(req.url == "/auth" && req.method == "POST") {
std.cache_req_body(1KB);
set req.http.username = regsub(xbody.get_req_body(),"^username=([^&]+)&password=(.+)$","\1");
set req.http.password = regsub(xbody.get_req_body(),"^username=([^&]+)&password=(.+)$","\2");
if(auth.get(req.http.username) != crypto.hex_encode(crypto.hash(sha256,req.http.password))) {
return (synth(401, "Invalid username & password"));
}
return(synth(700));
}
if (!jwt_reader.parse(regsub(req.http.Authorization,"^Bearer (.+)$","\1"))) {
return (synth(401, "Invalid token"));
}
if(!jwt_reader.set_key(keys.get(jwt_reader.to_string())) || !jwt_reader.verify("HS256")) {
return (synth(401, "Invalid token"));
}
}
sub create_jwt {
jwt_writer.set_alg("HS256");
jwt_writer.set_typ("JWT");
jwt_writer.set_sub(req.http.username);
jwt_writer.set_iat(now);
jwt_writer.set_duration(2h);
set resp.http.key = crypto.uuid_v4();
set resp.http.jwt = jwt_writer.generate(resp.http.key);
keys.set(resp.http.jwt,resp.http.key);
unset resp.http.key;
}
sub vcl_synth {
set resp.http.Content-Type = "application/json";
if(resp.status == 700) {
set resp.status = 200;
set resp.reason = "OK";
call create_jwt;
set resp.body = "{" + {""token": ""} + resp.http.jwt + {"""} + "}";
} else {
set resp.body = json.stringify(resp.reason);
}
unset resp.http.jwt;
return(deliver);
}
Let’s break this one down because there’s a lot more information in this example.
The /auth endpoint that this example provides is used to authenticate
users with a username and a password. These credentials are loaded into
a key-value store but are backed by the /etc/varnish/auth file, as
highlighted below:
new auth = kvstore.init();
auth.init_file("/etc/varnish/auth",":");
This is what the file looks like:
admin:5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
thijs:4e5d73505c74a4d6c80d7fe4c7b53ddb9563488ee9f2e91200a78413f86e2597
The passwords are SHA256 hashes.
Here’s the VCL code that performs the authentication:
if(req.url == "/auth" && req.method == "POST") {
std.cache_req_body(1KB);
set req.http.username = regsub(xbody.get_req_body(),"^username=([^&]+)&password=(.+)$","\1");
set req.http.password = regsub(xbody.get_req_body(),"^username=([^&]+)&password=(.+)$","\2");
if(auth.get(req.http.username) != crypto.hex_encode(crypto.hash(sha256,req.http.password))) {
return (synth(401, "Invalid username & password"));
}
return(synth(700));
}
It acts upon HTTP POST calls to the /auth endpoint and extracts the
username and password fields from the POST data. Via auth.get()
the username is matched to the content of the key-value store. The
password that was received is hashed using the SHA256-hashing
algorithm.
If the credentials don’t match, an HTTP 401 error is returned; if
there is a match, some custom logic is executed inside vcl_synth.
Because of the custom 700 status code, vcl_synth knows it needs to
issue a token.
Here is the content of vcl_synth:
sub vcl_synth {
set resp.http.Content-Type = "application/json";
if(resp.status == 700) {
set resp.status = 200;
set resp.reason = "OK";
call create_jwt;
set resp.body = "{" + {""token": ""} + resp.http.jwt + {"""} + "}";
} else {
set resp.body = json.stringify(resp.reason);
}
unset resp.http.jwt;
return(deliver);
}
The output for synthetic responses has the application/json content
type and is formatted as a JSON string. When the incoming status code
is 700, we intercept the request, change the status to 200, and
return a JSON object that contains the JWT.
The custom create_jwt subroutine is in charge of the token creation
and sends the token to the resp.http.jwt header that is used in
vcl_synth.
Here’s the content of create_jwt:
sub create_jwt {
jwt_writer.set_alg("HS256");
jwt_writer.set_typ("JWT");
jwt_writer.set_sub(req.http.username);
jwt_writer.set_iat(now);
jwt_writer.set_duration(2h);
set resp.http.key = crypto.uuid_v4();
set resp.http.jwt = jwt_writer.generate(resp.http.key);
keys.set(resp.http.jwt,resp.http.key);
unset resp.http.key;
}
As you can see, this subroutine creates a JWT using the JWT writer
object that was instantiated in vcl_init.
Here’s what happens:
alg property of the header is set to HS256.typ property of the header is set to JWT.sub claim is set to the username of the logged-in user.iat claim is set to the current timestamp.exp claim is set to a timestamp two hours in the future.keys key-value store, which is used
later for verification purposes.The following curl command can be used to generate the token:
$ curl -XPOST -d"username=thijs&password=feryn" https://localhost/auth
{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0aGlqcyIsImV4cCI6MTYxNDM0MjIxMCwiaWF0IjoxNjE0MzM1MDEwLCJuYmYiOjE2MTQzMzUwMTB9.S5tqkGjUIJD9sTW8n0Zf9UAXIbPK_3-wCCGVP8wQSg4"}
A final piece of VCL we want to cover on JWT is the key verification. Here’s the line of code that sets the secret key and verifies it:
if(!jwt_reader.set_key(keys.get(jwt_reader.to_string())) || !jwt_reader.verify("HS256")) {
return (synth(401, "Invalid token"));
}
Remember the UUID that was used as the secret key to sign the JWT in
the create_jwt subroutine? That UUID was stored in a key-value
store keys.set(). This means that every token has a unique secret
key.
At the validation level in vcl_recv, we now need to fetch that secret
key again via keys.get(). The way that secret key is identified in the
key-value store is through the JWT. By putting
jwt_reader.set_key(keys.get(jwt_reader.to_string())) in the code, we
fetch the entire JWT string, we use it as the key in the key-value
store, and whatever comes out is the secret key of the HMAC
signature.
OAuth is an authentication standard that delegates the processing of login credentials to a trusted third party. A typical example is the login with Google button that you see on many websites.
Delegating authentication to a third party results in not having to create a user account with separate credentials on each website. It’s also a matter of trust: the application that wants you to log in will never have your password. This is part of the delegation process.
The concept uses a series of redirections and callbacks to exchange information:
In the case of Google’s OAuth service, you receive an access token and an ID token:
If you look at what you need to offload OAuth in Varnish, it’s not that complicated:
vmod_http can take care of that.vmod_kvstore to store those
values.vmod_json and vmod_jwt
are the obvious candidates.And of course there’s a VCL example that showcases Varnish Enterprise’s OAuth capabilities using a collection of VMODs. However, this example has more than 200 lines of code. This is not practical.
My colleague Andrew created the necessary logic, which is available via https://gist.github.com/andrewwiik/3dcb9c028b15bf359ae1053b8e8f94b9.
In your VCL configuration, it’s just a matter of including that file,
overriding the necessary parameters, and calling gauth_check in
vcl_recv. The rest happens automatically.
Here’s the code that overrides the settings, includes the gauth.vcl
file, and runs the Google OAuth logic:
vcl 4.1;
include "gauth.vcl";
sub vcl_init {
gauth_config.set("client_id", "my-client-id");
gauth_config.set("client_secret", "my-client-secret");
gauth_config.set("callback_path", "/api/auth/google/callback");
gauth_config.set("auth_cookie", "auth_cookie");
gauth_config.set("signing_secret", "supersecret");
gauth_config.set("scope", "email");
gauth_config.set("allowed_domain", "my-domain.com");
}
sub vcl_recv {
call gauth_check;
}
Let’s quickly go over the various configuration parameters:
client_id is the client ID for the OAuth client you configured for
your project in the Google API console.client_secret is the corresponding client secret for the client ID.callback_path is the callback that is triggered when Google’s OAuth
service responds back with a code.auth_cookie is the cookie that Varnish will use to store the
JWT.signing_secret is the secret key that Varnish will use to sign the
JWT.scope is the scope of the OAuth request. In this case only the
email address is requested.allowed_domain refers to the domain that the email address should
have.Don’t forget to configure the allowed callback URLs in the Google API console. Otherwise the redirect to the callback URL will not be allowed. The hostname for this callback URL is the hostname that was used for the initial HTTP request. Also keep in mind that these are
https://URLs.