From c402f1ff874350cd0352764e4e95e4d5d84ba26b Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 4 Dec 2017 09:50:31 -0700 Subject: [PATCH] add per user api keys to the backend (#995) Adds in per user api keys to the backend of lemur. the basics are: - API Keys are really just JWTs with custom second length TTLs. - API Keys are provided in the exact same ways JWTs are now. - API Keys can be revoked/unrevoked at any time by their creator as well as have their TTL Change at anytime. - Users can create/view/list their own API Keys at will, and an admin role has permission to modify all api keys in the instance. Adds in support for lemur api keys to the frontend of lemur. doing this required a few changes to the backend as well, but it is now all working (maybe not the best way though, review will determine that). - fixes inconsistency in moduleauthor name I inputted during the first commit. - Allows the revoke schema to optionally allow a full api_key object. - Adds `/users/:user_id/api_keys/:api_key` and `/users/:user_id/api_keys` endpoints. - normalizes use of `userId` vs `userId` - makes `put` call respond with a JWT so the frontend can show the token on updating. - adds in the API Key views for clicking "API Keys" on the main nav. - adds in the API Key views for clicking into a users edit page. - adds tests for the API Key backend views I added. --- .gitignore | 3 + CHANGELOG.rst | 2 + lemur/__init__.py | 4 +- lemur/api_keys/__init__.py | 0 lemur/api_keys/cli.py | 41 ++ lemur/api_keys/models.py | 25 + lemur/api_keys/schemas.py | 51 ++ lemur/api_keys/service.py | 97 +++ lemur/api_keys/views.py | 558 ++++++++++++++++++ lemur/auth/permissions.py | 5 + lemur/auth/service.py | 31 +- lemur/migrations/versions/c05a8998b371_.py | 31 + .../app/angular/api_keys/api_key/api_key.js | 69 +++ .../angular/api_keys/api_key/api_key.tpl.html | 54 ++ lemur/static/app/angular/api_keys/services.js | 20 + .../static/app/angular/api_keys/view/view.js | 104 ++++ .../app/angular/api_keys/view/view.tpl.html | 50 ++ lemur/static/app/angular/users/services.js | 21 +- lemur/static/app/angular/users/user/user.js | 32 +- .../app/angular/users/user/user.tpl.html | 21 + lemur/static/app/index.html | 1 + lemur/tests/conftest.py | 5 +- lemur/tests/factories.py | 21 + lemur/tests/test_api_keys.py | 222 +++++++ lemur/tests/test_authorities.py | 20 +- lemur/tests/test_certificates.py | 22 +- lemur/tests/test_destinations.py | 11 +- lemur/tests/test_domains.py | 11 +- lemur/tests/test_endpoints.py | 11 +- lemur/tests/test_logs.py | 3 +- lemur/tests/test_notifications.py | 11 +- lemur/tests/test_roles.py | 12 +- lemur/tests/test_sources.py | 12 +- lemur/tests/test_users.py | 11 +- lemur/tests/vectors.py | 6 + 35 files changed, 1578 insertions(+), 20 deletions(-) create mode 100644 lemur/api_keys/__init__.py create mode 100644 lemur/api_keys/cli.py create mode 100644 lemur/api_keys/models.py create mode 100644 lemur/api_keys/schemas.py create mode 100644 lemur/api_keys/service.py create mode 100644 lemur/api_keys/views.py create mode 100644 lemur/migrations/versions/c05a8998b371_.py create mode 100644 lemur/static/app/angular/api_keys/api_key/api_key.js create mode 100644 lemur/static/app/angular/api_keys/api_key/api_key.tpl.html create mode 100644 lemur/static/app/angular/api_keys/services.js create mode 100644 lemur/static/app/angular/api_keys/view/view.js create mode 100644 lemur/static/app/angular/api_keys/view/view.tpl.html create mode 100644 lemur/tests/test_api_keys.py diff --git a/.gitignore b/.gitignore index eaaaedff..f6268f3d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ docs/_build .editorconfig .idea lemur/tests/tmp + +/lemur/plugins/lemur_email/tests/expiration-rendered.html +/lemur/plugins/lemur_email/tests/rotation-rendered.html diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 25ec8ce1..a3358b8b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,8 @@ Changelog Adds per-certificate rotation policies, requires a database migration. The default rotation policy for all certificates is 30 days. Every certificate will gain a policy regardless is auto-rotation is used. +Adds per-user API Keys, requires a database migration. + .. note:: This version is not yet released and is under active development diff --git a/lemur/__init__.py b/lemur/__init__.py index 51b9a77e..444ed56f 100644 --- a/lemur/__init__.py +++ b/lemur/__init__.py @@ -26,6 +26,7 @@ from lemur.notifications.views import mod as notifications_bp from lemur.sources.views import mod as sources_bp from lemur.endpoints.views import mod as endpoints_bp from lemur.logs.views import mod as logs_bp +from lemur.api_keys.views import mod as api_key_bp from lemur.__about__ import ( __author__, __copyright__, __email__, __license__, __summary__, __title__, @@ -51,7 +52,8 @@ LEMUR_BLUEPRINTS = ( notifications_bp, sources_bp, endpoints_bp, - logs_bp + logs_bp, + api_key_bp ) diff --git a/lemur/api_keys/__init__.py b/lemur/api_keys/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lemur/api_keys/cli.py b/lemur/api_keys/cli.py new file mode 100644 index 00000000..ad7d7a15 --- /dev/null +++ b/lemur/api_keys/cli.py @@ -0,0 +1,41 @@ +""" +.. module: lemur.api_keys.cli + :platform: Unix + :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. +.. moduleauthor:: Eric Coan +""" +from flask_script import Manager +from lemur.api_keys import service as api_key_service +from lemur.auth.service import create_token + +from datetime import datetime + +manager = Manager(usage="Handles all api key related tasks.") + + +@manager.option('-u', '--user-id', dest='uid', help='The User ID this access key belongs too.') +@manager.option('-n', '--name', dest='name', help='The name of this API Key.') +@manager.option('-t', '--ttl', dest='ttl', help='The TTL of this API Key. -1 for forever.') +def create(uid, name, ttl): + """ + Create a new api key for a user. + :return: + """ + print("[+] Creating a new api key.") + key = api_key_service.create(user_id=uid, name=name, + ttl=ttl, issued_at=int(datetime.utcnow().timestamp()), revoked=False) + print("[+] Successfully created a new api key. Generating a JWT...") + jwt = create_token(uid, key.id, key.ttl) + print("[+] Your JWT is: {jwt}".format(jwt=jwt)) + + +@manager.option('-a', '--api-key-id', dest='aid', help='The API Key ID to revoke.') +def revoke(aid): + """ + Revokes an api key for a user. + :return: + """ + print("[-] Revoking the API Key api key.") + api_key_service.revoke(aid=aid) + print("[+] Successfully revoked the api key") diff --git a/lemur/api_keys/models.py b/lemur/api_keys/models.py new file mode 100644 index 00000000..c9e4b523 --- /dev/null +++ b/lemur/api_keys/models.py @@ -0,0 +1,25 @@ +""" +.. module: lemur.api_keys.models + :platform: Unix + :synopsis: This module contains all of the models need to create an api key within Lemur. + :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. +.. moduleauthor:: Eric Coan +""" +from sqlalchemy import BigInteger, Boolean, Column, ForeignKey, Integer, String + +from lemur.database import db + + +class ApiKey(db.Model): + __tablename__ = 'api_keys' + id = Column(Integer, primary_key=True) + name = Column(String) + user_id = Column(Integer, ForeignKey('users.id')) + ttl = Column(BigInteger) + issued_at = Column(BigInteger) + revoked = Column(Boolean) + + def __repr__(self): + return "ApiKey(name={name}, user_id={user_id}, ttl={ttl}, issued_at={iat}, revoked={revoked})".format( + user_id=self.user_id, name=self.name, ttl=self.ttl, iat=self.issued_at, revoked=self.revoked) diff --git a/lemur/api_keys/schemas.py b/lemur/api_keys/schemas.py new file mode 100644 index 00000000..32fabf47 --- /dev/null +++ b/lemur/api_keys/schemas.py @@ -0,0 +1,51 @@ +""" +.. module: lemur.api_keys.schemas + :platform: Unix + :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. +.. moduleauthor:: Eric Coan +""" +from marshmallow import fields + +from lemur.common.schema import LemurInputSchema, LemurOutputSchema + + +class ApiKeyInputSchema(LemurInputSchema): + name = fields.String(required=False) + user_id = fields.Integer() + ttl = fields.Integer() + + +class ApiKeyRevokeSchema(LemurInputSchema): + id = fields.Integer(required=False) + name = fields.String() + user_id = fields.Integer(required=False) + revoked = fields.Boolean() + ttl = fields.Integer() + issued_at = fields.Integer(required=False) + + +class UserApiKeyInputSchema(LemurInputSchema): + name = fields.String(required=False) + ttl = fields.Integer() + + +class ApiKeyOutputSchema(LemurOutputSchema): + jwt = fields.String() + + +class ApiKeyDescribedOutputSchema(LemurOutputSchema): + id = fields.Integer() + name = fields.String() + user_id = fields.Integer() + ttl = fields.Integer() + issued_at = fields.Integer() + revoked = fields.Boolean() + + +api_key_input_schema = ApiKeyInputSchema() +api_key_revoke_schema = ApiKeyRevokeSchema() +api_key_output_schema = ApiKeyOutputSchema() +api_keys_output_schema = ApiKeyDescribedOutputSchema(many=True) +api_key_described_output_schema = ApiKeyDescribedOutputSchema() +user_api_key_input_schema = UserApiKeyInputSchema() diff --git a/lemur/api_keys/service.py b/lemur/api_keys/service.py new file mode 100644 index 00000000..9ebc685b --- /dev/null +++ b/lemur/api_keys/service.py @@ -0,0 +1,97 @@ +""" +.. module: lemur.api_keys.service + :platform: Unix + :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. +.. moduleauthor:: Eric Coan +""" +from lemur import database +from lemur.api_keys.models import ApiKey + + +def get(aid): + """ + Retrieves an api key by its ID. + :param aid: The access key id to get. + :return: + """ + return database.get(ApiKey, aid) + + +def delete(access_key): + """ + Delete an access key. This is one way to remove a key, though you probably should just set revoked. + :param access_key: + :return: + """ + database.delete(access_key) + + +def revoke(aid): + """ + Revokes an api key. + :param aid: + :return: + """ + api_key = get(aid) + setattr(api_key, 'revoked', False) + + return database.update(api_key) + + +def get_all_api_keys(): + """ + Retrieves all Api Keys. + :return: + """ + return ApiKey.query.all() + + +def create(**kwargs): + """ + Creates a new API Key. + + :param kwargs: + :return: + """ + api_key = ApiKey(**kwargs) + database.create(api_key) + return api_key + + +def update(api_key, **kwargs): + """ + Updates an api key. + :param api_key: + :param kwargs: + :return: + """ + for key, value in kwargs.items(): + setattr(api_key, key, value) + + return database.update(api_key) + + +def render(args): + """ + Helper to parse REST Api requests + + :param args: + :return: + """ + query = database.session_query(ApiKey) + user_id = args.pop('user_id', None) + aid = args.pop('id', None) + has_permission = args.pop('has_permission', False) + requesting_user_id = args.pop('requesting_user_id') + + if user_id: + query = query.filter(ApiKey.user_id == user_id) + + if aid: + query = query.filter(ApiKey.id == aid) + + if not has_permission: + query = query.filter(ApiKey.user_id == requesting_user_id) + + return database.sort_and_page(query, ApiKey, args) diff --git a/lemur/api_keys/views.py b/lemur/api_keys/views.py new file mode 100644 index 00000000..0a1a22e5 --- /dev/null +++ b/lemur/api_keys/views.py @@ -0,0 +1,558 @@ +""" +.. module: lemur.api_keys.views + :platform: Unix + :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. + +.. moduleauthor:: Eric Coan + +""" +from datetime import datetime + +from flask import Blueprint, g +from flask_restful import reqparse, Api + +from lemur.api_keys import service +from lemur.auth.service import AuthenticatedResource, create_token +from lemur.auth.permissions import ApiKeyCreatorPermission + +from lemur.common.schema import validate_schema +from lemur.common.utils import paginated_parser + +from lemur.api_keys.schemas import api_key_input_schema, api_key_revoke_schema, api_key_output_schema, \ + api_keys_output_schema, api_key_described_output_schema, user_api_key_input_schema + +mod = Blueprint('api_keys', __name__) +api = Api(mod) + + +class ApiKeyList(AuthenticatedResource): + """ Defines the 'api_keys' endpoint """ + def __init__(self): + super(ApiKeyList, self).__init__() + + @validate_schema(None, api_keys_output_schema) + def get(self): + """ + .. http:get:: /keys + + The current list of api keys, that you can see. + + **Example request**: + + .. sourcecode:: http + + GET /keys HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "items": [ + { + "id": 1, + "name": "custom name", + "user_id": 1, + "ttl": -1, + "issued_at": 12, + "revoked": false + } + ], + "total": 1 + } + + :query sortBy: field to sort on + :query sortDir: asc or desc + :query page: int default is 1 + :query count: count number. default is 10 + :query user_id: a user to filter by. + :query id: an access key to filter by. + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + parser = paginated_parser.copy() + args = parser.parse_args() + args['has_permission'] = ApiKeyCreatorPermission().can() + args['requesting_user_id'] = g.current_user.id + return service.render(args) + + @validate_schema(api_key_input_schema, api_key_output_schema) + def post(self, data=None): + """ + .. http:post:: /keys + + Creates an API Key. + + **Example request**: + + .. sourcecode:: http + + POST /keys HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + { + "name": "my custom name", + "user_id": 1, + "ttl": -1 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + + "jwt": "" + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + if not ApiKeyCreatorPermission().can(): + if data['user_id'] != g.current_user.id: + return dict(message="You are not authorized to create tokens for: {0}".format(data['user_id'])), 403 + + access_token = service.create(name=data['name'], user_id=data['user_id'], ttl=data['ttl'], + revoked=False, issued_at=int(datetime.utcnow().timestamp())) + return dict(jwt=create_token(access_token.user_id, access_token.id, access_token.ttl)) + + +class ApiKeyUserList(AuthenticatedResource): + """ Defines the 'keys' endpoint on the 'users' endpoint. """ + def __init__(self): + super(ApiKeyUserList, self).__init__() + + @validate_schema(None, api_keys_output_schema) + def get(self, user_id): + """ + .. http:get:: /users/:user_id/keys + + The current list of api keys for a user, that you can see. + + **Example request**: + + .. sourcecode:: http + + GET /users/1/keys HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "items": [ + { + "id": 1, + "name": "custom name", + "user_id": 1, + "ttl": -1, + "issued_at": 12, + "revoked": false + } + ], + "total": 1 + } + + :query sortBy: field to sort on + :query sortDir: asc or desc + :query page: int default is 1 + :query count: count number. default is 10 + :query id: an access key to filter by. + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + parser = paginated_parser.copy() + args = parser.parse_args() + args['has_permission'] = ApiKeyCreatorPermission().can() + args['requesting_user_id'] = g.current_user.id + args['user_id'] = user_id + return service.render(args) + + @validate_schema(user_api_key_input_schema, api_key_output_schema) + def post(self, user_id, data=None): + """ + .. http:post:: /users/:user_id/keys + + Creates an API Key for a user. + + **Example request**: + + .. sourcecode:: http + + POST /users/1/keys HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + { + "name": "my custom name" + "ttl": -1 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + + "jwt": "" + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + if not ApiKeyCreatorPermission().can(): + if user_id != g.current_user.id: + return dict(message="You are not authorized to create tokens for: {0}".format(user_id)), 403 + + access_token = service.create(name=data['name'], user_id=user_id, ttl=data['ttl'], + revoked=False, issued_at=int(datetime.utcnow().timestamp())) + return dict(jwt=create_token(access_token.user_id, access_token.id, access_token.ttl)) + + +class ApiKeys(AuthenticatedResource): + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(ApiKeys, self).__init__() + + @validate_schema(None, api_key_output_schema) + def get(self, aid): + """ + .. http:get:: /keys/1 + + Fetch one api key + + **Example request**: + + .. sourcecode:: http + + GET /keys/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "jwt": "" + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + access_key = service.get(aid) + if access_key is None: + return dict(message="This token does not exist!"), 404 + if access_key.user_id != g.current_user.id: + if not ApiKeyCreatorPermission().can(): + return dict(message="You are not authorized to view this token!"), 403 + return dict(jwt=create_token(access_key.user_id, access_key.id, access_key.ttl)) + + @validate_schema(api_key_revoke_schema, api_key_output_schema) + def put(self, aid, data=None): + """ + .. http:put:: /keys/1 + + update one api key + + **Example request**: + + .. sourcecode:: http + + PUT /keys/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + { + "name": "new_name", + "revoked": false, + "ttl": -1 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "jwt": "" + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + access_key = service.get(aid) + if access_key is None: + return dict(message="This token does not exist!"), 404 + if access_key.user_id != g.current_user.id: + if not ApiKeyCreatorPermission().can(): + return dict(message="You are not authorized to update this token!"), 403 + service.update(access_key, name=data['name'], revoked=data['revoked'], ttl=data['ttl']) + return dict(jwt=create_token(access_key.user_id, access_key.id, access_key.ttl)) + + def delete(self, aid): + """ + .. http:delete:: /keys/1 + + deletes one api key + + **Example request**: + + .. sourcecode:: http + + DELETE /keys/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "result": true + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + access_key = service.get(aid) + if access_key is None: + return dict(message="This token does not exist!"), 404 + if access_key.user_id != g.current_user.id: + if not ApiKeyCreatorPermission().can(): + return dict(message="You are not authorized to delete this token!"), 403 + service.delete(access_key) + return {'result': True} + + +class UserApiKeys(AuthenticatedResource): + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(UserApiKeys, self).__init__() + + @validate_schema(None, api_key_output_schema) + def get(self, uid, aid): + """ + .. http:get:: /users/1/keys/1 + + Fetch one api key + + **Example request**: + + .. sourcecode:: http + + GET /users/1/api_keys/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "jwt": "" + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + if uid != g.current_user.id: + if not ApiKeyCreatorPermission().can(): + return dict(message="You are not authorized to view this token!"), 403 + access_key = service.get(aid) + if access_key is None: + return dict(message="This token does not exist!"), 404 + if access_key.user_id != uid: + return dict(message="You are not authorized to view this token!"), 403 + return dict(jwt=create_token(access_key.user_id, access_key.id, access_key.ttl)) + + @validate_schema(api_key_revoke_schema, api_key_output_schema) + def put(self, uid, aid, data=None): + """ + .. http:put:: /users/1/keys/1 + + update one api key + + **Example request**: + + .. sourcecode:: http + + PUT /users/1/keys/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + { + "name": "new_name", + "revoked": false, + "ttl": -1 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "jwt": "" + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + if uid != g.current_user.id: + if not ApiKeyCreatorPermission().can(): + return dict(message="You are not authorized to view this token!"), 403 + access_key = service.get(aid) + if access_key is None: + return dict(message="This token does not exist!"), 404 + if access_key.user_id != uid: + return dict(message="You are not authorized to update this token!"), 403 + service.update(access_key, name=data['name'], revoked=data['revoked'], ttl=data['ttl']) + return dict(jwt=create_token(access_key.user_id, access_key.id, access_key.ttl)) + + def delete(self, uid, aid): + """ + .. http:delete:: /users/1/keys/1 + + deletes one api key + + **Example request**: + + .. sourcecode:: http + + DELETE /users/1/keys/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "result": true + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + if uid != g.current_user.id: + if not ApiKeyCreatorPermission().can(): + return dict(message="You are not authorized to view this token!"), 403 + access_key = service.get(aid) + if access_key is None: + return dict(message="This token does not exist!"), 404 + if access_key.user_id != uid: + return dict(message="You are not authorized to delete this token!"), 403 + service.delete(access_key) + return {'result': True} + + +class ApiKeysDescribed(AuthenticatedResource): + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(ApiKeysDescribed, self).__init__() + + @validate_schema(None, api_key_described_output_schema) + def get(self, aid): + """ + .. http:get:: /keys/1/described + + Fetch one api key + + **Example request**: + + .. sourcecode:: http + + GET /keys/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 2, + "name": "hoi", + "user_id": 2, + "ttl": -1, + "issued_at": 1222222, + "revoked": false + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + access_key = service.get(aid) + if access_key is None: + return dict(message="This token does not exist!"), 404 + if access_key.user_id != g.current_user.id: + if not ApiKeyCreatorPermission().can(): + return dict(message="You are not authorized to view this token!"), 403 + return access_key + + +api.add_resource(ApiKeyList, '/keys', endpoint='api_keys') +api.add_resource(ApiKeys, '/keys/', endpoint='api_key') +api.add_resource(ApiKeysDescribed, '/keys//described', endpoint='api_key_described') +api.add_resource(ApiKeyUserList, '/users//keys', endpoint='user_api_keys') +api.add_resource(UserApiKeys, '/users//keys/', endpoint='user_api_key') diff --git a/lemur/auth/permissions.py b/lemur/auth/permissions.py index 89da4674..4fa025f7 100644 --- a/lemur/auth/permissions.py +++ b/lemur/auth/permissions.py @@ -33,6 +33,11 @@ class CertificatePermission(Permission): super(CertificatePermission, self).__init__(*needs) +class ApiKeyCreatorPermission(Permission): + def __init__(self): + super(ApiKeyCreatorPermission, self).__init__(RoleNeed('admin')) + + RoleMember = namedtuple('role', ['method', 'value']) RoleMemberNeed = partial(RoleMember, 'member') diff --git a/lemur/auth/service.py b/lemur/auth/service.py index e42bf543..00419c9f 100644 --- a/lemur/auth/service.py +++ b/lemur/auth/service.py @@ -27,6 +27,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers from lemur.users import service as user_service +from lemur.api_keys import service as api_key_service from lemur.auth.permissions import AuthorityCreatorNeed, RoleMemberNeed @@ -48,9 +49,9 @@ def get_rsa_public_key(n, e): ) -def create_token(user): +def create_token(user, aid=None, ttl=None): """ - Create a valid JWT for a given user, this token is then used to authenticate + Create a valid JWT for a given user/api key, this token is then used to authenticate sessions until the token expires. :param user: @@ -58,10 +59,24 @@ def create_token(user): """ expiration_delta = timedelta(days=int(current_app.config.get('LEMUR_TOKEN_EXPIRATION', 1))) payload = { - 'sub': user.id, 'iat': datetime.utcnow(), 'exp': datetime.utcnow() + expiration_delta } + + # Handle Just a User ID & User Object. + if isinstance(user, int): + payload['sub'] = user + else: + payload['sub'] = user.id + if aid is not None: + payload['aid'] = aid + # Custom TTLs are only supported on Access Keys. + if ttl is not None and aid is not None: + # Tokens that are forever until revoked. + if ttl == -1: + del payload['exp'] + else: + payload['exp'] = ttl token = jwt.encode(payload, current_app.config['LEMUR_TOKEN_SECRET']) return token.decode('unicode_escape') @@ -94,6 +109,16 @@ def login_required(f): except jwt.InvalidTokenError: return dict(message='Token is invalid'), 403 + if 'aid' in payload: + access_key = api_key_service.get(payload['aid']) + if access_key.revoked: + return dict(message='Token has been revoked'), 403 + if access_key.ttl != -1: + current_time = datetime.utcnow() + expired_time = datetime.fromtimestamp(access_key.issued_at + access_key.ttl) + if current_time >= expired_time: + return dict(message='Token has expired'), 403 + user = user_service.get(payload['sub']) if not user.active: diff --git a/lemur/migrations/versions/c05a8998b371_.py b/lemur/migrations/versions/c05a8998b371_.py new file mode 100644 index 00000000..cf600043 --- /dev/null +++ b/lemur/migrations/versions/c05a8998b371_.py @@ -0,0 +1,31 @@ +"""Adds JWT Tokens to Users + +Revision ID: c05a8998b371 +Revises: ac483cfeb230 +Create Date: 2017-11-10 14:51:28.975927 + +""" + +# revision identifiers, used by Alembic. +revision = 'c05a8998b371' +down_revision = 'ac483cfeb230' + +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + +def upgrade(): + op.create_table('api_keys', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('ttl', sa.BigInteger(), nullable=False), + sa.Column('issued_at', sa.BigInteger(), nullable=False), + sa.Column('revoked', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade(): + op.drop_table('api_keys') diff --git a/lemur/static/app/angular/api_keys/api_key/api_key.js b/lemur/static/app/angular/api_keys/api_key/api_key.js new file mode 100644 index 00000000..28542818 --- /dev/null +++ b/lemur/static/app/angular/api_keys/api_key/api_key.js @@ -0,0 +1,69 @@ +'use strict'; + +angular.module('lemur') + .controller('ApiKeysCreateController', function ($scope, $uibModalInstance, PluginService, ApiKeyService, LemurRestangular, toaster) { + $scope.apiKey = LemurRestangular.restangularizeElement(null, {}, 'keys'); + + $scope.save = function (apiKey) { + ApiKeyService.create(apiKey).then( + function (responseBody) { + toaster.pop({ + type: 'success', + title: 'Success!', + body: 'Successfully Created JWT!' + }); + $scope.jwt = responseBody.jwt; + }, function (response) { + toaster.pop({ + type: 'error', + title: apiKey.name || 'Unnamed Api Key', + body: 'lemur-bad-request', + bodyOutputType: 'directive', + directiveData: response.data, + timeout: 100000 + }); + }); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; + + $scope.close = function() { + $uibModalInstance.close(); + }; + }) + .controller('ApiKeysEditController', function ($scope, $uibModalInstance, ApiKeyService, LemurRestangular, toaster, editId) { + LemurRestangular.one('keys', editId).customGET('described').then(function(apiKey) { + $scope.apiKey = apiKey; + }); + + $scope.save = function (apiKey) { + ApiKeyService.update(apiKey).then( + function (responseBody) { + toaster.pop({ + type: 'success', + title: 'Success', + body: 'Successfully updated JWT!' + }); + $scope.jwt = responseBody.jwt; + }, function (response) { + toaster.pop({ + type: 'error', + title: apiKey.name || 'Unnamed API Key', + body: 'lemur-bad-request', + bodyOutputType: 'directive', + directiveData: response.data, + timeout: 100000 + }); + }); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss('cancel'); + }; + + $scope.close = function() { + $uibModalInstance.close(); + }; + }); diff --git a/lemur/static/app/angular/api_keys/api_key/api_key.tpl.html b/lemur/static/app/angular/api_keys/api_key/api_key.tpl.html new file mode 100644 index 00000000..8c94f7be --- /dev/null +++ b/lemur/static/app/angular/api_keys/api_key/api_key.tpl.html @@ -0,0 +1,54 @@ + + + +
+

Successfully exported!

+

Your Token is:
{{ jwt }}

+
+ + + diff --git a/lemur/static/app/angular/api_keys/services.js b/lemur/static/app/angular/api_keys/services.js new file mode 100644 index 00000000..1c31dba6 --- /dev/null +++ b/lemur/static/app/angular/api_keys/services.js @@ -0,0 +1,20 @@ +'use strict'; + +angular.module('lemur') + .service('ApiKeyApi', function (LemurRestangular) { + return LemurRestangular.all('keys'); + }) + .service('ApiKeyService', function ($location, ApiKeyApi) { + var ApiKeyService = this; + ApiKeyService.update = function(apiKey) { + return apiKey.put(); + }; + + ApiKeyService.create = function (apiKey) { + return ApiKeyApi.post(apiKey); + }; + + ApiKeyService.delete = function (apiKey) { + return apiKey.remove(); + }; + }); diff --git a/lemur/static/app/angular/api_keys/view/view.js b/lemur/static/app/angular/api_keys/view/view.js new file mode 100644 index 00000000..95bef85c --- /dev/null +++ b/lemur/static/app/angular/api_keys/view/view.js @@ -0,0 +1,104 @@ +'use strict'; + +angular.module('lemur') + .config(function config($stateProvider) { + $stateProvider.state('keys', { + url: '/keys', + templateUrl: '/angular/api_keys/view/view.tpl.html', + controller: 'ApiKeysViewController' + }); + }) + .controller('ApiKeysViewController', function ($scope, $uibModal, ApiKeyApi, ApiKeyService, ngTableParams, toaster) { + $scope.filter = {}; + $scope.apiKeysTable = new ngTableParams({ + page: 1, // show first page + count: 10, // count per page + sorting: { + id: 'desc' // initial sorting + }, + filter: $scope.filter + }, { + total: 0, // length of data + getData: function ($defer, params) { + ApiKeyApi.getList(params.url()).then(function (data) { + params.total(data.total); + $defer.resolve(data); + }); + } + }); + + $scope.updateRevoked = function (apiKey) { + ApiKeyService.update(apiKey).then( + function () { + toaster.pop({ + type: 'success', + title: 'Updated JWT!', + body: apiKey.jwt + }); + }, + function (response) { + toaster.pop({ + type: 'error', + title: apiKey.name || 'Unnamed API Key', + body: 'Unable to update! ' + response.data.message, + timeout: 100000 + }); + }); + }; + + $scope.create = function () { + var uibModalInstance = $uibModal.open({ + animation: true, + controller: 'ApiKeysCreateController', + templateUrl: '/angular/api_keys/api_key/api_key.tpl.html', + size: 'lg', + backdrop: 'static' + }); + + uibModalInstance.result.then(function () { + $scope.apiKeysTable.reload(); + }); + + }; + + $scope.remove = function (apiKey) { + apiKey.remove().then( + function () { + toaster.pop({ + type: 'success', + title: 'Removed!', + body: 'Deleted that API Key!' + }); + $scope.apiKeysTable.reload(); + }, + function (response) { + toaster.pop({ + type: 'error', + title: 'Opps', + body: 'I see what you did there: ' + response.data.message + }); + } + ); + }; + + $scope.edit = function (apiKeyId) { + var uibModalInstance = $uibModal.open({ + animation: true, + templateUrl: '/angular/api_keys/api_key/api_key.tpl.html', + controller: 'ApiKeysEditController', + size: 'lg', + backdrop: 'static', + resolve: { + editId: function () { + return apiKeyId; + } + } + }); + + uibModalInstance.result.then(function () { + $scope.apiKeysTable.reload(); + }); + + }; + + }); diff --git a/lemur/static/app/angular/api_keys/view/view.tpl.html b/lemur/static/app/angular/api_keys/view/view.tpl.html new file mode 100644 index 00000000..f622b3ba --- /dev/null +++ b/lemur/static/app/angular/api_keys/view/view.tpl.html @@ -0,0 +1,50 @@ +
+
+

API Keys + For accidentally uploading to github

+
+
+
+ +
+
+
+
+ + + + + + + + + + + +
+ {{ apiKey.id }} + + {{ apiKey.name || 'Unnamed Api Key' }} + + {{ apiKey.userId }} + + {{ apiKey.ttl == -1 ? 'Forever' : apiKey.ttl }} + +
+ +
+
+
+ + +
+
+
+
+
+
diff --git a/lemur/static/app/angular/users/services.js b/lemur/static/app/angular/users/services.js index 6c853aa3..46b404c5 100644 --- a/lemur/static/app/angular/users/services.js +++ b/lemur/static/app/angular/users/services.js @@ -3,7 +3,7 @@ */ 'use strict'; angular.module('lemur') - .service('UserApi', function (LemurRestangular) { + .service('UserApi', function (LemurRestangular, ApiKeyService) { LemurRestangular.extendModel('users', function (obj) { return angular.extend(obj, { attachRole: function (role) { @@ -15,6 +15,11 @@ angular.module('lemur') }, removeRole: function (index) { this.roles.splice(index, 1); + }, + removeApiKey: function (index) { + var removedApiKeys = this.apiKeys.splice(index, 1); + var removedApiKey = removedApiKeys[0]; + return ApiKeyService.delete(removedApiKey); } }); }); @@ -41,6 +46,12 @@ angular.module('lemur') }); }; + UserService.getApiKeys = function (user) { + user.getList('keys').then(function (apiKeys) { + user.apiKeys = apiKeys; + }); + }; + UserService.loadMoreRoles = function (user, page) { user.getList('roles', {page: page}).then(function (roles) { _.each(roles, function (role) { @@ -49,6 +60,14 @@ angular.module('lemur') }); }; + UserService.loadMoreApiKeys = function (user, page) { + user.getList('keys', {page: page}).then(function (apiKeys) { + _.each(apiKeys, function (apiKey) { + user.apiKeys.push(apiKey); + }); + }); + }; + UserService.create = function (user) { return UserApi.post(user); }; diff --git a/lemur/static/app/angular/users/user/user.js b/lemur/static/app/angular/users/user/user.js index bd1af730..b37fb9b7 100644 --- a/lemur/static/app/angular/users/user/user.js +++ b/lemur/static/app/angular/users/user/user.js @@ -2,14 +2,17 @@ angular.module('lemur') - .controller('UsersEditController', function ($scope, $uibModalInstance, UserApi, UserService, RoleService, toaster, editId) { + .controller('UsersEditController', function ($scope, $uibModalInstance, UserApi, UserService, RoleService, ApiKeyService, toaster, editId) { UserApi.get(editId).then(function (user) { $scope.user = user; + UserService.getApiKeys(user); }); $scope.roleService = RoleService; + $scope.apiKeyService = ApiKeyService; $scope.rolePage = 1; + $scope.apiKeyPage = 1; $scope.save = function (user) { UserService.update(user).then( @@ -36,15 +39,40 @@ angular.module('lemur') $uibModalInstance.dismiss('cancel'); }; + $scope.removeApiKey = function (idx) { + UserApi.removeApiKey(idx).then(function () { + toaster.pop({ + type: 'success', + title: 'Removed API Key!', + body: 'Successfully removed the api key!' + }); + }, function(err) { + toaster.pop({ + type: 'error', + title: 'Failed to remove API Key!', + body: 'lemur-bad-request', + bodyOutputType: 'directive', + directiveData: err, + timeout: 100000 + }); + }); + }; + $scope.loadMoreRoles = function () { $scope.rolePage += 1; UserService.loadMoreRoles($scope.user, $scope.rolePage); }; + + $scope.loadMoreApiKeys = function () { + $scope.apiKeyPage += 1; + UserService.loadMoreApiKeys($scope.user, $scope.apiKeyPage); + }; }) - .controller('UsersCreateController', function ($scope, $uibModalInstance, UserService, LemurRestangular, RoleService, toaster) { + .controller('UsersCreateController', function ($scope, $uibModalInstance, UserService, LemurRestangular, RoleService, ApiKeyService, toaster) { $scope.user = LemurRestangular.restangularizeElement(null, {}, 'users'); $scope.roleService = RoleService; + $scope.apiKeyService = ApiKeyService; $scope.save = function (user) { UserService.create(user).then( diff --git a/lemur/static/app/angular/users/user/user.tpl.html b/lemur/static/app/angular/users/user/user.tpl.html index be98795d..9f762ea5 100644 --- a/lemur/static/app/angular/users/user/user.tpl.html +++ b/lemur/static/app/angular/users/user/user.tpl.html @@ -85,6 +85,27 @@ +
+ +
+ + + + + + + + + + +
{{ apiKey.name || "Unnamed Api Key" }} + +
More
+
+