Use OpenIDC for authentication fixes #58

This commit is contained in:
Jared Kerim 2017-06-13 15:08:50 -04:00 коммит произвёл Jared Kerim
Родитель 80483bb2b4
Коммит dd1db67b0b
15 изменённых файлов: 1221 добавлений и 24 удалений

Просмотреть файл

@ -5,3 +5,5 @@ DB_NAME=postgres
DB_USER=postgres
DB_PASS=postgres
DB_HOST=db
OPENIDC_CLIENT_ID=
OPENIDC_CLIENT_SECRET=

Просмотреть файл

@ -23,7 +23,7 @@ makemigrations: compose_build
docker-compose run app python manage.py makemigrations
migrate: compose_build
docker-compose run app python manage.py migrate
docker-compose run app sh -c "/app/bin/wait-for-it.sh db:5432 -- python manage.py migrate"
createuser: compose_build
docker-compose run app python manage.py createsuperuser

Просмотреть файл

Просмотреть файл

@ -0,0 +1,58 @@
from django.core.urlresolvers import resolve, Resolver404
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import User, Group, Permission
from django.http import HttpResponse
class OpenIDCAuthMiddleware(object):
"""
An authentication middleware that depends on a header being set in the
request. This header will be populated by nginx configured to authenticate
with OpenIDC.
We will automatically create a user object and attach it to the
experimenters group.
"""
def get_experimenter_group(self):
experimenter_group, created = Group.objects.get_or_create(
name='Experimenters')
if created:
apps = ('projects', 'experiments')
content_types = ContentType.objects.filter(app_label__in=apps)
permissions = Permission.objects.filter(
content_type__in=content_types)
experimenter_group.permissions.add(*permissions)
return experimenter_group
def process_request(self, request):
try:
resolved = resolve(request.path)
if resolved.url_name in settings.OPENIDC_AUTH_WHITELIST:
# If the requested path is in our auth whitelist,
# skip authentication entirely
return
except Resolver404:
pass
openidc_email = request.META.get(settings.OPENIDC_EMAIL_HEADER, None)
if openidc_email is None:
# If a user has bypassed the OpenIDC flow entirely and no header
# is set then we reject the request entirely
return HttpResponse(
'Please login using OpenID Connect', status=401)
try:
user = User.objects.get(username=openidc_email)
except User.DoesNotExist:
user = User(username=openidc_email, email=openidc_email)
user.is_staff = True
user.save()
user.groups.add(self.get_experimenter_group())
request.user = user

Просмотреть файл

Просмотреть файл

@ -0,0 +1,95 @@
from django.conf import settings
from django.contrib.auth.models import User, Group
from django.core.urlresolvers import Resolver404
from django.test import TestCase
import mock
from experimenter.openidc.middleware import OpenIDCAuthMiddleware
class OpenIDCAuthMiddlewareTests(TestCase):
def setUp(self):
self.middleware = OpenIDCAuthMiddleware()
mock_resolve_patcher = mock.patch(
'experimenter.openidc.middleware.resolve')
self.mock_resolve = mock_resolve_patcher.start()
self.addCleanup(mock_resolve_patcher.stop)
def test_get_group_creates_group_with_project_experiment_permissions(self):
self.assertFalse(Group.objects.all().exists())
group = self.middleware.get_experimenter_group()
self.assertTrue(Group.objects.all().exists())
self.assertEqual(group.permissions.all().count(), 9)
self.assertEqual(
set([
permission.content_type.app_label for
permission in group.permissions.all()
]),
set(['projects', 'experiments']),
)
def test_get_group_only_creates_one_group(self):
self.assertEqual(Group.objects.all().count(), 0)
self.middleware.get_experimenter_group()
self.assertEqual(Group.objects.all().count(), 1)
self.middleware.get_experimenter_group()
self.assertEqual(Group.objects.all().count(), 1)
def test_whitelisted_url_is_not_authed(self):
request = mock.Mock()
request.path = '/whitelisted-view/'
whitelisted_view_name = 'whitelisted-view'
with self.settings(OPENIDC_AUTH_WHITELIST=[whitelisted_view_name]):
mock_view = mock.Mock()
mock_view.url_name = whitelisted_view_name
self.mock_resolve.return_value = mock_view
response = self.middleware.process_request(request)
self.assertEqual(response, None)
def test_404_path_forces_authentication(self):
request = mock.Mock()
request.META = {
}
self.mock_resolve.side_effect = Resolver404
response = self.middleware.process_request(request)
self.assertEqual(response.status_code, 401)
def test_request_missing_headers_raises_401(self):
request = mock.Mock()
request.META = {
}
with self.settings(OPENIDC_AUTH_WHITELIST=[]):
response = self.middleware.process_request(request)
self.assertEqual(response.status_code, 401)
def test_user_created_with_correct_email_from_header(self):
user_email = 'user@example.com'
request = mock.Mock()
request.META = {
settings.OPENIDC_EMAIL_HEADER: user_email,
}
self.assertEqual(User.objects.all().count(), 0)
with self.settings(OPENIDC_AUTH_WHITELIST=[]):
response = self.middleware.process_request(request)
self.assertEqual(response, None)
self.assertEqual(User.objects.all().count(), 1)
self.assertEqual(request.user.email, user_email)
self.assertTrue(request.user.is_staff)
self.assertTrue(
self.middleware.get_experimenter_group()
in request.user.groups.all()
)

