auth.py 11.3 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Copyright 2018 Markus Scheidgen
#
# Licensed 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.

"""
16
The API is protected with *keycloak* and *OpenIDConnect*. All API endpoints that require
17
18
or support authentication accept OIDC bearer tokens via HTTP header (``Authentication``).
These token can be acquired from the NOMAD keycloak server or through the ``/auth`` endpoint
19
that also supports HTTP Basic authentication and passes the given credentials to
20
21
keycloak. For GUI's it is recommended to accquire an access token through the regular OIDC
login flow.
22
23

Authenticated user information is available via FLASK's build in flask.g.user object.
24
25
It is set to None, if no user information is available. To protect endpoints use the following
decorator.
26

27
.. autofunction:: authenticate
28
29
30
31

To allow authentification with signed urls, use this decorator:

.. autofunction:: with_signature_token
32
"""
33
34
from flask import g, request
from flask_restplus import abort, Resource, fields
35
import functools
36
37
import jwt
import datetime
38
39
40
import hmac
import hashlib
import uuid
41

42
from nomad import config, processing, utils, infrastructure, datamodel
43
from nomad.metainfo.flask_restplus import generate_flask_restplus_model
44

Markus Scheidgen's avatar
Markus Scheidgen committed
45
from .api import api
46
47


48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# Authentication scheme definitions, for swagger
api.authorizations = {
    'HTTP Basic Authentication': {
        'type': 'basic'
    },
    'OpenIDConnect Bearer Token': {
        'type': 'apiKey',
        'in': 'header',
        'name': 'Authorization'
    },
    'NOMAD upload token': {
        'type': 'apiKey',
        'in': 'query',
        'name': 'token'
    },
    'NOMAD signature': {
        'type': 'apiKey',
        'in': 'query',
        'name': 'signature_token'
    }
}


71
def _verify_upload_token(token) -> str:
72
73
74
75
76
77
78
79
    """
    Verifies the upload token generated with :func:`generate_upload_token`.

    Returns: The user UUID or None if the toke could not be verified.
    """
    payload, signature = token.split('.')
    payload = utils.base64_decode(payload)
    signature = utils.base64_decode(signature)
80

81
82
83
84
    compare = hmac.new(
        bytes(config.services.api_secret, 'utf-8'),
        msg=payload,
        digestmod=hashlib.sha1)
85

86
87
88
89
90
91
92
93
94
    if signature != compare.digest():
        return None

    return str(uuid.UUID(bytes=payload))


def authenticate(
        basic: bool = False, upload_token: bool = False, signature_token: bool = False,
        required: bool = False, admin_only: bool = False):
95
    """
96
97
98
99
100
101
102
103
104
105
    A decorator to protect API endpoints with authentication. Uses keycloak access
    token to authenticate users. Other methods might apply. Will abort with 401
    if necessary.

    Arguments:
        basic: Also allow Basic HTTP authentication
        upload_token: Also allow upload_token
        signature_token: Also allow signed urls
        required: Authentication is required
        admin_only: Only the admin user is allowed to use the endpoint.
106
    """
107
108
109
110
111
112
113
114
    methods = ['OpenIDConnect Bearer Token']
    if basic:
        methods.append('HTTP Basic Authentication')
    if upload_token:
        methods.append('NOMAD upload token')
    if signature_token:
        methods.append('NOMAD signature')

115
116
    def decorator(func):
        @functools.wraps(func)
117
118
        @api.response(401, 'Not authorized, some data require authentication and authorization')
        @api.doc(security=methods)
119
        def wrapper(*args, **kwargs):
120
121
122
123
            g.user = None

            if upload_token and 'token' in request.args:
                token = request.args['token']
124
                user_id = _verify_upload_token(token)
125
126
127
128
129
130
131
132
                if user_id is not None:
                    g.user = infrastructure.keycloak.get_user(user_id)

            elif signature_token and 'signature_token' in request.args:
                token = request.args.get('signature_token', None)
                if token is not None:
                    try:
                        decoded = jwt.decode(token, config.services.api_secret, algorithms=['HS256'])
133
                        user = datamodel.User(user_id=decoded['user'])
134
135
136
137
138
139
140
141
142
143
144
145
                        if user is None:
                            abort(401, 'User for the given signature does not exist')
                        else:
                            g.user = user
                    except KeyError:
                        abort(401, 'Token with invalid/unexpected payload')
                    except jwt.ExpiredSignatureError:
                        abort(401, 'Expired token')
                    except jwt.InvalidTokenError:
                        abort(401, 'Invalid token')

            elif 'token' in request.args:
146
                abort(401, 'Query param token not supported for this endpoint')
147

148
149
150
151
            else:
                error = infrastructure.keycloak.authorize_flask(basic=basic)
                if error is not None:
                    abort(401, message=error)
152
153
154
155
156

            if required and g.user is None:
                abort(401, message='Authentication is required for this endpoint')
            if admin_only and (g.user is None or not g.user.is_admin):
                abort(401, message='Only the admin user is allowed to use this endpoint')
157

158
            return func(*args, **kwargs)
159

160
161
162
        return wrapper

    return decorator
163
164


165
166
167
168
169
170
171
172
173
174
175
176
def generate_upload_token(user):
    payload = uuid.UUID(user.user_id).bytes
    signature = hmac.new(
        bytes(config.services.api_secret, 'utf-8'),
        msg=payload,
        digestmod=hashlib.sha1)

    return '%s.%s' % (
        utils.base64_encode(payload),
        utils.base64_encode(signature.digest()))


