udata 11.1.2.dev8__py3-none-any.whl → 11.1.2.dev11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of udata might be problematic. Click here for more details.
- udata/api/oauth2.py +22 -3
- udata/app.py +3 -0
- udata/auth/__init__.py +11 -0
- udata/auth/forms.py +70 -3
- udata/auth/mails.py +6 -0
- udata/auth/proconnect.py +127 -0
- udata/auth/views.py +57 -2
- udata/core/__init__.py +2 -0
- udata/core/captchetat.py +80 -0
- udata/core/dataset/api.py +2 -2
- udata/core/dataset/api_fields.py +3 -4
- udata/core/dataset/apiv2.py +6 -6
- udata/core/dataset/commands.py +0 -10
- udata/core/dataset/constants.py +124 -38
- udata/core/dataset/factories.py +2 -1
- udata/core/dataset/forms.py +14 -10
- udata/core/dataset/models.py +8 -36
- udata/core/dataset/rdf.py +76 -54
- udata/core/dataset/tasks.py +2 -50
- udata/cors.py +19 -2
- udata/harvest/backends/ckan/harvesters.py +10 -14
- udata/harvest/backends/maaf.py +15 -14
- udata/harvest/tests/ckan/test_ckan_backend.py +4 -3
- udata/harvest/tests/test_dcat_backend.py +3 -2
- udata/i18n.py +7 -32
- udata/migrations/2025-09-04-update-legacy-frequencies.py +36 -0
- udata/settings.py +27 -0
- udata/templates/security/email/reset_instructions.html +1 -1
- udata/templates/security/email/reset_instructions.txt +1 -1
- udata/tests/api/test_datasets_api.py +41 -12
- udata/tests/dataset/test_dataset_model.py +17 -53
- udata/tests/dataset/test_dataset_rdf.py +27 -28
- udata/translations/udata.pot +226 -150
- udata/utils.py +8 -1
- {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/METADATA +1 -1
- {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/RECORD +40 -40
- udata/templates/mail/frequency_reminder.html +0 -34
- udata/templates/mail/frequency_reminder.txt +0 -18
- udata/tests/test_i18n.py +0 -93
- {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/WHEEL +0 -0
- {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/entry_points.txt +0 -0
- {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/licenses/LICENSE +0 -0
- {udata-11.1.2.dev8.dist-info → udata-11.1.2.dev11.dist-info}/top_level.txt +0 -0
udata/api/oauth2.py
CHANGED
|
@@ -29,7 +29,7 @@ from authlib.oauth2.rfc6750 import BearerTokenValidator
|
|
|
29
29
|
from authlib.oauth2.rfc7009 import RevocationEndpoint
|
|
30
30
|
from authlib.oauth2.rfc7636 import CodeChallenge
|
|
31
31
|
from bson import ObjectId
|
|
32
|
-
from flask import current_app, render_template, request
|
|
32
|
+
from flask import abort, current_app, jsonify, render_template, request
|
|
33
33
|
from flask_security.utils import verify_password
|
|
34
34
|
from werkzeug.exceptions import Unauthorized
|
|
35
35
|
|
|
@@ -40,6 +40,7 @@ from udata.core.storages import default_image_basename, images
|
|
|
40
40
|
from udata.i18n import I18nBlueprint
|
|
41
41
|
from udata.i18n import lazy_gettext as _
|
|
42
42
|
from udata.mongo import db
|
|
43
|
+
from udata.utils import wants_json
|
|
43
44
|
|
|
44
45
|
blueprint = I18nBlueprint("oauth", __name__, url_prefix="/oauth")
|
|
45
46
|
oauth = AuthorizationServer()
|
|
@@ -292,18 +293,31 @@ class BearerToken(BearerTokenValidator):
|
|
|
292
293
|
return token.revoked
|
|
293
294
|
|
|
294
295
|
|
|
295
|
-
@blueprint.route("/token", methods=["POST"],
|
|
296
|
+
@blueprint.route("/token", methods=["POST"], endpoint="token")
|
|
296
297
|
@csrf.exempt
|
|
297
298
|
def access_token():
|
|
298
299
|
return oauth.create_token_response()
|
|
299
300
|
|
|
300
301
|
|
|
301
|
-
@blueprint.route("/revoke", methods=["POST"]
|
|
302
|
+
@blueprint.route("/revoke", methods=["POST"])
|
|
302
303
|
@csrf.exempt
|
|
303
304
|
def revoke_token():
|
|
304
305
|
return oauth.create_endpoint_response(RevokeToken.ENDPOINT_NAME)
|
|
305
306
|
|
|
306
307
|
|
|
308
|
+
@blueprint.route("/client_info", methods=["GET"])
|
|
309
|
+
def client_info(*args, **kwargs):
|
|
310
|
+
if not current_user or not current_user.is_authenticated:
|
|
311
|
+
abort(401)
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
grant = oauth.get_consent_grant(end_user=current_user)
|
|
315
|
+
except OAuth2Error as error:
|
|
316
|
+
return error.error
|
|
317
|
+
|
|
318
|
+
return jsonify({"client": {"name": grant.client.name}, "scopes": ["default"]})
|
|
319
|
+
|
|
320
|
+
|
|
307
321
|
@blueprint.route("/authorize", methods=["GET", "POST"])
|
|
308
322
|
@login_required
|
|
309
323
|
def authorize(*args, **kwargs):
|
|
@@ -313,8 +327,13 @@ def authorize(*args, **kwargs):
|
|
|
313
327
|
except OAuth2Error as error:
|
|
314
328
|
return error.error
|
|
315
329
|
# Bypass authorization screen for internal clients
|
|
330
|
+
# It's not used right now…
|
|
316
331
|
if grant.client.internal:
|
|
317
332
|
return oauth.create_authorization_response(grant_user=current_user)
|
|
333
|
+
|
|
334
|
+
if wants_json():
|
|
335
|
+
return jsonify({"client": {"name": grant.client.name}, "scopes": ["default"]})
|
|
336
|
+
|
|
318
337
|
return render_template("api/oauth_authorize.html", grant=grant)
|
|
319
338
|
elif request.method == "POST":
|
|
320
339
|
accept = "accept" in request.form
|
udata/app.py
CHANGED
|
@@ -223,6 +223,7 @@ def register_extensions(app):
|
|
|
223
223
|
sentry,
|
|
224
224
|
tasks,
|
|
225
225
|
)
|
|
226
|
+
from udata.auth import proconnect
|
|
226
227
|
|
|
227
228
|
cors.init_app(app)
|
|
228
229
|
tasks.init_app(app)
|
|
@@ -236,6 +237,8 @@ def register_extensions(app):
|
|
|
236
237
|
mail.init_app(app)
|
|
237
238
|
search.init_app(app)
|
|
238
239
|
sentry.init_app(app)
|
|
240
|
+
proconnect.init_app(app)
|
|
241
|
+
|
|
239
242
|
app.after_request(return_404_html_if_requested)
|
|
240
243
|
app.register_error_handler(NotFound, page_not_found)
|
|
241
244
|
return app
|
udata/auth/__init__.py
CHANGED
|
@@ -39,6 +39,7 @@ def init_app(app):
|
|
|
39
39
|
from udata.models import datastore
|
|
40
40
|
|
|
41
41
|
from .forms import (
|
|
42
|
+
ExtendedForgotPasswordForm,
|
|
42
43
|
ExtendedLoginForm,
|
|
43
44
|
ExtendedRegisterForm,
|
|
44
45
|
ExtendedResetPasswordForm,
|
|
@@ -47,6 +48,15 @@ def init_app(app):
|
|
|
47
48
|
from .password_validation import UdataPasswordUtil
|
|
48
49
|
from .views import create_security_blueprint
|
|
49
50
|
|
|
51
|
+
# We want to alias SECURITY_POST_CONFIRM_VIEW to the CDATA_BASE_URL (the homepage)
|
|
52
|
+
# but can't do it in `settings.py` because it's not defined yet (`CDATA_BASE_URL` is set
|
|
53
|
+
# in the env)
|
|
54
|
+
# :SecurityPostConfirmViewAtRuntime
|
|
55
|
+
if app.config["CDATA_BASE_URL"]:
|
|
56
|
+
app.config.setdefault(
|
|
57
|
+
"SECURITY_POST_CONFIRM_VIEW", app.config["CDATA_BASE_URL"] + "?flash=post_confirm"
|
|
58
|
+
)
|
|
59
|
+
|
|
50
60
|
security.init_app(
|
|
51
61
|
app,
|
|
52
62
|
datastore,
|
|
@@ -56,6 +66,7 @@ def init_app(app):
|
|
|
56
66
|
confirm_register_form=ExtendedRegisterForm,
|
|
57
67
|
register_form=ExtendedRegisterForm,
|
|
58
68
|
reset_password_form=ExtendedResetPasswordForm,
|
|
69
|
+
forgot_password_form=ExtendedForgotPasswordForm,
|
|
59
70
|
mail_util_cls=UdataMailUtil,
|
|
60
71
|
password_util_cls=UdataPasswordUtil,
|
|
61
72
|
)
|
udata/auth/forms.py
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
import datetime
|
|
2
|
+
import logging
|
|
2
3
|
|
|
4
|
+
import requests
|
|
3
5
|
from flask import current_app
|
|
4
6
|
from flask_login import current_user
|
|
5
|
-
from flask_security.forms import
|
|
6
|
-
|
|
7
|
+
from flask_security.forms import (
|
|
8
|
+
ForgotPasswordForm,
|
|
9
|
+
Form,
|
|
10
|
+
LoginForm,
|
|
11
|
+
RegisterForm,
|
|
12
|
+
ResetPasswordForm,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from udata.core.captchetat import bearer_token
|
|
7
16
|
from udata.forms import fields, validators
|
|
8
17
|
from udata.i18n import lazy_gettext as _
|
|
9
18
|
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WithCaptcha:
|
|
23
|
+
captcha_code = fields.StringField(_("Captcha code"))
|
|
24
|
+
captcha_uuid = fields.StringField(_("Captcha ID"))
|
|
25
|
+
|
|
26
|
+
def validate_captcha(self):
|
|
27
|
+
if check_captchetat(self.captcha_uuid.data, self.captcha_code.data):
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
self.captcha_code.errors = [_("Invalid Captcha")]
|
|
31
|
+
return False
|
|
32
|
+
|
|
10
33
|
|
|
11
|
-
class ExtendedRegisterForm(RegisterForm):
|
|
34
|
+
class ExtendedRegisterForm(WithCaptcha, RegisterForm):
|
|
12
35
|
first_name = fields.StringField(
|
|
13
36
|
_("First name"),
|
|
14
37
|
[
|
|
@@ -23,12 +46,21 @@ class ExtendedRegisterForm(RegisterForm):
|
|
|
23
46
|
validators.NoURLs(_("URLs not allowed in this field")),
|
|
24
47
|
],
|
|
25
48
|
)
|
|
49
|
+
accept_conditions = fields.BooleanField(
|
|
50
|
+
_("J'accepte les conditions générales d'utilisation"),
|
|
51
|
+
validators=[
|
|
52
|
+
validators.DataRequired(message=_("Vous devez accepter les CGU pour continuer."))
|
|
53
|
+
],
|
|
54
|
+
)
|
|
26
55
|
|
|
27
56
|
def validate(self, **kwargs):
|
|
28
57
|
# no register allowed when read only mode is on
|
|
29
58
|
if not super().validate(**kwargs) or current_app.config.get("READ_ONLY_MODE"):
|
|
30
59
|
return False
|
|
31
60
|
|
|
61
|
+
if not self.validate_captcha():
|
|
62
|
+
return False
|
|
63
|
+
|
|
32
64
|
return True
|
|
33
65
|
|
|
34
66
|
|
|
@@ -57,6 +89,17 @@ class ExtendedResetPasswordForm(ResetPasswordForm):
|
|
|
57
89
|
return True
|
|
58
90
|
|
|
59
91
|
|
|
92
|
+
class ExtendedForgotPasswordForm(WithCaptcha, ForgotPasswordForm):
|
|
93
|
+
def validate(self, **kwargs):
|
|
94
|
+
if not super().validate(**kwargs):
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
if not self.validate_captcha():
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
60
103
|
class ChangeEmailForm(Form):
|
|
61
104
|
new_email = fields.StringField(_("New email"), [validators.DataRequired(), validators.Email()])
|
|
62
105
|
new_email_confirm = fields.StringField(
|
|
@@ -77,3 +120,27 @@ class ChangeEmailForm(Form):
|
|
|
77
120
|
)
|
|
78
121
|
return False
|
|
79
122
|
return True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_captchetat(id: str, code: str) -> bool:
|
|
126
|
+
captchetat_url = current_app.config.get("CAPTCHETAT_BASE_URL")
|
|
127
|
+
if not captchetat_url:
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
if not id or not code:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
headers = {"Authorization": "Bearer " + bearer_token()}
|
|
134
|
+
try:
|
|
135
|
+
resp = requests.post(
|
|
136
|
+
f"{captchetat_url}/valider-captcha",
|
|
137
|
+
headers=headers,
|
|
138
|
+
json={
|
|
139
|
+
"uuid": id,
|
|
140
|
+
"code": code,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
return resp.text == "true"
|
|
144
|
+
except requests.exceptions.RequestException as err:
|
|
145
|
+
log.error(f"Failed to query CaptchEtat: {err}")
|
|
146
|
+
return True
|
udata/auth/mails.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
1
3
|
import email_validator
|
|
2
4
|
from flask import current_app
|
|
3
5
|
|
|
4
6
|
from udata.tasks import task
|
|
5
7
|
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
6
10
|
|
|
7
11
|
@task
|
|
8
12
|
def sendmail(msg):
|
|
@@ -11,6 +15,8 @@ def sendmail(msg):
|
|
|
11
15
|
if send_mail:
|
|
12
16
|
mail = current_app.extensions.get("mail")
|
|
13
17
|
mail.send(msg)
|
|
18
|
+
else:
|
|
19
|
+
log.warning(msg)
|
|
14
20
|
|
|
15
21
|
|
|
16
22
|
class UdataMailUtil:
|
udata/auth/proconnect.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from authlib.common.urls import add_params_to_uri
|
|
4
|
+
from authlib.integrations.flask_client import OAuth
|
|
5
|
+
from flask import abort, redirect, request, session, url_for
|
|
6
|
+
from werkzeug.security import gen_salt
|
|
7
|
+
|
|
8
|
+
from udata.api import API, api
|
|
9
|
+
from udata.auth import login_user
|
|
10
|
+
from udata.uris import homepage_url
|
|
11
|
+
|
|
12
|
+
ns = api.namespace("proconnect", "Proconnect related operations")
|
|
13
|
+
oauth = OAuth()
|
|
14
|
+
# blueprint = I18nBlueprint("proconnect", __name__, url_prefix="/proconnect")
|
|
15
|
+
STATE_KEY = "proconnect_state"
|
|
16
|
+
ID_TOKEN_KEY = "id_token"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def init_app(app):
|
|
20
|
+
if app.config.get("PROCONNECT_OPENID_CONF_URL"):
|
|
21
|
+
# ProConnect SSO
|
|
22
|
+
oauth.init_app(app)
|
|
23
|
+
oauth.register(
|
|
24
|
+
name="proconnect",
|
|
25
|
+
client_id=app.config.get("PROCONNECT_CLIENT_ID"),
|
|
26
|
+
client_secret=app.config.get("PROCONNECT_CLIENT_SECRET"),
|
|
27
|
+
server_metadata_url=app.config.get("PROCONNECT_OPENID_CONF_URL"),
|
|
28
|
+
client_kwargs={"scope": app.config.get("PROCONNECT_SCOPE")},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_logout_url():
|
|
33
|
+
id_token = session.get(ID_TOKEN_KEY)
|
|
34
|
+
if id_token is None:
|
|
35
|
+
# No id_token, so no way to logout from ProConnect.
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
metadata = oauth.proconnect.load_server_metadata()
|
|
39
|
+
end_session_endpoint = metadata["end_session_endpoint"]
|
|
40
|
+
# Generate a random state that we send to ProConnect, they'll return it so we can check it.
|
|
41
|
+
state = gen_salt(50)
|
|
42
|
+
session[STATE_KEY] = state
|
|
43
|
+
redirect_uri = url_for("api.proconnect_logout", _external=True)
|
|
44
|
+
|
|
45
|
+
return add_params_to_uri(
|
|
46
|
+
end_session_endpoint,
|
|
47
|
+
(
|
|
48
|
+
("id_token_hint", id_token),
|
|
49
|
+
("state", state),
|
|
50
|
+
("post_logout_redirect_uri", redirect_uri),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@ns.route("/login/", endpoint="proconnect_login")
|
|
56
|
+
class ProconnectLoginAPI(API):
|
|
57
|
+
def get(self):
|
|
58
|
+
redirect_uri = url_for("api.proconnect_auth", _external=True)
|
|
59
|
+
return oauth.proconnect.authorize_redirect(redirect_uri, acr_values="eidas1")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@ns.route("/auth", endpoint="proconnect_auth")
|
|
63
|
+
class ProconnectAuthAPI(API):
|
|
64
|
+
def get(self):
|
|
65
|
+
from udata.models import datastore
|
|
66
|
+
|
|
67
|
+
token = oauth.proconnect.authorize_access_token()
|
|
68
|
+
# Store the user info in the session, it'll be used when logging out from ProConnect.
|
|
69
|
+
session[ID_TOKEN_KEY] = token[ID_TOKEN_KEY]
|
|
70
|
+
# /!\ DIRTY HACK.
|
|
71
|
+
# authlib expects the userinfo to either be in the token["id_token"] as a jwt...
|
|
72
|
+
# but in this case, it's not there, it's some other information.
|
|
73
|
+
# We thus need to go get the userinfo from the userinfo_endpoint, but authlib
|
|
74
|
+
# expects it to be plain json. However, proconnect returns a jwt.
|
|
75
|
+
# So we can't use authlib's client.userinfo() helper, we need to do it ourselves.
|
|
76
|
+
metadata = oauth.proconnect.load_server_metadata()
|
|
77
|
+
resp = oauth.proconnect.get(metadata["userinfo_endpoint"])
|
|
78
|
+
resp.raise_for_status()
|
|
79
|
+
# Create a new token that `client.parse_id_token` expects. Replace the initial
|
|
80
|
+
# `id_token` with the jwt we received from the `userinfo_endpoint`.
|
|
81
|
+
userinfo_token = token.copy()
|
|
82
|
+
userinfo_token[ID_TOKEN_KEY] = resp.content
|
|
83
|
+
proconnect_user = oauth.proconnect.parse_id_token(userinfo_token, nonce=None)
|
|
84
|
+
# We now have the user information decoded from the jwt, ready to be used.
|
|
85
|
+
user = datastore.find_user(email=proconnect_user["email"])
|
|
86
|
+
if not user:
|
|
87
|
+
user = datastore.create_user(
|
|
88
|
+
email=proconnect_user["email"],
|
|
89
|
+
first_name=proconnect_user.get("given_name"),
|
|
90
|
+
last_name=proconnect_user.get("usual_name"),
|
|
91
|
+
confirmed_at=datetime.now(),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if not login_user(user):
|
|
95
|
+
return {"message": "ProConnect Authentication failed"}, 401
|
|
96
|
+
|
|
97
|
+
return redirect(homepage_url(flash="connected"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@ns.route("/logout_oauth", endpoint="proconnect_logout_oauth")
|
|
101
|
+
class ProconnectLogoutOAuthAPI(API):
|
|
102
|
+
def get(self):
|
|
103
|
+
# At the time of this writing, authlib didn't implement OpenIDC session management:
|
|
104
|
+
# https://github.com/lepture/authlib/issues/292
|
|
105
|
+
# So we implement it ourselves. This code may be simplified (or even removed?) in the future
|
|
106
|
+
# if we update to a version that supports it.
|
|
107
|
+
end_session_url = get_logout_url()
|
|
108
|
+
if end_session_url is None:
|
|
109
|
+
return redirect(url_for("security.logout"))
|
|
110
|
+
|
|
111
|
+
return redirect(end_session_url)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@ns.route("/logout", endpoint="proconnect_logout")
|
|
115
|
+
class ProconnectLogoutAPI(API):
|
|
116
|
+
def get(self):
|
|
117
|
+
# Double check that the request hasn't been forged by checking the random "state" we provided.
|
|
118
|
+
state = request.args["state"]
|
|
119
|
+
stored_state = session.get(STATE_KEY)
|
|
120
|
+
if state != stored_state:
|
|
121
|
+
abort(401)
|
|
122
|
+
|
|
123
|
+
# We're logged out from ProConnect, cleanup the ProConnect related data from the session.
|
|
124
|
+
session.pop(ID_TOKEN_KEY, None)
|
|
125
|
+
session.pop(STATE_KEY, None)
|
|
126
|
+
|
|
127
|
+
return redirect(url_for("security.logout"))
|
udata/auth/views.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from flask import current_app, redirect, url_for
|
|
1
|
+
from flask import current_app, jsonify, redirect, request, url_for
|
|
2
2
|
from flask_login import current_user, login_required
|
|
3
3
|
from flask_security.utils import (
|
|
4
4
|
check_and_get_token_status,
|
|
@@ -21,10 +21,13 @@ from flask_security.views import (
|
|
|
21
21
|
send_login,
|
|
22
22
|
token_login,
|
|
23
23
|
)
|
|
24
|
+
from flask_wtf.csrf import generate_csrf
|
|
24
25
|
from werkzeug.local import LocalProxy
|
|
25
26
|
|
|
27
|
+
from udata.auth.proconnect import get_logout_url
|
|
26
28
|
from udata.i18n import lazy_gettext as _
|
|
27
29
|
from udata.uris import homepage_url
|
|
30
|
+
from udata.utils import wants_json
|
|
28
31
|
|
|
29
32
|
from .forms import ChangeEmailForm
|
|
30
33
|
|
|
@@ -98,6 +101,16 @@ def confirm_change_email(token):
|
|
|
98
101
|
return redirect(homepage_url(flash="change_email_confirmed"))
|
|
99
102
|
|
|
100
103
|
|
|
104
|
+
def get_csrf():
|
|
105
|
+
# We need to have a public endpoint for getting a CSRF token.
|
|
106
|
+
# In Flask, we can query the form with an Accept:application/json,
|
|
107
|
+
# for example: GET `/login` to get a JSON with the CSRF token.
|
|
108
|
+
# It's not working in our implementation because GET `/login` is routed to
|
|
109
|
+
# cdata and not udata. So we need to have an endpoint existing only on udata
|
|
110
|
+
# so we can fetch a valid CSRF token.
|
|
111
|
+
return jsonify({"response": {"csrf_token": generate_csrf()}})
|
|
112
|
+
|
|
113
|
+
|
|
101
114
|
@login_required
|
|
102
115
|
def change_email():
|
|
103
116
|
"""Change email page."""
|
|
@@ -107,6 +120,10 @@ def change_email():
|
|
|
107
120
|
if form.validate_on_submit():
|
|
108
121
|
new_email = form.new_email.data
|
|
109
122
|
send_change_email_confirmation_instructions(current_user, new_email)
|
|
123
|
+
|
|
124
|
+
if wants_json():
|
|
125
|
+
return jsonify({})
|
|
126
|
+
|
|
110
127
|
return redirect(
|
|
111
128
|
homepage_url(
|
|
112
129
|
flash="change_email",
|
|
@@ -116,6 +133,9 @@ def change_email():
|
|
|
116
133
|
)
|
|
117
134
|
)
|
|
118
135
|
|
|
136
|
+
if wants_json():
|
|
137
|
+
return jsonify({"response": {"csrf_token": generate_csrf()}})
|
|
138
|
+
|
|
119
139
|
return _security.render_template("security/change_email.html", change_email_form=form)
|
|
120
140
|
|
|
121
141
|
|
|
@@ -134,7 +154,9 @@ def create_security_blueprint(app, state, import_name):
|
|
|
134
154
|
template_folder="templates",
|
|
135
155
|
)
|
|
136
156
|
|
|
137
|
-
bp.route(app.config["SECURITY_LOGOUT_URL"], endpoint="logout")(
|
|
157
|
+
bp.route(app.config["SECURITY_LOGOUT_URL"], methods=["GET", "POST"], endpoint="logout")(
|
|
158
|
+
logout_with_proconnect_url
|
|
159
|
+
)
|
|
138
160
|
|
|
139
161
|
if state.passwordless:
|
|
140
162
|
bp.route(app.config["SECURITY_LOGIN_URL"], methods=["GET", "POST"], endpoint="login")(
|
|
@@ -187,5 +209,38 @@ def create_security_blueprint(app, state, import_name):
|
|
|
187
209
|
)(confirm_change_email)
|
|
188
210
|
|
|
189
211
|
bp.route("/change-email", methods=["GET", "POST"], endpoint="change_email")(change_email)
|
|
212
|
+
bp.route("/get-csrf", methods=["GET"], endpoint="get_csrf")(get_csrf)
|
|
190
213
|
|
|
191
214
|
return bp
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def logout_with_proconnect_url():
|
|
218
|
+
"""
|
|
219
|
+
Extends the flask-security `logout` by returning the ProConnect logout URL (if any)
|
|
220
|
+
so `cdata` can redirect to it if the user was connected via ProConnect.
|
|
221
|
+
"""
|
|
222
|
+
# after the redirection to ProConnect logout, the user will be redirected
|
|
223
|
+
# to our logout again with ProconnectLogoutAPI.
|
|
224
|
+
if request.method == "POST" and wants_json():
|
|
225
|
+
proconnect_logout_url = get_logout_url()
|
|
226
|
+
|
|
227
|
+
if proconnect_logout_url:
|
|
228
|
+
return jsonify(
|
|
229
|
+
{
|
|
230
|
+
"proconnect_logout_url": get_logout_url(),
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Calling the flask-security logout endpoint
|
|
235
|
+
logout()
|
|
236
|
+
|
|
237
|
+
# But rewriting the response since we want to redirect with a flash
|
|
238
|
+
# query param for cdata. Flask-Security redirects to the homepage without
|
|
239
|
+
# any information.
|
|
240
|
+
# PS: in a normal logout it's a JSON request, but after logout from ProConnect
|
|
241
|
+
# the user is redirected to this endpoint as a normal HTTP request, so we must
|
|
242
|
+
# manage the basic redirection in this case.
|
|
243
|
+
if request.method == "POST" and wants_json():
|
|
244
|
+
return jsonify({})
|
|
245
|
+
|
|
246
|
+
return redirect(homepage_url(flash="logout"))
|
udata/core/__init__.py
CHANGED
udata/core/captchetat.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from flask import abort, current_app, make_response
|
|
5
|
+
|
|
6
|
+
from udata.api import API, apiv2
|
|
7
|
+
from udata.app import cache
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
ns = apiv2.namespace("captchetat", "CaptchEtat related operations")
|
|
12
|
+
captchetat_parser = apiv2.parser()
|
|
13
|
+
captchetat_parser.add_argument(
|
|
14
|
+
"get", type=str, location="args", help="type of data wanted from captchetat"
|
|
15
|
+
)
|
|
16
|
+
captchetat_parser.add_argument("c", type=str, location="args", help="captcha name")
|
|
17
|
+
captchetat_parser.add_argument(
|
|
18
|
+
"t",
|
|
19
|
+
type=str,
|
|
20
|
+
location="args",
|
|
21
|
+
help="this is a technical argument auto-generated for audio content",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
CAPTCHETAT_ERROR = "CaptchEtat request didn't failed but didn't contain any access_token"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bearer_token():
|
|
28
|
+
"""Get CaptchEtat bearer token from cache or get a new one from CaptchEtat Oauth server"""
|
|
29
|
+
token_cache_key = current_app.config.get("CAPTCHETAT_TOKEN_CACHE_KEY")
|
|
30
|
+
url = current_app.config.get("CAPTCHETAT_OAUTH_BASE_URL")
|
|
31
|
+
previous_value = cache.get(token_cache_key)
|
|
32
|
+
if previous_value:
|
|
33
|
+
return previous_value
|
|
34
|
+
log.debug(f"New access token requested from {url}")
|
|
35
|
+
try:
|
|
36
|
+
oauth = requests.post(
|
|
37
|
+
f"{url}/api/oauth/token",
|
|
38
|
+
data={
|
|
39
|
+
"grant_type": "client_credentials",
|
|
40
|
+
"scope": "piste.captchetat",
|
|
41
|
+
"client_id": current_app.config.get("CAPTCHETAT_CLIENT_ID"),
|
|
42
|
+
"client_secret": current_app.config.get("CAPTCHETAT_CLIENT_SECRET"),
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
oauth.raise_for_status()
|
|
46
|
+
body = oauth.json()
|
|
47
|
+
access_token = body.get("access_token")
|
|
48
|
+
if not access_token:
|
|
49
|
+
raise requests.exceptions.RequestException(CAPTCHETAT_ERROR)
|
|
50
|
+
cache.set(token_cache_key, access_token, timeout=body.get("expires_in", 0))
|
|
51
|
+
except requests.exceptions.RequestException as request_exception:
|
|
52
|
+
log.exception(f"Error while getting access token from {url}")
|
|
53
|
+
raise request_exception
|
|
54
|
+
else:
|
|
55
|
+
return access_token
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@ns.route("/", endpoint="captchetat")
|
|
59
|
+
class CaptchEtatAPI(API):
|
|
60
|
+
@apiv2.expect(captchetat_parser)
|
|
61
|
+
@apiv2.doc("captchetat")
|
|
62
|
+
def get(self):
|
|
63
|
+
"""CaptchEtat endpoint for captcha generation and validation"""
|
|
64
|
+
args = captchetat_parser.parse_args()
|
|
65
|
+
try:
|
|
66
|
+
token = bearer_token()
|
|
67
|
+
headers = {}
|
|
68
|
+
if token:
|
|
69
|
+
headers = {"Authorization": "Bearer " + token}
|
|
70
|
+
captchetat_url = current_app.config.get("CAPTCHETAT_BASE_URL")
|
|
71
|
+
req = requests.get(
|
|
72
|
+
f"{captchetat_url}/simple-captcha-endpoint", headers=headers, params=args
|
|
73
|
+
)
|
|
74
|
+
req.raise_for_status()
|
|
75
|
+
except requests.exceptions.RequestException:
|
|
76
|
+
abort(500, description="Catptcha internal error")
|
|
77
|
+
|
|
78
|
+
resp = make_response(bytes(req.content))
|
|
79
|
+
resp.headers["Content-Type"] = req.headers.get("Content-Type")
|
|
80
|
+
return resp
|
udata/core/dataset/api.py
CHANGED
|
@@ -64,7 +64,7 @@ from .api_fields import (
|
|
|
64
64
|
upload_community_fields,
|
|
65
65
|
upload_fields,
|
|
66
66
|
)
|
|
67
|
-
from .constants import RESOURCE_TYPES,
|
|
67
|
+
from .constants import RESOURCE_TYPES, UpdateFrequency
|
|
68
68
|
from .exceptions import (
|
|
69
69
|
SchemasCacheUnavailableException,
|
|
70
70
|
SchemasCatalogNotFoundException,
|
|
@@ -890,7 +890,7 @@ class FrequenciesAPI(API):
|
|
|
890
890
|
@api.marshal_list_with(frequency_fields)
|
|
891
891
|
def get(self):
|
|
892
892
|
"""List all available frequencies"""
|
|
893
|
-
return [{"id": id, "label": label} for
|
|
893
|
+
return [{"id": f.id, "label": f.label} for f in UpdateFrequency]
|
|
894
894
|
|
|
895
895
|
|
|
896
896
|
@ns.route("/extensions/", endpoint="allowed_extensions")
|
udata/core/dataset/api_fields.py
CHANGED
|
@@ -9,11 +9,10 @@ from udata.core.user.api_fields import user_ref_fields
|
|
|
9
9
|
from .constants import (
|
|
10
10
|
CHECKSUM_TYPES,
|
|
11
11
|
DEFAULT_CHECKSUM_TYPE,
|
|
12
|
-
DEFAULT_FREQUENCY,
|
|
13
12
|
DEFAULT_LICENSE,
|
|
14
13
|
RESOURCE_FILETYPES,
|
|
15
14
|
RESOURCE_TYPES,
|
|
16
|
-
|
|
15
|
+
UpdateFrequency,
|
|
17
16
|
)
|
|
18
17
|
|
|
19
18
|
checksum_fields = api.model(
|
|
@@ -361,8 +360,8 @@ dataset_fields = api.model(
|
|
|
361
360
|
"frequency": fields.String(
|
|
362
361
|
description="The update frequency",
|
|
363
362
|
required=True,
|
|
364
|
-
enum=list(
|
|
365
|
-
default=
|
|
363
|
+
enum=list(UpdateFrequency),
|
|
364
|
+
default=UpdateFrequency.UNKNOWN,
|
|
366
365
|
),
|
|
367
366
|
"frequency_date": fields.ISODateTime(
|
|
368
367
|
description=(
|
udata/core/dataset/apiv2.py
CHANGED
|
@@ -30,7 +30,7 @@ from .api_fields import (
|
|
|
30
30
|
temporal_coverage_fields,
|
|
31
31
|
user_ref_fields,
|
|
32
32
|
)
|
|
33
|
-
from .constants import
|
|
33
|
+
from .constants import DEFAULT_LICENSE, FULL_OBJECTS_HEADER, UpdateFrequency
|
|
34
34
|
from .models import CommunityResource, Dataset
|
|
35
35
|
from .search import DatasetSearch
|
|
36
36
|
|
|
@@ -157,13 +157,13 @@ dataset_fields = apiv2.model(
|
|
|
157
157
|
),
|
|
158
158
|
"frequency": fields.Raw(
|
|
159
159
|
attribute=lambda d: {
|
|
160
|
-
"id": d.frequency or
|
|
161
|
-
"label":
|
|
160
|
+
"id": (d.frequency or UpdateFrequency.UNKNOWN).id,
|
|
161
|
+
"label": (d.frequency or UpdateFrequency.UNKNOWN).label,
|
|
162
162
|
}
|
|
163
163
|
if request.headers.get(FULL_OBJECTS_HEADER, False, bool)
|
|
164
|
-
else d.frequency,
|
|
165
|
-
enum=list(
|
|
166
|
-
default=
|
|
164
|
+
else (d.frequency or UpdateFrequency.UNKNOWN),
|
|
165
|
+
enum=list(UpdateFrequency),
|
|
166
|
+
default=UpdateFrequency.UNKNOWN,
|
|
167
167
|
required=True,
|
|
168
168
|
description="The update frequency (full Frequency object if `X-Get-Datasets-Full-Objects` is set, ID of the frequency otherwise)",
|
|
169
169
|
),
|
udata/core/dataset/commands.py
CHANGED
|
@@ -10,7 +10,6 @@ from udata.core.dataset.constants import DEFAULT_LICENSE
|
|
|
10
10
|
from udata.models import Dataset, License
|
|
11
11
|
|
|
12
12
|
from . import actions
|
|
13
|
-
from .tasks import send_frequency_reminder
|
|
14
13
|
|
|
15
14
|
log = logging.getLogger(__name__)
|
|
16
15
|
|
|
@@ -66,15 +65,6 @@ def licenses(source=DEFAULT_LICENSE_FILE):
|
|
|
66
65
|
success("Done")
|
|
67
66
|
|
|
68
67
|
|
|
69
|
-
@cli.command()
|
|
70
|
-
def frequency_reminder():
|
|
71
|
-
"""Send a unique email per organization to members
|
|
72
|
-
|
|
73
|
-
to remind them they have outdated datasets on the website.
|
|
74
|
-
"""
|
|
75
|
-
send_frequency_reminder()
|
|
76
|
-
|
|
77
|
-
|
|
78
68
|
@cli.group("dataset")
|
|
79
69
|
def grp():
|
|
80
70
|
"""Dataset related operations"""
|