Просмотреть файл

@ -45,8 +45,9 @@ INSTALLED_APPS = [
'raven.contrib.django.raven_compat',
'rest_framework',
'experimenter.projects',
'experimenter.openidc',
'experimenter.experiments',
'experimenter.projects',
]
MIDDLEWARE_CLASSES = [
@ -54,12 +55,12 @@ MIDDLEWARE_CLASSES = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'dockerflow.django.middleware.DockerflowMiddleware',
'experimenter.openidc.middleware.OpenIDCAuthMiddleware',
]
ROOT_URLCONF = 'experimenter.urls'
@ -118,6 +119,11 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
OPENIDC_EMAIL_HEADER = 'HTTP_X_FORWARDED_USER'
OPENIDC_AUTH_WHITELIST = (
'experiments-list',
)
# Internationalization
# https://docs.djangoproject.com/en/1.9/topics/i18n/

Просмотреть файл

@ -17,6 +17,7 @@ services:
nginx:
build: ./nginx
env_file: .env
links:
- app
ports:

Просмотреть файл

@ -1,7 +1,14 @@
FROM nginx:1.9.9
FROM openresty/openresty:trusty
RUN rm /etc/nginx/nginx.conf
COPY nginx.conf /etc/nginx/
RUN apt-get update
RUN apt-get install -y libssl-dev git-core
RUN luarocks install lua-resty-session
RUN luarocks install lua-resty-openidc
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx-site.conf /etc/nginx/conf.d/
RUN rm /usr/local/openresty/nginx/conf/nginx.conf
COPY nginx.conf /usr/local/openresty/nginx/conf/
COPY openidc.lua /usr/local/openresty/luajit/share/lua/5.1/resty/openidc.lua
COPY openidc_layer.lua /usr/local/openresty/nginx/conf/
COPY login_conf.lua /usr/local/openresty/nginx/conf/

Просмотреть файл

@ -0,0 +1,83 @@
local cjson = require("cjson")
-- configs
local opts = {
client_id = ngx.var.oidc_client_id,
client_secret = ngx.var.oidc_client_secret,
discovery = ngx.var.oidc_discovery or "https://auth.mozilla.auth0.com/.well-known/openid-configuration",
logout_path = ngx.var.oidc_logout_path,
redirect_uri_path = ngx.var.oidc_redirect_uri_path,
redirect_uri_scheme = "https",
scope = ngx.var.oidc_scope or "openid email profile",
token_endpoint_auth_method = "client_secret_post",
}
if ngx.var.oidc_hd then
opts.authorization_params = {hd=ngx.var.oidc_hd}
end
-- call authenticate for OpenID Connect user authentication
local res, err = require("resty.openidc").authenticate(opts)
if err then
ngx.status = 500
ngx.say(err)
ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
-- require hd matches if specified
if ngx.var.oidc_hd and res.id_token.hd ~= ngx.var.oidc_hd then
ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- if at least one email or group is specified, require user is in one of them
local validate_user, valid_user
for _, email in (ngx.var.oidc_emails or ""):gmatch("([^,]+),?") do
validate_user = true
if res.user.email == email then
valid_user = true
break
end
end
for _, group in (ngx.var.oidc_groups or ""):gmatch("([^,]+),?") do
validate_user = true
for _, usergroup in res.user.groups do
if usergroup == group then
valid_user = true
break
end
end
end
if validate_user and not valid_user then
ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- delete OIDC* headers from the request
for key, _ in pairs(ngx.req.get_headers()) do
if key:sub(1,4):upper() == "OIDC" then
ngx.req.clear_header(key)
end
end
-- set headers with user info
ngx.req.set_header("REMOTE-USER", res.id_token.user_id)
ngx.req.set_header("OIDC-CLAIM-ACCESS-TOKEN", res.access_token)
local function build_headers(t, name)
for k,v in pairs(t) do
k = k:gsub("_", "-")
-- unpack tables
if type(v) == "table" then
local j = cjson.encode(v)
ngx.req.set_header("OIDC-CLAIM-"..name..k, j)
else
ngx.req.set_header("OIDC-CLAIM-"..name..k, tostring(v))
end
end
end
build_headers(res.id_token, "ID-TOKEN-")
build_headers(res.user, "USER-PROFILE-")

13
nginx/login_conf.lua Normal file
Просмотреть файл

