auth.py 7.84 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
35
from flask import g, request
from flask_restplus import abort, Resource, fields
36
import functools
37
38
import jwt
import datetime
39

40
from nomad import config, processing, files, utils, infrastructure, datamodel
41

42
from .app import api, RFC3339DateTime
43
44


45
def login_if_available(token_only: bool = True):
46
47
48
49
    """
    A decorator for API endpoint implementations that might authenticate users, but
    provide limited functionality even without users.
    """
50
51
52
53
54
55
56
57
58
59
60
61
62
    def decorator(func):
        @functools.wraps(func)
        @api.response(401, 'Not authorized, some data require authentication and authorization')
        @api.doc(security=list('OpenIDConnect Bearer Token'))
        def wrapper(*args, **kwargs):
            user_or_error = infrastructure.keycloak.authorize_flask(token_only)
            if user_or_error is None:
                pass
            elif isinstance(user_or_error, datamodel.User):
                g.user = user_or_error
            else:
                abort(401, message=user_or_error)

63
64
            return func(*args, **kwargs)

65
66
67
        return wrapper

    return decorator
68
69


70
def login_really_required(token_only: bool = True):
71
72
73
74
    """
    A decorator for API endpoint implementations that forces user authentication on
    endpoints.
    """
75
76
77
78
79
80
81
82
    def decorator(func):
        @functools.wraps(func)
        @api.response(401, 'Not authorized, this endpoint requires authorization')
        @login_if_available(token_only)
        def wrapper(*args, **kwargs):
            if g.user is None:
                abort(401, 'Not authorized, this endpoint requires authorization')

83
            return func(*args, **kwargs)
84

85
86
87
        return wrapper

    return decorator
88
89


90
91
92
93
def admin_login_required(func):
    """
    A decorator for API endpoint implementations that should only work for the admin user.
    """
94
    @functools.wraps(func)
95
    @api.response(401, 'Authentication required or not authorized as admin user. Only admin can access this endpoint.')
96
    @login_really_required
97
    def wrapper(*args, **kwargs):
98
99
100
101
        if not g.user.is_admin:
            abort(401, message='Only the admin user use this endpoint')

        return func(*args, **kwargs)
102
103
104
105

    return wrapper


106
ns = api.namespace(
107
108
    'auth',
    description='Authentication related endpoints.')
109
110


111
user_model = api.model('User', {
112
    'user_id': fields.Integer(description='The id to use in the repo db, make sure it does not already exist.'),
113
114
115
    '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'),
116
117
118
    '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')})),
119
    'password': fields.String(description='The bcrypt 2y-indented password for initial and changed password'),
120
121
    'token': fields.String(
        description='The access token that authenticates the user with the API. '
122
        'User the HTTP header "X-Token" to provide it in API requests.'),
123
    'created': RFC3339DateTime(description='The create date for the user.')
124
125
126
})


127
128
129
130
@ns.route('/')
class AuthResource(Resource):
    @api.doc('get_token')
    @api.marshal_with(user_model, skip_none=True, code=200, description='User info send')
131
    @login_really_required(token_only=False)
132
    def get(self):
133
134
135
        return g.user


136
137
138
token_model = api.model('Token', {
    'user': fields.Nested(user_model),
    'token': fields.String(description='The short term token to sign URLs'),
139
    'expiries_at': RFC3339DateTime(desription='The time when the token expires')
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
})


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.
        """
159
160
161
162
163
        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')

164
165
166
167
168
169
170
171
172
173
174
        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.
    """
175
    @functools.wraps(func)
176
177
178
179
180
    @api.response(401, 'Invalid or expired signature token')
    def wrapper(*args, **kwargs):
        token = request.args.get('token', None)
        if token is not None:
            try:
181
182
183
184
185
186
187
188
189
190
191
192
                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')
193
194

        return func(*args, **kwargs)
195

196
197
198
    return wrapper


199
def create_authorization_predicate(upload_id, calc_id=None):
200
201
202
203
204
205
206
207
    """
    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
208
        elif g.user.is_admin:
209
210
            # the admin user does have authorization to access everything
            return True
211

212
213
        # look in mongodb
        processing.Upload.get(upload_id).user_id == g.user.user_id
214
215

        # There are no db entries for the given resource
216
        if files.UploadFiles.get(upload_id) is not None:
217
            logger = utils.get_logger(__name__, upload_id=upload_id, calc_id=calc_id)
218
219
220
221
            logger.error('Upload files without respective db entry')

        raise KeyError
    return func