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

Markus Scheidgen's avatar
Markus Scheidgen committed
44
from .api import api
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
                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'])
132
                        user = datamodel.User(user_id=decoded['user'], email=None)
133
134
135
136
137
138
139
140
141
142
143
144
145
146
                        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')

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

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

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

159
160
161
        return wrapper

    return decorator
162
163


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


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


181
182
183
184
185
186
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')
})

187

188
189
@ns.route('/')
class AuthResource(Resource):
190
191
    @api.doc('get_auth')
    @api.marshal_with(auth_model, skip_none=True, code=200, description='Auth info send')
192
    @authenticate(required=True, basic=True)
193
194
    def get(self):
        """
195
        Provides authentication information. This endpoint requires authentification.
196
197
198
199
        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.

200
        The response contains a short (10s) term JWT token that can be used to sign
201
202
203
        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.
204
        """
205
206
207
208
209
210

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

212
213
214
215
216
217
218
219
220
        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')
221
222
223
224


def with_signature_token(func):
    """
225
226
    A decorator for API endpoint implementations that validates signed URLs. Token to
    sign URLs can be retrieved via the ``/auth`` endpoint.
227
    """
228
    @functools.wraps(func)
229
230
231
232
233
    @api.response(401, 'Invalid or expired signature token')
    def wrapper(*args, **kwargs):
        token = request.args.get('token', None)
        if token is not None:
            try:
234
235
236
237
238
239
240
241
242
243
244
245
                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')
246
247

        return func(*args, **kwargs)
248

249
250
251
    return wrapper


252
def create_authorization_predicate(upload_id, calc_id=None):
253
254
255
256
257
258
259
260
    """
    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
261
        elif g.user.is_admin:
262
263
            # the admin user does have authorization to access everything
            return True
264

265
266
267
268
        # look in mongo
        try:
            upload = processing.Upload.get(upload_id)
            return g.user.user_id == upload.user_id
269

270
        except KeyError as e:
271
            logger = utils.get_logger(__name__, upload_id=upload_id, calc_id=calc_id)
272
            logger.error('Upload files without respective db entry')
273
            raise e
274
275

    return func