@ -0,0 +1,13 @@
-- lua-resty-openidc options
opts = {
redirect_uri_path = "/openid/callback/login/",
discovery = "https://auth.mozilla.auth0.com/.well-known/openid-configuration",
client_id = os.getenv("OPENIDC_CLIENT_ID"),
client_secret = os.getenv("OPENIDC_CLIENT_SECRET"),
scope = "openid email profile",
iat_slack = 600,
redirect_uri_scheme = "http",
logout_path = "/logout",
redirect_after_logout_uri = "/",
refresh_session_interval = 900
}

Просмотреть файл

@ -1,9 +0,0 @@
server {
listen 80;
location / {
proxy_pass http://app:7001/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

Просмотреть файл

@ -1,24 +1,26 @@
user nginx;
env OPENIDC_CLIENT_ID;
env OPENIDC_CLIENT_SECRET;
env SECRET_KEY;
worker_processes 4;
error_log /var/log/nginx/error.log warn;
error_log /usr/local/openresty/nginx/logs/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 19000;
}
http {
include /etc/nginx/mime.types;
include /usr/local/openresty/nginx/conf/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
access_log /usr/local/openresty/nginx/logs/access.log main;
sendfile on;
#tcp_nopush on;
@ -27,5 +29,35 @@ http {
#gzip on;
include /etc/nginx/conf.d/*.conf;
resolver 8.8.8.8;
lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
lua_ssl_verify_depth 5;
lua_shared_dict discovery 1m;
lua_shared_dict introspection 15m;
lua_shared_dict sessions 10m;
server {
listen 80;
server_name localhost;
set $session_storage shm;
set $session_cookie_persistent on;
set $session_cookie_path "/";
set $session_check_ssi off;
set_by_lua $session_secret 'return os.getenv("SECRET_KEY")';
set $config_loader "/usr/local/openresty/nginx/conf/login_conf.lua";
location /api/ {
proxy_pass http://app:7001/api/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
access_by_lua_file '/usr/local/openresty/nginx/conf/openidc_layer.lua';
proxy_pass http://app:7001/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}

849
nginx/openidc.lua Normal file
Просмотреть файл

@ -0,0 +1,849 @@
--[[
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
***************************************************************************
Copyright (C) 2015-2017 Ping Identity Corporation
All rights reserved.
For further information please contact:
Ping Identity Corporation
1099 18th St Suite 2950
Denver, CO 80202
303.468.2900
http://www.pingidentity.com
DISCLAIMER OF WARRANTIES:
THE SOFTWARE PROVIDED HEREUNDER IS PROVIDED ON AN "AS IS" BASIS, WITHOUT
ANY WARRANTIES OR REPRESENTATIONS EXPRESS, IMPLIED OR STATUTORY; INCLUDING,
WITHOUT LIMITATION, WARRANTIES OF QUALITY, PERFORMANCE, NONINFRINGEMENT,
MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. NOR ARE THERE ANY
WARRANTIES CREATED BY A COURSE OR DEALING, COURSE OF PERFORMANCE OR TRADE
USAGE. FURTHERMORE, THERE ARE NO WARRANTIES THAT THE SOFTWARE WILL MEET
YOUR NEEDS OR BE FREE FROM ERRORS, OR THAT THE OPERATION OF THE SOFTWARE
WILL BE UNINTERRUPTED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@Author: Hans Zandbelt - hans.zandbelt@zmartzone.eu
--]]
local require = require
local cjson = require "cjson"
local http = require "resty.http"
local string = string
local ipairs = ipairs
local pairs = pairs
local type = type
local ngx = ngx
local openidc = {
_VERSION = "1.3.1"
}
openidc.__index = openidc
-- set value in server-wide cache if available
local function openidc_cache_set(type, key, value, exp)
local dict = ngx.shared[type]
if dict then
local success, err, forcible = dict:set(key, value, exp)
ngx.log(ngx.DEBUG, "cache set: success=", success, " err=", err, " forcible=", forcible)
end
end
-- retrieve value from server-wide cache if available
local function openidc_cache_get(type, key)
local dict = ngx.shared[type]
local value
local flags
if dict then
value, flags = dict:get(key)
if value then ngx.log(ngx.DEBUG, "cache hit: type=", type, " key=", key) end
end
return value
end
-- validate the contents of and id_token
local function openidc_validate_id_token(opts, id_token, nonce)
-- check issuer
if opts.discovery.issuer ~= id_token.iss then
ngx.log(ngx.ERR, "issuer \"", id_token.iss, " in id_token is not equal to the issuer from the discovery document \"", opts.discovery.issuer, "\"")
return false
end
-- check nonce
if nonce and nonce ~= id_token.nonce then
ngx.log(ngx.ERR, "nonce \"", id_token.nonce, " in id_token is not equal to the nonce that was sent in the request \"", nonce, "\"")
return false
end
-- check issued-at timestamp
if not id_token.iat then
ngx.log(ngx.ERR, "no \"iat\" claim found in id_token")
return false
end
local slack=opts.iat_slack and opts.iat_slack or 120
if id_token.iat < (ngx.time() - slack) then
ngx.log(ngx.ERR, "token is not valid yet: id_token.iat=", id_token.iat, ", ngx.time()=", ngx.time())
return false
end
-- check expiry timestamp
if id_token.exp < ngx.time() then
ngx.log(ngx.ERR, "token expired: id_token.exp=", id_token.exp, ", ngx.time()=", ngx.time())
return false
end
-- check audience (array or string)
if (type(id_token.aud) == "table") then
for key, value in pairs(id_token.aud) do
if value == opts.client_id then
return true
end
end
ngx.log(ngx.ERR, "no match found token audience array: client_id=", opts.client_id )
return false
elseif (type(id_token.aud) == "string") then
if id_token.aud ~= opts.client_id then
ngx.log(ngx.ERR, "token audience does not match: id_token.aud=", id_token.aud, ", client_id=", opts.client_id )
return false
end
end
return true
end
-- assemble the redirect_uri
local function openidc_get_redirect_uri(opts)
local scheme = opts.redirect_uri_scheme or ngx.req.get_headers()['X-Forwarded-Proto'] or ngx.var.scheme
return scheme.."://"..ngx.var.http_host..opts.redirect_uri_path
end
-- perform base64url decoding
local function openidc_base64_url_decode(input)
local reminder = #input % 4
if reminder > 0 then
local padlen = 4 - reminder
input = input .. string.rep('=', padlen)
end
input = input:gsub('-','+'):gsub('_','/')
return ngx.decode_base64(input)
end
-- perform base64url encoding
local function openidc_base64_url_encode(input)
input = ngx.encode_base64(input)
return input:gsub('+','-'):gsub('/','_'):gsub('=','')
end
-- send the browser of to the OP's authorization endpoint
local function openidc_authorize(opts, session, target_url)
local resty_random = require "resty.random"
local resty_string = require "resty.string"
-- generate state and nonce
local state = resty_string.to_hex(resty_random.bytes(16))
local nonce = resty_string.to_hex(resty_random.bytes(16))
-- assemble the parameters to the authentication request
local params = {
client_id=opts.client_id,
response_type="code",
scope=opts.scope and opts.scope or "openid email profile",
redirect_uri=openidc_get_redirect_uri(opts),
state=state,
nonce=nonce,
prompt=opts.prompt and opts.prompt or ""
}
-- merge any provided extra parameters
if opts.authorization_params then
for k,v in pairs(opts.authorization_params) do params[k] = v end
end
-- store state in the session
session:start()
session.data.original_url = target_url
session.data.state = state
session.data.nonce = nonce
session.data.last_authenticated = ngx.time()
session:save()
-- redirect to the /authorization endpoint
return ngx.redirect(opts.discovery.authorization_endpoint.."?"..ngx.encode_args(params))
end
-- parse the JSON result from a call to the OP
local function openidc_parse_json_response(response)
local err
local res
-- check the response from the OP
if response.status ~= 200 then
err = "response indicates failure, status="..response.status..", body="..response.body
else
-- decode the response and extract the JSON object
res = cjson.decode(response.body)
if not res then
err = "JSON decoding failed"
end
end
return res, err
end
-- make a call to the token endpoint
local function openidc_call_token_endpoint(opts, endpoint, body, auth)
local headers = {
["Content-Type"] = "application/x-www-form-urlencoded"
}
if auth then
if auth == "client_secret_basic" then
headers.Authorization = "Basic "..ngx.encode_base64( opts.client_id..":"..opts.client_secret)
ngx.log(ngx.DEBUG,"client_secret_basic: authorization header '"..headers.Authorization.."'")
end
if auth == "client_secret_post" then
body.client_id=opts.client_id
body.client_secret=opts.client_secret
ngx.log(ngx.DEBUG, "client_secret_post: client_id and client_secret being sent in POST body")
end
end
ngx.log(ngx.DEBUG, "request body for token endpoint call: ", ngx.encode_args(body))
local httpc = http.new()
local res, err = httpc:request_uri(endpoint, {
method = "POST",
body = ngx.encode_args(body),
headers = headers,
ssl_verify = (opts.ssl_verify ~= "no")
})
if not res then
err = "accessing token endpoint ("..endpoint..") failed: "..err
ngx.log(ngx.ERR, err)
return nil, err
end
ngx.log(ngx.DEBUG, "token endpoint response: ", res.body)
return openidc_parse_json_response(res);
end
-- make a call to the userinfo endpoint
local function openidc_call_userinfo_endpoint(opts, access_token)
if not opts.discovery.userinfo_endpoint then
ngx.log(ngx.DEBUG, "no userinfo endpoint supplied")
return nil, nil
end
local httpc = http.new()
local res, err = httpc:request_uri(opts.discovery.userinfo_endpoint, {
headers = {
["Authorization"] = "Bearer "..access_token,
}
})
if not res then
err = "accessing userinfo endpoint ("..opts.discovery.userinfo_endpoint..") failed: "..err
ngx.log(ngx.ERR, err)
return nil, err
end
ngx.log(ngx.DEBUG, "userinfo response: ", res.body)
-- parse the response from the user info endpoint
return openidc_parse_json_response(res)
end
-- computes access_token expires_in value (in seconds)
local function openidc_access_token_expires_in(opts, expires_in)
return (expires_in or opts.access_token_expires_in or 3600) - 1 - (opts.access_token_expires_leeway or 0)
end
-- handle a "code" authorization response from the OP
local function openidc_authorization_response(opts, session)
local args = ngx.req.get_uri_args()
local err
if not args.code or not args.state then
err = "unhandled request to the redirect_uri: "..ngx.var.request_uri
ngx.log(ngx.ERR, err)
return nil, err, session.data.original_url, session
end
-- check that the state returned in the response against the session; prevents CSRF
if args.state ~= session.data.state then
err = "state from argument: "..(args.state and args.state or "nil").." does not match state restored from session: "..(session.data.state and session.data.state or "nil")
ngx.log(ngx.ERR, err)
return nil, err, session.data.original_url, session
end
-- check the iss if returned from the OP
if args.iss and args.iss ~= opts.discovery.issuer then
err = "iss from argument: "..args.iss.." does not match expected issuer: "..opts.discovery.issuer
ngx.log(ngx.ERR, err)
return nil, err, session.data.original_url, session
end
-- check the client_id if returned from the OP
if args.client_id and args.client_id ~= opts.client_id then
err = "client_id from argument: "..args.client_id.." does not match expected client_id: "..opts.client_id
ngx.log(ngx.ERR, err)
return nil, err, session.data.original_url, session
end
-- assemble the parameters to the token endpoint
local body = {
grant_type="authorization_code",
code=args.code,
redirect_uri=openidc_get_redirect_uri(opts),
state = session.data.state
}
local current_time = ngx.time()
-- make the call to the token endpoint
local json, err = openidc_call_token_endpoint(opts, opts.discovery.token_endpoint, body, opts.token_endpoint_auth_method)
if err then
return nil, err, session.data.original_url, session
end
-- process the token endpoint response with the id_token and access_token
local enc_hdr, enc_pay, enc_sign = string.match(json.id_token, '^(.+)%.(.+)%.(.+)$')
local jwt = openidc_base64_url_decode(enc_pay)
local id_token = cjson.decode(jwt)
-- validate the id_token contents
if openidc_validate_id_token(opts, id_token, session.data.nonce) == false then
err = "id_token validation failed"
return nil, err, session.data.original_url, session
end
-- call the user info endpoint
-- TODO: should this error be checked?
local user, err = openidc_call_userinfo_endpoint(opts, json.access_token)
session:start()
session.data.user = user
session.data.id_token = id_token
session.data.enc_id_token = json.id_token
session.data.access_token = json.access_token
session.data.access_token_expiration = current_time
+ openidc_access_token_expires_in(opts, json.expires_in)
if json.refresh_token ~= nil then
session.data.refresh_token = json.refresh_token
end
-- save the session with the obtained id_token
session:save()
-- redirect to the URL that was accessed originally
return ngx.redirect(session.data.original_url), session
end
-- get the Discovery metadata from the specified URL
local function openidc_discover(url, ssl_verify)
ngx.log(ngx.DEBUG, "In openidc_discover - URL is "..url)
local json, err
local v = openidc_cache_get("discovery", url)
if not v then
ngx.log(ngx.DEBUG, "Discovery data not in cache. Making call to discovery endpoint")
-- make the call to the discovery endpoint
local httpc = http.new()
local res, error = httpc:request_uri(url, {
ssl_verify = (ssl_verify ~= "no")
})
if not res then
err = "accessing discovery url ("..url..") failed: "..error
ngx.log(ngx.ERR, err)
else
ngx.log(ngx.DEBUG, "Response data: "..res.body)
json, err = openidc_parse_json_response(res)
if json then
if string.sub(url, 1, string.len(json['issuer'])) == json['issuer'] then
openidc_cache_set("discovery", url, cjson.encode(json), 24 * 60 * 60)
else
err = "issuer field in Discovery data does not match URL"
json = nil
end
else
err = "could not decode JSON from Discovery data"
end
end
else
json = cjson.decode(v)
end
return json, err
end
local function openidc_jwks(url, ssl_verify)
ngx.log(ngx.DEBUG, "In openidc_jwks - URL is "..url)
local json, err
local v = openidc_cache_get("jwks", url)
if not v then
ngx.log(ngx.DEBUG, "JWKS data not in cache. Making call to jwks endpoint")
-- make the call to the jwks endpoint
local httpc = http.new()
local res, error = httpc:request_uri(url, {
ssl_verify = (ssl_verify ~= "no")
})
if not res then
err = "accessing jwks url ("..url..") failed: "..error
ngx.log(ngx.ERR, err)
else
ngx.log(ngx.DEBUG, "Response data: "..res.body)
json, err = openidc_parse_json_response(res)
if json then
openidc_cache_set("jwks", url, cjson.encode(json), 24 * 60 * 60)
end
end
else
json = cjson.decode(v)
end
return json, err
end
local function split_by_chunk(text, chunkSize)
local s = {}
for i=1, #text, chunkSize do
s[#s+1] = text:sub(i,i+chunkSize - 1)
end
return s
end
local function get_jwk (keys, kid)
for _, value in pairs(keys) do
if value.kid == kid then
return value
end
end
return nil
end
local function pem_from_jwk (opts, kid)
local cache_id = opts.discovery.jwks_uri .. '#' .. kid
local v = openidc_cache_get("jwks", cache_id)
if v then
return v
end
local jwks, err = openidc_jwks(opts.discovery.jwks_uri, opts.ssl_verify)
if err then
return nil, err
end
local x5c = get_jwk(jwks.keys, kid).x5c
-- TODO check x5c length
local chunks = split_by_chunk(ngx.encode_base64(openidc_base64_url_decode(x5c[1])), 64)
local pem = "-----BEGIN CERTIFICATE-----\n" .. table.concat(chunks, "\n") .. "\n-----END CERTIFICATE-----"
openidc_cache_set("jwks", cache_id, pem, 24 * 60 * 60)
return pem
end
local openidc_transparent_pixel = "\137\080\078\071\013\010\026\010\000\000\000\013\073\072\068\082" ..
"\000\000\000\001\000\000\000\001\008\004\000\000\000\181\028\012" ..
"\002\000\000\000\011\073\068\065\084\120\156\099\250\207\000\000" ..
"\002\007\001\002\154\028\049\113\000\000\000\000\073\069\078\068" ..
"\174\066\096\130"
-- handle logout
local function openidc_logout(opts, session)
session:destroy()
local headers = ngx.req.get_headers()
local header = headers['Accept']
if header and header:find("image/png") then
ngx.header["Cache-Control"] = "no-cache, no-store"
ngx.header["Pragma"] = "no-cache"
ngx.header["P3P"] = "CAO PSA OUR"
ngx.header["Expires"] = "0"
ngx.header["X-Frame-Options"] = "DENY"
ngx.header.content_type = "image/png"
ngx.print(openidc_transparent_pixel)
ngx.exit(ngx.OK)
return
elseif opts.redirect_after_logout_uri then
return ngx.redirect(opts.redirect_after_logout_uri)
elseif opts.discovery.end_session_endpoint then
return ngx.redirect(opts.discovery.end_session_endpoint)
elseif opts.discovery.ping_end_session_endpoint then
return ngx.redirect(opts.discovery.ping_end_session_endpoint)
end
ngx.header.content_type = "text/html"
ngx.say("<html><body>Logged Out</body></html>")
ngx.exit(ngx.OK)
end
-- get the token endpoint authentication method
local function openidc_get_token_auth_method(opts)
local result
if opts.discovery.token_endpoint_auth_methods_supported ~= nil then
-- if set check to make sure the discovery data includes the selected client auth method
if opts.token_endpoint_auth_method ~= nil then
for index, value in ipairs (opts.discovery.token_endpoint_auth_methods_supported) do
ngx.log(ngx.DEBUG, index.." => "..value)
if value == opts.token_endpoint_auth_method then
ngx.log(ngx.DEBUG, "configured value for token_endpoint_auth_method ("..opts.token_endpoint_auth_method..") found in token_endpoint_auth_methods_supported in metadata")
result = opts.token_endpoint_auth_method
break
end
end
if result == nil then
ngx.log(ngx.ERR, "configured value for token_endpoint_auth_method ("..opts.token_endpoint_auth_method..") NOT found in token_endpoint_auth_methods_supported in metadata")
return nil
end
else
result = opts.discovery.token_endpoint_auth_methods_supported[1]
ngx.log(ngx.DEBUG, "no configuration setting for option so select the first method specified by the OP: "..result)
end
else
result = opts.token_endpoint_auth_method
end
-- set a sane default if auto-configuration failed
if result == nil then
result = "client_secret_basic"
end
ngx.log(ngx.DEBUG, "token_endpoint_auth_method result set to "..result)
return result
end
-- returns a valid access_token (eventually refreshing the token)
local function openidc_access_token(opts, session)
local err
if session.data.access_token == nil then
return nil, err
end
local current_time = ngx.time()
if current_time < session.data.access_token_expiration then
return session.data.access_token, err
end
if session.data.refresh_token == nil then
return nil, err
end
ngx.log(ngx.DEBUG, "refreshing expired access_token: ", session.data.access_token, " with: ", session.data.refresh_token)
-- assemble the parameters to the token endpoint
local body = {
grant_type="refresh_token",
refresh_token=session.data.refresh_token,
scope=opts.scope and opts.scope or "openid email profile"
}
local json, err = openidc_call_token_endpoint(opts, opts.discovery.token_endpoint, body, opts.token_endpoint_auth_method)
if err then
return nil, err
end
ngx.log(ngx.DEBUG, "access_token refreshed: ", json.access_token, " updated refresh_token: ", json.refresh_token)
session:start()
session.data.access_token = json.access_token
session.data.access_token_expiration = current_time + openidc_access_token_expires_in(opts, json.expires_in)
if json.refresh_token ~= nil then
session.data.refresh_token = json.refresh_token
end
-- save the session with the new access_token and optionally the new refresh_token
session:save()
return session.data.access_token, err
end
-- main routine for OpenID Connect user authentication
function openidc.authenticate(opts, target_url, unauth_action, session_opts)
local err
local session = require("resty.session").open(session_opts)
local target_url = target_url or ngx.var.request_uri
local access_token
if type(opts.discovery) == "string" then
--if session.data.discovery then
-- opts.discovery = session.data.discovery
--else
-- session.data.discovery = opts.discovery
--end
opts.discovery, err = openidc_discover(opts.discovery, opts.ssl_verify)
if err then
return nil, err, target_url, session
end
end
-- set the authentication method for the token endpoint
opts.token_endpoint_auth_method = openidc_get_token_auth_method(opts)
-- see if this is a request to the redirect_uri i.e. an authorization response
local path = target_url:match("(.-)%?") or target_url
if path == opts.redirect_uri_path then
if not session.present then
err = "request to the redirect_uri_path but there's no session state found"
ngx.log(ngx.ERR, err)
return nil, err, target_url, session
end
return openidc_authorization_response(opts, session), session
end
-- see if this is a request to logout
if path == (opts.logout_path and opts.logout_path or "/logout") then
return openidc_logout(opts, session), session
end
-- if we have no id_token then redirect to the OP for authentication
if not session.present or not session.data.id_token then
if unauth_action == "pass" then
return
nil,
err,
target_url,
session
end
return openidc_authorize(opts, session, target_url), session
end
-- silently reauthenticate if necessary (mainly used for session refresh/getting updated id_token data)
if opts.refresh_session_interval ~= nil then
if session.data.last_authenticated == nil or (session.data.last_authenticated+opts.refresh_session_interval) < ngx.time() then
opts.prompt = "none"
return openidc_authorize(opts, session, target_url), session
end
end
-- refresh access_token if necessary
access_token, err = openidc_access_token(opts, session)
if err then
return nil, err, target_url, session
end
-- log id_token contents
ngx.log(ngx.DEBUG, "id_token=", cjson.encode(session.data.id_token))
-- return the id_token to the caller Lua script for access control purposes
return
{
id_token=session.data.id_token,
access_token=access_token,
user=session.data.user
},
err,
target_url,
session
end
-- get a valid access_token (eventually refreshing the token), or nil if there's no valid access_token
function openidc.access_token(opts, session_opts)
local session = require("resty.session").open(session_opts)
return openidc_access_token(opts, session)
end
-- get an OAuth 2.0 bearer access token from the HTTP request
local function openidc_get_bearer_access_token(opts)
local err
-- get the access token from the Authorization header
local headers = ngx.req.get_headers()
local header = headers['Authorization']
if header == nil or header:find(" ") == nil then
err = "no Authorization header found"
ngx.log(ngx.ERR, err)
return nil, err
end
local divider = header:find(' ')
if string.lower(header:sub(0, divider-1)) ~= string.lower("Bearer") then
err = "no Bearer authorization header value found"
ngx.log(ngx.ERR, err)
return nil, err
end
local access_token = header:sub(divider+1)
if access_token == nil then
err = "no Bearer access token value found"
ngx.log(ngx.ERR, err)
return nil, err
end
return access_token, err
end
-- main routine for OAuth 2.0 token introspection
function openidc.introspect(opts)
-- get the access token from the request
local access_token, err = openidc_get_bearer_access_token(opts)
if access_token == nil then
return nil, err
end
-- see if we've previously cached the introspection result for this access token
local json
local v = openidc_cache_get("introspection", access_token)
if not v then
-- assemble the parameters to the introspection (token) endpoint
local token_param_name = opts.introspection_token_param_name and opts.introspection_token_param_name or "access_token"
local body = {}
body[token_param_name]= access_token
if opts.client_id then
body.client_id=opts.client_id
end
if opts.client_secret then
body.client_secret=opts.client_secret
end
-- merge any provided extra parameters
if opts.introspection_params then
for k,v in pairs(opts.introspection_params) do body[k] = v end
end
-- call the introspection endpoint
json, err = openidc_call_token_endpoint(opts, opts.introspection_endpoint, body, nil)
-- cache the results
if json then
local expiry_claim = opts.expiry_claim or "expires_in"
local ttl = json[expiry_claim]
if expiry_claim ~= "exp" then --https://tools.ietf.org/html/rfc7662#section-2.2
ttl = ttl - ngx.time()
end
openidc_cache_set("introspection", access_token, cjson.encode(json), ttl)
end
else
json = cjson.decode(v)
end
return json, err
end
-- main routine for OAuth 2.0 JWT token validation
-- optional args are claim specs, see jwt-validators in resty.jwt
function openidc.jwt_verify(access_token, opts, ...)
local err
local json
-- see if we've previously cached the validation result for this access token
local v = openidc_cache_get("introspection", access_token)
if not v then
-- do the verification first time
local jwt = require "resty.jwt"
-- No secret given try getting it from the jwks endpoint
if not opts.secret and opts.discovery then
ngx.log(ngx.DEBUG, "bearer_jwt_verify using discovery.")
if type(opts.discovery) == "string" then
opts.discovery, err = openidc_discover(opts.discovery, opts.ssl_verify)
if err then
return nil, err
end
end
-- We decode the token twice, could be saved
local jwt_obj = jwt:load_jwt(access_token, nil)
if not jwt_obj.valid then
return nil, "invalid jwt"
end
opts.secret, err = pem_from_jwk(opts, jwt_obj.header.kid)
if opts.secret == nil then
return nil, err
end
end
json = jwt:verify(opts.secret, access_token, ...)
ngx.log(ngx.DEBUG, "jwt: ", cjson.encode(json))
-- cache the results
if json and json.valid == true and json.verified == true then
json = json.payload
openidc_cache_set("introspection", access_token, cjson.encode(json), json.exp - ngx.time())
else
err = "invalid token: ".. json.reason
end
else
-- decode from the cache
json = cjson.decode(v)
end
-- check the token expiry
if json then
if json.exp and json.exp < ngx.time() then
ngx.log(ngx.ERR, "token expired: json.exp=", json.exp, ", ngx.time()=", ngx.time())
err = "JWT expired"
end
end
return json, err
end
function openidc.bearer_jwt_verify(opts, ...)
local err
local json
-- get the access token from the request
local access_token, err = openidc_get_bearer_access_token(opts)
if access_token == nil then
return nil, err
end
ngx.log(ngx.DEBUG, "access_token: ", access_token)
json, err = openidc.jwt_verify(access_token, opts, ...)
return json, err, access_token
end
return openidc

60
nginx/openidc_layer.lua Normal file
Просмотреть файл

@ -0,0 +1,60 @@
-- Lua reference for nginx: https://github.com/openresty/lua-nginx-module
-- Lua reference for openidc: https://github.com/pingidentity/lua-resty-openidc
local oidc = require("resty.openidc")
local cjson = require( "cjson" )
-- Load config
local f, e = loadfile(ngx.var.config_loader)
if f == nil then
ngx.log(ngx.ERR, "can't initialize loadfile: "..e)
end
ok, e = pcall(f)
if not ok then
ngx.log(ngx.ERR, "can't load configuration: "..e)
end
-- Authenticate with lua-resty-openidc if necessary (this will return quickly if no authentication is necessary)
local res, err, url, session = oidc.authenticate(opts)
-- Check if authentication succeeded, otherwise kick the user out
if err then
if session ~= nil then
session:destroy()
end
ngx.redirect(opts.logout_path)
else
ngx.log(ngx.ERR, "no error was returned but session is not set. Are you using lua-resty-openidc>=1.3.2?")
end
-- Access control: only allow specific users in (this is optional, without it all authenticated users are allowed in)
-- (TODO: add example)
-- Set headers with user info and OIDC claims for the underlaying web application to use (this is optional)
-- These header names are voluntarily similar to Apaches mod_auth_openidc, but may of course be modified
ngx.req.set_header("REMOTE_USER", session.data.user.email)
ngx.req.set_header("X-Forwarded-User", session.data.user.email)
ngx.req.set_header("OIDC_CLAIM_ACCESS_TOKEN", session.data.access_token)
ngx.req.set_header("OIDC_CLAIM_ID_TOKEN", session.data.enc_id_token)
ngx.req.set_header("via",session.data.user.email)
local function build_headers(t, name)
for k,v in pairs(t) do
-- unpack tables
if type(v) == "table" then
local j = cjson.encode(v)
ngx.req.set_header("OIDC_CLAIM_"..name..k, j)
else
ngx.req.set_header("OIDC_CLAIM_"..name..k, tostring(v))
end
end
end
build_headers(session.data.id_token, "ID_TOKEN_")
build_headers(session.data.user, "USER_PROFILE_")
-- Flat groups, useful for some RP's that won't read JSON
local gprs = ""
for k,v in pairs(session.data.user.groups) do
grps = grps and grps.."|"..v or v
end
ngx.req.set_header("X-Forwarded-Groups", grps)