Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add oauth support #53

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ RUN set -xe; \
# Also symlink nginx binary to a location in PATH
ln -s /usr/local/openresty/nginx/sbin/nginx /usr/sbin/nginx

# Certs
# Certs and OAuth
RUN set -xe; \
apk add --update --no-cache \
openssl \
git \
; \
# Create a folder for custom vhost certs (mount custom certs here)
mkdir -p /etc/certs/custom; \
Expand All @@ -71,7 +72,16 @@ RUN set -xe; \
-extensions ext \
-config ext.conf; \
rm -rf ext.conf; \
apk del openssl && rm -rf /var/cache/apk/*;
; \
# Install OAuth dependencies
git clone -c transfer.fsckobjects=true https://github.com/pintsized/lua-resty-http.git /tmp/lua-resty-http; \
cd /tmp/lua-resty-http; \
# https://github.com/pintsized/lua-resty-http/releases/tag/v0.07 v0.07
git checkout 69695416d408f9cfdaae1ca47650ee4523667c3d; \
mkdir -p /etc/nginx/lua; \
cp -aR /tmp/lua-resty-http/lib/resty /etc/nginx/lua/resty; \
rm -rf /tmp/lua-resty-http; \
apk del openssl git && rm -rf /var/cache/apk/*;

COPY conf/nginx/ /etc/nginx/
COPY conf/sudoers /etc/sudoers
Expand Down
1 change: 1 addition & 0 deletions bin/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ fi
if [[ "$1" == "supervisord" ]]; then
# Generate config files from templates
gomplate --file /etc/nginx/nginx.conf.tmpl --out /etc/nginx/nginx.conf
gomplate --file /etc/nginx/conf.d/auth.conf.tmpl --out /etc/nginx/conf.d/auth.conf

exec supervisord -c /etc/supervisord.conf
# Command mode
Expand Down
41 changes: 41 additions & 0 deletions conf/nginx/conf.d/auth.conf.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{{ if and (getenv "NGO_CALLBACK_HOST") (getenv "NGO_CLIENT_ID") (getenv "NGO_CLIENT_SECRET") (getenv "NGO_TOKEN_SECRET") }}
# Enable oauth

server {
listen 80;
listen 443 ssl http2;
server_name auth;
server_name auth.*;
ssl_certificate /etc/certs/server.crt;
ssl_certificate_key /etc/certs/server.key;
ssl_session_cache builtin:1000 shared:SSL:10m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4;
ssl_prefer_server_ciphers on;

lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
lua_ssl_verify_depth 5;

set $ngo_callback_uri '/_oauth';
set $ngo_callback_host '{{ getenv "NGO_CALLBACK_HOST" }}';
set $ngo_client_id '{{ getenv "NGO_CLIENT_ID" }}';
set $ngo_client_secret '{{ getenv "NGO_CLIENT_SECRET" }}';
set $ngo_token_secret '{{ getenv "NGO_TOKEN_SECRET" }}';
set $ngo_user 'unknown';
set $ngo_email_as_user 'true';
set $ngo_extra_validity '0';
set_by_lua $ngo_domain 'return os.getenv("NGO_DOMAIN")';
set_by_lua $ngo_whitelist 'return os.getenv("NGO_WHITELIST")';
set_by_lua $ngo_blacklist 'return os.getenv("NGO_BLACKLIST")';

expires 0;

add_header Google-User $ngo_user;

location / {
# See https://github.com/openresty/lua-nginx-module#ngxeof
proxy_ignore_client_abort on;
access_by_lua_file "/etc/nginx/lua/access.lua";
}
}
{{ end }}
35 changes: 33 additions & 2 deletions conf/nginx/conf.d/vhosts.conf.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,39 @@
ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4;
ssl_prefer_server_ciphers on;

{{ if and .Host.Env.NGO_CALLBACK_HOST .Host.Env.NGO_CLIENT_ID .Host.Env.NGO_CLIENT_SECRET .Host.Env.NGO_TOKEN_SECRET .Container.Env.OAUTH_ENABLED }}
{{ if eq .Container.Env.OAUTH_ENABLED "1" }}
# Enable oauth
resolver 8.8.8.8 ipv6=off;

lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
lua_ssl_verify_depth 5;

set $ngo_callback_uri '/_oauth';
set $ngo_callback_host '{{ .Host.Env.NGO_CALLBACK_HOST }}';
set $ngo_client_id '{{ .Host.Env.NGO_CLIENT_ID }}';
set $ngo_client_secret '{{ .Host.Env.NGO_CLIENT_SECRET }}';
set $ngo_token_secret '{{ .Host.Env.NGO_TOKEN_SECRET }}';
set $ngo_user 'unknown';
set $ngo_email_as_user 'true';
set $ngo_extra_validity '0';
set_by_lua $ngo_domain 'return os.getenv("NGO_DOMAIN")';
set_by_lua $ngo_whitelist 'return os.getenv("NGO_WHITELIST")';
set_by_lua $ngo_blacklist 'return os.getenv("NGO_BLACKLIST")';

expires 0;

add_header Google-User $ngo_user;

location / {
proxy_ignore_client_abort on;
access_by_lua_file "/etc/nginx/lua/access.lua";
proxy_pass http://{{ .Upstream }};
{{ end }}
{{ else }}
location / {
proxy_pass http://{{ .Upstream }};
{{ end }}
}
}
{{ end }}
Expand Down Expand Up @@ -122,7 +153,7 @@
{{ $cert := (coalesce $certName $vhostCert) }}

{{/* Generate HTTP/HTTPS server config */}}
{{ template "server" (dict "Hosts" $hosts "Upstream" $upstream "Cert" $cert) }}
{{ template "server" (dict "Hosts" $hosts "Upstream" $upstream "Cert" $cert "Container" $pr_container "Host" $CurrentContainer) }}

