diff --git a/api/.chalice/config.json b/api/.chalice/config.json index 66c7f3fa3..5cc01aa47 100644 --- a/api/.chalice/config.json +++ b/api/.chalice/config.json @@ -53,7 +53,7 @@ "S3_KEY": "", "S3_SECRET": "", "invitation_link": "/api/users/invitation?token=%s", - "change_password_link": "/changepassword?invitation=%s&&pass=%s", + "change_password_link": "/reset-password?invitation=%s&&pass=%s", "version_number": "1.2.0" }, "lambda_timeout": 150, diff --git a/api/chalicelib/blueprints/bp_core_dynamic.py b/api/chalicelib/blueprints/bp_core_dynamic.py index 108de050c..34a755546 100644 --- a/api/chalicelib/blueprints/bp_core_dynamic.py +++ b/api/chalicelib/blueprints/bp_core_dynamic.py @@ -364,7 +364,7 @@ def process_invitation_link(): user = users.get_by_invitation_token(params["token"]) if user is None: return {"errors": ["invitation not found"]} - if user["expired"]: + if user["expiredInvitation"]: return {"errors": ["expired invitation, please ask your admin to send a new one"]} pass_token = users.allow_password_change(user_id=user["userId"]) return Response( diff --git a/api/chalicelib/core/users.py b/api/chalicelib/core/users.py index 5f8871783..2537d5ddb 100644 --- a/api/chalicelib/core/users.py +++ b/api/chalicelib/core/users.py @@ -16,10 +16,6 @@ def __generate_invitation_token(): return secrets.token_urlsafe(64) -def __is_authorized_to_manage_users(): - pass - - def create_new_member(email, invitation_token, admin, name, owner=False): with pg_client.PostgresClient() as cur: query = cur.mogrify(f"""\ @@ -42,8 +38,7 @@ def create_new_member(email, invitation_token, admin, name, owner=False): (CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member, au.invitation_token FROM u,au;""", - {"email": email, "password": invitation_token, - "role": "owner" if owner else "admin" if admin else "member", "name": name, + {"email": email, "role": "owner" if owner else "admin" if admin else "member", "name": name, "data": json.dumps({"lastAnnouncementView": TimeUTC.now()}), "invitation_token": invitation_token}) cur.execute( @@ -112,6 +107,7 @@ def generate_new_invitation(user_id): return __get_invitation_link(cur.fetchone().pop("invitation_token")) + def reset_member(tenant_id, editor_id, user_id_to_update): admin = get(tenant_id=tenant_id, user_id=editor_id) if not admin["admin"] and not admin["superAdmin"]: @@ -368,7 +364,10 @@ def get_members(tenant_id): basic_authentication.generated_password, (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, - (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member + (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member, + DATE_PART('day',timezone('utc'::text, now()) \ + - COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation, + basic_authentication.password IS NOT NULL AS joined FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id WHERE users.deleted_at IS NULL ORDER BY name, id""" @@ -475,7 +474,8 @@ def get_by_invitation_token(token, pass_token=None): cur.mogrify( f"""SELECT *, - DATE_PART('day',timezone('utc'::text, now())- invited_at)>=1 AS expired, + DATE_PART('day',timezone('utc'::text, now()) \ + - COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation, change_pwd_expire_at <= timezone('utc'::text, now()) AS expired_change FROM public.users INNER JOIN public.basic_authentication USING(user_id) WHERE invitation_token = %(token)s {"AND change_pwd_token = %(pass_token)s" if pass_token else ""} diff --git a/ee/api/.chalice/config.json b/ee/api/.chalice/config.json index 761a8cfa7..55ace4632 100644 --- a/ee/api/.chalice/config.json +++ b/ee/api/.chalice/config.json @@ -61,7 +61,9 @@ "idp_entityId": "", "idp_sso_url": "", "idp_x509cert": "", - "idp_sls_url": "" + "idp_sls_url": "", + "invitation_link": "/api/users/invitation?token=%s", + "change_password_link": "/reset-password?invitation=%s&&pass=%s" }, "lambda_timeout": 150, "lambda_memory_size": 400, diff --git a/ee/api/chalicelib/blueprints/bp_core_dynamic.py b/ee/api/chalicelib/blueprints/bp_core_dynamic.py index af9b1ddd3..46c663bc1 100644 --- a/ee/api/chalicelib/blueprints/bp_core_dynamic.py +++ b/ee/api/chalicelib/blueprints/bp_core_dynamic.py @@ -362,6 +362,38 @@ def add_member(context): return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data) +@app.route('/users/invitation', methods=['GET'], authorizer=None) +def process_invitation_link(): + params = app.current_request.query_params + if params is None or len(params.get("token", "")) < 64: + return {"errors": ["please provide a valid invitation"]} + user = users.get_by_invitation_token(params["token"]) + if user is None: + return {"errors": ["invitation not found"]} + if user["expiredInvitation"]: + return {"errors": ["expired invitation, please ask your admin to send a new one"]} + pass_token = users.allow_password_change(user_id=user["userId"]) + return Response( + status_code=307, + body='', + headers={'Location': environ["SITE_URL"] + environ["change_password_link"] % (params["token"], pass_token), + 'Content-Type': 'text/plain'}) + + +@app.route('/users/invitation/password', methods=['POST', 'PUT'], authorizer=None) +def change_password_by_invitation(): + data = app.current_request.json_body + if data is None or len(data.get("invitation", "")) < 64 or len(data.get("pass", "")) < 8: + return {"errors": ["please provide a valid invitation & pass"]} + user = users.get_by_invitation_token(token=data["token"], pass_token=data["pass"]) + if user is None: + return {"errors": ["invitation not found"]} + if user["expiredChange"]: + return {"errors": ["expired change, please re-use the invitation link"]} + + return users.set_password_invitation(new_password=data["password"], user_id=user["userId"]) + + @app.route('/client/members/{memberId}', methods=['PUT', 'POST']) def edit_member(memberId, context): data = app.current_request.json_body @@ -369,6 +401,11 @@ def edit_member(memberId, context): user_id_to_update=memberId) +@app.route('/client/members/{memberId}/reset', methods=['GET']) +def reset_reinvite_member(memberId, context): + return users.reset_member(tenant_id=context['tenantId'], editor_id=context['userId'], user_id_to_update=memberId) + + @app.route('/client/members/{memberId}', methods=['DELETE']) def delete_member(memberId, context): return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId) diff --git a/ee/api/chalicelib/core/reset_password.py b/ee/api/chalicelib/core/reset_password.py index 85588bf73..3a636c967 100644 --- a/ee/api/chalicelib/core/reset_password.py +++ b/ee/api/chalicelib/core/reset_password.py @@ -1,8 +1,4 @@ -import chalicelib.utils.TimeUTC from chalicelib.utils import email_helper, captcha, helper -import secrets -from chalicelib.utils import pg_client - from chalicelib.core import users @@ -18,49 +14,23 @@ def step1(data): a_users = users.get_by_email_only(data["email"]) if len(a_users) > 1: print(f"multiple users found for [{data['email']}] please contact our support") - return {"errors": ["please contact our support"]} + return {"errors": ["multiple users, please contact our support"]} elif len(a_users) == 1: a_users = a_users[0] - reset_token = secrets.token_urlsafe(6) - users.update(tenant_id=a_users["tenantId"], user_id=a_users["id"], - changes={"token": reset_token}) - email_helper.send_reset_code(recipient=data["email"], reset_code=reset_token) + invitation_link = users.generate_new_invitation(user_id=a_users["id"]) + email_helper.send_forgot_password(recipient=data["email"], invitation_link=invitation_link) else: print(f"invalid email address [{data['email']}]") return {"errors": ["invalid email address"]} return {"data": {"state": "success"}} - -def step2(data): - print("====================== change password 2 ===============") - user = users.get_by_email_reset(data["email"], data["code"]) - if not user: - print("error: wrong email or reset code") - return {"errors": ["wrong email or reset code"]} - users.update(tenant_id=user["tenantId"], user_id=user["id"], - changes={"token": None, "password": data["password"], "generatedPassword": False, - "verifiedEmail": True}) - return {"data": {"state": "success"}} - - -def cron(): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify("""\ - SELECT user_id - FROM public.basic_authentication - WHERE token notnull - AND (token_requested_at isnull or (EXTRACT(EPOCH FROM token_requested_at)*1000)::BIGINT < %(time)s);""", - {"time": chalicelib.utils.TimeUTC.TimeUTC.now(delta_days=-1)}) - ) - results = cur.fetchall() - if len(results) == 0: - return - results = tuple([r["user_id"] for r in results]) - cur.execute( - cur.mogrify("""\ - UPDATE public.basic_authentication - SET token = NULL, token_requested_at = NULL - WHERE user_id in %(ids)s;""", - {"ids": results}) - ) \ No newline at end of file +# def step2(data): +# print("====================== change password 2 ===============") +# user = users.get_by_email_reset(data["email"], data["code"]) +# if not user: +# print("error: wrong email or reset code") +# return {"errors": ["wrong email or reset code"]} +# users.update(tenant_id=user["tenantId"], user_id=user["id"], +# changes={"token": None, "password": data["password"], "generatedPassword": False, +# "verifiedEmail": True}) +# return {"data": {"state": "success"}} diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index 3331245a8..b0e63d6c5 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -4,11 +4,17 @@ from chalicelib.core import authorizers from chalicelib.core import tenants from chalicelib.utils import helper from chalicelib.utils import pg_client +from chalicelib.utils import dev from chalicelib.utils.TimeUTC import TimeUTC from chalicelib.utils.helper import environ +import secrets -def create_new_member(tenant_id, email, password, admin, name, owner=False): +def __generate_invitation_token(): + return secrets.token_urlsafe(64) + + +def create_new_member(tenant_id, email, invitation_token, admin, name, owner=False): with pg_client.PostgresClient() as cur: query = cur.mogrify(f"""\ WITH u AS ( @@ -16,10 +22,12 @@ def create_new_member(tenant_id, email, password, admin, name, owner=False): VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s) RETURNING user_id,email,role,name,appearance ), - au AS (INSERT - INTO public.basic_authentication (user_id, password, generated_password) - VALUES ((SELECT user_id FROM u), crypt(%(password)s, gen_salt('bf', 12)), TRUE)) + au AS (INSERT INTO public.basic_authentication (user_id, generated_password, invitation_token, invited_at) + VALUES ((SELECT user_id FROM u), TRUE, %(invitation_token)s, timezone('utc'::text, now())) + RETURNING invitation_token + ) SELECT u.user_id AS id, + u.user_id, u.email, u.role, u.name, @@ -27,18 +35,19 @@ def create_new_member(tenant_id, email, password, admin, name, owner=False): (CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, (CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member, - u.appearance - FROM u;""", - {"tenantId": tenant_id, "email": email, "password": password, + au.invitation_token + FROM u,au;""", + {"tenantId": tenant_id, "email": email, "role": "owner" if owner else "admin" if admin else "member", "name": name, - "data": json.dumps({"lastAnnouncementView": TimeUTC.now()})}) + "data": json.dumps({"lastAnnouncementView": TimeUTC.now()}), + "invitation_token": invitation_token}) cur.execute( query ) return helper.dict_to_camel_case(cur.fetchone()) -def restore_member(tenant_id, user_id, email, password, admin, name, owner=False): +def restore_member(tenant_id, user_id, email, invitation_token, admin, name, owner=False): with pg_client.PostgresClient() as cur: query = cur.mogrify(f"""\ UPDATE public.users @@ -56,31 +65,61 @@ def restore_member(tenant_id, user_id, email, password, admin, name, owner=False TRUE AS change_password, (CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin, - (CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member, - appearance;""", + (CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member;""", {"tenant_id": tenant_id, "user_id": user_id, "email": email, "role": "owner" if owner else "admin" if admin else "member", "name": name}) cur.execute( query ) - result = helper.dict_to_camel_case(cur.fetchone()) + result = cur.fetchone() query = cur.mogrify("""\ UPDATE public.basic_authentication - SET password= crypt(%(password)s, gen_salt('bf', 12)), - generated_password= TRUE, - token=NULL, - token_requested_at=NULL - WHERE user_id=%(user_id)s;""", - {"user_id": user_id, "password": password}) + SET generated_password = TRUE, + invitation_token = %(invitation_token)s, + invited_at = timezone('utc'::text, now()), + change_pwd_expire_at = NULL, + change_pwd_token = NULL + WHERE user_id=%(user_id)s + RETURNING invitation_token;""", + {"user_id": user_id, "invitation_token": invitation_token}) cur.execute( query ) + result["invitation_token"] = cur.fetchone()["invitation_token"] - return result + return helper.dict_to_camel_case(result) + + +def generate_new_invitation(user_id): + invitation_token = __generate_invitation_token() + with pg_client.PostgresClient() as cur: + query = cur.mogrify("""\ + UPDATE public.basic_authentication + SET invitation_token = %(invitation_token)s, + invited_at = timezone('utc'::text, now()), + change_pwd_expire_at = NULL, + change_pwd_token = NULL + WHERE user_id=%(user_id)s + RETURNING invitation_token;""", + {"user_id": user_id, "invitation_token": invitation_token}) + cur.execute( + query + ) + return __get_invitation_link(cur.fetchone().pop("invitation_token")) + + +def reset_member(tenant_id, editor_id, user_id_to_update): + admin = get(tenant_id=tenant_id, user_id=editor_id) + if not admin["admin"] and not admin["superAdmin"]: + return {"errors": ["unauthorized"]} + user = get(tenant_id=tenant_id, user_id=user_id_to_update) + if not user: + return {"errors": ["user not found"]} + return {"data": {"invitationLink": generate_new_invitation(user_id_to_update)}} def update(tenant_id, user_id, changes): - AUTH_KEYS = ["password", "generatedPassword", "token"] + AUTH_KEYS = ["password", "generatedPassword", "invitationToken", "invitedAt", "changePwdExpireAt", "changePwdToken"] if len(changes.keys()) == 0: return None @@ -91,13 +130,6 @@ def update(tenant_id, user_id, changes): if key == "password": sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))") sub_query_bauth.append("changed_at = timezone('utc'::text, now())") - elif key == "token": - if changes[key] is not None: - sub_query_bauth.append("token = %(token)s") - sub_query_bauth.append("token_requested_at = timezone('utc'::text, now())") - else: - sub_query_bauth.append("token = NULL") - sub_query_bauth.append("token_requested_at = NULL") else: sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s") else: @@ -166,26 +198,43 @@ def create_member(tenant_id, user_id, data): return {"errors": ["invalid user name"]} if name is None: name = data["email"] - temp_pass = helper.generate_salt()[:8] + invitation_token = __generate_invitation_token() user = get_deleted_user_by_email(email=data["email"]) if user is not None: - new_member = restore_member(tenant_id=tenant_id, email=data["email"], password=temp_pass, + new_member = restore_member(tenant_id=tenant_id, email=data["email"], invitation_token=invitation_token, admin=data.get("admin", False), name=name, user_id=user["userId"]) else: - new_member = create_new_member(tenant_id=tenant_id, email=data["email"], password=temp_pass, + new_member = create_new_member(tenant_id=tenant_id, email=data["email"], invitation_token=invitation_token, admin=data.get("admin", False), name=name) - + new_member["invitationLink"] = __get_invitation_link(new_member.pop("invitationToken")) helper.async_post(environ['email_basic'] % 'member_invitation', { "email": data["email"], - "userName": data["email"], - "tempPassword": temp_pass, + "invitationLink": new_member["invitationLink"], "clientId": tenants.get_by_tenant_id(tenant_id)["name"], "senderName": admin["name"] }) return {"data": new_member} +def __get_invitation_link(invitation_token): + return environ["SITE_URL"] + environ["invitation_link"] % invitation_token + + +def allow_password_change(user_id, delta_min=10): + pass_token = secrets.token_urlsafe(8) + with pg_client.PostgresClient() as cur: + query = cur.mogrify(f"""UPDATE public.basic_authentication + SET change_pwd_expire_at = timezone('utc'::text, now()+INTERVAL '%(delta)s MINUTES'), + change_pwd_token = %(pass_token)s + WHERE user_id = %(user_id)s""", + {"user_id": user_id, "delta": delta_min, "pass_token": pass_token}) + cur.execute( + query + ) + return pass_token + + def get(user_id, tenant_id): with pg_client.PostgresClient() as cur: cur.execute( @@ -321,7 +370,10 @@ def get_members(tenant_id): basic_authentication.generated_password, (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, - (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member + (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member, + DATE_PART('day',timezone('utc'::text, now()) \ + - COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation, + basic_authentication.password IS NOT NULL AS joined FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id WHERE users.tenant_id = %(tenantId)s AND users.deleted_at IS NULL ORDER BY name, id""", @@ -374,6 +426,15 @@ def change_password(tenant_id, user_id, email, old_password, new_password): "jwt": authenticate(email, new_password)["jwt"]} +def set_password_invitation(user_id, new_password): + changes = {"password": new_password, "generatedPassword": False, + "invitationToken": None, "invitedAt": None, + "changePwdExpireAt": None, "changePwdToken": None} + user = update(tenant_id=-1, user_id=user_id, changes=changes) + return {"data": user, + "jwt": authenticate(user["email"], new_password)["jwt"]} + + def count_members(tenant_id): with pg_client.PostgresClient() as cur: cur.execute( @@ -393,7 +454,7 @@ def email_exists(email): cur.mogrify( f"""SELECT count(user_id) - FROM public.users + FROM public.users WHERE email = %(email)s AND deleted_at IS NULL @@ -410,7 +471,7 @@ def get_deleted_user_by_email(email): cur.mogrify( f"""SELECT * - FROM public.users + FROM public.users WHERE email = %(email)s AND deleted_at NOTNULL @@ -421,6 +482,24 @@ def get_deleted_user_by_email(email): return helper.dict_to_camel_case(r) +def get_by_invitation_token(token, pass_token=None): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + f"""SELECT + *, + DATE_PART('day',timezone('utc'::text, now()) \ + - COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation, + change_pwd_expire_at <= timezone('utc'::text, now()) AS expired_change + FROM public.users INNER JOIN public.basic_authentication USING(user_id) + WHERE invitation_token = %(token)s {"AND change_pwd_token = %(pass_token)s" if pass_token else ""} + LIMIT 1;""", + {"token": token, "pass_token": token}) + ) + r = cur.fetchone() + return helper.dict_to_camel_case(r) + + def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud): with pg_client.PostgresClient() as cur: cur.execute( @@ -450,6 +529,7 @@ def change_jwt_iat(user_id): return cur.fetchone().get("jwt_iat") +@dev.timed def authenticate(email, password, for_change_password=False, for_plugin=False): with pg_client.PostgresClient() as cur: query = cur.mogrify(