auth.py 10.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
.. autofunction:: authenticate
27
"""
28
29
from flask import g, request
from flask_restplus import abort, Resource, fields
30
import functools
31
32
import jwt
import datetime
33
34
35
import hmac
import hashlib
import uuid
36

37
from nomad import config, processing, utils, infrastructure, datamodel
38

39
from .app import api, RFC3339DateTime
40
41


42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# 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'
    }
}


def generate_upload_token(user):
66
    """
67
68
69
70
    Generates a short user authenticating token based on its keycloak UUID.
    It can be used to authenticate users in less security relevant but short curl commands.

    It uses the users UUID as urlsafe base64 encoded payload with a HMACSHA1 signature.
71
    """
72
73
74
75
76
    payload = uuid.UUID(user.user_id).bytes
    signature = hmac.new(
        bytes(config.services.api_secret, 'utf-8'),
        msg=payload,
        digestmod=hashlib.sha1)
77

78
79
80
    return '%s.%s' % (
        utils.base64_encode(payload),
        utils.base64_encode(signature.digest()))
81

82

83
84
85
86
87
88
89
90
91
def verify_upload_token(token) -> str:
    """
    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)
92

93
94
95
96
    compare = hmac.new(
        bytes(config.services.api_secret, 'utf-8'),
        msg=payload,
        digestmod=hashlib.sha1)
97

98
99
100
101
102
103
104
105
106
    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):
107
    """
108
109
110
111
112
113
114
115
116
117
    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.
118
    """
119
120
121
122
123
124
125
126
    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')

127
128
    def decorator(func):
        @functools.wraps(func)
129
130
        @api.response(401, 'Not authorized, some data require authentication and authorization')
        @api.doc(security=methods)
131
        def wrapper(*args, **kwargs):
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
            g.user = None

            if upload_token and 'token' in request.args:
                token = request.args['token']
                user_id = verify_upload_token(token)
                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'])
                        user = datamodel.User.get(decoded['user'])
                        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:
                abort(401, 'Queram param token not supported for this endpoint')

            user_or_error = infrastructure.keycloak.authorize_flask(basic=basic)
            if user_or_error is not None:
                if isinstance(user_or_error, datamodel.User):
                    g.user = user_or_error
                else:
                    abort(401, message=user_or_error)

            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')
171

172
            return func(*args, **kwargs)
173

174
175
176
        return wrapper

    return decorator
177
178


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


184
user_model = api.model('User', {
185
186
    'user_id': fields.String(description='The users UUID.'),
    'name': fields.String('The publically visible user name.'),
187
188
189
    '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'),
190
191
192
    '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')})),
193
    'password': fields.String(description='The bcrypt 2y-indented password for initial and changed password'),
194
195
    'token': fields.String(
        description='The access token that authenticates the user with the API. '
196
        'User the HTTP header "X-Token" to provide it in API requests.'),
197
    'created': RFC3339DateTime(description='The create date for the user.')
198
199
200
})


201
202
@ns.route('/')
class AuthResource(Resource):
203
    @api.doc('get_user')
204
    @api.marshal_with(user_model, skip_none=True, code=200, description='User info send')
205
    @authenticate(required=True, basic=True)
206
    def get(self):
207
208
209
        return g.user


210
token_model = api.model('Token', {
211
    'user': fields.Nested(user_model, skip_none=True),
212
    'token': fields.String(description='The short term token to sign URLs'),
213
    'expiries_at': RFC3339DateTime(desription='The time when the token expires')
214
215
216
217
218
219
220
})


@ns.route('/token')
class TokenResource(Resource):
    @api.doc('get_token')
    @api.marshal_with(token_model, skip_none=True, code=200, description='Token send')
221
    @authenticate(required=True)
222
223
224
225
226
227
    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.
        """
228
229
230
231
232
        expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=10)
        token = jwt.encode(
            dict(user=g.user.user_id, exp=expires_at),
            config.services.api_secret, 'HS256').decode('utf-8')

233
234
235
        return {
            'user': g.user,
            'token': token,
236
            'expires_at': expires_at.isoformat(),
237
238
239
240
241
242
243
        }


def with_signature_token(func):
    """
    A decorator for API endpoint implementations that validates signed URLs.
    """
244
    @functools.wraps(func)
245
246
247
248
249
    @api.response(401, 'Invalid or expired signature token')
    def wrapper(*args, **kwargs):
        token = request.args.get('token', None)
        if token is not None:
            try:
250
251
252
253
254
255
256
257
258
259
260
261
                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')
262
263

        return func(*args, **kwargs)
264

265
266
267
    return wrapper


268
def create_authorization_predicate(upload_id, calc_id=None):
269
270
271
272
273
274
275
276
    """
    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
277
        elif g.user.is_admin:
278
279
            # the admin user does have authorization to access everything
            return True
280

281
282
283
284
        # look in mongo
        try:
            upload = processing.Upload.get(upload_id)
            return g.user.user_id == upload.user_id
285

286
        except KeyError as e:
287
            logger = utils.get_logger(__name__, upload_id=upload_id, calc_id=calc_id)
288
            logger.error('Upload files without respective db entry')
289
            raise e
290
291

    return func