{{ end }}
# -------------------------------------------------- #
Expand Down Expand Up @@ -179,7 +210,7 @@
{{ $cert := (coalesce $certName $vhostCert) }}

{{/* Generate HTTP/HTTPS server config */}}
{{ template "server" (dict "Hosts" $hosts "Upstream" $upstream "Cert" $cert) }}
{{ template "server" (dict "Hosts" $hosts "Upstream" $upstream "Cert" $cert "Container" $container "Host" $CurrentContainer) }}

{{ end }}

Expand Down
255 changes: 255 additions & 0 deletions conf/nginx/lua/access.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
-- Copyright 2015-2016 CloudFlare
-- Copyright 2014-2015 Aaron Westendorf

local json = require("cjson")
local http = require("resty.http")

local uri = ngx.var.uri
local uri_args = ngx.req.get_uri_args()
local scheme = ngx.var.scheme

local client_id = ngx.var.ngo_client_id
local client_secret = ngx.var.ngo_client_secret
local token_secret = ngx.var.ngo_token_secret
local domain = ngx.var.ngo_domain
local cb_scheme = ngx.var.ngo_callback_scheme or scheme
local cb_server_name = ngx.var.ngo_callback_host or ngx.var.server_name
local cb_uri = ngx.var.ngo_callback_uri or "/_oauth"
local cb_url = cb_scheme .. "://" .. cb_server_name .. cb_uri
local redirect_url = cb_scheme .. "://" .. cb_server_name .. ngx.var.request_uri
local extra_validity = tonumber(ngx.var.ngo_extra_validity or "0")
local whitelist = ngx.var.ngo_whitelist or ""
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 email_as_user = ngx.var.ngo_email_as_user == "true" or false
local session_id = uri_args["state"] or ngx.var.cookie_session
local session = ngx.shared.session;

if whitelist:len() == 0 then
whitelist = nil
end

if blacklist:len() == 0 then
blacklist = nil
end

local function create_uuid (length)
local index, pw, rnd = 0, ""
local chars = {
"abcdefghijklmnopqrstuvwxyz",
"0123456789"
}
math.randomseed(os.clock())
repeat
index = index + 1
rnd = math.random(chars[index]:len())
if math.random(2) == 1 then
pw = pw .. chars[index]:sub(rnd, rnd)
else
pw = chars[index]:sub(rnd, rnd) .. pw
end
index = index % #chars
until pw:len() >= length
return pw
end

local function check_domain(email, whitelist_failed)
local oauth_domain = email:match("[^@]+@(.+)")
-- if domain is configured, check it, if it isn't, permit request
if domain:len() ~= 0 then
if not string.find(" " .. domain .. " ", " " .. oauth_domain .. " ", 1, true) then
if whitelist_failed then
ngx.log(ngx.ERR, email .. " is not on " .. domain .. " nor in the whitelist")
else
ngx.log(ngx.ERR, email .. " is not on " .. domain)
end
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
end
end

