auth.py 10.7 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

44
from .app import api, RFC3339DateTime
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
# 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'
    }
}


70
def _verify_upload_token(token) -> str:
71
72
73
74
75
76
77
78
    """
    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)
79

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

85
86
87
88
89
90
91
92
93
    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):
94
    """
95
96
97
98
99
100
101
102
103
104
    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.
105
    """
106
107
108
109
110
111
112
113
    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')

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

            if upload_token and 'token' in request.args:
                token = request.args['token']
123
                user_id = _verify_upload_token(token)
124
125
126
127
128
129
130
131
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
                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')
158

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

161
162
163
        return wrapper

    return decorator
164
165


166
167
168
169
170
171
172
173
174
175
176
177
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()))


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


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

199
200
201
202
203
204
205
auth_model = api.model('Auth', {
    'user': fields.Nested(user_model, skip_none=True, description='The authenticated user info'),
    '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')
})

206

207
208
@ns.route('/')
class AuthResource(Resource):
209
210
    @api.doc('get_auth')
    @api.marshal_with(auth_model, skip_none=True, code=200, description='Auth info send')
211
    @authenticate(required=True, basic=True)
212
213
    def get(self):
        """
214
215
216
217
218
219
220
221
222
223
        Provides user and authentication information. This endpoint requires authentification.
        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.

        The response contains information about the authentificated user; a
        a short (10s) term JWT token that can be used to sign
        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.
224
        """
225
226
227
228
229
230

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

232
233
        return {
            'user': g.user,
234
235
236
            'upload_token': generate_upload_token(g.user),
            'signature_token': signature_token(),
            'access_token': infrastructure.keycloak.access_token
237
238
239
240
241
        }


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

        return func(*args, **kwargs)
265

266
267
268
    return wrapper


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

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

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

    return func