From 19b472c72f350f41679414573180428da584fe4e Mon Sep 17 00:00:00 2001 From: Tiago Teodosio Date: Fri, 6 Apr 2018 17:40:47 +0200 Subject: [PATCH] Adding support for Google Suite Directory Groups Google Suite Directory Groups can be fetched for the user. A white list of group emails can be defined grant access. The list of groups can be passed to an NGINX variable which can be passed to the applications for fine grained access control. A service account with Google Suite Domain-Wide Delegation of Authority is required to access Google Directory API. Added the possibility to extract also user email and name from the Oauth profile data and pass them on as NGINX variables. Requires Lua module luaossl, which provides OpenSSL bindings. --- README.md | 32 ++++++++++- access.lua | 155 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 174 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 01177db..ebb4c21 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,32 @@ variables are: returned from Google (portion left of '@' in email). - **$ngo_email_as_user** If set and `$ngo_user` is defined, username returned will be full email address. +- **$ngo_email** Optional, boolean. If set to true, it will be populated with + the OAuth email returned from Google. +- **$ngo_name** Optional, boolean. If set to true, it will be populated with + the OAuth name returned from Google. +- **$ngo_groups** Optional, boolean. If set to true, it will be populated with + the Google Directory Groups of which the user is a member, within the Google + Suite domain defined in variable **$ngo_groups_domain**. + Requires the definition of **$ngo_service_account_json_file**, + **$ngo_organization_admin_email** and **$ngo_groups_domain**. +- **$ngo_allowed_groups** Optional, space separated list of email addresses of + Google Directory Groups. If set, will be used for access control, so that + only members of the defined groups will be authorized. + Requires the definition of **$ngo_service_account_json_file**, + **$ngo_organization_admin_email** and **$ngo_groups_domain**. +- **$ngo_service_account_json_file** Optional, path to JSON credentials file of the Google Service Account which has been granted domain-wide-delegation of the Google Suite Domain. +Please follow the [official documentation](https://developers.google.com/admin-sdk/directory/v1/guides/delegation) to setup Google Suite Domain-Wide Delegation of Authority and define at least the following scopes: +``` +https://www.googleapis.com/auth/admin.directory.user.readonly +https://www.googleapis.com/auth/admin.directory.group.readonly +https://www.googleapis.com/auth/admin.directory.group.member.readonly +``` +- **$ngo_organization_admin_email** Optional, the email of a Google Suite + administrator account. The Service Account will impersonate this user to + access Google Directory API. +- **ngo_groups_domain** Optional, the domain of the Google Suite account where + groups are fetched from. ## Available endpoints @@ -80,6 +106,8 @@ Endpoint that reports your OAuth token in a JSON object: ```json { "email": "foo@example.com", + "name": "Foo Name", + "groups": "group1@example.com group2@example.com", "token": "abc..xyz", "expires": 1445455680 } @@ -91,6 +119,8 @@ Endpoint that reports your OAuth token in text format: ``` email: foo@example.com +name: Foo Name +groups: group1@example.com group2@example.com token: abc..xyz expires: 1445455680 ``` @@ -100,7 +130,7 @@ expires: 1445455680 Endpoint that reports your OAuth token as `curl` arguments for header auth: ``` --H "OauthEmail: foo@example.com" -H "OauthAccessToken: abc..xyz" -H "OauthExpires: 1445455680" +-H "OauthEmail: foo@example.com" -H "OauthName: Foo Name" -H "OauthGroups: group1@example.com group2@example.com" -H "OauthAccessToken: abc..xyz" -H "OauthExpires: 1445455680" ``` You can add it to your `curl` command to make it work with OAuth. diff --git a/access.lua b/access.lua index 0452e85..bb36591 100644 --- a/access.lua +++ b/access.lua @@ -24,7 +24,14 @@ local blacklist = ngx.var.ngo_blacklist or "" local secure_cookies = ngx.var.ngo_secure_cookies == "true" or false local http_only_cookies = ngx.var.ngo_http_only_cookies == "true" or false local set_user = ngx.var.ngo_user or false +local set_email = ngx.var.ngo_email or false +local set_name = ngx.var.ngo_name or false local email_as_user = ngx.var.ngo_email_as_user == "true" or false +local sa_json_file = ngx.var.ngo_service_account_json_file or false +local org_admin_email = ngx.var.ngo_organization_admin_email or false +local set_groups = ngx.var.ngo_groups or false +local allowed_groups = ngx.var.ngo_allowed_groups or "" +local groups_domain = ngx.var.ngo_groups_domain or false if whitelist:len() == 0 then whitelist = nil @@ -34,11 +41,17 @@ if blacklist:len() == 0 then blacklist = nil end -local function handle_token_uris(email, token, expires) +if allowed_groups:len() == 0 then + allowed_groups = nil +end + +local function handle_token_uris(email, name, groups, token, expires) if uri == "/_token.json" then - ngx.header["Content-type"] = "application/json" + ngx.header["Content-type"] = "application/json; charset=utf-8" ngx.say(json.encode({ email = email, + name = name, + groups = groups, token = token, expires = expires, })) @@ -46,14 +59,14 @@ local function handle_token_uris(email, token, expires) end if uri == "/_token.txt" then - ngx.header["Content-type"] = "text/plain" - ngx.say("email: " .. email .. "\n" .. "token: " .. token .. "\n" .. "expires: " .. expires .. "\n") + ngx.header["Content-type"] = "text/plain; charset=utf-8" + ngx.say("email: " .. email .. "\n" .. "name: " .. name .. "\n" .. "groups: " .. groups .. "\n" .. "token: " .. token .. "\n" .. "expires: " .. expires .. "\n") ngx.exit(ngx.OK) end if uri == "/_token.curl" then - ngx.header["Content-type"] = "text/plain" - ngx.say("-H \"OauthEmail: " .. email .. "\" -H \"OauthAccessToken: " .. token .. "\" -H \"OauthExpires: " .. expires .. "\"\n") + ngx.header["Content-type"] = "text/plain; charset=utf-8" + ngx.say("-H \"OauthEmail: " .. email .. "\" -H \"OauthName: " .. name .. "\" -H \"OauthGroups: " .. groups .. "\" -H \"OauthAccessToken: " .. token .. "\" -H \"OauthExpires: " .. expires .. "\"\n") ngx.exit(ngx.OK) end end @@ -73,7 +86,7 @@ local function check_domain(email, whitelist_failed) end end -local function on_auth(email, token, expires) +local function on_auth(email, name, groups, token, expires) if blacklist then -- blacklisted user is always rejected if string.find(" " .. blacklist .. " ", " " .. email .. " ", 1, true) then @@ -92,6 +105,19 @@ local function on_auth(email, token, expires) check_domain(email, false) end + if allowed_groups then + local allow_group = false + for group in groups:gmatch("%S+") do + if string.find(" " .. allowed_groups .. " ", " " .. group .. " ", 1, true) then + allow_group = true + break + end + end + if not allow_group then + ngx.log(ngx.ERR, "none of the user groups (" .. groups .. ") are present in allowed_groups (" .. allowed_groups .. ")") + return ngx.exit(ngx.HTTP_FORBIDDEN) + end + end if set_user then if email_as_user then @@ -101,7 +127,19 @@ local function on_auth(email, token, expires) end end - handle_token_uris(email, token, expires) + if set_email then + ngx.var.ngo_email = email + end + + if set_name then + ngx.var.ngo_name = name + end + + if set_groups then + ngx.var.ngo_groups = groups + end + + handle_token_uris(email, name, groups, token, expires) end local function request_access_token(code) @@ -134,6 +172,79 @@ local function request_access_token(code) return json.decode(res.body) end +local function base64url(text) + return ngx.encode_base64(text):gsub("+", "-"):gsub("/", "_") +end + +local function request_groups(email) + if not (sa_json_file and org_admin_email and groups_domain) then + return "" + end + + local digest = require("openssl.digest") + local pkey = require("openssl.pkey") + + local json_file = io.open(sa_json_file, "r") + if json_file then + service_account = json.decode(json_file:read("*a")) + io.close(json_file) + else + ngx.log(ngx.ERR, "failed to open service account JSON file: " .. sa_json_file) + return "" + end + + local now = os.time() + local header = '{"alg":"RS256","typ":"JWT"}' + local claims = '{"iss":"' .. service_account["client_email"] .. '","sub":"' .. org_admin_email .. '", "scope":"https://www.googleapis.com/auth/admin.directory.user.readonly https://www.googleapis.com/auth/admin.directory.group.readonly https://www.googleapis.com/auth/admin.directory.group.member.readonly", "aud":"https://www.googleapis.com/oauth2/v4/token","exp":' .. now + 300 .. ', "iat":' .. now .. '}' + local signature = pkey.new(service_account["private_key"]):sign(digest.new("sha256"):update(base64url(header) .. "." .. base64url(claims))) + local assertion = base64url(header) .. "." .. base64url(claims) .. "." .. base64url(signature) + + local request = http.new() + request:set_timeout(7000) + local res, err = request:request_uri("https://www.googleapis.com/oauth2/v4/token", { + method = "POST", + body = ngx.encode_args({ + assertion = assertion, + grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer", + }), + headers = { + ["Content-type"] = "application/x-www-form-urlencoded" + }, + ssl_verify = true, + }) + if not res then + return nil, (err or "domain auth token request failed: " .. (err or "unknown reason")) + end + if res.status ~= 200 then + return nil, "received " .. res.status .. " from https://www.googleapis.com/oauth2/v4/token: " .. res.body + end + access_token = json.decode(res.body)['access_token'] + + local request = http.new() + request:set_timeout(7000) + local res, err = request:request_uri("https://www.googleapis.com/admin/directory/v1/groups?domain=" .. groups_domain .. "&userKey=" .. email, { + headers = { + ["Authorization"] = "Bearer " .. access_token, + }, + ssl_verify = true, + }) + if not res then + return nil, "auth info request failed: " .. (err or "unknown reason") + end + + if res.status ~= 200 then + return nil, "received " .. res.status .. " from https://www.googleapis.com/admin/directory/v1/groups?domain=" .. groups_domain .. "&userKey=" .. email + end + + local user_groups = json.decode(res.body) + local groups = "" + for i, group in ipairs(user_groups["groups"]) do + groups = groups .. " " .. group["email"] + end + + return groups +end + local function request_profile(token) local request = http.new() @@ -161,6 +272,8 @@ local function is_authorized() local expires = tonumber(ngx.var.cookie_OauthExpires) or 0 local email = ngx.unescape_uri(ngx.var.cookie_OauthEmail or "") + local name = ngx.unescape_uri(ngx.var.cookie_OauthName or "") + local groups = ngx.unescape_uri(ngx.var.cookie_OauthGroups or "") local token = ngx.unescape_uri(ngx.var.cookie_OauthAccessToken or "") if expires == 0 and headers["oauthexpires"] then @@ -171,14 +284,23 @@ local function is_authorized() email = headers["oauthemail"] end + if name:len() == 0 and headers["oauthname"] then + name = headers["oauthname"] + end + + if groups:len() == 0 and headers["oauthgroups"] then + groups = headers["oauthgroups"] + end + if token:len() == 0 and headers["oauthaccesstoken"] then token = headers["oauthaccesstoken"] end local expected_token = ngx.encode_base64(ngx.hmac_sha1(token_secret, cb_server_name .. email .. expires)) + local expected_token = ngx.encode_base64(ngx.hmac_sha1(token_secret, cb_server_name .. email .. name .. groups .. expires)) if token == expected_token and expires and expires > ngx.time() - extra_validity then - on_auth(email, expected_token, expires) + on_auth(email, name, groups, expected_token, expires) return true else return false @@ -189,7 +311,7 @@ local function redirect_to_auth() -- google seems to accept space separated domain list in the login_hint, so use this undocumented feature. return ngx.redirect("https://accounts.google.com/o/oauth2/auth?" .. ngx.encode_args({ client_id = client_id, - scope = "email", + scope = "email profile", response_type = "code", redirect_uri = cb_url, state = redirect_url, @@ -229,12 +351,21 @@ local function authorize() end local email = profile["email"] - local user_token = ngx.encode_base64(ngx.hmac_sha1(token_secret, cb_server_name .. email .. expires)) + local name = profile["name"] + + local groups, groups_err = request_groups(email) + if not groups then + ngx.log(ngx.ERR, "got error during groups request: " .. groups_err) + groups = "" + end + local user_token = ngx.encode_base64(ngx.hmac_sha1(token_secret, cb_server_name .. email .. name .. groups .. expires)) - on_auth(email, user_token, expires) + on_auth(email, name, groups, user_token, expires) ngx.header["Set-Cookie"] = { "OauthEmail=" .. ngx.escape_uri(email) .. cookie_tail, + "OauthName=" .. ngx.escape_uri(name) .. cookie_tail, + "OauthGroups=" .. ngx.escape_uri(groups) .. cookie_tail, "OauthAccessToken=" .. ngx.escape_uri(user_token) .. cookie_tail, "OauthExpires=" .. expires .. cookie_tail, }