From 44af7eba1158b44f6badcd65465243f180439b20 Mon Sep 17 00:00:00 2001 From: Donald Zou Date: Fri, 5 Sep 2025 15:48:11 +0800 Subject: [PATCH] Finished forgot password for clients app --- src/client.py | 80 +++++- src/dashboard.py | 10 +- src/modules/DashboardClients.py | 81 +++++- src/modules/DashboardConfig.py | 2 +- src/modules/DashboardOIDC.py | 4 +- src/modules/Email.py | 2 +- .../src/components/SignIn/oidc/oidc.vue | 2 +- .../src/components/SignIn/signInForm.vue | 8 +- src/static/client/src/router/router.js | 6 + .../client/src/views/forgotPassword.vue | 231 ++++++++++++++++++ 10 files changed, 401 insertions(+), 25 deletions(-) create mode 100644 src/static/client/src/views/forgotPassword.vue diff --git a/src/client.py b/src/client.py index 97c94397..7f665b33 100644 --- a/src/client.py +++ b/src/client.py @@ -1,3 +1,5 @@ +import datetime + from tzlocal import get_localzone from functools import wraps @@ -7,6 +9,8 @@ import os from modules.WireguardConfiguration import WireguardConfiguration from modules.DashboardConfig import DashboardConfig +from modules.Email import EmailSender + def ResponseObject(status=True, message=None, data=None, status_code = 200) -> Flask.response_class: response = Flask.make_response(current_app, { @@ -46,7 +50,7 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration], @client.post(f'{prefix}/api/signup') def ClientAPI_SignUp(): - data = request.json + data = request.get_json() status, msg = dashboardClients.SignUp(**data) return ResponseObject(status, msg) @@ -64,7 +68,7 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration], if not oidc: return ResponseObject(status=False, message="OIDC is disabled") - data = request.json + data = request.get_json() status, oidcData = dashboardClients.SignIn_OIDC(**data) if not status: return ResponseObject(status, oidcData) @@ -77,7 +81,7 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration], @client.post(f'{prefix}/api/signin') def ClientAPI_SignIn(): - data = request.json + data = request.get_json() status, msg = dashboardClients.SignIn(**data) if status: session['Email'] = data.get('Email') @@ -85,6 +89,70 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration], session['TotpVerified'] = False return ResponseObject(status, msg) + @client.post(f'{prefix}/api/resetPassword/generateResetToken') + def ClientAPI_ResetPassword_GenerateResetToken(): + date = datetime.datetime.now(tz=datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC') + + emailSender = EmailSender(dashboardConfig) + if not emailSender.ready(): + return ResponseObject(False, "We can't send you an email due to your Administrator has not setup email service. Please contact your administrator.") + + data = request.get_json() + email = data.get('Email', None) + if not email: + return ResponseObject(False, "Please provide a valid Email") + + u = dashboardClients.SignIn_UserExistence(email) + if not u: + return ResponseObject(False, "Please provide a valid Email") + + token = dashboardClients.GenerateClientPasswordResetToken(u.get('ClientID')) + + status, msg = emailSender.send( + email, "[WGDashboard | Client] Reset Password", + f"Hi {email}, \n\nIt looks like you're trying to reset your password at {date} \n\nEnter this 6 digits code on the Forgot Password to continue:\n\n{token}\n\nThis code will expire in 30 minutes for your security. If you didn’t request a password reset, you can safely ignore this email—your current password will remain unchanged.\n\nIf you need help, feel free to contact support.\n\nBest regards,\n\nWGDashboard" + ) + + return ResponseObject(status, msg) + + @client.post(f'{prefix}/api/resetPassword/validateResetToken') + def ClientAPI_ResetPassword_ValidateResetToken(): + data = request.get_json() + email = data.get('Email', None) + token = data.get('Token', None) + if not all([email, token]): + return ResponseObject(False, "Please provide a valid Email") + + u = dashboardClients.SignIn_UserExistence(email) + if not u: + return ResponseObject(False, "Please provide a valid Email") + + return ResponseObject(status=dashboardClients.ValidateClientPasswordResetToken(u.get('ClientID'), token)) + + @client.post(f'{prefix}/api/resetPassword') + def ClientAPI_ResetPassword(): + data = request.get_json() + email = data.get('Email', None) + token = data.get('Token', None) + password = data.get('Password', None) + confirmPassword = data.get('ConfirmPassword', None) + if not all([email, token, password, confirmPassword]): + return ResponseObject(False, "Please provide a valid Email") + + u = dashboardClients.SignIn_UserExistence(email) + if not u: + return ResponseObject(False, "Please provide a valid Email") + + if not dashboardClients.ValidateClientPasswordResetToken(u.get('ClientID'), token): + return ResponseObject(False, "Verification code is either invalid or expired") + + status, msg = dashboardClients.ResetClientPassword(u.get('ClientID'), password, confirmPassword) + + dashboardClients.RevokeClientPasswordResetToken(u.get('ClientID'), token) + + return ResponseObject(status, msg) + + @client.get(f'{prefix}/api/signout') def ClientAPI_SignOut(): if session.get("SignInMethod") == "OIDC": @@ -103,7 +171,7 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration], @client.post(f'{prefix}/api/signin/totp') def ClientAPI_SignIn_ValidateTOTP(): - data = request.json + data = request.get_json() token = data.get('Token', None) userProvidedTotp = data.get('UserProvidedTOTP', None) if not all([token, userProvidedTotp]): @@ -154,8 +222,8 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration], @login_required def ClientAPI_Settings_UpdatePassword(): data = request.get_json() - status, message = dashboardClients.UpdateClientPassword(session['Email'], **data) + status, message = dashboardClients.UpdateClientPassword(session['ClientID'], **data) - return ResponseObject(status, message) + return ResponseObject(status, message) return client \ No newline at end of file diff --git a/src/dashboard.py b/src/dashboard.py index 05023974..8fbc0a31 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -591,6 +591,14 @@ def API_deletePeers(configName: str) -> ResponseObject: return ResponseObject(False, "Please specify one or more peers", status_code=400) configuration = WireguardConfigurations.get(configName) status, msg = configuration.deletePeers(peers, AllPeerJobs, AllPeerShareLinks) + + # Delete Assignment + + for p in peers: + assignments = DashboardClients.DashboardClientsPeerAssignment.GetAssignedClients(configName, p) + for c in assignments: + DashboardClients.DashboardClientsPeerAssignment.UnassignClients(c.AssignmentID) + return ResponseObject(status, msg) return ResponseObject(False, "Configuration does not exist", status_code=404) @@ -1412,7 +1420,7 @@ def API_Clients_GeneratePasswordResetLink(): if not DashboardClients.GetClient(clientId): return ResponseObject(False, "Client does not exist") - token = DashboardClients.GenerateClientPasswordResetLink(clientId) + token = DashboardClients.GenerateClientPasswordResetToken(clientId) if token: return ResponseObject(data=token) return ResponseObject(False, "Failed to generate link") diff --git a/src/modules/DashboardClients.py b/src/modules/DashboardClients.py index ed3a87bb..f783a4f1 100644 --- a/src/modules/DashboardClients.py +++ b/src/modules/DashboardClients.py @@ -1,5 +1,6 @@ import datetime import hashlib +import random import uuid import bcrypt @@ -304,12 +305,46 @@ class DashboardClients: def GetClientAssignedPeers(self, ClientID): return self.DashboardClientsPeerAssignment.GetAssignedPeers(ClientID) + + def ResetClientPassword(self, ClientID, NewPassword, ConfirmNewPassword) -> tuple[bool, str] | tuple[bool, None]: + c = self.GetClient(ClientID) + if c is None: + return False, "Client does not exist" + + if NewPassword != ConfirmNewPassword: + return False, "New passwords does not match" + + pwStrength, msg = ValidatePasswordStrength(NewPassword) + if not pwStrength: + return pwStrength, msg + try: + with self.engine.begin() as conn: + conn.execute( + self.dashboardClientsTable.update().values({ + "TotpKeyVerified": None, + "TotpKey": pyotp.random_base32(), + "Password": bcrypt.hashpw(NewPassword.encode('utf-8'), bcrypt.gensalt()).decode("utf-8"), + }).where( + self.dashboardClientsTable.c.ClientID == ClientID + ) + ) + self.logger.log(Message=f"User {ClientID} reset password and TOTP") + except Exception as e: + self.logger.log(Status="false", Message=f"User {ClientID} reset password failed, reason: {str(e)}") + return False, "Reset password failed." + + + return True, None - def UpdateClientPassword(self, Email, CurrentPassword, NewPassword, ConfirmNewPassword): + def UpdateClientPassword(self, ClientID, CurrentPassword, NewPassword, ConfirmNewPassword) -> tuple[bool, str] | tuple[bool, None]: + c = self.GetClient(ClientID) + if c is None: + return False, "Client does not exist" + if not all([CurrentPassword, NewPassword, ConfirmNewPassword]): return False, "Please fill in all fields" - if not self.SignIn_ValidatePassword(Email, CurrentPassword): + if not self.SignIn_ValidatePassword(c.get('Email'), CurrentPassword): return False, "Current password does not match" if NewPassword != ConfirmNewPassword: @@ -324,13 +359,13 @@ class DashboardClients: self.dashboardClientsTable.update().values({ "Password": bcrypt.hashpw(NewPassword.encode('utf-8'), bcrypt.gensalt()).decode("utf-8"), }).where( - self.dashboardClientsTable.c.Email == Email + self.dashboardClientsTable.c.ClientID == ClientID ) ) - self.logger.log(Message=f"User {Email} updated password") + self.logger.log(Message=f"User {ClientID} updated password") except Exception as e: - self.logger.log(Status="false", Message=f"Signed up failed, reason: {str(e)}") - return False, "Signed up failed." + self.logger.log(Status="false", Message=f"User {ClientID} update password failed, reason: {str(e)}") + return False, "Update password failed." return True, None def UpdateClientProfile(self, ClientID, Name): @@ -381,16 +416,17 @@ class DashboardClients: For WGDashboard Admin to Manage Clients ''' - def GenerateClientPasswordResetLink(self, ClientID) -> bool | str: + def GenerateClientPasswordResetToken(self, ClientID) -> bool | str: c = self.GetClient(ClientID) if c is None: return False - newToken = str(uuid.uuid4()) + newToken = str(random.randint(0, 999999)).zfill(6) with self.engine.begin() as conn: conn.execute( self.dashboardClientsPasswordResetLinkTable.update().values({ - "ExpiryDate": db.func.now() + "ExpiryDate": datetime.datetime.now() + }).where( db.and_( self.dashboardClientsPasswordResetLinkTable.c.ClientID == ClientID, @@ -402,13 +438,38 @@ class DashboardClients: self.dashboardClientsPasswordResetLinkTable.insert().values({ "ResetToken": newToken, "ClientID": ClientID, + "CreatedDate": datetime.datetime.now(), "ExpiryDate": datetime.datetime.now() + datetime.timedelta(minutes=30) }) ) return newToken - + def ValidateClientPasswordResetToken(self, ClientID, Token): + c = self.GetClient(ClientID) + if c is None: + return False + with self.engine.connect() as conn: + t = conn.execute( + self.dashboardClientsPasswordResetLinkTable.select().where( + self.dashboardClientsPasswordResetLinkTable.c.ClientID == ClientID, + self.dashboardClientsPasswordResetLinkTable.c.ResetToken == Token, + self.dashboardClientsPasswordResetLinkTable.c.ExpiryDate > datetime.datetime.now() + ) + ).mappings().fetchone() + return t is not None + + def RevokeClientPasswordResetToken(self, ClientID, Token): + with self.engine.begin() as conn: + conn.execute( + self.dashboardClientsPasswordResetLinkTable.update().values({ + "ExpiryDate": datetime.datetime.now() + }).where( + self.dashboardClientsPasswordResetLinkTable.c.ClientID == ClientID, + self.dashboardClientsPasswordResetLinkTable.c.ResetToken == Token + ) + ) + return True def GetAssignedPeerClients(self, ConfigurationName, PeerID): c = self.DashboardClientsPeerAssignment.GetAssignedClients(ConfigurationName, PeerID) diff --git a/src/modules/DashboardConfig.py b/src/modules/DashboardConfig.py index 1b78b830..014bbd22 100644 --- a/src/modules/DashboardConfig.py +++ b/src/modules/DashboardConfig.py @@ -47,7 +47,7 @@ class DashboardConfig: "dashboard_sort": "status", "dashboard_theme": "dark", "dashboard_api_key": "false", - "dashboard_language": "en" + "dashboard_language": "en-US" }, "Peers": { "peer_global_DNS": "1.1.1.1", diff --git a/src/modules/DashboardOIDC.py b/src/modules/DashboardOIDC.py index 96a5bda2..45029d88 100644 --- a/src/modules/DashboardOIDC.py +++ b/src/modules/DashboardOIDC.py @@ -124,7 +124,6 @@ class DashboardOIDC: for k in providers.keys(): if all([providers[k]['client_id'], providers[k]['client_secret'], providers[k]['issuer']]): try: - print("Requesting " + f"{providers[k]['issuer'].strip('/')}/.well-known/openid-configuration") oidc_config = requests.get( f"{providers[k]['issuer'].strip('/')}/.well-known/openid-configuration", timeout=3, @@ -136,8 +135,9 @@ class DashboardOIDC: 'openid_configuration': oidc_config } self.provider_secret[k] = providers[k]['client_secret'] + current_app.logger.info(f"Registered OIDC Provider: {k}") except Exception as e: - current_app.logger.error("Failed to request OIDC config for this provider: " + providers[k]['issuer'].strip('/'), exc_info=e) + current_app.logger.error(f"Failed to register OIDC config for {k}", exc_info=e) except Exception as e: current_app.logger.error('Read OIDC file failed. Reason: ' + str(e)) return False \ No newline at end of file diff --git a/src/modules/Email.py b/src/modules/Email.py index f6416e46..61c2b8c4 100644 --- a/src/modules/Email.py +++ b/src/modules/Email.py @@ -35,7 +35,7 @@ class EmailSender: def ready(self): return all([self.Server(), self.Port(), self.Encryption(), self.Username(), self.Password(), self.SendFrom()]) - def send(self, receiver, subject, body, includeAttachment = False, attachmentName = ""): + def send(self, receiver, subject, body, includeAttachment = False, attachmentName = "") -> tuple[bool, str] | tuple[bool, None]: if self.ready(): try: self.smtp = smtplib.SMTP(self.Server(), port=int(self.Port())) diff --git a/src/static/client/src/components/SignIn/oidc/oidc.vue b/src/static/client/src/components/SignIn/oidc/oidc.vue index 984c42bc..38879d54 100644 --- a/src/static/client/src/components/SignIn/oidc/oidc.vue +++ b/src/static/client/src/components/SignIn/oidc/oidc.vue @@ -7,7 +7,7 @@ import OidcBtn from "@/components/SignIn/oidc/oidcBtn.vue"; const providerExist = ref(false) const providers = ref(undefined) const getProviders = await axiosGet("/api/signin/oidc/providers") -if (getProviders){ +if (getProviders && Object.keys(getProviders.data).length > 0){ providerExist.value = true; providers.value = getProviders.data console.log(providers.value) diff --git a/src/static/client/src/components/SignIn/signInForm.vue b/src/static/client/src/components/SignIn/signInForm.vue index 43061628..6f3746fb 100644 --- a/src/static/client/src/components/SignIn/signInForm.vue +++ b/src/static/client/src/components/SignIn/signInForm.vue @@ -46,7 +46,7 @@ if (route.query.Email){