├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── lib └── resty │ └── aws_auth.lua ├── lua-resty-aws-auth-0.11-0.src.rock ├── lua-resty-aws-auth-0.12-0.rockspec ├── nginx.conf └── t └── test.lua /Dockerfile: -------------------------------------------------------------------------------- 1 | from openresty/openresty:1.25.3.1-2-centos7 2 | RUN yum install -y libtermcap-devel ncurses-devel libevent-devel readline-devel 3 | RUN yum install -y lua lua-devel 4 | Run yum install -y git 5 | RUN curl -R -O http://luarocks.github.io/luarocks/releases/luarocks-3.9.2.tar.gz 6 | RUN tar -zxf luarocks-3.9.2.tar.gz && cd luarocks-3.9.2 && \ 7 | ./configure --with-lua-include=/usr/include \ 8 | && make && make install 9 | RUN luarocks install lua-resty-aws-auth && luarocks install lua-resty-crypto 10 | COPY ./nginx.conf /usr/local/openresty/nginx/conf/nginx.conf 11 | # COPY ./lib/resty/aws_auth.lua /usr/local/share/lua/5.1/resty/aws_auth.lua -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jeffry L 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OPENRESTY_PREFIX=/usr/local/openresty 2 | 3 | PREFIX ?= /usr/local/openresty 4 | LUA_INCLUDE_DIR ?= $(PREFIX)/include 5 | LUA_LIB_DIR ?= $(PREFIX)/lualib 6 | INSTALL ?= install 7 | 8 | .PHONY: all install 9 | 10 | all: ; 11 | 12 | install: all 13 | $(INSTALL) -d $(LUA_LIB_DIR)/resty 14 | $(INSTALL) lib/resty/*.lua $(LUA_LIB_DIR)/resty/ 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lua-resty-aws-auth 2 | Simple lua resty utilities to generate amazon v4 authorization and signature headers. 3 | 4 | # Installation 5 | 6 | Openresty installation should be compiled with *--with-luajit* directive otherwise you will get an error, 7 | 8 | module 'ffi' not found 9 | 10 | Install the package using luarocks 11 | 12 | ```lua 13 | #luarocks install lua-resty-aws-auth 14 | ``` 15 | 16 | # Usage 17 | 18 | ```lua 19 | 20 | local aws_auth = require "resty.aws-auth" 21 | local config = { 22 | aws_host = "email.us-east-1.amazonaws.com", 23 | aws_key = "AKIDEXAMPLE", 24 | aws_secret = "xxxsecret", 25 | aws_region = "us-east-1", 26 | aws_service = "ses", 27 | content_type = "application/x-www-form-urlencoded", 28 | request_method = "POST", 29 | request_path = "/", 30 | request_body = { hello="world" } -- table of all request params 31 | } 32 | 33 | local aws = aws_auth:new(config) 34 | 35 | -- get the generated authorization header 36 | -- eg: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request, 37 | --- SignedHeaders=content-type;host;x-amz-date, Signature=xxx 38 | local auth = aws:get_authorization_header() 39 | 40 | -- get the x-amz-date header 41 | local amz_date = aws:get_amz_date_header() 42 | 43 | ``` 44 | 45 | Add _Authorization_ and _x-amz-date_ header to ngx.req.headers 46 | 47 | ```lua 48 | aws:set_ngx_auth_headers() 49 | 50 | ``` 51 | For out-of-box experience, use the following docker image: 52 | 53 | ```bash 54 | cd lua-resty-aws-auth 55 | docker build -t lua-resty-aws-auth . 56 | docker run -d --name nginx -p 8080:8080 -e AWS_ACCESS_KEY_ID=yourkey -e AWS_ACCESS_KEY_SECRET=yoursecret -e AWS_HOST=yourhost:port -e AWS_REGION=us-east-1 -e AWS_SERVICE=s3 lua-resty-aws-auth 57 | ``` 58 | the Dockerfile works both with arm64 and amd64 architectures. 59 | then you can test the aws service by accessing localhost:8080. Remember to replace the environment variables with your own aws credentials. 60 | 61 | actually the backend service can be any s3 compatible service, not only from aws, minio, ceph etc. 62 | 63 | Reference 64 | [Signing AWS With Signature V4](https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html) 65 | [AWS service namespaces list](http://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html) 66 | [AWS region and endpoints](http://docs.aws.amazon.com/general/latest/gr/rande.html) 67 | -------------------------------------------------------------------------------- /lib/resty/aws_auth.lua: -------------------------------------------------------------------------------- 1 | -- generate amazon v4 authorization signature 2 | -- https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html 3 | -- Author: jeffry L. paragasu@gmail.com 4 | -- Licence: MIT 5 | 6 | 7 | local resty_digest = require "resty.digest" 8 | local str = require "resty.utils.string" 9 | local aws_key, aws_secret, aws_region, aws_service, aws_host, aws_stoken 10 | local iso_date, iso_tz, cont_type, req_method, req_path, req_body, req_querystr 11 | 12 | local _M = { 13 | _VERSION = '0.2.0' 14 | } 15 | 16 | local mt = { __index = _M } 17 | 18 | -- init new aws auth 19 | function _M.new(self, config) 20 | aws_key = config.aws_key 21 | aws_secret = config.aws_secret 22 | aws_stoken = config.aws_secret_token 23 | aws_region = config.aws_region 24 | aws_service = config.aws_service 25 | aws_host = config.aws_host 26 | cont_type = config.content_type 27 | req_method = config.request_method or "POST" 28 | req_path = config.request_path or "/" 29 | req_body = config.request_body 30 | req_querystr = config.request_querystr or "" 31 | 32 | -- set default time 33 | self:set_iso_date(ngx.time()) 34 | return setmetatable(_M, mt) 35 | end 36 | 37 | 38 | -- required for testing 39 | function _M.set_iso_date(self, microtime) 40 | iso_date = os.date('!%Y%m%d', microtime) 41 | iso_tz = os.date('!%Y%m%dT%H%M%SZ', microtime) 42 | end 43 | 44 | 45 | -- create canonical headers 46 | -- header must be sorted asc 47 | function _M.get_canonical_header(self) 48 | local h = {} 49 | 50 | if cont_type and cont_type ~= "" then 51 | table.insert(h, "content-type:" .. cont_type) 52 | end 53 | 54 | table.insert(h, "host:" .. aws_host) 55 | 56 | -- The x-amz-content-sha256 header is required for Amazon S3 AWS requests. It provides a hash of 57 | -- the request payload. If there is no payload, you must provide the hash of an empty string. 58 | if aws_service:sub(1,2) == "s3" then 59 | table.insert(h, "x-amz-content-sha256:" .. self:get_signed_request_body()) 60 | end 61 | 62 | table.insert(h, "x-amz-date:" .. iso_tz) 63 | 64 | if aws_stoken and aws_stoken ~= "" then 65 | table.insert(h, "x-amz-security-token:" .. aws_stoken) 66 | end 67 | 68 | return table.concat(h, '\n') 69 | end 70 | 71 | 72 | function _M.get_signed_request_body(self) 73 | local params = req_body 74 | if type(req_body) == 'table' then 75 | table.sort(params) 76 | params = ngx.encode_args(params) 77 | end 78 | local digest = self:get_sha256_digest(params or '') 79 | return string.lower(digest) -- hash must be in lowercase hex string 80 | end 81 | 82 | 83 | -- get canonical request 84 | -- https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html 85 | function _M.get_canonical_request(self) 86 | local signed_header = self:get_signed_header() 87 | local canonical_header = self:get_canonical_header() 88 | local canonical_querystr = self:get_canonical_query_string() 89 | local signed_body = self:get_signed_request_body() 90 | local param = { 91 | req_method, 92 | req_path, 93 | canonical_querystr, 94 | canonical_header, 95 | '', -- required 96 | signed_header, 97 | signed_body 98 | } 99 | local canonical_request = table.concat(param, '\n') 100 | ngx.log(ngx.INFO, "canonical_request: ", canonical_request) 101 | return self:get_sha256_digest(canonical_request) 102 | end 103 | 104 | 105 | -- generate sha256 from the given string 106 | function _M.get_sha256_digest(self, s) 107 | local h = resty_digest.new("sha256") 108 | h:update(s) 109 | return str.tohex(h:final()) 110 | end 111 | 112 | 113 | function _M.hmac(self, secret, message) 114 | ngx.log(ngx.INFO, "secret: ", secret) 115 | ngx.log(ngx.INFO, "message: ", message) 116 | local h = resty_digest.new("sha256", secret) 117 | h:update(message) 118 | local s = h:final() 119 | h:reset() 120 | return s 121 | end 122 | 123 | 124 | -- get signing key 125 | -- https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html 126 | function _M.get_signing_key(self) 127 | local k_date = self:hmac('AWS4' .. aws_secret, iso_date) 128 | local k_region = self:hmac(k_date, aws_region) 129 | local k_service = self:hmac(k_region, aws_service) 130 | local k_signing = self:hmac(k_service, 'aws4_request') 131 | return k_signing 132 | end 133 | 134 | 135 | -- get string 136 | function _M.get_string_to_sign(self) 137 | local param = { iso_date, aws_region, aws_service, 'aws4_request' } 138 | local cred = table.concat(param, '/') 139 | local req = self:get_canonical_request() 140 | return table.concat({ 'AWS4-HMAC-SHA256', iso_tz, cred, req}, '\n') 141 | end 142 | 143 | 144 | -- generate signature 145 | function _M.get_signature(self) 146 | local signing_key = self:get_signing_key() 147 | local string_to_sign = self:get_string_to_sign() 148 | ngx.log(ngx.INFO, "string_to_sign: ", string_to_sign) 149 | ngx.log(ngx.INFO, "signing_key: ", signing_key) 150 | return str.tohex(self:hmac(signing_key, string_to_sign)) 151 | end 152 | 153 | 154 | -- get authorization string 155 | -- x-amz-content-sha256 required by s3 156 | function _M.get_authorization_header(self) 157 | local param = { aws_key, iso_date, aws_region, aws_service, 'aws4_request' } 158 | local header = { 159 | 'AWS4-HMAC-SHA256 Credential=' .. table.concat(param, '/'), 160 | 'SignedHeaders=' .. self:get_signed_header(), 161 | 'Signature=' .. self:get_signature() 162 | } 163 | return table.concat(header, ', ') 164 | end 165 | 166 | 167 | -- update ngx.request.headers 168 | -- will all the necessary aws required headers 169 | -- for authentication 170 | function _M.set_ngx_auth_headers(self) 171 | ngx.req.set_header('Authorization', self:get_authorization_header()) 172 | ngx.req.set_header('X-Amz-Date', iso_tz) 173 | ngx.req.set_header("host", aws_host) 174 | ngx.req.set_header("content-type", cont_type) 175 | if aws_service:sub(1,2) == "s3" then 176 | ngx.req.set_header("X-Amz-Content-SHA256", self:get_signed_request_body()) 177 | end 178 | 179 | if aws_stoken and aws_stoken ~= "" then 180 | ngx.req.set_header('X-Amz-Security-Token', aws_stoken) 181 | end 182 | end 183 | 184 | 185 | -- get the current timestamp in iso8601 basic format 186 | function _M.get_date_header() 187 | return iso_tz 188 | end 189 | 190 | -- create canonical headers 191 | -- header must be sorted asc 192 | function _M.get_signed_header(self) 193 | local signed_header = {} 194 | 195 | if cont_type and cont_type ~= "" then 196 | table.insert(signed_header, "content-type") 197 | end 198 | 199 | table.insert(signed_header, "host") 200 | 201 | if aws_service:sub(1,2) == "s3" then 202 | table.insert(signed_header, "x-amz-content-sha256") 203 | end 204 | 205 | table.insert(signed_header, "x-amz-date") 206 | 207 | if aws_stoken and aws_stoken ~= "" then 208 | table.insert(signed_header, "x-amz-security-token") 209 | end 210 | 211 | 212 | return table.concat(signed_header, ";") 213 | end 214 | 215 | 216 | -- encode query string using URI encode rules 217 | -- see UriEncode @ https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html 218 | function _M.encode_querystr(self, querystr) 219 | local q = {} 220 | local i = 1 221 | local length = #querystr 222 | 223 | while i <= length do 224 | local c = querystr:sub(i, i) 225 | 226 | if c:match("[A-Za-z0-9%-%.%_%~=&]") then 227 | table.insert(q, c) 228 | elseif c == " " then 229 | table.insert(q, "%20") 230 | elseif c == "%" then 231 | if i + 2 <= length then 232 | local digit1 = querystr:sub(i+1, i+1) 233 | local digit2 = querystr:sub(i+2, i+2) 234 | 235 | if digit1:match("[0-9A-F]") and digit2:match("[0-9A-F]") then 236 | table.insert(q, "%" .. digit1 .. digit2) 237 | i = i + 2 238 | else 239 | table.insert(q, c) 240 | end 241 | 242 | end 243 | else 244 | table.insert(q, string.format("%%%02X", string.byte(c))) 245 | end 246 | 247 | i = i + 1 248 | end 249 | 250 | return table.concat(q) 251 | end 252 | 253 | 254 | -- create canoncial query string 255 | -- query string must be sorted by parameter name asc 256 | function _M.get_canonical_query_string(self) 257 | local encoded = self:encode_querystr(req_querystr) 258 | ngx.log(ngx.DEBUG, "encoded = " .. encoded) 259 | local parsed = {} 260 | for key, value in string.gmatch(encoded, "([^&=?]+)=?([^&=?]*)") do 261 | parsed[key] = value 262 | end 263 | ngx.log(ngx.DEBUG, "parsed = " .. table.concat(parsed, '|')) 264 | local sorted_keys = {} 265 | for key in pairs(parsed) do 266 | table.insert(sorted_keys, key) 267 | end 268 | table.sort(sorted_keys) 269 | 270 | local sorted = {} 271 | for _, key in ipairs(sorted_keys) do 272 | local value = parsed[key] 273 | if value == "" then 274 | table.insert(sorted, key .. "=") 275 | else 276 | table.insert(sorted, key .. "=" .. value) 277 | end 278 | end 279 | 280 | return table.concat(sorted, "&") 281 | end 282 | 283 | return _M 284 | -------------------------------------------------------------------------------- /lua-resty-aws-auth-0.11-0.src.rock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paragasu/lua-resty-aws-auth/4a26d404aea721bf51e2ae32f88158510b997462/lua-resty-aws-auth-0.11-0.src.rock -------------------------------------------------------------------------------- /lua-resty-aws-auth-0.12-0.rockspec: -------------------------------------------------------------------------------- 1 | package = "lua-resty-aws-auth" 2 | version = "0.12-0" 3 | source = { 4 | url = "git://github.com/paragasu/lua-resty-aws-auth", 5 | tag = "v0.12-0" 6 | } 7 | description = { 8 | summary = "Lua resty module to calculate AWS signature v4 authorization header", 9 | homepage = "https://github.com/paragasu/lua-resty-aws-auth", 10 | license = "MIT", 11 | maintainer = "Jeffry L. " 12 | } 13 | dependencies = { 14 | "lua >= 5.1", 15 | "lua-erento-hmac", 16 | "lua-resty-string" 17 | } 18 | build = { 19 | type = "builtin", 20 | modules = { 21 | ["resty.aws_auth"] = "lib/resty/aws_auth.lua", 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | 2 | #user nobody; 3 | worker_processes 1; 4 | 5 | #error_log logs/error.log; 6 | #error_log logs/error.log notice; 7 | #error_log logs/error.log info; 8 | 9 | #pid logs/nginx.pid; 10 | 11 | 12 | events { 13 | worker_connections 1024; 14 | } 15 | env AWS_ACCESS_KEY_ID; 16 | env AWS_ACCESS_KEY_SECRET; 17 | env AWS_REGION; 18 | env AWS_SERVICE; 19 | env AWS_HOST; 20 | 21 | http { 22 | lua_package_path '/usr/local/share/lua/5.1/?.lua;;'; 23 | include mime.types; 24 | default_type application/octet-stream; 25 | #log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 26 | # '$status $body_bytes_sent "$http_referer" ' 27 | # '"$http_user_agent" "$http_x_forwarded_for"'; 28 | 29 | #access_log logs/access.log main; 30 | 31 | sendfile on; 32 | #tcp_nopush on; 33 | 34 | #keepalive_timeout 0; 35 | keepalive_timeout 65; 36 | 37 | #gzip on; 38 | server_tokens off; 39 | server { 40 | listen 8080; 41 | server_name localhost; 42 | client_max_body_size 100M; 43 | 44 | #charset koi8-r; 45 | 46 | #access_log logs/host.access.log main; 47 | 48 | location / { 49 | set $backend ''; # 初始化变量 50 | 51 | # modify request headers 52 | access_by_lua_block { 53 | local aws_auth = require "resty.aws_auth" 54 | 55 | local aws_host = os.getenv("AWS_HOST") 56 | ngx.var.backend = aws_host 57 | local aws_key = os.getenv("AWS_ACCESS_KEY_ID") 58 | local aws_secret = os.getenv("AWS_ACCESS_KEY_SECRET") 59 | local aws_region = os.getenv("AWS_REGION") 60 | local aws_service = os.getenv("AWS_SERVICE") 61 | 62 | local headers = ngx.req.get_headers() 63 | for k, v in pairs(headers) do 64 | ngx.log(ngx.INFO, "headers before setting: ", k, ": ", v) 65 | end 66 | 67 | local config = { 68 | aws_host = aws_host, 69 | aws_key = aws_key, 70 | aws_secret = aws_secret, 71 | aws_region = aws_region, 72 | aws_service = aws_service, 73 | content_type = headers["content-type"] or "application/x-www-form-urlencoded", 74 | request_method = ngx.req.get_method(), 75 | request_path = ngx.var.uri, 76 | request_body = ngx.req.get_body_data(), 77 | request_querystr = ngx.var.query_string 78 | } 79 | 80 | -- addtional logging 81 | ngx.log(ngx.INFO, "request_path: ", config.request_path) 82 | ngx.log(ngx.INFO, "request_querystr: ", config.request_querystr) 83 | 84 | local aws = aws_auth:new(config) 85 | -- set aws auth headers 86 | aws:set_ngx_auth_headers() 87 | 88 | local headers2 = ngx.req.get_headers() 89 | 90 | for k, v in pairs(headers2) do 91 | ngx.log(ngx.INFO, "headers after setting: ", k, ": ", v) 92 | end 93 | } 94 | proxy_pass "http://$backend"; 95 | proxy_set_header Host $backend; 96 | } 97 | 98 | #error_page 404 /404.html; 99 | 100 | # redirect server error pages to the static page /50x.html 101 | # 102 | error_page 500 502 503 504 /50x.html; 103 | location = /50x.html { 104 | root html; 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /t/test.lua: -------------------------------------------------------------------------------- 1 | -- https://docs.aws.amazon.com/general/latest/gr/samples/aws4_testsuite.zip 2 | local str = require 'resty.string' 3 | local luaunit = require 'luaunit' 4 | local aws_auth = require 'lib/resty/aws_auth' 5 | 6 | local aws = aws_auth:new({ 7 | aws_key = 'AKIDEXAMPLE', 8 | aws_secret = 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', 9 | aws_region = 'us-east-1', 10 | aws_service = 'iam', 11 | aws_host = 'email.us-east-1.amazonaws.com', 12 | request_body = { 13 | Param1 = 'value1', 14 | Param2 = 'value2' 15 | } 16 | }) 17 | 18 | test_aws = {} 19 | 20 | -- random time i come up with 21 | aws:set_iso_date(1470764347) 22 | 23 | function test_aws:test_canonical_header() 24 | local canonical_header = aws:get_canonical_header() 25 | local expected_header = 'content-type:application/x-www-form-urlencoded\nhost:email.us-east-1.amazonaws.com\nx-amz-date:20160809T173907Z' 26 | luaunit.assertEquals(canonical_header, expected_header) 27 | end 28 | 29 | function test_aws:test_get_signed_request_body() 30 | local request_body = aws:get_signed_request_body() 31 | local expected_body = '36e6fb33c956b824c3855578bf2554ed4597a4025b88fbb84b4495b1cba45cd3' 32 | luaunit.assertEquals(request_body, expected_body) 33 | end 34 | 35 | function test_aws:test_get_canonical_request() 36 | local canonical_request = aws:get_canonical_request() 37 | luaunit.assertEquals(canonical_request, '5138ee1d1d5617f6759505102e4f1ab97cea3fd53f0741cba89cd9f652f5d1e3') 38 | end 39 | 40 | function test_aws:test_signing_key() 41 | local signing_key = aws:get_signing_key() 42 | luaunit.assertEquals(str.to_hex(signing_key), 'ed171583b5a52c2f0a2fa41aebdd3374b7bdbd34f672dbc9072a877b7105c5d1') 43 | end 44 | 45 | function test_aws:test_get_string_to_sign() 46 | local string_to_sign = aws:get_string_to_sign() 47 | local expected_string = 'AWS4-HMAC-SHA256\n20160809T173907Z\n20160809/us-east-1/iam/aws4_request\n5138ee1d1d5617f6759505102e4f1ab97cea3fd53f0741cba89cd9f652f5d1e3' 48 | luaunit.assertEquals(string_to_sign, expected_string) 49 | end 50 | 51 | function test_aws:test_get_signature() 52 | local signature = aws:get_signature() 53 | luaunit.assertEquals(signature, '30eaed16517ac88225f74ccb07b83fc0f9fde1653558420f9c48d88d131b383d') 54 | end 55 | 56 | function test_aws:test_get_authorization_header() 57 | local auth = aws:get_authorization_header() 58 | local s = 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20160809/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=30eaed16517ac88225f74ccb07b83fc0f9fde1653558420f9c48d88d131b383d' 59 | luaunit.assertEquals(auth, s) 60 | end 61 | 62 | luaunit.LuaUnit.run() 63 | --------------------------------------------------------------------------------