auth.py 12.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
17
18
19
20
21
The API is protected with *keycloak* and *OpenIDConnect*. All API endpoints that require
or support authentication accept OIDC bearer tokens via HTTP header (``Authentication``,
recommended), query (``access_token``), or form parameter (``access_token``). These
token can be acquired from the NOMAD keycloak server or through the ``/auth`` endpoint
that also supports HTTP Basic authentication and passes the given credentials to
keycloak.
22
23
24
25

Authenticated user information is available via FLASK's build in flask.g.user object.
It is set to None, if no user information is available.

26
27
There are three decorators for FLASK API endpoints that can be used to protect
endpoints that require or support authentication.
28
29
30

.. autofunction:: login_if_available
.. autofunction:: login_really_required
31
.. autofunction:: admin_login_required
32
33
"""

34
from typing import Tuple
35
36
from flask import g, request
from flask_restplus import abort, Resource, fields
37
from datetime import datetime
38
39
import functools
import basicauth
40

41
42
from nomad import config, processing, files, utils, coe_repo, infrastructure
from nomad.coe_repo import LoginException
43

44
from .app import api, RFC3339DateTime, oidc
45
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
71
72
73
74
75
76
77
78
79
80
class User:
    """
    A data class that holds all information for a single user. This can be the logged in
    and authenticated user, or other users (i.e. co-authors, etc.).
    """
    def __init__(
            self, email, name=None, first_name='', last_name='', affiliation=None,
            created: datetime = None, **kwargs):
        assert email is not None, 'Users must have an email, it is used as unique id'

        self.email = email

        first_name = kwargs.get('firstName', first_name)
        last_name = kwargs.get('lastName', last_name)
        name = kwargs.get('username', name)
        created_timestamp = kwargs.get('createdTimestamp', None)

        if len(last_name) > 0 and len(first_name) > 0:
            name = '%s, %s' % (last_name, first_name)
        elif len(last_name) != 0:
            name = last_name
        elif len(first_name) != 0:
            name = first_name
        elif name is None:
            name = 'unnamed user'

        self.name = name

        if created is not None:
            self.created = None
        elif created_timestamp is not None:
            self.created = datetime.fromtimestamp(created_timestamp)
        else:
            self.created = None
81

82
        # TODO affliation
83
84


85
86
87
88
def _validate_token(require_token: bool = True, **kwargs) -> Tuple[bool, str]:
    """
    Uses OIDC to check if the request carries token based authentication and if
    this authentication is valid.
89

