initial commit
This commit is contained in:
0
lemur/auth/__init__.py
Normal file
0
lemur/auth/__init__.py
Normal file
62
lemur/auth/permissions.py
Normal file
62
lemur/auth/permissions.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""
|
||||
.. module: permissions
|
||||
:platform: Unix
|
||||
:synopsis: This module defines all the permission used within Lemur
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from functools import partial
|
||||
from collections import namedtuple
|
||||
|
||||
from flask.ext.principal import Permission, RoleNeed
|
||||
|
||||
# Permissions
|
||||
operator_permission = Permission(RoleNeed('operator'))
|
||||
admin_permission = Permission(RoleNeed('secops@netflix.com'))
|
||||
|
||||
CertificateCreator = namedtuple('certificate', ['method', 'value'])
|
||||
CertificateCreatorNeed = partial(CertificateCreator, 'certificateView')
|
||||
|
||||
CertificateOwner = namedtuple('certificate', ['method', 'value'])
|
||||
CertificateOwnerNeed = partial(CertificateOwner, 'certificateView')
|
||||
|
||||
|
||||
class ViewKeyPermission(Permission):
|
||||
def __init__(self, role_id, certificate_id):
|
||||
c_need = CertificateCreatorNeed(unicode(certificate_id))
|
||||
o_need = CertificateOwnerNeed(unicode(role_id))
|
||||
super(ViewKeyPermission, self).__init__(o_need, c_need, RoleNeed('admin'))
|
||||
|
||||
|
||||
class UpdateCertificatePermission(Permission):
|
||||
def __init__(self, role_id, certificate_id):
|
||||
c_need = CertificateCreatorNeed(unicode(certificate_id))
|
||||
o_need = CertificateOwnerNeed(unicode(role_id))
|
||||
super(UpdateCertificatePermission, self).__init__(o_need, c_need, RoleNeed('admin'))
|
||||
|
||||
|
||||
RoleUser = namedtuple('role', ['method', 'value'])
|
||||
ViewRoleCredentialsNeed = partial(RoleUser, 'roleView')
|
||||
|
||||
|
||||
class ViewRoleCredentialsPermission(Permission):
|
||||
def __init__(self, role_id):
|
||||
need = ViewRoleCredentialsNeed(unicode(role_id))
|
||||
super(ViewRoleCredentialsPermission, self).__init__(need, RoleNeed('admin'))
|
||||
|
||||
|
||||
AuthorityCreator = namedtuple('authority', ['method', 'value'])
|
||||
AuthorityCreatorNeed = partial(AuthorityCreator, 'authorityUse')
|
||||
|
||||
AuthorityOwner = namedtuple('authority', ['method', 'value'])
|
||||
AuthorityOwnerNeed = partial(AuthorityOwner, 'role')
|
||||
|
||||
|
||||
class AuthorityPermission(Permission):
|
||||
def __init__(self, authority_id, roles):
|
||||
needs = [RoleNeed('admin'), AuthorityCreatorNeed(unicode(authority_id))]
|
||||
for r in roles:
|
||||
needs.append(AuthorityOwnerNeed(unicode(r)))
|
||||
|
||||
super(AuthorityPermission, self).__init__(*needs)
|
188
lemur/auth/service.py
Normal file
188
lemur/auth/service.py
Normal file
@ -0,0 +1,188 @@
|
||||
"""
|
||||
.. module: lemur.auth.service
|
||||
:platform: Unix
|
||||
:synopsis: This module contains all of the authentication duties for
|
||||
lemur
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
||||
"""
|
||||
import jwt
|
||||
import json
|
||||
import base64
|
||||
import binascii
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import g, current_app, jsonify, request
|
||||
|
||||
from flask.ext.restful import Resource
|
||||
from flask.ext.principal import identity_loaded, RoleNeed, UserNeed
|
||||
|
||||
from flask.ext.principal import Identity, identity_changed
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
||||
|
||||
from lemur.users import service as user_service
|
||||
from lemur.auth.permissions import CertificateOwnerNeed, CertificateCreatorNeed, \
|
||||
AuthorityCreatorNeed, AuthorityOwnerNeed, ViewRoleCredentialsNeed
|
||||
|
||||
|
||||
def base64url_decode(data):
|
||||
if isinstance(data, unicode):
|
||||
data = str(data)
|
||||
|
||||
rem = len(data) % 4
|
||||
|
||||
if rem > 0:
|
||||
data += b'=' * (4 - rem)
|
||||
|
||||
return base64.urlsafe_b64decode(data)
|
||||
|
||||
|
||||
def base64url_encode(data):
|
||||
return base64.urlsafe_b64encode(data).replace(b'=', b'')
|
||||
|
||||
|
||||
def get_rsa_public_key(n, e):
|
||||
"""
|
||||
Retrieve an RSA public key based on a module and exponent as provided by the JWKS format.
|
||||
|
||||
:param n:
|
||||
:param e:
|
||||
:return: a RSA Public Key in PEM format
|
||||
"""
|
||||
n = int(binascii.hexlify(base64url_decode(n)), 16)
|
||||
e = int(binascii.hexlify(base64url_decode(e)), 16)
|
||||
pub = RSAPublicNumbers(e, n).public_key(default_backend())
|
||||
return pub.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
|
||||
def create_token(user):
|
||||
"""
|
||||
Create a valid JWT for a given user, this token is then used to authenticate
|
||||
sessions until the token expires.
|
||||
|
||||
:param user:
|
||||
:return:
|
||||
"""
|
||||
expiration_delta = timedelta(days=int(current_app.config.get('TOKEN_EXPIRATION', 1)))
|
||||
payload = {
|
||||
'sub': user.id,
|
||||
'iat': datetime.now(),
|
||||
'exp': datetime.now() + expiration_delta
|
||||
}
|
||||
token = jwt.encode(payload, current_app.config['TOKEN_SECRET'])
|
||||
return token.decode('unicode_escape')
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""
|
||||
Validates the JWT and ensures that is has not expired.
|
||||
|
||||
:param f:
|
||||
:return:
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not request.headers.get('Authorization'):
|
||||
response = jsonify(message='Missing authorization header')
|
||||
response.status_code = 401
|
||||
return response
|
||||
|
||||
token = request.headers.get('Authorization').split()[1]
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['TOKEN_SECRET'])
|
||||
except jwt.DecodeError:
|
||||
return dict(message='Token is invalid'), 403
|
||||
except jwt.ExpiredSignatureError:
|
||||
return dict(message='Token has expired'), 403
|
||||
except jwt.InvalidTokenError:
|
||||
return dict(message='Token is invalid'), 403
|
||||
|
||||
g.current_user = user_service.get(payload['sub'])
|
||||
|
||||
if not g.current_user.id:
|
||||
return dict(message='You are not logged in'), 403
|
||||
|
||||
# Tell Flask-Principal the identity changed
|
||||
identity_changed.send(current_app._get_current_object(), identity=Identity(g.current_user.id))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def fetch_token_header(token):
|
||||
"""
|
||||
Fetch the header out of the JWT token.
|
||||
|
||||
:param token:
|
||||
:return: :raise jwt.DecodeError:
|
||||
"""
|
||||
token = token.encode('utf-8')
|
||||
try:
|
||||
signing_input, crypto_segment = token.rsplit(b'.', 1)
|
||||
header_segment, payload_segment = signing_input.split(b'.', 1)
|
||||
except ValueError:
|
||||
raise jwt.DecodeError('Not enough segments')
|
||||
|
||||
try:
|
||||
return json.loads(base64url_decode(header_segment))
|
||||
except TypeError, binascii.Error:
|
||||
raise jwt.DecodeError('Invalid header padding')
|
||||
|
||||
|
||||
|
||||
@identity_loaded.connect
|
||||
def on_identity_loaded(sender, identity):
|
||||
"""
|
||||
Sets the identity of a given option, assigns additional permissions based on
|
||||
the role that the user is a part of.
|
||||
|
||||
:param sender:
|
||||
:param identity:
|
||||
"""
|
||||
# load the user
|
||||
user = user_service.get(identity.id)
|
||||
|
||||
# add the UserNeed to the identity
|
||||
identity.provides.add(UserNeed(identity.id))
|
||||
|
||||
# identity with the roles that the user provides
|
||||
if hasattr(user, 'roles'):
|
||||
for role in user.roles:
|
||||
identity.provides.add(CertificateOwnerNeed(unicode(role.id)))
|
||||
identity.provides.add(ViewRoleCredentialsNeed(unicode(role.id)))
|
||||
identity.provides.add(RoleNeed(role.name))
|
||||
|
||||
# apply ownership for authorities
|
||||
if hasattr(user, 'authorities'):
|
||||
for authority in user.authorities:
|
||||
identity.provides.add(AuthorityCreatorNeed(unicode(authority.id)))
|
||||
|
||||
# apply ownership of certificates
|
||||
if hasattr(user, 'certificates'):
|
||||
for certificate in user.certificates:
|
||||
identity.provides.add(CertificateCreatorNeed(unicode(certificate.id)))
|
||||
|
||||
g.user = user
|
||||
|
||||
|
||||
class AuthenticatedResource(Resource):
|
||||
"""
|
||||
Inherited by all resources that need to be protected by authentication.
|
||||
"""
|
||||
method_decorators = [login_required]
|
||||
|
||||
def __init__(self):
|
||||
super(AuthenticatedResource, self).__init__()
|
||||
|
||||
|
257
lemur/auth/views.py
Normal file
257
lemur/auth/views.py
Normal file
@ -0,0 +1,257 @@
|
||||
"""
|
||||
.. module: lemur.auth.views
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import jwt
|
||||
import base64
|
||||
import requests
|
||||
|
||||
from flask import g, Blueprint, current_app, abort
|
||||
|
||||
from flask.ext.restful import reqparse, Resource, Api
|
||||
from flask.ext.principal import Identity, identity_changed
|
||||
|
||||
from lemur.common.crypto import unlock
|
||||
|
||||
from lemur.auth.permissions import admin_permission
|
||||
from lemur.users import service as user_service
|
||||
from lemur.roles import service as role_service
|
||||
from lemur.certificates import service as cert_service
|
||||
from lemur.auth.service import AuthenticatedResource, create_token, fetch_token_header, get_rsa_public_key
|
||||
|
||||
|
||||
mod = Blueprint('auth', __name__)
|
||||
api = Api(mod)
|
||||
|
||||
|
||||
class Login(Resource):
|
||||
"""
|
||||
Provides an endpoint for Lemur's basic authentication. It takes a username and password
|
||||
combination and returns a JWT token.
|
||||
|
||||
This token token is required for each API request and must be provided in the Authorization Header for the request.
|
||||
::
|
||||
|
||||
Authorization:Bearer <token>
|
||||
|
||||
Tokens have a set expiration date. You can inspect the token expiration be base64 decoding the token and inspecting
|
||||
it's contents.
|
||||
|
||||
.. note:: It is recommended that the token expiration is fairly short lived (hours not days). This will largely depend \
|
||||
on your uses cases but. It is important to not that there is currently no build in method to revoke a users token \
|
||||
and force re-authentication.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(Login, self).__init__()
|
||||
|
||||
def post(self):
|
||||
"""
|
||||
.. http:post:: /auth/login
|
||||
|
||||
Login with username:password
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /auth/login HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"username": "test",
|
||||
"password": "test"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"token": "12343243243"
|
||||
}
|
||||
|
||||
:arg username: username
|
||||
:arg password: password
|
||||
:statuscode 401: invalid credentials
|
||||
:statuscode 200: no error
|
||||
"""
|
||||
self.reqparse.add_argument('username', type=str, required=True, location='json')
|
||||
self.reqparse.add_argument('password', type=str, required=True, location='json')
|
||||
|
||||
args = self.reqparse.parse_args()
|
||||
|
||||
if '@' in args['username']:
|
||||
user = user_service.get_by_email(args['username'])
|
||||
else:
|
||||
user = user_service.get_by_username(args['username'])
|
||||
|
||||
if user and user.check_password(args['password']):
|
||||
# Tell Flask-Principal the identity changed
|
||||
identity_changed.send(current_app._get_current_object(),
|
||||
identity=Identity(user.id))
|
||||
return dict(token=create_token(user))
|
||||
|
||||
return dict(message='The supplied credentials are invalid'), 401
|
||||
|
||||
def get(self):
|
||||
return {'username': g.current_user.username, 'roles': [r.name for r in g.current_user.roles]}
|
||||
|
||||
|
||||
class Ping(Resource):
|
||||
"""
|
||||
This class serves as an example of how one might implement an SSO provider for use with Lemur. In
|
||||
this example we use a OpenIDConnect authentication flow, that is essentially OAuth2 underneath. If you have an
|
||||
OAuth2 provider you want to use Lemur there would be two steps:
|
||||
|
||||
1. Define your own class that inherits from :class:`flask.ext.restful.Resource` and create the HTTP methods the \
|
||||
provider uses for it's callbacks.
|
||||
2. Add or change the Lemur AngularJS Configuration to point to your new provider
|
||||
"""
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(Ping, self).__init__()
|
||||
|
||||
def post(self):
|
||||
self.reqparse.add_argument('clientId', type=str, required=True, location='json')
|
||||
self.reqparse.add_argument('redirectUri', type=str, required=True, location='json')
|
||||
self.reqparse.add_argument('code', type=str, required=True, location='json')
|
||||
|
||||
args = self.reqparse.parse_args()
|
||||
|
||||
# take the information we have received from Meechum to create a new request
|
||||
params = {
|
||||
'client_id': args['clientId'],
|
||||
'grant_type': 'authorization_code',
|
||||
'scope': 'openid email profile address',
|
||||
'redirect_uri': args['redirectUri'],
|
||||
'code': args['code']
|
||||
}
|
||||
|
||||
# you can either discover these dynamically or simply configure them
|
||||
access_token_url = current_app.config.get('PING_ACCESS_TOKEN_URL')
|
||||
user_api_url = current_app.config.get('PING_USER_API_URL')
|
||||
|
||||
# the secret and cliendId will be given to you when you signup for meechum
|
||||
basic = base64.b64encode('{0}:{1}'.format(args['clientId'], current_app.config.get("PING_SECRET")))
|
||||
headers = {'Authorization': 'Basic {0}'.format(basic)}
|
||||
|
||||
# exchange authorization code for access token.
|
||||
|
||||
r = requests.post(access_token_url, headers=headers, params=params)
|
||||
id_token = r.json()['id_token']
|
||||
access_token = r.json()['access_token']
|
||||
|
||||
# fetch token public key
|
||||
header_data = fetch_token_header(id_token)
|
||||
jwks_url = current_app.config.get('PING_JWKS_URL')
|
||||
|
||||
# retrieve the key material as specified by the token header
|
||||
r = requests.get(jwks_url)
|
||||
for key in r.json()['keys']:
|
||||
if key['kid'] == header_data['kid']:
|
||||
secret = get_rsa_public_key(key['n'], key['e'])
|
||||
algo = header_data['alg']
|
||||
break
|
||||
else:
|
||||
return dict(message='Key not found'), 403
|
||||
|
||||
# validate your token based on the key it was signed with
|
||||
try:
|
||||
jwt.decode(id_token, secret, algorithms=[algo], audience=args['clientId'])
|
||||
except jwt.DecodeError:
|
||||
return dict(message='Token is invalid'), 403
|
||||
except jwt.ExpiredSignatureError:
|
||||
return dict(message='Token has expired'), 403
|
||||
except jwt.InvalidTokenError:
|
||||
return dict(message='Token is invalid'), 403
|
||||
|
||||
user_params = dict(access_token=access_token, schema='profile')
|
||||
|
||||
# retrieve information about the current user.
|
||||
r = requests.get(user_api_url, params=user_params)
|
||||
profile = r.json()
|
||||
|
||||
user = user_service.get_by_email(profile['email'])
|
||||
|
||||
# update their google 'roles'
|
||||
roles = []
|
||||
|
||||
# Legacy edge case - 'admin' has some special privileges associated with it
|
||||
if 'secops@netflix.com' in profile['googleGroups']:
|
||||
roles.append(role_service.get_by_name('admin'))
|
||||
|
||||
for group in profile['googleGroups']:
|
||||
role = role_service.get_by_name(group)
|
||||
if not role:
|
||||
role = role_service.create(group, description='This is a google group based role created by Lemur')
|
||||
roles.append(role)
|
||||
|
||||
# if we get an sso user create them an account
|
||||
# we still pick a random password in case sso is down
|
||||
if not user:
|
||||
# every user is an operator (tied to the verisignCA)
|
||||
v = role_service.get_by_name('verisign')
|
||||
if v:
|
||||
roles.append(v)
|
||||
|
||||
user = user_service.create(
|
||||
profile['email'],
|
||||
cert_service.create_challenge(),
|
||||
profile['email'],
|
||||
True,
|
||||
profile.get('thumbnailPhotoUrl'),
|
||||
roles
|
||||
)
|
||||
|
||||
else:
|
||||
# we add 'lemur' specific roles, so they do not get marked as removed
|
||||
for ur in user.roles:
|
||||
if ur.authority_id:
|
||||
roles.append(ur)
|
||||
|
||||
# update any changes to the user
|
||||
user_service.update(
|
||||
user.id,
|
||||
profile['email'],
|
||||
profile['email'],
|
||||
True,
|
||||
profile.get('thumbnailPhotoUrl'), # incase profile isn't google+ enabled
|
||||
roles
|
||||
)
|
||||
|
||||
# Tell Flask-Principal the identity changed
|
||||
identity_changed.send(current_app._get_current_object(), identity=Identity(user.id))
|
||||
|
||||
return dict(token=create_token(user))
|
||||
|
||||
|
||||
class Unlock(AuthenticatedResource):
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(Unlock, self).__init__()
|
||||
|
||||
@admin_permission.require(http_exception=403)
|
||||
def post(self):
|
||||
self.reqparse.add_argument('password', type=str, required=True, location='json')
|
||||
args = self.reqparse.parse_args()
|
||||
unlock(args['password'])
|
||||
return {
|
||||
"message": "You have successfully unlocked this Lemur instance",
|
||||
"type": "success"
|
||||
}
|
||||
|
||||
|
||||
api.add_resource(Login, '/auth/login', endpoint='login')
|
||||
api.add_resource(Ping, '/auth/ping', endpoint='ping')
|
||||
api.add_resource(Unlock, '/auth/unlock', endpoint='unlock')
|
||||
|
||||
|
Reference in New Issue
Block a user