177
ns = api.namespace(
178
179
    'auth',
    description='Authentication related endpoints.')
180
181


182
183
184
185
186
187
auth_model = api.model('Auth', {
    'access_token': fields.String(description='The OIDC access token'),
    'upload_token': fields.String(description='A short token for human readable upload URLs'),
    'signature_token': fields.String(description='A short term token to sign URLs')
})

188

189
190
@ns.route('/')
class AuthResource(Resource):
191
192
    @api.doc('get_auth')
    @api.marshal_with(auth_model, skip_none=True, code=200, description='Auth info send')
193
    @authenticate(required=True, basic=True)
194
195
    def get(self):
        """
196
        Provides authentication information. This endpoint requires authentification.
197
198
199
200
        Like all endpoints the OIDC access token based authentification. In additional,
        basic HTTP authentification can be used. This allows to login and acquire an
        access token.

201
        The response contains a short (10s) term JWT token that can be used to sign
202
203
204
        URLs with a ``signature_token`` query parameter, e.g. for file downloads on the
        raw or archive api endpoints; a short ``upload_token`` that is used in
        ``curl`` command line based uploads; and the OIDC JWT access token.
205
        """
206
207
208
209
210
211

        def signature_token():
            expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=10)
            return jwt.encode(
                dict(user=g.user.user_id, exp=expires_at),
                config.services.api_secret, 'HS256').decode('utf-8')
212

213
214
215
216
217
218
219
220
221
        try:
            return {
                'upload_token': generate_upload_token(g.user),
                'signature_token': signature_token(),
                'access_token': infrastructure.keycloak.access_token
            }

        except KeyError:
            abort(401, 'The authenticated user does not exist')
222
223


224
user_model = generate_flask_restplus_model(api, datamodel.User.m_def)
225
users_model = api.model('UsersModel', {
226
    'users': fields.Nested(user_model, skip_none=True)
227
228
})

229

230
231
232
233
234
235
236
237
238
239
240
241
users_parser = api.parser()
users_parser.add_argument(
    'query', default='',
    help='Only return users that contain this string in their names, usernames, or emails.')


@ns.route('/users')
class UsersResource(Resource):
    @api.doc('get_users')
    @api.marshal_with(users_model, code=200, description='User suggestions send')
    @api.expect(users_parser, validate=True)
    def get(self):
242
        """ Get existing users. """
243
244
245
246
        args = users_parser.parse_args()

        return dict(users=infrastructure.keycloak.search_user(args.get('query')))

247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
    @api.doc('invite_user')
    @api.marshal_with(user_model, code=200, skip_none=True, description='User invited')
    @api.expect(user_model, validate=True)
    def put(self):
        """ Invite a new user. """
        json_data = request.get_json()
        try:
            user = datamodel.User.m_from_dict(json_data)
        except Exception as e:
            abort(400, 'Invalid user data: %s' % str(e))

        if user.email is None:
            abort(400, 'Invalid user data: email is required')

        try:
            error = infrastructure.keycloak.add_user(user, invite=True)
        except KeyError as e:
            abort(400, 'Invalid user data: %s' % str(e))

        if error is not None:
            abort(400, 'Could not invite user: %s' % error)

Markus Scheidgen's avatar
Markus Scheidgen committed
269
        return datamodel.User.get(username=user.username), 200
270

271

272
273
def with_signature_token(func):
    """
274
275
    A decorator for API endpoint implementations that validates signed URLs. Token to
    sign URLs can be retrieved via the ``/auth`` endpoint.
276
    """
277
    @functools.wraps(func)
278
279
280
281
282
    @api.response(401, 'Invalid or expired signature token')
    def wrapper(*args, **kwargs):
        token = request.args.get('token', None)
        if token is not None:
            try:
283
284
285
286
287
288
289
290
291
292
293
294
                decoded = jwt.decode(token, config.services.api_secret, algorithms=['HS256'])
                user = datamodel.User.get(decoded['user'])
                if user is None:
                    abort(401, 'User for token does not exist')
                else:
                    g.user = user
            except KeyError:
                abort(401, 'Token with invalid/unexpected payload')
            except jwt.ExpiredSignatureError:
                abort(401, 'Expired token')
            except jwt.InvalidTokenError:
                abort(401, 'Invalid token')
295
296

        return func(*args, **kwargs)
297

298
299
300
    return wrapper


301
def create_authorization_predicate(upload_id, calc_id=None):
302
303
304
305
306
307
308
309
    """
    Returns a predicate that determines if the logged in user has the authorization
    to access the given upload and calculation.
    """
    def func():
        if g.user is None:
            # guest users don't have authorized access to anything
            return False
310
        elif g.user.is_admin:
311
312
            # the admin user does have authorization to access everything
            return True
313

314
315
316
        # look in mongo
        try:
            upload = processing.Upload.get(upload_id)
317
318
319
320
321
322
323
324
            if g.user.user_id == upload.user_id:
                return True

            try:
                calc = processing.Calc.get(calc_id)
            except KeyError:
                return False
            return g.user.user_id in calc.metadata.get('shared_with', [])
325

326
        except KeyError as e:
327
            logger = utils.get_logger(__name__, upload_id=upload_id, calc_id=calc_id)
328
            logger.error('Upload files without respective db entry')
329
            raise e
330
331

    return func