90
91
92
93
94
95
96
97
98
    Returns: A tuple with bool and potential error message
    """
    token = None
    if 'Authorization' in request.headers and request.headers['Authorization'].startswith('Bearer '):
        token = request.headers['Authorization'].split(None, 1)[1].strip()
    if 'access_token' in request.form:
        token = request.form['access_token']
    elif 'access_token' in request.args:
        token = request.args['access_token']
99

100
    validity = oidc.validate_token(token, **kwargs)
101

102
103
    if validity:
        g.oidc_id_token = g.oidc_token_info
104

105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
    return (validity is True) or (not require_token), validity


def _get_user():
    """
    Retrieves OIDC user info and populate the global flask ``g.user`` variable.
    """
    if g.oidc_id_token:
        try:
            g.user = User(**oidc.user_getinfo([
                'email', 'firstName', 'lastName', 'username', 'createdTimestamp']))
        except Exception as e:
            ## TODO logging
            raise e
    else:
        g.user = None
121
122
123
124
125
126
127


def login_if_available(func):
    """
    A decorator for API endpoint implementations that might authenticate users, but
    provide limited functionality even without users.
    """
128
    @functools.wraps(func)
129
    @api.response(401, 'Not authorized, some data require authentication and authorization')
130
    @api.doc(security=list('OpenIDConnect Bearer Token'))
131
    def wrapper(*args, **kwargs):
132
133
134
135
136
137
138
        valid, msg = _validate_token(require_token=False)
        if valid:
            _get_user()
            return func(*args, **kwargs)
        else:
            abort(401, message=msg)

139
140
141
142
143
144
145
146
    return wrapper


def login_really_required(func):
    """
    A decorator for API endpoint implementations that forces user authentication on
    endpoints.
    """
147
148
149
    @functools.wraps(func)
    @api.response(401, 'Not authorized, this endpoint required authorization')
    @api.doc(security=list('OpenIDConnect Bearer Token'))
150
    def wrapper(*args, **kwargs):
151
152
153
        valid, msg = _validate_token(require_token=True)
        if valid:
            _get_user()
154
            return func(*args, **kwargs)
155
156
157
        else:
            abort(401, message=msg)

158
159
160
    return wrapper


161
162
163
164
def admin_login_required(func):
    """
    A decorator for API endpoint implementations that should only work for the admin user.
    """
165
    @functools.wraps(func)
166
    @api.response(401, 'Authentication required or not authorized as admin user. Only admin can access this endpoint.')
167
168
    @api.doc(security=list('OpenIDConnect Bearer Token'))
    @oidc.accept_token(require_token=True)
169
    def wrapper(*args, **kwargs):
170
        if oidc.user_getfield('email') == config.keycloak.adminEmail:
171
            return func(*args, **kwargs)
172
173
        else:
            abort(401, message='Only the admin user can perform reset.')
174
175
176
177

    return wrapper


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


183
user_model = api.model('User', {
184
    'user_id': fields.Integer(description='The id to use in the repo db, make sure it does not already exist.'),
185
186
187
    'first_name': fields.String(description='The user\'s first name'),
    'last_name': fields.String(description='The user\'s last name'),
    'email': fields.String(description='Guess what, the user\'s email'),
188
189
190
    'affiliation': fields.Nested(model=api.model('Affiliation', {
        'name': fields.String(description='The name of the affiliation', default='not given'),
        'address': fields.String(description='The address of the affiliation', default='not given')})),
191
    'password': fields.String(description='The bcrypt 2y-indented password for initial and changed password'),
192
193
    'token': fields.String(
        description='The access token that authenticates the user with the API. '
194
        'User the HTTP header "X-Token" to provide it in API requests.'),
195
    'created': RFC3339DateTime(description='The create date for the user.')
196
197
198
})


199
200
201
202
203
@ns.route('/')
class AuthResource(Resource):
    @api.doc('get_token')
    @api.marshal_with(user_model, skip_none=True, code=200, description='User info send')
    @login_if_available
204
    def get(self):
205
        if g.user is not None:
206
            return g.user
207

208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
        if 'Authorization' in request.headers and request.headers['Authorization'].startswith('Basic '):
            try:
                username, password = basicauth.decode(request.headers['Authorization'])
                token = infrastructure.keycloak_oidc_client.token(username=username, password=password)
                validity = oidc.validate_token(token['access_token'])
            except Exception as e:
                # TODO logging
                abort(401, message='Could not authenticate Basic auth: %s' % str(e))

            if validity is not True:
                abort(401, message=validity)
            else:
                g.oidc_id_token = g.oidc_token_info
                _get_user()
        else:
            abort(401, message='Authentication credentials found in your request')

        if g.user is None:
            abort(401, message='User not authenticated')

        return g.user


@ns.route('/user')
class UserResource(Resource):
233
234
    @api.doc('create_user')
    @api.expect(user_model)
235
    @api.response(400, 'Invalid user data')
236
    @api.marshal_with(user_model, skip_none=True, code=200, description='User created')
237
    @admin_login_required
238
239
240
241
242
243
244
245
246
247
248
249
250
251
    def put(self):
        """
        Creates a new user account. Currently only the admin user is allows. The
        NOMAD-CoE repository GUI should be used to create user accounts for now.
        Passwords have to be encrypted by the client with bcrypt and 2y indent.
        """
        data = request.get_json()
        if data is None:
            data = {}

        for required_key in ['last_name', 'first_name', 'password', 'email']:
            if required_key not in data:
                abort(400, message='The %s is missing' % required_key)

252
253
254
255
        if 'user_id' in data:
            if coe_repo.User.from_user_id(data['user_id']) is not None:
                abort(400, 'User with given user_id %d already exists.' % data['user_id'])

256
257
258
        user = coe_repo.User.create_user(
            email=data['email'], password=data.get('password', None), crypted=True,
            first_name=data['first_name'], last_name=data['last_name'],
259
            created=data.get('created', datetime.utcnow()),
260
261
            affiliation=data.get('affiliation', None), token=data.get('token', None),
            user_id=data.get('user_id', None))
262
263
264

        return user, 200

265

266
267
268
token_model = api.model('Token', {
    'user': fields.Nested(user_model),
    'token': fields.String(description='The short term token to sign URLs'),
269
    'expiries_at': RFC3339DateTime(desription='The time when the token expires')
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
})


signature_token_argument = dict(
    name='token', type=str, help='Token that signs the URL and authenticates the user',
    location='args')


@ns.route('/token')
class TokenResource(Resource):
    @api.doc('get_token')
    @api.marshal_with(token_model, skip_none=True, code=200, description='Token send')
    @login_really_required
    def get(self):
        """
        Generates a short (10s) term JWT token that can be used to authenticate the user in
        URLs towards most API get request, e.g. for file downloads on the
        raw or archive api endpoints. Use the token query parameter to sign URLs.
        """
        token, expires_at = g.user.get_signature_token()
        return {
            'user': g.user,
            'token': token,
            'expires_at': expires_at.isoformat()
        }


def with_signature_token(func):
    """
    A decorator for API endpoint implementations that validates signed URLs.
    """
    @api.response(401, 'Invalid or expired signature token')
    def wrapper(*args, **kwargs):
        token = request.args.get('token', None)
        if token is not None:
            try:
                g.user = coe_repo.User.verify_signature_token(token)
            except LoginException:
                abort(401, 'Invalid or expired signature token')

        return func(*args, **kwargs)
    wrapper.__name__ = func.__name__
    wrapper.__doc__ = func.__doc__
    return wrapper


316
def create_authorization_predicate(upload_id, calc_id=None):
317
318
319
320
321
322
323
324
    """
    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
325
326
327
        elif g.user.user_id == 0:
            # the admin user does have authorization to access everything
            return True
328
329

        # look in repository
330
        upload = coe_repo.Upload.from_upload_id(upload_id)
331
332
333
334
        if upload is not None:
            return upload.user_id == g.user.user_id

        # look in staging
335
        staging_upload = processing.Upload.get(upload_id)
336
337
338
339
        if staging_upload is not None:
            return str(g.user.user_id) == str(staging_upload.user_id)

        # There are no db entries for the given resource
340
        if files.UploadFiles.get(upload_id) is not None:
341
            logger = utils.get_logger(__name__, upload_id=upload_id, calc_id=calc_id)
342
343
344
345
            logger.error('Upload files without respective db entry')

        raise KeyError
    return func