def handle_response(response): response.headers["_RV"] = str(version) PROJECT_VERSION = get_project_configuration("project.version", default=None) if PROJECT_VERSION is not None: response.headers["Version"] = str(PROJECT_VERSION) # If it is an upload, DO NOT consume request.data or request.json, # otherwise the content gets lost try: if request.mimetype in ["application/octet-stream", "multipart/form-data"]: data = "STREAM_UPLOAD" elif request.data: data = handle_log_output(request.data) elif request.form: data = obfuscate_dict(request.form) else: data = "" if data: data = f" {data}" except Exception as e: # pragma: no cover log.debug(e) data = "" url = obfuscate_query_parameters(request.url) if GZIP_ENABLE and "gzip" in request.headers.get("Accept-Encoding", "").lower(): response.direct_passthrough = False content, headers = ResponseMaker.gzip_response( response.data, response.status_code, response.headers.get("Content-Encoding"), response.headers.get("Content-Type"), ) if content: response.data = content try: response.headers.update(headers) # Back-compatibility for Werkzeug 0.16.1 as used in B2STAGE except AttributeError: # pragma: no cover for k, v in headers.items(): response.headers.set(k, v) resp = str(response).replace("<Response ", "").replace(">", "") log.info( "{} {} {}{} -> {}", BaseAuthentication.get_remote_ip(), request.method, url, data, resp, ) return response
def get_qrcode(self, user: User) -> str: secret = self.get_totp_secret(user) totp = pyotp.TOTP(secret) project_name = get_project_configuration("project.title", "No project name") otpauth_url = totp.provisioning_uri(project_name) qr_url = segno.make(otpauth_url) qr_stream = BytesIO() qr_url.save(qr_stream, kind="png", scale=5) return base64.b64encode(qr_stream.getvalue()).decode("utf-8")
def send_notification( subject: str, template: str, # if None will be sent to the administrator to_address: Optional[str] = None, data: Optional[Dict[str, Any]] = None, user: Optional[User] = None, send_async: bool = False, ) -> bool: # Always enabled during tests if not Connector.check_availability("smtp"): # pragma: no cover return False title = get_project_configuration("project.title", default="Unkown title") reply_to = Env.get("SMTP_NOREPLY", Env.get("SMTP_ADMIN", "")) if data is None: data = {} data.setdefault("project", title) data.setdefault("reply_to", reply_to) if user: data.setdefault("username", user.email) data.setdefault("name", user.name) data.setdefault("surname", user.surname) html_body, plain_body = get_html_template(template, data) if not html_body: # pragma: no cover log.error("Can't load {}", template) return False subject = f"{title}: {subject}" if send_async: Mail.send_async( subject=subject, body=html_body, to_address=to_address, plain_body=plain_body, ) return False smtp_client = smtp.get_instance() return smtp_client.send( subject=subject, body=html_body, to_address=to_address, plain_body=plain_body, )
def post(self, **kwargs: Any) -> Response: """ Register new user """ email = kwargs.get("email") user = self.auth.get_user(username=email) if user is not None: raise Conflict(f"This user already exists: {email}") password_confirm = kwargs.pop("password_confirm") if kwargs.get("password") != password_confirm: raise Conflict("Your password doesn't match the confirmation") if self.auth.VERIFY_PASSWORD_STRENGTH: check, msg = self.auth.verify_password_strength( kwargs.get("password"), None) if not check: raise Conflict(msg) kwargs["is_active"] = False user = self.auth.create_user(kwargs, [self.auth.default_role]) default_group = self.auth.get_group(name=DEFAULT_GROUP_NAME) self.auth.add_user_to_group(user, default_group) self.auth.save_user(user) self.log_event(self.events.create, user, kwargs) try: smtp_client = smtp.get_instance() if Env.get_bool("REGISTRATION_NOTIFICATIONS"): # Sending an email to the administrator title = get_project_configuration("project.title", default="Unkown title") subject = f"{title} New credentials requested" body = f"New credentials request from {user.email}" smtp_client.send(body, subject) send_activation_link(smtp_client, self.auth, user) except BaseException as e: # pragma: no cover self.auth.delete_user(user) raise ServiceUnavailable( f"Errors during account registration: {e}") return self.response( "We are sending an email to your email address where " "you will find the link to activate your account")
def handle_response(response: FlaskResponse) -> FlaskResponse: response.headers["_RV"] = str(version) PROJECT_VERSION = get_project_configuration("project.version", default="0") if PROJECT_VERSION is not None: response.headers["Version"] = str(PROJECT_VERSION) data_string = get_data_from_request() url = obfuscate_query_parameters(request.url) if (GZIP_ENABLE and not response.is_streamed and "gzip" in request.headers.get("Accept-Encoding", "").lower()): response.direct_passthrough = False content, headers = ResponseMaker.gzip_response( response.data, response.status_code, response.headers.get("Content-Encoding"), response.headers.get("Content-Type"), ) if content: response.data = content response.headers.update(headers) resp = str(response).replace("<Response ", "").replace(">", "") ip = BaseAuthentication.get_remote_ip(raise_warnings=False) is_healthcheck = (ip == "127.0.0.1" and request.method == "GET" and url == "/api/status") if is_healthcheck: log.debug( "{} {} {}{} -> {} [HEALTHCHECK]", ip, request.method, url, data_string, resp, ) else: log.info( "{} {} {}{} -> {}", ip, request.method, url, data_string, resp, ) return response
def load_roles() -> None: BaseAuthentication.roles_data = get_project_configuration( "variables.roles" ).copy() if not BaseAuthentication.roles_data: # pragma: no cover print_and_exit("No roles configured") BaseAuthentication.default_role = BaseAuthentication.roles_data.pop("default") BaseAuthentication.roles = [] for role, description in BaseAuthentication.roles_data.items(): if description != ROLE_DISABLED: BaseAuthentication.roles.append(role) if not BaseAuthentication.default_role: # pragma: no cover print_and_exit( "Default role {} not available!", BaseAuthentication.default_role )
def post(self, reset_email: str) -> Response: reset_email = reset_email.lower() self.auth.verify_blocked_username(reset_email) user = self.auth.get_user(username=reset_email) if user is None: raise Forbidden( f"Sorry, {reset_email} is not recognized as a valid username", ) self.auth.verify_user_status(user) title = get_project_configuration("project.title", default="Unkown title") reset_token, payload = self.auth.create_temporary_token( user, self.auth.PWD_RESET) server_url = get_frontend_url() rt = reset_token.replace(".", "+") uri = Env.get("RESET_PASSWORD_URI", "/public/reset") complete_uri = f"{server_url}{uri}/{rt}" smtp_client = smtp.get_instance() send_password_reset_link(smtp_client, complete_uri, title, reset_email) ################## # Completing the reset task self.auth.save_token(user, reset_token, payload, token_type=self.auth.PWD_RESET) msg = "We'll send instructions to the email provided if it's associated " msg += "with an account. Please check your spam/junk folder." self.log_event(self.events.reset_password_request, user=user) return self.response(msg)
def send_activation_link(smtp, auth, user): title = get_project_configuration("project.title", default="Unkown title") activation_token, payload = auth.create_temporary_token( user, auth.ACTIVATE_ACCOUNT) server_url = get_frontend_url() rt = activation_token.replace(".", "+") log.debug("Activation token: {}", rt) url = f"{server_url}/public/register/{rt}" body: Optional[str] = f"Follow this link to activate your account: {url}" # customized template template_file = "activate_account.html" html_body = get_html_template( template_file, { "url": url, "username": user.email, "name": user.name, "surname": user.surname, }, ) if html_body is None: html_body = body body = None default_subject = f"{title} account activation" subject = os.getenv("EMAIL_ACTIVATION_SUBJECT", default_subject) sent = smtp.send(html_body, subject, user.email, plain_body=body) if not sent: # pragma: no cover raise BaseException("Error sending email, please retry") auth.save_token(user, activation_token, payload, token_type=auth.ACTIVATE_ACCOUNT)
def verify_credentials_ban_notification(self) -> str: # Verify email sent to notify credentials block, # + extract and return the unlock url mail = self.read_mock_email() body = mail.get("body", "") project_tile = get_project_configuration( "project.title", default="YourProject" ) assert body is not None assert mail.get("headers", "") is not None title = "Your credentials have been blocked" assert f"Subject: {project_tile}: {title}" in mail.get("headers", "") # Body can't be asserted if can be changed at project level... # assert "this email is to inform you that your credentials have been " # "temporarily due to the number of failed login attempts" in body # assert "inspect the list below to detect any unwanted login" in body # assert "Your credentials will be automatically unlocked in" in body token = self.get_token_from_body(body) assert token is not None return token
def wrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) except BaseException: task_id = self.request.id task_name = self.request.task log.error("Celery task {} failed ({})", task_id, task_name) arguments = str(self.request.args) log.error("Failed task arguments: {}", arguments[0:256]) log.error("Task error: {}", traceback.format_exc()) if Connector.check_availability("smtp"): log.info("Sending error report by email", task_id, task_name) body = f""" Celery task {task_id} failed Name: {task_name} Arguments: {self.request.args} Error: {traceback.format_exc()} """ project = get_project_configuration( "project.title", default="Unkown title", ) subject = f"{project}: task {task_name} failed" from restapi.connectors import smtp smtp_client = smtp.get_instance() smtp_client.send(body, subject)
def send_notification(smtp, user, unhashed_password, is_update=False): title = get_project_configuration("project.title", default="Unkown title") if is_update: subject = f"{title}: password changed" template = "update_credentials.html" else: subject = f"{title}: new credentials" template = "new_credentials.html" replaces = {"username": user.email, "password": unhashed_password} html = get_html_template(template, replaces) body = f""" Username: {user.email} Password: {unhashed_password} """ if html is None: smtp.send(body, subject, user.email) else: smtp.send(html, subject, user.email, plain_body=body)
def test_registration(self, client: FlaskClient, faker: Faker) -> None: # Always enabled during core tests if not Env.get_bool("ALLOW_REGISTRATION"): # pragma: no cover log.warning("User registration is disabled, skipping tests") return project_tile = get_project_configuration("project.title", default="YourProject") proto = "https" if PRODUCTION else "http" # registration, empty input r = client.post(f"{AUTH_URI}/profile") assert r.status_code == 400 # registration, missing information r = client.post(f"{AUTH_URI}/profile", data={"x": "y"}) assert r.status_code == 400 registration_data = {} registration_data["password"] = faker.password(5) r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 400 registration_data["email"] = BaseAuthentication.default_user r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 400 registration_data["name"] = faker.first_name() r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 400 registration_data["surname"] = faker.last_name() r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 400 registration_data["password_confirm"] = faker.password(strong=True) r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 400 min_pwd_len = Env.get_int("AUTH_MIN_PASSWORD_LENGTH", 9999) registration_data["password"] = faker.password(min_pwd_len - 1) r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 400 registration_data["password"] = faker.password(min_pwd_len) r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 409 m = f"This user already exists: {BaseAuthentication.default_user}" assert self.get_content(r) == m registration_data["email"] = faker.ascii_email() r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 409 assert self.get_content( r) == "Your password doesn't match the confirmation" registration_data["password"] = faker.password(min_pwd_len, low=False, up=True) registration_data["password_confirm"] = registration_data["password"] r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 409 m = "Password is too weak, missing lower case letters" assert self.get_content(r) == m registration_data["password"] = faker.password(min_pwd_len, low=True) registration_data["password_confirm"] = registration_data["password"] r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 409 m = "Password is too weak, missing upper case letters" assert self.get_content(r) == m registration_data["password"] = faker.password(min_pwd_len, low=True, up=True) registration_data["password_confirm"] = registration_data["password"] r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 409 m = "Password is too weak, missing numbers" assert self.get_content(r) == m registration_data["password"] = faker.password(min_pwd_len, low=True, up=True, digits=True) registration_data["password_confirm"] = registration_data["password"] r = client.post(f"{AUTH_URI}/profile", data=registration_data) assert r.status_code == 409 m = "Password is too weak, missing special characters" assert self.get_content(r) == m registration_data["password"] = faker.password(strong=True) registration_data["password_confirm"] = registration_data["password"] r = client.post(f"{AUTH_URI}/profile", data=registration_data) # now the user is created but INACTIVE, activation endpoint is needed assert r.status_code == 200 registration_message = "We are sending an email to your email address where " registration_message += "you will find the link to activate your account" assert self.get_content(r) == registration_message events = self.get_last_events(1) assert events[0].event == Events.create.value assert events[0].user == "-" assert events[0].target_type == "User" assert "name" in events[0].payload assert "password" in events[0].payload assert events[0].payload["password"] == OBSCURE_VALUE mail = self.read_mock_email() body = mail.get("body") assert body is not None assert mail.get("headers") is not None # Subject: is a key in the MIMEText assert f"Subject: {project_tile} account activation" in mail.get( "headers") assert f"{proto}://localhost/public/register/" in body # This will fail because the user is not active _, error = self.do_login( client, registration_data["email"], registration_data["password"], status_code=403, ) assert error == "Sorry, this account is not active" # Also password reset is not allowed data = {"reset_email": registration_data["email"]} r = client.post(f"{AUTH_URI}/reset", data=data) assert r.status_code == 403 assert self.get_content(r) == "Sorry, this account is not active" events = self.get_last_events(2) assert events[0].event == Events.refused_login.value assert events[0].payload["username"] == data["reset_email"] assert events[0].payload["motivation"] == "account not active" assert events[1].event == Events.refused_login.value assert events[1].payload["username"] == data["reset_email"] assert events[1].payload["motivation"] == "account not active" # Activation, missing or wrong information r = client.post(f"{AUTH_URI}/profile/activate") assert r.status_code == 400 r = client.post(f"{AUTH_URI}/profile/activate", data=faker.pydict(2)) assert r.status_code == 400 # It isn't an email invalid = faker.pystr(10) r = client.post(f"{AUTH_URI}/profile/activate", data={"username": invalid}) assert r.status_code == 400 headers, _ = self.do_login(client, None, None) activation_message = "We are sending an email to your email address where " activation_message += "you will find the link to activate your account" # request activation, wrong username r = client.post(f"{AUTH_URI}/profile/activate", data={"username": faker.ascii_email()}) # return is 200, but no token will be generated and no mail will be sent # but it respond with the activation msg and hides the non existence of the user assert r.status_code == 200 assert self.get_content(r) == activation_message events = self.get_last_events(1) assert events[0].event != Events.activation.value assert self.read_mock_email() is None # request activation, correct username r = client.post( f"{AUTH_URI}/profile/activate", data={"username": registration_data["email"]}, ) assert r.status_code == 200 assert self.get_content(r) == activation_message mail = self.read_mock_email() body = mail.get("body") assert body is not None assert mail.get("headers") is not None # Subject: is a key in the MIMEText assert f"Subject: {project_tile} account activation" in mail.get( "headers") assert f"{proto}://localhost/public/register/" in body token = self.get_token_from_body(body) assert token is not None # profile activation r = client.put(f"{AUTH_URI}/profile/activate/thisisatoken") # this token is not valid assert r.status_code == 400 # profile activation r = client.put(f"{AUTH_URI}/profile/activate/{token}") assert r.status_code == 200 assert self.get_content(r) == "Account activated" events = self.get_last_events(1) assert events[0].event == Events.activation.value assert events[0].user == registration_data["email"] assert events[0].target_type == "User" # Activation token is no longer valid r = client.put(f"{AUTH_URI}/profile/activate/{token}") assert r.status_code == 400 assert self.get_content(r) == "Invalid activation token" # Token created for another user token = self.get_crafted_token("a") r = client.put(f"{AUTH_URI}/profile/activate/{token}") assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid activation token" # Token created for another user token = self.get_crafted_token("a", wrong_algorithm=True) r = client.put(f"{AUTH_URI}/profile/activate/{token}") assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid activation token" # Token created for another user token = self.get_crafted_token("a", wrong_secret=True) r = client.put(f"{AUTH_URI}/profile/activate/{token}") assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid activation token" headers, _ = self.do_login(client, None, None) r = client.get(f"{AUTH_URI}/profile", headers=headers) assert r.status_code == 200 uuid = self.get_content(r).get("uuid") token = self.get_crafted_token("x", user_id=uuid) r = client.put(f"{AUTH_URI}/profile/activate/{token}") assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid activation token" # token created for the correct user, but from outside the system!! token = self.get_crafted_token("a", user_id=uuid) r = client.put(f"{AUTH_URI}/profile/activate/{token}") assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid activation token" # Immature token token = self.get_crafted_token("a", user_id=uuid, immature=True) r = client.put(f"{AUTH_URI}/profile/activate/{token}") assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid activation token" # Expired token token = self.get_crafted_token("a", user_id=uuid, expired=True) r = client.put(f"{AUTH_URI}/profile/activate/{token}") assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid activation token: this request is expired" # Testing the following use case: # 1 - user registration # 2 - user activation using unconventional channel, e.g. by admins # 3 - user tries to activate and fails because already active registration_data["email"] = faker.ascii_email() r = client.post(f"{AUTH_URI}/profile", data=registration_data) # now the user is created but INACTIVE, activation endpoint is needed assert r.status_code == 200 mail = self.read_mock_email() body = mail.get("body") assert body is not None assert mail.get("headers") is not None assert f"{proto}://localhost/public/register/" in body token = self.get_token_from_body(body) assert token is not None headers, _ = self.do_login(client, None, None) r = client.get(f"{API_URI}/admin/users", headers=headers) assert r.status_code == 200 users = self.get_content(r) uuid = None for u in users: if u.get("email") == registration_data["email"]: uuid = u.get("uuid") break assert uuid is not None r = client.put(f"{API_URI}/admin/users/{uuid}", data={"is_active": True}, headers=headers) assert r.status_code == 204 r = client.put(f"{AUTH_URI}/profile/activate/{token}") assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid activation token: this request is no longer valid" r = client.get(f"{API_URI}/admin/tokens", headers=headers) content = self.get_content(r) for t in content: if t.get("token") == token: # pragma: no cover pytest.fail( "Token not properly invalidated, still bount to user {}", t.get(id))
def test_admin_users(self, client: FlaskClient, faker: Faker) -> None: if not Env.get_bool("MAIN_LOGIN_ENABLE") or not Env.get_bool( "AUTH_ENABLE"): log.warning("Skipping admin/users tests") return project_tile = get_project_configuration("project.title", default="YourProject") auth = Connector.get_authentication_instance() staff_role_enabled = Role.STAFF.value in [ r.name for r in auth.get_roles() ] for role in ( Role.ADMIN, Role.STAFF, ): if not staff_role_enabled: # pragma: no cover log.warning( "Skipping tests of admin/users endpoints, role Staff not enabled" ) continue else: log.warning("Testing admin/users endpoints as {}", role) if role == Role.ADMIN: user_email = BaseAuthentication.default_user user_password = BaseAuthentication.default_password elif role == Role.STAFF: _, user_data = self.create_user(client, roles=[Role.STAFF]) user_email = user_data.get("email") user_password = user_data.get("password") headers, _ = self.do_login(client, user_email, user_password) r = client.get(f"{API_URI}/admin/users", headers=headers) assert r.status_code == 200 schema = self.get_dynamic_input_schema(client, "admin/users", headers) data = self.buildData(schema) data["email_notification"] = True data["is_active"] = True data["expiration"] = None # Event 1: create r = client.post(f"{API_URI}/admin/users", json=data, headers=headers) assert r.status_code == 200 uuid = self.get_content(r) assert isinstance(uuid, str) # A new User is created events = self.get_last_events(1, filters={"target_type": "User"}) assert events[0].event == Events.create.value assert events[0].user == user_email assert events[0].target_type == "User" assert events[0].url == "/api/admin/users" assert "name" in events[0].payload assert "surname" in events[0].payload assert "email" in events[0].payload # Save it for the following tests event_target_id1 = events[0].target_id mail = self.read_mock_email() body = mail.get("body", "") # Subject: is a key in the MIMEText assert body is not None assert mail.get("headers") is not None assert f"Subject: {project_tile}: New credentials" in mail.get( "headers", "") assert data.get("email", "MISSING").lower() in body assert (data.get("password", "MISSING") in body or escape(str(data.get("password"))) in body) # Test the differences between post and put schema post_schema = {s["key"]: s for s in schema} tmp_schema = self.get_dynamic_input_schema(client, f"admin/users/{uuid}", headers, method="put") put_schema = {s["key"]: s for s in tmp_schema} assert "email" in post_schema assert post_schema["email"]["required"] assert "email" not in put_schema assert "name" in post_schema assert post_schema["name"]["required"] assert "name" in put_schema assert not put_schema["name"]["required"] assert "surname" in post_schema assert post_schema["surname"]["required"] assert "surname" in put_schema assert not put_schema["surname"]["required"] assert "password" in post_schema assert post_schema["password"]["required"] assert "password" in put_schema assert not put_schema["password"]["required"] assert "group" in post_schema assert post_schema["group"]["required"] assert "group" in put_schema assert not put_schema["group"]["required"] # Event 2: read r = client.get(f"{API_URI}/admin/users/{uuid}", headers=headers) assert r.status_code == 200 users_list = self.get_content(r) assert isinstance(users_list, dict) assert len(users_list) > 0 # email is saved lowercase assert users_list.get("email") == data.get("email", "MISSING").lower() # Access to the user events = self.get_last_events(1, filters={"target_type": "User"}) assert events[0].event == Events.access.value assert events[0].user == user_email assert events[0].target_type == "User" assert events[0].target_id == event_target_id1 assert events[0].url == f"/api/admin/users/{event_target_id1}" assert len(events[0].payload) == 0 # Check duplicates r = client.post(f"{API_URI}/admin/users", json=data, headers=headers) assert r.status_code == 409 assert (self.get_content(r) == f"A User already exists with email: {data['email']}") data["email"] = BaseAuthentication.default_user r = client.post(f"{API_URI}/admin/users", json=data, headers=headers) assert r.status_code == 409 assert ( self.get_content(r) == f"A User already exists with email: {BaseAuthentication.default_user}" ) # Create another user data2 = self.buildData(schema) data2["email_notification"] = True data2["is_active"] = True data2["expiration"] = None # Event 3: create r = client.post(f"{API_URI}/admin/users", json=data2, headers=headers) assert r.status_code == 200 uuid2 = self.get_content(r) assert isinstance(uuid2, str) # Another User is created events = self.get_last_events(1, filters={"target_type": "User"}) assert events[0].event == Events.create.value assert events[0].user == user_email assert events[0].target_type == "User" assert events[0].target_id != event_target_id1 assert events[0].url == "/api/admin/users" assert "name" in events[0].payload assert "surname" in events[0].payload assert "email" in events[0].payload # Save it for the following tests event_target_id2 = events[0].target_id mail = self.read_mock_email() body = mail.get("body", "") # Subject: is a key in the MIMEText assert body is not None assert mail.get("headers") is not None assert f"Subject: {project_tile}: New credentials" in mail.get( "headers", "") assert data2.get("email", "MISSING").lower() in body pwd = data2.get("password", "MISSING") assert pwd in body or escape(str(pwd)) in body # send and invalid user_id r = client.put( f"{API_URI}/admin/users/invalid", json={"name": faker.name()}, headers=headers, ) assert r.status_code == 404 # Event 4: modify r = client.put( f"{API_URI}/admin/users/{uuid}", json={"name": faker.name()}, headers=headers, ) assert r.status_code == 204 # User 1 modified (same target_id as above) events = self.get_last_events(1, filters={"target_type": "User"}) assert events[0].event == Events.modify.value assert events[0].user == user_email assert events[0].target_type == "User" assert events[0].target_id == event_target_id1 assert events[0].url == f"/api/admin/users/{event_target_id1}" assert "name" in events[0].payload assert "surname" not in events[0].payload assert "email" not in events[0].payload assert "password" not in events[0].payload # email cannot be modified new_data = {"email": data.get("email")} r = client.put(f"{API_URI}/admin/users/{uuid2}", json=new_data, headers=headers) # from webargs >= 6 this endpoint no longer return a 204 but a 400 # because email is an unknown field # assert r.status_code == 204 assert r.status_code == 400 # Event 5: read r = client.get(f"{API_URI}/admin/users/{uuid2}", headers=headers) assert r.status_code == 200 users_list = self.get_content(r) assert isinstance(users_list, dict) assert len(users_list) > 0 # email is not modified -> still equal to data2, not data1 assert users_list.get("email") != data.get("email", "MISSING").lower() assert users_list.get("email") == data2.get("email", "MISSING").lower() # Access to user 2 events = self.get_last_events(1, filters={"target_type": "User"}) assert events[0].event == Events.access.value assert events[0].user == user_email assert events[0].target_type == "User" assert events[0].target_id == event_target_id2 assert events[0].url == f"/api/admin/users/{event_target_id2}" assert len(events[0].payload) == 0 r = client.delete(f"{API_URI}/admin/users/invalid", headers=headers) assert r.status_code == 404 # Event 6: delete r = client.delete(f"{API_URI}/admin/users/{uuid}", headers=headers) assert r.status_code == 204 # User 1 is deleted (same target_id as above) events = self.get_last_events(1, filters={"target_type": "User"}) assert events[0].event == Events.delete.value assert events[0].user == user_email assert events[0].target_type == "User" assert events[0].target_id == event_target_id1 assert events[0].url == f"/api/admin/users/{event_target_id1}" assert len(events[0].payload) == 0 r = client.get(f"{API_URI}/admin/users/{uuid}", headers=headers) assert r.status_code == 404 # change password of user2 # Event 7: modify newpwd = faker.password(strong=True) data = {"password": newpwd, "email_notification": True} r = client.put(f"{API_URI}/admin/users/{uuid2}", json=data, headers=headers) assert r.status_code == 204 # User 2 modified (same target_id as above) events = self.get_last_events(1, filters={"target_type": "User"}) assert events[0].event == Events.modify.value assert events[0].user == user_email assert events[0].target_type == "User" assert events[0].target_id == event_target_id2 assert events[0].url == f"/api/admin/users/{event_target_id2}" assert "name" not in events[0].payload assert "surname" not in events[0].payload assert "email" not in events[0].payload assert "password" in events[0].payload assert "email_notification" in events[0].payload # Verify that the password is obfuscated in the log: assert events[0].payload["password"] == OBSCURE_VALUE mail = self.read_mock_email() # Subject: is a key in the MIMEText assert mail.get("body", "") is not None assert mail.get("headers", "") is not None assert f"Subject: {project_tile}: Password changed" in mail.get( "headers", "") assert data2.get("email", "MISSING").lower() in mail.get("body", "") assert newpwd in mail.get( "body", "") or escape(newpwd) in mail.get("body", "") # login with a newly created user headers2, _ = self.do_login(client, data2.get("email"), newpwd) # normal users cannot access to this endpoint r = client.get(f"{API_URI}/admin/users", headers=headers2) assert r.status_code == 401 r = client.get(f"{API_URI}/admin/users/{uuid}", headers=headers2) assert r.status_code == 401 r = client.post(f"{API_URI}/admin/users", json=data, headers=headers2) assert r.status_code == 401 r = client.put( f"{API_URI}/admin/users/{uuid}", json={"name": faker.name()}, headers=headers2, ) assert r.status_code == 401 r = client.delete(f"{API_URI}/admin/users/{uuid}", headers=headers2) assert r.status_code == 401 # Users are not authorized to /admin/tokens # These two tests should be moved in test_endpoints_tokens.py r = client.get(f"{API_URI}/admin/tokens", headers=headers2) assert r.status_code == 401 r = client.delete(f"{API_URI}/admin/tokens/xyz", headers=headers2) assert r.status_code == 401 # let's delete the second user # Event 8: delete r = client.delete(f"{API_URI}/admin/users/{uuid2}", headers=headers) assert r.status_code == 204 # User 2 is deleted (same target_id as above) events = self.get_last_events(1, filters={"target_type": "User"}) assert events[0].event == Events.delete.value assert events[0].user == user_email assert events[0].target_type == "User" assert events[0].target_id == event_target_id2 assert events[0].url == f"/api/admin/users/{event_target_id2}" assert len(events[0].payload) == 0 # Restore the default password (changed due to FORCE_FIRST_PASSWORD_CHANGE) # or MAX_PASSWORD_VALIDITY errors r = client.get(f"{AUTH_URI}/profile", headers=headers) assert r.status_code == 200 content = self.get_content(r) assert isinstance(content, dict) uuid = str(content.get("uuid")) data = { "password": user_password, # very important, otherwise the default user will lose its role "roles": orjson.dumps([role]).decode("UTF8"), } # Event 9: modify r = client.put(f"{API_URI}/admin/users/{uuid}", json=data, headers=headers) assert r.status_code == 204 # Default user is modified events = self.get_last_events(1, filters={"target_type": "User"}) assert events[0].event == Events.modify.value assert events[0].user == user_email assert events[0].target_type == "User" assert events[0].target_id != event_target_id1 assert events[0].target_id != event_target_id2 assert events[0].url != f"/api/admin/users/{event_target_id1}" assert events[0].url != f"/api/admin/users/{event_target_id2}" assert "name" not in events[0].payload assert "surname" not in events[0].payload assert "email" not in events[0].payload assert "password" in events[0].payload assert "roles" in events[0].payload assert "email_notification" not in events[0].payload # Verify that the password is obfuscated in the log: assert events[0].payload["password"] == OBSCURE_VALUE r = client.get(f"{AUTH_URI}/logout", headers=headers) assert r.status_code == 204
def test_admin_users(self, client: FlaskClient, faker: Faker) -> None: if not Env.get_bool("MAIN_LOGIN_ENABLE"): # pragma: no cover log.warning("Skipping admin/users tests") return project_tile = get_project_configuration("project.title", default="YourProject") headers, _ = self.do_login(client, None, None) r = client.get(f"{API_URI}/admin/users", headers=headers) assert r.status_code == 200 schema = self.getDynamicInputSchema(client, "admin/users", headers) data = self.buildData(schema) data["email_notification"] = True data["is_active"] = True data["expiration"] = None # Event 1: create r = client.post(f"{API_URI}/admin/users", data=data, headers=headers) assert r.status_code == 200 uuid = self.get_content(r) mail = self.read_mock_email() # Subject: is a key in the MIMEText assert mail.get("body") is not None assert mail.get("headers") is not None assert f"Subject: {project_tile}: new credentials" in mail.get( "headers") assert f"Username: {data.get('email', 'MISSING').lower()}" in mail.get( "body") assert f"Password: {data.get('password')}" in mail.get("body") # Event 2: read r = client.get(f"{API_URI}/admin/users/{uuid}", headers=headers) assert r.status_code == 200 users_list = self.get_content(r) assert len(users_list) > 0 # email is saved lowercase assert users_list[0].get("email") == data.get("email", "MISSING").lower() # Check duplicates r = client.post(f"{API_URI}/admin/users", data=data, headers=headers) assert r.status_code == 409 # Create another user data2 = self.buildData(schema) data2["email_notification"] = True data2["is_active"] = True data2["expiration"] = None # Event 3: create r = client.post(f"{API_URI}/admin/users", data=data2, headers=headers) assert r.status_code == 200 uuid2 = self.get_content(r) mail = self.read_mock_email() # Subject: is a key in the MIMEText assert mail.get("body") is not None assert mail.get("headers") is not None assert f"Subject: {project_tile}: new credentials" in mail.get( "headers") assert f"Username: {data2.get('email', 'MISSING').lower()}" in mail.get( "body") assert f"Password: {data2.get('password')}" in mail.get("body") # send and invalid user_id r = client.put( f"{API_URI}/admin/users/invalid", data={"name": faker.name()}, headers=headers, ) assert r.status_code == 404 # Event 4: modify r = client.put( f"{API_URI}/admin/users/{uuid}", data={"name": faker.name()}, headers=headers, ) assert r.status_code == 204 # email cannot be modified new_data = {"email": data.get("email")} r = client.put(f"{API_URI}/admin/users/{uuid2}", data=new_data, headers=headers) # from webargs >= 6 this endpoint no longer return a 204 but a 400 # because email is an unknown field # assert r.status_code == 204 assert r.status_code == 400 # Event 5: read r = client.get(f"{API_URI}/admin/users/{uuid2}", headers=headers) assert r.status_code == 200 users_list = self.get_content(r) assert len(users_list) > 0 # email is not modified -> still equal to data2, not data1 assert users_list[0].get("email") != data.get("email", "MISSING").lower() assert users_list[0].get("email") == data2.get("email", "MISSING").lower() r = client.delete(f"{API_URI}/admin/users/invalid", headers=headers) assert r.status_code == 404 # Event 6: delete r = client.delete(f"{API_URI}/admin/users/{uuid}", headers=headers) assert r.status_code == 204 r = client.get(f"{API_URI}/admin/users/{uuid}", headers=headers) assert r.status_code == 404 # change password of user2 # Event 7: modify newpwd = faker.password(strong=True) data = {"password": newpwd, "email_notification": True} r = client.put(f"{API_URI}/admin/users/{uuid2}", data=data, headers=headers) assert r.status_code == 204 mail = self.read_mock_email() # Subject: is a key in the MIMEText assert mail.get("body") is not None assert mail.get("headers") is not None assert f"Subject: {project_tile}: password changed" in mail.get( "headers") assert f"Username: {data2.get('email', 'MISSING').lower()}" in mail.get( "body") assert f"Password: {newpwd}" in mail.get("body") # login with a newly created user headers2, _ = self.do_login(client, data2.get("email"), newpwd) # normal users cannot access to this endpoint r = client.get(f"{API_URI}/admin/users", headers=headers2) assert r.status_code == 401 r = client.get(f"{API_URI}/admin/users/{uuid}", headers=headers2) assert r.status_code == 401 r = client.post(f"{API_URI}/admin/users", data=data, headers=headers2) assert r.status_code == 401 r = client.put( f"{API_URI}/admin/users/{uuid}", data={"name": faker.name()}, headers=headers2, ) assert r.status_code == 401 r = client.delete(f"{API_URI}/admin/users/{uuid}", headers=headers2) assert r.status_code == 401 # Users are not authorized to /admin/tokens # These two tests should be moved in test_endpoints_tokens.py r = client.get(f"{API_URI}/admin/tokens", headers=headers2) assert r.status_code == 401 r = client.delete(f"{API_URI}/admin/tokens/xyz", headers=headers2) assert r.status_code == 401 # let's delete the second user # Event 8: delete r = client.delete(f"{API_URI}/admin/users/{uuid2}", headers=headers) assert r.status_code == 204 # Restore the default password, if it changed due to FORCE_FIRST_PASSWORD_CHANGE # or MAX_PASSWORD_VALIDITY errors r = client.get(f"{AUTH_URI}/profile", headers=headers) assert r.status_code == 200 uuid = self.get_content(r).get("uuid") data = { "password": BaseAuthentication.default_password, # very important, otherwise the default user will lose its admin role "roles": json.dumps(["admin_root"]), } # Event 9: modify r = client.put(f"{API_URI}/admin/users/{uuid}", data=data, headers=headers) assert r.status_code == 204 r = client.get(f"{AUTH_URI}/logout", headers=headers) assert r.status_code == 204
def create_app( name: str = __name__, mode: ServerModes = ServerModes.NORMAL, options: Optional[Dict[str, bool]] = None, ) -> Flask: """ Create the server istance for Flask application """ if PRODUCTION and TESTING and not FORCE_PRODUCTION_TESTS: # pragma: no cover print_and_exit("Unable to execute tests in production") # TERM is not catched by Flask # https://github.com/docker/compose/issues/4199#issuecomment-426109482 # signal.signal(signal.SIGTERM, teardown_handler) # SIGINT is registered as STOPSIGNAL in Dockerfile signal.signal(signal.SIGINT, teardown_handler) # Flask app instance # template_folder = template dir for output in HTML microservice = Flask( name, template_folder=os.path.join(ABS_RESTAPI_PATH, "templates") ) # CORS if not PRODUCTION: cors = CORS( allow_headers=[ "Content-Type", "Authorization", "X-Requested-With", "x-upload-content-length", "x-upload-content-type", "content-range", ], supports_credentials=["true"], methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], ) cors.init_app(microservice) log.debug("CORS Injected") # Flask configuration from config file microservice.config.from_object(config) log.debug("Flask app configured") if PRODUCTION: log.info("Production server mode is ON") endpoints_loader = EndpointsLoader() mem.configuration = endpoints_loader.load_configuration() mem.initializer = Meta.get_class("initialization", "Initializer") if not mem.initializer: # pragma: no cover print_and_exit("Invalid Initializer class") mem.customizer = Meta.get_instance("customization", "Customizer") if not mem.customizer: # pragma: no cover print_and_exit("Invalid Customizer class") if not isinstance(mem.customizer, BaseCustomizer): # pragma: no cover print_and_exit("Invalid Customizer class, it should inherit BaseCustomizer") Connector.init_app(app=microservice, worker_mode=(mode == ServerModes.WORKER)) # Initialize reading of all files mem.geo_reader = geolite2.reader() # when to close?? # geolite2.close() if mode == ServerModes.INIT: Connector.project_init(options=options) if mode == ServerModes.DESTROY: Connector.project_clean() # Restful plugin with endpoint mapping (skipped in INIT|DESTROY|WORKER modes) if mode == ServerModes.NORMAL: logging.getLogger("werkzeug").setLevel(logging.ERROR) # ignore warning messages from apispec warnings.filterwarnings( "ignore", message="Multiple schemas resolved to the name " ) mem.cache = Cache.get_instance(microservice) endpoints_loader.load_endpoints() mem.authenticated_endpoints = endpoints_loader.authenticated_endpoints mem.private_endpoints = endpoints_loader.private_endpoints # Triggering automatic mapping of REST endpoints rest_api = Api(catch_all_404s=True) for endpoint in endpoints_loader.endpoints: # Create the restful resource with it; # this method is from RESTful plugin rest_api.add_resource(endpoint.cls, *endpoint.uris) # HERE all endpoints will be registered by using FlaskRestful rest_api.init_app(microservice) # APISpec configuration api_url = get_backend_url() scheme, host = api_url.rstrip("/").split("://") spec = APISpec( title=get_project_configuration( "project.title", default="Your application name" ), version=get_project_configuration("project.version", default="0.0.1"), openapi_version="2.0", # OpenApi 3 not working with FlaskApiSpec # -> Duplicate parameter with name body and location body # https://github.com/jmcarp/flask-apispec/issues/170 # Find other warning like this by searching: # **FASTAPI** # openapi_version="3.0.2", plugins=[MarshmallowPlugin()], host=host, schemes=[scheme], tags=endpoints_loader.tags, ) # OpenAPI 3 changed the definition of the security level. # Some changes needed here? api_key_scheme = {"type": "apiKey", "in": "header", "name": "Authorization"} spec.components.security_scheme("Bearer", api_key_scheme) microservice.config.update( { "APISPEC_SPEC": spec, # 'APISPEC_SWAGGER_URL': '/api/swagger', "APISPEC_SWAGGER_URL": None, # 'APISPEC_SWAGGER_UI_URL': '/api/swagger-ui', # Disable Swagger-UI "APISPEC_SWAGGER_UI_URL": None, } ) mem.docs = FlaskApiSpec(microservice) # Clean app routes ignore_verbs = {"HEAD", "OPTIONS"} for rule in microservice.url_map.iter_rules(): endpoint = microservice.view_functions[rule.endpoint] if not hasattr(endpoint, "view_class"): continue newmethods = ignore_verbs.copy() rulename = str(rule) for verb in rule.methods - ignore_verbs: method = verb.lower() if method in endpoints_loader.uri2methods[rulename]: # remove from flask mapping # to allow 405 response newmethods.add(verb) rule.methods = newmethods # Register swagger. Note: after method mapping cleaning with microservice.app_context(): for endpoint in endpoints_loader.endpoints: try: mem.docs.register(endpoint.cls) except TypeError as e: # pragma: no cover print(e) log.error("Cannot register {}: {}", endpoint.cls.__name__, e) # marshmallow errors handler microservice.register_error_handler(422, handle_marshmallow_errors) # Logging responses microservice.after_request(handle_response) if SENTRY_URL is not None: # pragma: no cover if PRODUCTION: sentry_sdk.init( dsn=SENTRY_URL, # already catched by handle_marshmallow_errors ignore_errors=[werkzeug.exceptions.UnprocessableEntity], integrations=[FlaskIntegration()], ) log.info("Enabled Sentry {}", SENTRY_URL) else: # Could be enabled in print mode # sentry_sdk.init(transport=print) log.info("Skipping Sentry, only enabled in PRODUCTION mode") log.info("Boot completed") return microservice
def create_app( name: str = __name__, mode: ServerModes = ServerModes.NORMAL, options: Optional[Dict[str, bool]] = None, ) -> Flask: """Create the server istance for Flask application""" if PRODUCTION and TESTING and not FORCE_PRODUCTION_TESTS: # pragma: no cover print_and_exit("Unable to execute tests in production") # TERM is not catched by Flask # https://github.com/docker/compose/issues/4199#issuecomment-426109482 # signal.signal(signal.SIGTERM, teardown_handler) # SIGINT is registered as STOPSIGNAL in Dockerfile signal.signal(signal.SIGINT, teardown_handler) # Flask app instance # template_folder = template dir for output in HTML flask_app = Flask(name, template_folder=str(ABS_RESTAPI_PATH.joinpath("templates"))) # CORS if not PRODUCTION: if TESTING: cors_origin = "*" else: # pragma: no cover cors_origin = get_frontend_url() # Beware, this only works because get_frontend_url never append a port cors_origin += ":*" CORS( flask_app, allow_headers=[ "Content-Type", "Authorization", "X-Requested-With", "x-upload-content-length", "x-upload-content-type", "content-range", ], supports_credentials=["true"], methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], resources={r"*": {"origins": cors_origin}}, ) log.debug("CORS Enabled") # Flask configuration from config file flask_app.config.from_object(config) flask_app.json_encoder = ExtendedJSONEncoder # Used to force flask to avoid json sorting and ensure that # the output to reflect the order of field in the Marshmallow schema flask_app.config["JSON_SORT_KEYS"] = False log.debug("Flask app configured") if PRODUCTION: log.info("Production server mode is ON") endpoints_loader = EndpointsLoader() if HOST_TYPE == DOCS: # pragma: no cover log.critical("Creating mocked configuration") mem.configuration = {} log.critical("Loading Mocked Initializer and Customizer classes") from restapi.mocks import Customizer, Initializer mem.initializer = Initializer mem.customizer = Customizer() else: mem.configuration = endpoints_loader.load_configuration() mem.initializer = Meta.get_class("initialization", "Initializer") if not mem.initializer: # pragma: no cover print_and_exit("Invalid Initializer class") customizer = Meta.get_class("customization", "Customizer") if not customizer: # pragma: no cover print_and_exit("Invalid Customizer class") mem.customizer = customizer() if not isinstance(mem.customizer, BaseCustomizer): # pragma: no cover print_and_exit("Invalid Customizer class, it should inherit BaseCustomizer") Connector.init_app(app=flask_app, worker_mode=(mode == ServerModes.WORKER)) # Initialize reading of all files mem.geo_reader = geolite2.reader() # when to close?? # geolite2.close() if mode == ServerModes.INIT: Connector.project_init(options=options) if mode == ServerModes.DESTROY: Connector.project_clean() # Restful plugin with endpoint mapping (skipped in INIT|DESTROY|WORKER modes) if mode == ServerModes.NORMAL: logging.getLogger("werkzeug").setLevel(logging.ERROR) # warnings levels: # default # Warn once per call location # error # Convert to exceptions # always # Warn every time # module # Warn once per calling module # once # Warn once per Python process # ignore # Never warn # Types of warnings: # Warning: This is the base class of all warning category classes # UserWarning: The default category for warn(). # DeprecationWarning: Base category for warnings about deprecated features when # those warnings are intended for other Python developers # SyntaxWarning: Base category for warnings about dubious syntactic features. # RuntimeWarning: Base category for warnings about dubious runtime features. # FutureWarning: Base category for warnings about deprecated features when those # warnings are intended for end users # PendingDeprecationWarning: Base category for warnings about features that will # be deprecated in the future (ignored by default). # ImportWarning: Base category for warnings triggered during the process of # importing a module # UnicodeWarning: Base category for warnings related to Unicode. # BytesWarning: Base category for warnings related to bytes and bytearray. # ResourceWarning: Base category for warnings related to resource usage if TESTING: warnings.simplefilter("always", Warning) warnings.simplefilter("error", UserWarning) warnings.simplefilter("error", DeprecationWarning) warnings.simplefilter("error", SyntaxWarning) warnings.simplefilter("error", RuntimeWarning) warnings.simplefilter("error", FutureWarning) # warnings about features that will be deprecated in the future warnings.simplefilter("default", PendingDeprecationWarning) warnings.simplefilter("error", ImportWarning) warnings.simplefilter("error", UnicodeWarning) warnings.simplefilter("error", BytesWarning) # Can't set this an error due to false positives with downloads # a lot of issues like: https://github.com/pallets/flask/issues/2468 warnings.simplefilter("always", ResourceWarning) warnings.simplefilter("default", Neo4jExperimentalWarning) # Remove me in a near future, this is due to hypothesis with pytest 7 # https://github.com/HypothesisWorks/hypothesis/issues/3222 warnings.filterwarnings( "ignore", message="A private pytest class or function was used." ) elif PRODUCTION: # pragma: no cover warnings.simplefilter("ignore", Warning) warnings.simplefilter("always", UserWarning) warnings.simplefilter("default", DeprecationWarning) warnings.simplefilter("ignore", SyntaxWarning) warnings.simplefilter("ignore", RuntimeWarning) warnings.simplefilter("ignore", FutureWarning) warnings.simplefilter("ignore", PendingDeprecationWarning) warnings.simplefilter("ignore", ImportWarning) warnings.simplefilter("ignore", UnicodeWarning) warnings.simplefilter("ignore", BytesWarning) warnings.simplefilter("ignore", ResourceWarning) # even if ignore it is raised once # because of the imports executed before setting this to ignore warnings.simplefilter("ignore", Neo4jExperimentalWarning) else: # pragma: no cover warnings.simplefilter("default", Warning) warnings.simplefilter("always", UserWarning) warnings.simplefilter("always", DeprecationWarning) warnings.simplefilter("default", SyntaxWarning) warnings.simplefilter("default", RuntimeWarning) warnings.simplefilter("always", FutureWarning) warnings.simplefilter("default", PendingDeprecationWarning) warnings.simplefilter("default", ImportWarning) warnings.simplefilter("default", UnicodeWarning) warnings.simplefilter("default", BytesWarning) warnings.simplefilter("always", ResourceWarning) # even if ignore it is raised once # because of the imports executed before setting this to ignore warnings.simplefilter("ignore", Neo4jExperimentalWarning) # ignore warning messages from apispec warnings.filterwarnings( "ignore", message="Multiple schemas resolved to the name " ) # ignore warning messages on flask socket after teardown warnings.filterwarnings("ignore", message="unclosed <socket.socket") # from flask_caching 1.10.1 with python 3.10 on core tests... # try to remove this once upgraded flask_caching in a near future warnings.filterwarnings( "ignore", message="_SixMetaPathImporter.find_spec", ) # Raised from sentry_sdk 1.5.11 with python 3.10 events warnings.filterwarnings( "ignore", message="SelectableGroups dict interface is deprecated. Use select.", ) mem.cache = Cache.get_instance(flask_app) endpoints_loader.load_endpoints() mem.authenticated_endpoints = endpoints_loader.authenticated_endpoints mem.private_endpoints = endpoints_loader.private_endpoints for endpoint in endpoints_loader.endpoints: ename = endpoint.cls.__name__.lower() endpoint_view = endpoint.cls.as_view(ename) for url in endpoint.uris: flask_app.add_url_rule(url, view_func=endpoint_view) # APISpec configuration api_url = get_backend_url() scheme, host = api_url.rstrip("/").split("://") spec = APISpec( title=get_project_configuration( "project.title", default="Your application name" ), version=get_project_configuration("project.version", default="0.0.1"), openapi_version="2.0", # OpenApi 3 not working with FlaskApiSpec # -> Duplicate parameter with name body and location body # https://github.com/jmcarp/flask-apispec/issues/170 # Find other warning like this by searching: # **FASTAPI** # openapi_version="3.0.2", plugins=[MarshmallowPlugin()], host=host, schemes=[scheme], tags=endpoints_loader.tags, ) # OpenAPI 3 changed the definition of the security level. # Some changes needed here? if Env.get_bool("AUTH_ENABLE"): api_key_scheme = {"type": "apiKey", "in": "header", "name": "Authorization"} spec.components.security_scheme("Bearer", api_key_scheme) flask_app.config.update( { "APISPEC_SPEC": spec, # 'APISPEC_SWAGGER_URL': '/api/swagger', "APISPEC_SWAGGER_URL": None, # 'APISPEC_SWAGGER_UI_URL': '/api/swagger-ui', # Disable Swagger-UI "APISPEC_SWAGGER_UI_URL": None, } ) mem.docs = FlaskApiSpec(flask_app) # Clean app routes ignore_verbs = {"HEAD", "OPTIONS"} for rule in flask_app.url_map.iter_rules(): view_function = flask_app.view_functions[rule.endpoint] if not hasattr(view_function, "view_class"): continue newmethods = ignore_verbs.copy() rulename = str(rule) if rule.methods: for verb in rule.methods - ignore_verbs: method = verb.lower() if method in endpoints_loader.uri2methods[rulename]: # remove from flask mapping # to allow 405 response newmethods.add(verb) rule.methods = newmethods # Register swagger. Note: after method mapping cleaning with flask_app.app_context(): for endpoint in endpoints_loader.endpoints: try: mem.docs.register(endpoint.cls) except TypeError as e: # pragma: no cover print(e) log.error("Cannot register {}: {}", endpoint.cls.__name__, e) # marshmallow errors handler # Can't get the typing to work with flask 2.1 flask_app.register_error_handler(422, handle_marshmallow_errors) # type: ignore flask_app.register_error_handler(400, handle_http_errors) # type: ignore flask_app.register_error_handler(404, handle_http_errors) # type: ignore flask_app.register_error_handler(405, handle_http_errors) # type: ignore flask_app.register_error_handler(500, handle_http_errors) # type: ignore # flask_app.before_request(inspect_request) # Logging responses # Can't get the typing to work with flask 2.1 flask_app.after_request(handle_response) # type: ignore if SENTRY_URL is not None: # pragma: no cover if PRODUCTION: sentry_sdk_init( dsn=SENTRY_URL, # already catched by handle_marshmallow_errors ignore_errors=[werkzeug.exceptions.UnprocessableEntity], integrations=[FlaskIntegration()], ) log.info("Enabled Sentry {}", SENTRY_URL) else: # Could be enabled in print mode # sentry_sdk_init(transport=print) log.info("Skipping Sentry, only enabled in PRODUCTION mode") log.info("Boot completed") if PRODUCTION and not TESTING and name == MAIN_SERVER_NAME: # pragma: no cover save_event_log( event=Events.server_startup, payload={"server": name}, user=None, target=None, ) return flask_app
def test_password_reset(self, client: FlaskClient, faker: Faker) -> None: if not Env.get_bool("ALLOW_PASSWORD_RESET") or not Env.get_bool("AUTH_ENABLE"): log.warning("Password reset is disabled, skipping tests") return project_tile = get_project_configuration("project.title", default="YourProject") proto = "https" if PRODUCTION else "http" # Request password reset, missing information r = client.post(f"{AUTH_URI}/reset") assert r.status_code == 400 # Request password reset, missing information r = client.post(f"{AUTH_URI}/reset", json=faker.pydict(2)) assert r.status_code == 400 headers, _ = self.do_login(client, None, None) # Request password reset, wrong email wrong_email = faker.ascii_email() data = {"reset_email": wrong_email} r = client.post(f"{AUTH_URI}/reset", json=data) assert r.status_code == 403 msg = f"Sorry, {wrong_email} is not recognized as a valid username" assert self.get_content(r) == msg # Request password reset, correct email data = {"reset_email": BaseAuthentication.default_user} r = client.post(f"{AUTH_URI}/reset", json=data) assert r.status_code == 200 events = self.get_last_events(1) assert events[0].event == Events.reset_password_request.value assert events[0].user == data["reset_email"] assert events[0].url == "/auth/reset" resetmsg = "We'll send instructions to the email provided " resetmsg += "if it's associated with an account. " resetmsg += "Please check your spam/junk folder." assert self.get_content(r) == resetmsg mail = self.read_mock_email() body = mail.get("body") assert body is not None assert mail.get("headers") is not None # Subject: is a key in the MIMEText assert f"Subject: {project_tile}: Password Reset" in mail.get("headers", "") assert f"{proto}://localhost/public/reset/" in body token = self.get_token_from_body(body) assert token is not None # Do password reset r = client.put(f"{AUTH_URI}/reset/thisisatoken", json={}) # this token is not valid assert r.status_code == 400 # Check if token is valid r = client.put(f"{AUTH_URI}/reset/{token}", json={}) assert r.status_code == 204 # Token is still valid because no password still sent r = client.put(f"{AUTH_URI}/reset/{token}", json={}) assert r.status_code == 204 # Missing information data = { "new_password": BaseAuthentication.default_password, } r = client.put(f"{AUTH_URI}/reset/{token}", json=data) assert r.status_code == 400 assert self.get_content(r) == "Invalid password" data = { "password_confirm": BaseAuthentication.default_password, } r = client.put(f"{AUTH_URI}/reset/{token}", json=data) assert r.status_code == 400 assert self.get_content(r) == "Invalid password" # Request with old password data = { "new_password": BaseAuthentication.default_password, "password_confirm": BaseAuthentication.default_password, } r = client.put(f"{AUTH_URI}/reset/{token}", json=data) assert r.status_code == 409 error = "The new password cannot match the previous password" assert self.get_content(r) == error min_pwd_len = Env.get_int("AUTH_MIN_PASSWORD_LENGTH", 9999) # Password too short data["new_password"] = faker.password(min_pwd_len - 1) data["password_confirm"] = faker.password(min_pwd_len - 1) r = client.put(f"{AUTH_URI}/reset/{token}", json=data) assert r.status_code == 400 data["password_confirm"] = data["new_password"] r = client.put(f"{AUTH_URI}/reset/{token}", json=data) assert r.status_code == 400 data["new_password"] = faker.password(min_pwd_len, strong=True) data["password_confirm"] = faker.password(min_pwd_len, strong=True) r = client.put(f"{AUTH_URI}/reset/{token}", json=data) assert r.status_code == 400 assert self.get_content(r) == "New password does not match with confirmation" new_pwd = faker.password(min_pwd_len, strong=True) data["new_password"] = new_pwd data["password_confirm"] = new_pwd r = client.put(f"{AUTH_URI}/reset/{token}", json=data) assert r.status_code == 200 # After a change password a spam of delete Token is expected # Reverse the list and skip all delete tokens to find the change password event events = self.get_last_events(100) events.reverse() for event in events: if event.event == Events.delete.value: assert event.target_type == "Token" continue assert event.event == Events.change_password.value assert event.user == BaseAuthentication.default_user break self.do_login(client, None, None, status_code=401) headers, _ = self.do_login(client, None, new_pwd) # Token is no longer valid r = client.put(f"{AUTH_URI}/reset/{token}", json={}) assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid reset token" # Restore the default password if Env.get_bool("AUTH_SECOND_FACTOR_AUTHENTICATION"): data["totp_code"] = BaseTests.generate_totp(BaseAuthentication.default_user) data["password"] = new_pwd data["new_password"] = BaseAuthentication.default_password data["password_confirm"] = data["new_password"] r = client.put(f"{AUTH_URI}/profile", json=data, headers=headers) assert r.status_code == 204 # After a change password a spam of delete Token is expected # Reverse the list and skip all delete tokens to find the change password event events = self.get_last_events(100) events.reverse() for event in events: if event.event == Events.delete.value: assert event.target_type == "Token" continue assert event.event == Events.change_password.value assert event.user == BaseAuthentication.default_user break self.do_login(client, None, new_pwd, status_code=401) self.do_login(client, None, None) # Token created for another user token = self.get_crafted_token("r") r = client.put(f"{AUTH_URI}/reset/{token}", json={}) assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid reset token" # Token created for another user token = self.get_crafted_token("r", wrong_algorithm=True) r = client.put(f"{AUTH_URI}/reset/{token}", json={}) assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid reset token" # Token created for another user token = self.get_crafted_token("r", wrong_secret=True) r = client.put(f"{AUTH_URI}/reset/{token}", json={}) assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid reset token" headers, _ = self.do_login(client, None, None) r = client.get(f"{AUTH_URI}/profile", headers=headers) assert r.status_code == 200 response = self.get_content(r) assert isinstance(response, dict) uuid = response.get("uuid") token = self.get_crafted_token("x", user_id=uuid) r = client.put(f"{AUTH_URI}/reset/{token}", json={}) assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid reset token" # token created for the correct user, but from outside the system!! token = self.get_crafted_token("r", user_id=uuid) r = client.put(f"{AUTH_URI}/reset/{token}", json={}) assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid reset token" # Immature token token = self.get_crafted_token("r", user_id=uuid, immature=True) r = client.put(f"{AUTH_URI}/reset/{token}", json={}) assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid reset token" # Expired token token = self.get_crafted_token("r", user_id=uuid, expired=True) r = client.put(f"{AUTH_URI}/reset/{token}", json={}) assert r.status_code == 400 c = self.get_content(r) assert c == "Invalid reset token: this request is expired"
def test_celery(app: Flask, faker: Faker) -> None: log.info("Executing {} tests", CONNECTOR) obj = connector.get_instance() assert obj is not None task = obj.celery_app.send_task("test_task", args=("myinput", )) assert task is not None assert task.id is not None # Mocked task task_output = BaseTests.send_task(app, "test_task", "myinput") # As defined in task template assert task_output == "Task executed!" # wrong is a special value included in tasks template with pytest.raises(Ignore): BaseTests.send_task(app, "test_task", "wrong") project_title = get_project_configuration("project.title", default="YourProject") mail = BaseTests.read_mock_email() body = mail.get("body") headers = mail.get("headers") assert body is not None assert headers is not None assert f"Subject: {project_title}: Task test_task failed" in headers assert "this email is to notify you that a Celery task failed!" in body # fixed-id is a mocked value set in TESTING mode by @task in Celery connector assert "Task ID: fixed-id" in body assert "Task name: test_task" in body assert "Arguments: ('wrong',)" in body assert "Error Stack" in body assert "Traceback (most recent call last):" in body exc = ( "AttributeError: " "You can raise exceptions to stop the task execution in case of errors" ) assert exc in body # celery.exceptions.Ignore exceptions are ignored BaseTests.delete_mock_email() # ignore is a special value included in tasks template with pytest.raises(Ignore): BaseTests.send_task(app, "test_task", "ignore") # the errors decorator re-raise the Ignore exception, without any further action # No email is sent in case of Ignore exceptions with pytest.raises(FileNotFoundError): mail = BaseTests.read_mock_email() # retry is a special value included in tasks template with pytest.raises(CeleryRetryTask): BaseTests.send_task(app, "test_task", "retry") mail = BaseTests.read_mock_email() body = mail.get("body") headers = mail.get("headers") assert body is not None assert headers is not None assert f"Subject: {project_title}: Task test_task failed (failure #1)" in headers assert "this email is to notify you that a Celery task failed!" in body # fixed-id is a mocked value set in TESTING mode by @task in Celery connector assert "Task ID: fixed-id" in body assert "Task name: test_task" in body assert "Arguments: ('retry',)" in body assert "Error Stack" in body assert "Traceback (most recent call last):" in body exc = "CeleryRetryTask: Force the retry of this task" assert exc in body # retry2 is a special value included in tasks template # Can't easily import the custom exception defined in the task... # a generic exception is enough here with pytest.raises(Exception): BaseTests.send_task(app, "test_task", "retry2") mail = BaseTests.read_mock_email() body = mail.get("body") headers = mail.get("headers") assert body is not None assert headers is not None assert f"Subject: {project_title}: Task test_task failed (failure #1)" in headers assert "this email is to notify you that a Celery task failed!" in body # fixed-id is a mocked value set in TESTING mode by @task in Celery connector assert "Task ID: fixed-id" in body assert "Task name: test_task" in body assert "Arguments: ('retry2',)" in body assert "Error Stack" in body assert "Traceback (most recent call last):" in body exc = "MyException: Force the retry of this task by using a custom exception" assert exc in body with pytest.raises(AttributeError, match=r"Task not found"): BaseTests.send_task(app, "does-not-exist") if obj.variables.get("backend_service") == "RABBIT": log.warning( "Due to limitations on RABBIT backend task results will not be tested" ) else: try: r = task.get(timeout=10) assert r is not None # This is the task output, as defined in task_template.py.j2 assert r == "Task executed!" assert task.status == "SUCCESS" assert task.result == "Task executed!" except celery.exceptions.TimeoutError: # pragma: no cover pytest.fail( f"Task timeout, result={task.result}, status={task.status}") obj.disconnect() # a second disconnect should not raise any error obj.disconnect() # Create new connector with short expiration time obj = connector.get_instance(expiration=2, verification=1) obj_id = id(obj) # Connector is expected to be still valid obj = connector.get_instance(expiration=2, verification=1) assert id(obj) == obj_id time.sleep(1) # The connection should have been checked and should be still valid obj = connector.get_instance(expiration=2, verification=1) assert id(obj) == obj_id time.sleep(1) # Connection should have been expired and a new connector been created obj = connector.get_instance(expiration=2, verification=1) assert id(obj) != obj_id assert obj.is_connected() obj.disconnect() assert not obj.is_connected() # ... close connection again ... nothing should happen obj.disconnect() with connector.get_instance() as obj: assert obj is not None app = create_app(mode=ServerModes.WORKER) assert app is not None