prompt: We are currently solving the following issue within our repository. Here is the issue text:
  --- BEGIN ISSUE ---
  Fix recovery link in email
  The email that is sent when resetting a password, contains a faulty link.
  
  Currentlly `https://piserver/#/reset/...` is sent. It has to be `https://piserver/#!/reset/..`.
  
  This happens here:
  
  https://github.com/privacyidea/privacyidea/blob/35f0963b1bbacfbbad128c12ba0a0953fc17a339/privacyidea/lib/passwordreset.py#L67
  
  Also see https://community.privacyidea.org/t/q-password-reset-what-policies-needed/2493/4
  
  --- END ISSUE ---
  
  Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
  --- BEGIN FILES ---
  Path: `privacyidea/lib/passwordreset.py`
  Content:
  ```
  1 # -*- coding: utf-8 -*-
  2 #
  3 #  2015-01-06 Cornelius Kölbel <cornelius@privacyidea.org>
  4 #             The Password reset functions
  5 #
  6 # This code is free software; you can redistribute it and/or
  7 # modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
  8 # License as published by the Free Software Foundation; either
  9 # version 3 of the License, or any later version.
  10 #
  11 # This code is distributed in the hope that it will be useful,
  12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  14 # GNU AFFERO GENERAL PUBLIC LICENSE for more details.
  15 #
  16 # You should have received a copy of the GNU Affero General Public
  17 # License along with this program.  If not, see <http://www.gnu.org/licenses/>.
  18 #
  19 #
  20 from privacyidea.models import PasswordReset
  21 from privacyidea.lib.crypto import (hash_with_pepper, verify_with_pepper,
  22                                     generate_password)
  23 import logging
  24 from privacyidea.lib.log import log_with
  25 from privacyidea.lib.error import UserError, privacyIDEAError, ConfigAdminError
  26 from privacyidea.lib.smtpserver import send_email_identifier
  27 from privacyidea.lib.config import get_from_config
  28 from privacyidea.lib.resolver import get_resolver_list
  29 from privacyidea.lib.policy import ACTION, SCOPE, Match
  30 from sqlalchemy import and_
  31 from datetime import datetime
  32 
  33 
  34 __doc__ = """
  35 This is the library for creating a recovery code for password reset.
  36 The recovery code is sent to the user.
  37 
  38 The salted/peppered hash of the recovery code is stored in the passwordreset
  39 database table.
  40 
  41 This module is tested in tests/test_lib_passwordreset.py
  42 """
  43 
  44 log = logging.getLogger(__name__)
  45 
  46 BODY = u"""Someone requested to reset the password within privacyIDEA.
  47 
  48 To reset your user password please visit the link
  49 
  50 {0!s}/reset/{1!s}@{2!s}/{3!s}
  51 """
  52 
  53 
  54 @log_with(log)
  55 def create_recoverycode(user, email=None, expiration_seconds=3600,
  56                         recoverycode=None, base_url=""):
  57     """
  58     Create and send a password recovery code
  59 
  60     :param user: User for whom the password reset code should be sent
  61     :type user: User Object
  62     :param email: The optional email of the user
  63     :param recoverycode: Only used for testing purpose
  64     :return: bool
  65     """
  66     base_url = base_url.strip("recover")
  67     base_url += "#"
  68     recoverycode = recoverycode or generate_password(size=24)
  69     hash_code = hash_with_pepper(recoverycode)
  70     # send this recoverycode
  71     #
  72     pwreset = PasswordReset(hash_code, username=user.login,
  73                             realm=user.realm,
  74                             expiration_seconds=expiration_seconds)
  75     pwreset.save()
  76 
  77     res = False
  78     if not user:
  79         raise UserError("User required for recovery token.")
  80     user_email = user.info.get("email")
  81     if email and email.lower() != user_email.lower():
  82         raise UserError("The email does not match the users email.")
  83 
  84     identifier = get_from_config("recovery.identifier")
  85     if identifier:
  86         # send email
  87         r = send_email_identifier(identifier, user_email,
  88                                   "Your password reset",
  89                                   BODY.format(base_url,
  90                                               user.login, user.realm,
  91                                               recoverycode))
  92         if not r:
  93             raise privacyIDEAError("Failed to send email. {0!s}".format(r))
  94     else:
  95         raise ConfigAdminError("Missing configuration "
  96                                "recovery.identifier.")
  97     res = True
  98     return res
  99 
  100 
  101 @log_with(log)
  102 def check_recoverycode(user, recoverycode):
  103     """
  104     Check if the given recovery code is a valid recovery code for this user
  105 
  106     :param user: User, who wants to reset his password.
  107     :type user: User object
  108     :param recoverycode: The recovery code
  109     :type recoverycode: str
  110     :return: True is code was correct
  111     """
  112     recoverycode_valid = False
  113     # delete old entries
  114     r = PasswordReset.query.filter(and_(PasswordReset.expiration <
  115                                       datetime.now())).delete()
  116     log.debug("{0!s} old password recoverycodes deleted.".format(r))
  117     sql_query = PasswordReset.query.filter(and_(PasswordReset.username ==
  118                                             user.login,
  119                                                 PasswordReset.realm
  120                                                 == user.realm))
  121     for pwr in sql_query:
  122         if verify_with_pepper(pwr.recoverycode, recoverycode):
  123             recoverycode_valid = True
  124             log.debug(u"Found valid recoverycode for user {0!r}".format(user))
  125             # Delete the recovery code, so that it can only be used once!
  126             r = pwr.delete()
  127             log.debug("{0!s} used password recoverycode deleted.".format(r))
  128 
  129     return recoverycode_valid
  130 
  131 
  132 @log_with(log)
  133 def is_password_reset(g):
  134     """
  135     Check if password reset is allowed.
  136 
  137     We need to check, if a user policy with password_reset exists AND if an
  138     editable resolver exists. Otherwise password_reset does not make any sense.
  139 
  140     :return: True or False
  141     """
  142     rlist = get_resolver_list(editable=True)
  143     log.debug("Number of editable resolvers: {0!s}".format(len(rlist)))
  144     pwreset = Match.generic(g, scope=SCOPE.USER,
  145                             action=ACTION.PASSWORDRESET).allowed(write_to_audit_log=False)
  146     log.debug("Password reset allowed via policies: {0!s}".format(pwreset))
  147     return bool(rlist and pwreset)
  148
  ```
  --- END FILES ---
  
  Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.
  
  Here is an example:
  
  ```diff
  diff --git a/examples/server_async.py b/examples/server_async.py
  --- a/examples/server_async.py
  +++ b/examples/server_async.py
  @@ -313,4 +313,4 @@
   
   
   if __name__ == "__main__":
  -    asyncio.run(run_async_server("."), debug=True)
  +    asyncio.run(run_async_server(), debug=True)
  diff --git a/examples/server_sync.py b/examples/server_sync.py
  --- a/examples/server_sync.py
  +++ b/examples/server_sync.py
  @@ -313,5 +313,5 @@
   
   
   if __name__ == "__main__":
  -    server = run_sync_server(".")
  +    server = run_sync_server()
       server.shutdown()
  
  ```
  