local function on_auth(email, token, expires)
if blacklist then
-- blacklisted user is always rejected
if string.find(" " .. blacklist .. " ", " " .. email .. " ", 1, true) then
ngx.log(ngx.ERR, email .. " is in blacklist")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
end

if whitelist then
-- if whitelisted, no need check the if it's a valid domain
if not string.find(" " .. whitelist .. " ", " " .. email .. " ", 1, true) then
check_domain(email, true)
end
else
-- empty whitelist, lets check if it's a valid domain
check_domain(email, false)
end


if set_user then
if email_as_user then
ngx.var.ngo_user = email
else
ngx.var.ngo_user = email:match("([^@]+)@.+")
end
end
end

local function request_access_token(code)
local request = http.new()

request:set_timeout(7000)

local res, err = request:request_uri("https://accounts.google.com/o/oauth2/token", {
method = "POST",
body = ngx.encode_args({
code = code,
client_id = client_id,
client_secret = client_secret,
redirect_uri = cb_url,
grant_type = "authorization_code",
}),
headers = {
["Content-type"] = "application/x-www-form-urlencoded"
},
ssl_verify = true,
})
if not res then
return nil, (err or "auth token request failed: " .. (err or "unknown reason"))
end

if res.status ~= 200 then
return nil, "received " .. res.status .. " from https://accounts.google.com/o/oauth2/token: " .. res.body
end

return json.decode(res.body)
end

local function request_profile(token)
local request = http.new()

request:set_timeout(7000)

local res, err = request:request_uri("https://www.googleapis.com/oauth2/v2/userinfo", {
headers = {
["Authorization"] = "Bearer " .. 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/oauth2/v2/userinfo"
end

return json.decode(res.body)
end

local function is_authorized()
local session_data = json.decode(session:get(session_id))
local expires = session_data.expires
local email = session_data.email
local token = session_data.token

local expected_token = ngx.encode_base64(ngx.hmac_sha1(token_secret, cb_server_name .. email .. expires))

if token == expected_token and expires and expires > ngx.time() - extra_validity then
session:set(session_id, json.encode(session_data), 3600)
return true
else
return false
end
end

local function redirect_to_auth()
ngx.header["Set-Cookie"] = {"session=" .. session_id}

-- 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",
response_type = "code",
redirect_uri = cb_url,
state = session_id,
login_hint = domain,
}))
end

local function authorize()
if uri ~= cb_uri then
return redirect_to_auth()
end

if uri_args["error"] then
ngx.log(ngx.ERR, "received " .. uri_args["error"] .. " from https://accounts.google.com/o/oauth2/auth")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end

local token, token_err = request_access_token(uri_args["code"])
if not token then
ngx.log(ngx.ERR, "got error during access token request: " .. token_err)
return ngx.exit(ngx.HTTP_FORBIDDEN)
end

local profile, profile_err = request_profile(token["access_token"])
if not profile then
ngx.log(ngx.ERR, "got error during profile request: " .. profile_err)
return ngx.exit(ngx.HTTP_FORBIDDEN)
end

local expires = ngx.time() + token["expires_in"]
local cookie_tail = ";version=1;path=/;Max-Age=" .. extra_validity + token["expires_in"]
if secure_cookies then
cookie_tail = cookie_tail .. ";secure"
end
if http_only_cookies then
cookie_tail = cookie_tail .. ";httponly"
end

local email = profile["email"]
local user_token = ngx.encode_base64(ngx.hmac_sha1(token_secret, cb_server_name .. email .. expires))

-- Update session with data from auth response
local session_data = json.decode(session:get(session_id))
session_data.expires = expires
session_data.email = email
session_data.token = user_token
session:set(session_id, json.encode(session_data), 3600)
return ngx.redirect(session_data.original_url)
end

--------------------------------
-- Main code
--------------------------------
-- Flush expired sessions
ngx.shared.session:flush_expired()

-- Create new session if session_id is empty or session with session_id does not exists
if session_id == nil or session:get(session_id) == nil then
session_id = create_uuid(32)
local session_data = {
["id"] = session_id,
["original_url"] = cb_scheme .. "://" .. ngx.var.host .. ngx.var.request_uri,
["expires"] = 0,
["email"] = "",
["token"] = "",
}
session:set(session_id, json.encode(session_data), 300)
else
session_data = json.decode(session:get(session_id))
session_id = session_data.id
end

if not is_authorized() then
authorize()
end

if uri == "/_oauth" then
return ngx.redirect(session_data.original_url)
end
Loading