golden_diff: diff --git a/privacyidea/lib/passwordreset.py b/privacyidea/lib/passwordreset.py
  --- a/privacyidea/lib/passwordreset.py
  +++ b/privacyidea/lib/passwordreset.py
  @@ -1,5 +1,7 @@
   # -*- coding: utf-8 -*-
   #
  +#  2022-06-06 Cornelius Kölbel <cornelius.koelbel@netknights.it>
  +#             Fix recovery link
   #  2015-01-06 Cornelius Kölbel <cornelius@privacyidea.org>
   #             The Password reset functions
   #
  @@ -64,7 +66,7 @@
       :return: bool
       """
       base_url = base_url.strip("recover")
  -    base_url += "#"
  +    base_url += "#!"
       recoverycode = recoverycode or generate_password(size=24)
       hash_code = hash_with_pepper(recoverycode)
       # send this recoverycode
  
verification_info: {"golden_diff": "diff --git a/privacyidea/lib/passwordreset.py b/privacyidea/lib/passwordreset.py\n--- a/privacyidea/lib/passwordreset.py\n+++ b/privacyidea/lib/passwordreset.py\n@@ -1,5 +1,7 @@\n # -*- coding: utf-8 -*-\n #\n+#  2022-06-06 Cornelius K\u00f6lbel <cornelius.koelbel@netknights.it>\n+#             Fix recovery link\n #  2015-01-06 Cornelius K\u00f6lbel <cornelius@privacyidea.org>\n #             The Password reset functions\n #\n@@ -64,7 +66,7 @@\n     :return: bool\n     \"\"\"\n     base_url = base_url.strip(\"recover\")\n-    base_url += \"#\"\n+    base_url += \"#!\"\n     recoverycode = recoverycode or generate_password(size=24)\n     hash_code = hash_with_pepper(recoverycode)\n     # send this recoverycode\n", "issue": "Fix recovery link in email\nThe email that is sent when resetting a password, contains a faulty link.\r\n\r\nCurrentlly `https://piserver/#/reset/...` is sent. It has to be `https://piserver/#!/reset/..`.\r\n\r\nThis happens here:\r\n\r\nhttps://github.com/privacyidea/privacyidea/blob/35f0963b1bbacfbbad128c12ba0a0953fc17a339/privacyidea/lib/passwordreset.py#L67\r\n\r\nAlso see https://community.privacyidea.org/t/q-password-reset-what-policies-needed/2493/4\n", "before_files": [{"content": "# -*- coding: utf-8 -*-\n#\n#  2015-01-06 Cornelius K\u00f6lbel <cornelius@privacyidea.org>\n#             The Password reset functions\n#\n# This code is free software; you can redistribute it and/or\n# modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE\n# License as published by the Free Software Foundation; either\n# version 3 of the License, or any later version.\n#\n# This code is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU AFFERO GENERAL PUBLIC LICENSE for more details.\n#\n# You should have received a copy of the GNU Affero General Public\n# License along with this program.  If not, see <http://www.gnu.org/licenses/>.\n#\n#\nfrom privacyidea.models import PasswordReset\nfrom privacyidea.lib.crypto import (hash_with_pepper, verify_with_pepper,\n                                    generate_password)\nimport logging\nfrom privacyidea.lib.log import log_with\nfrom privacyidea.lib.error import UserError, privacyIDEAError, ConfigAdminError\nfrom privacyidea.lib.smtpserver import send_email_identifier\nfrom privacyidea.lib.config import get_from_config\nfrom privacyidea.lib.resolver import get_resolver_list\nfrom privacyidea.lib.policy import ACTION, SCOPE, Match\nfrom sqlalchemy import and_\nfrom datetime import datetime\n\n\n__doc__ = \"\"\"\nThis is the library for creating a recovery code for password reset.\nThe recovery code is sent to the user.\n\nThe salted/peppered hash of the recovery code is stored in the passwordreset\ndatabase table.\n\nThis module is tested in tests/test_lib_passwordreset.py\n\"\"\"\n\nlog = logging.getLogger(__name__)\n\nBODY = u\"\"\"Someone requested to reset the password within privacyIDEA.\n\nTo reset your user password please visit the link\n\n{0!s}/reset/{1!s}@{2!s}/{3!s}\n\"\"\"\n\n\n@log_with(log)\ndef create_recoverycode(user, email=None, expiration_seconds=3600,\n                        recoverycode=None, base_url=\"\"):\n    \"\"\"\n    Create and send a password recovery code\n\n    :param user: User for whom the password reset code should be sent\n    :type user: User Object\n    :param email: The optional email of the user\n    :param recoverycode: Only used for testing purpose\n    :return: bool\n    \"\"\"\n    base_url = base_url.strip(\"recover\")\n    base_url += \"#\"\n    recoverycode = recoverycode or generate_password(size=24)\n    hash_code = hash_with_pepper(recoverycode)\n    # send this recoverycode\n    #\n    pwreset = PasswordReset(hash_code, username=user.login,\n                            realm=user.realm,\n                            expiration_seconds=expiration_seconds)\n    pwreset.save()\n\n    res = False\n    if not user:\n        raise UserError(\"User required for recovery token.\")\n    user_email = user.info.get(\"email\")\n    if email and email.lower() != user_email.lower():\n        raise UserError(\"The email does not match the users email.\")\n\n    identifier = get_from_config(\"recovery.identifier\")\n    if identifier:\n        # send email\n        r = send_email_identifier(identifier, user_email,\n                                  \"Your password reset\",\n                                  BODY.format(base_url,\n                                              user.login, user.realm,\n                                              recoverycode))\n        if not r:\n            raise privacyIDEAError(\"Failed to send email. {0!s}\".format(r))\n    else:\n        raise ConfigAdminError(\"Missing configuration \"\n                               \"recovery.identifier.\")\n    res = True\n    return res\n\n\n@log_with(log)\ndef check_recoverycode(user, recoverycode):\n    \"\"\"\n    Check if the given recovery code is a valid recovery code for this user\n\n    :param user: User, who wants to reset his password.\n    :type user: User object\n    :param recoverycode: The recovery code\n    :type recoverycode: str\n    :return: True is code was correct\n    \"\"\"\n    recoverycode_valid = False\n    # delete old entries\n    r = PasswordReset.query.filter(and_(PasswordReset.expiration <\n                                      datetime.now())).delete()\n    log.debug(\"{0!s} old password recoverycodes deleted.\".format(r))\n    sql_query = PasswordReset.query.filter(and_(PasswordReset.username ==\n                                            user.login,\n                                                PasswordReset.realm\n                                                == user.realm))\n    for pwr in sql_query:\n        if verify_with_pepper(pwr.recoverycode, recoverycode):\n            recoverycode_valid = True\n            log.debug(u\"Found valid recoverycode for user {0!r}\".format(user))\n            # Delete the recovery code, so that it can only be used once!\n            r = pwr.delete()\n            log.debug(\"{0!s} used password recoverycode deleted.\".format(r))\n\n    return recoverycode_valid\n\n\n@log_with(log)\ndef is_password_reset(g):\n    \"\"\"\n    Check if password reset is allowed.\n\n    We need to check, if a user policy with password_reset exists AND if an\n    editable resolver exists. Otherwise password_reset does not make any sense.\n\n    :return: True or False\n    \"\"\"\n    rlist = get_resolver_list(editable=True)\n    log.debug(\"Number of editable resolvers: {0!s}\".format(len(rlist)))\n    pwreset = Match.generic(g, scope=SCOPE.USER,\n                            action=ACTION.PASSWORDRESET).allowed(write_to_audit_log=False)\n    log.debug(\"Password reset allowed via policies: {0!s}\".format(pwreset))\n    return bool(rlist and pwreset)\n", "path": "privacyidea/lib/passwordreset.py"}], "after_files": [{"content": "# -*- coding: utf-8 -*-\n#\n#  2022-06-06 Cornelius K\u00f6lbel <cornelius.koelbel@netknights.it>\n#             Fix recovery link\n#  2015-01-06 Cornelius K\u00f6lbel <cornelius@privacyidea.org>\n#             The Password reset functions\n#\n# This code is free software; you can redistribute it and/or\n# modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE\n# License as published by the Free Software Foundation; either\n# version 3 of the License, or any later version.\n#\n# This code is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU AFFERO GENERAL PUBLIC LICENSE for more details.\n#\n# You should have received a copy of the GNU Affero General Public\n# License along with this program.  If not, see <http://www.gnu.org/licenses/>.\n#\n#\nfrom privacyidea.models import PasswordReset\nfrom privacyidea.lib.crypto import (hash_with_pepper, verify_with_pepper,\n                                    generate_password)\nimport logging\nfrom privacyidea.lib.log import log_with\nfrom privacyidea.lib.error import UserError, privacyIDEAError, ConfigAdminError\nfrom privacyidea.lib.smtpserver import send_email_identifier\nfrom privacyidea.lib.config import get_from_config\nfrom privacyidea.lib.resolver import get_resolver_list\nfrom privacyidea.lib.policy import ACTION, SCOPE, Match\nfrom sqlalchemy import and_\nfrom datetime import datetime\n\n\n__doc__ = \"\"\"\nThis is the library for creating a recovery code for password reset.\nThe recovery code is sent to the user.\n\nThe salted/peppered hash of the recovery code is stored in the passwordreset\ndatabase table.\n\nThis module is tested in tests/test_lib_passwordreset.py\n\"\"\"\n\nlog = logging.getLogger(__name__)\n\nBODY = u\"\"\"Someone requested to reset the password within privacyIDEA.\n\nTo reset your user password please visit the link\n\n{0!s}/reset/{1!s}@{2!s}/{3!s}\n\"\"\"\n\n\n@log_with(log)\ndef create_recoverycode(user, email=None, expiration_seconds=3600,\n                        recoverycode=None, base_url=\"\"):\n    \"\"\"\n    Create and send a password recovery code\n\n    :param user: User for whom the password reset code should be sent\n    :type user: User Object\n    :param email: The optional email of the user\n    :param recoverycode: Only used for testing purpose\n    :return: bool\n    \"\"\"\n    base_url = base_url.strip(\"recover\")\n    base_url += \"#!\"\n    recoverycode = recoverycode or generate_password(size=24)\n    hash_code = hash_with_pepper(recoverycode)\n    # send this recoverycode\n    #\n    pwreset = PasswordReset(hash_code, username=user.login,\n                            realm=user.realm,\n                            expiration_seconds=expiration_seconds)\n    pwreset.save()\n\n    res = False\n    if not user:\n        raise UserError(\"User required for recovery token.\")\n    user_email = user.info.get(\"email\")\n    if email and email.lower() != user_email.lower():\n        raise UserError(\"The email does not match the users email.\")\n\n    identifier = get_from_config(\"recovery.identifier\")\n    if identifier:\n        # send email\n        r = send_email_identifier(identifier, user_email,\n                                  \"Your password reset\",\n                                  BODY.format(base_url,\n                                              user.login, user.realm,\n                                              recoverycode))\n        if not r:\n            raise privacyIDEAError(\"Failed to send email. {0!s}\".format(r))\n    else:\n        raise ConfigAdminError(\"Missing configuration \"\n                               \"recovery.identifier.\")\n    res = True\n    return res\n\n\n@log_with(log)\ndef check_recoverycode(user, recoverycode):\n    \"\"\"\n    Check if the given recovery code is a valid recovery code for this user\n\n    :param user: User, who wants to reset his password.\n    :type user: User object\n    :param recoverycode: The recovery code\n    :type recoverycode: str\n    :return: True is code was correct\n    \"\"\"\n    recoverycode_valid = False\n    # delete old entries\n    r = PasswordReset.query.filter(and_(PasswordReset.expiration <\n                                      datetime.now())).delete()\n    log.debug(\"{0!s} old password recoverycodes deleted.\".format(r))\n    sql_query = PasswordReset.query.filter(and_(PasswordReset.username ==\n                                            user.login,\n                                                PasswordReset.realm\n                                                == user.realm))\n    for pwr in sql_query:\n        if verify_with_pepper(pwr.recoverycode, recoverycode):\n            recoverycode_valid = True\n            log.debug(u\"Found valid recoverycode for user {0!r}\".format(user))\n            # Delete the recovery code, so that it can only be used once!\n            r = pwr.delete()\n            log.debug(\"{0!s} used password recoverycode deleted.\".format(r))\n\n    return recoverycode_valid\n\n\n@log_with(log)\ndef is_password_reset(g):\n    \"\"\"\n    Check if password reset is allowed.\n\n    We need to check, if a user policy with password_reset exists AND if an\n    editable resolver exists. Otherwise password_reset does not make any sense.\n\n    :return: True or False\n    \"\"\"\n    rlist = get_resolver_list(editable=True)\n    log.debug(\"Number of editable resolvers: {0!s}\".format(len(rlist)))\n    pwreset = Match.generic(g, scope=SCOPE.USER,\n                            action=ACTION.PASSWORDRESET).allowed(write_to_audit_log=False)\n    log.debug(\"Password reset allowed via policies: {0!s}\".format(pwreset))\n    return bool(rlist and pwreset)\n", "path": "privacyidea/lib/passwordreset.py"}]}