Repository: privacyidea/privacyidea

Issue:
make the user_obj available in the before-method of /auth
At the /auth endpoint, we do not have the "user" available so user-specific event handlers cannot be uses in a self-service scenario at the WebUI.

One specific requirement was to enroll tokens ~~after~~ **before** a successful login at the webui.

Note: It is not necessary, to differentiate different users or usernames, but the user object is needed, so that e.g. the email is available to notify or the user_obj is available to enroll a token to this very user. It is not about *conditions* but about having the user_obj in the context at all.


Discussion:
Check:

* Which user (userobject) is used in the event handler
* Which one is available after auth req... probably ``logged_in_user``. 

Can we safely use this or not?
What is, if the role is admin, and if this is a local admin or not?

The problem is, that in certain scenarios we have different user_objs.
If an administrator logs in to privacyidea, the user_obj is the user, on whom the administrator is acting. 

If a user logs in to privacyidea, the user_obj (later) is the ``logged_in_user``. But not in the /auth request, yet.

**NOTE** We have a generic ``before_request`` decorator for other requests, that fill in the ``request.User``.
See https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/before_after.py#L146

The auth request however, does not create the ``request.User`` in the before:
See: https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/auth.py#L90

However, the validate request create a ``request.User``:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/validate.py#L125

We can not create the ``request.User`` in the before decorator of ``auth`` since we do not know, if the user will be an administrator or a normal user. We could however create the ``request.User`` in the after decorator.

I.e. the user could be availabe in PostEventHandlers but not in preeventhandlers, if we go the way by filling the ``request.User`` which I actually would prefer.

@laclaro What was actually the very detailed scenario?

Well, actuall checking the code, it should work **after**:

Here the user_obj is determined and created:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/auth.py#L285

And here it is written to ``request.User``:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/auth.py#L314

So finally I am absolutely not clear, what this issue is actually about?
Future issues should be more specific.
The requirement was to **enroll token** in **pre events**. 
Why does it work in validate/check? 
So why wouldn't it work in /auth?
THis might be the same problem as #2093 
In the /validate/check request in the before-method the user object is set
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/validate.py#L125

This could also happen in the before-method of the auth endpoint here:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/auth.py#L90

But this can not easily be done without a logical challenge. Why? What is different?

The validate endpoint is only called by userobjects existing in user resolvers.
The auth endpoint is called by userobjects in existing user resolvers but it is also called by internal admins. I.e. in the before-method we can not know yet, if the user, who tries to authenticate, is an internal admin or a user in the user-resolver.
This is why the ``request.User`` is only filled [here](https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/auth.py#L314) in the /auth endpoint, when we actually *know*, who the user in the request is.

Imagine there is a user as internal admin called "userA" and if there is a userresolver-user called "userA". Note: Both users can login as long as they have different passwords.
But if you would generate a user object in the before-method, then the following can happen:

The admin "userA" tries to log in. The before-method does not yet know, if this is an internal admin. It find the user in the resolver and triggers a pre-event. The admin is logged in.
THus, an admin would automatically create a token in the original example, each time he logs in.

We could add in the before-method of the auth endpoint here:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/auth.py#L90
a line like this (where we need to be aware, that the loginname is contained in ``username`` and not in ``user``!):

~~~~python
try:
    # move attribute *username* to attribute *user*
    request.all_data.user = request.all_data.username
    request.User = get_user_from_param(request.all_data)
except privacyIDEAUserError:
    # The user can not be found in any resolver. Obviously an internal admin is about to log in
    pass

~~~~

But this can result in the above mentioned side effect!!!
We should add an additional hint/note in the docs or in the log file or somewhere!

Is my input still needed? The primary use-case requested was to trigger an event handler before the user actually logs in.

One request I remember was like this: If the user has no token, enroll a challenge-response-capable one and use it for the WebUI login.
The coding is not a big issue - rather an issue of a few lines of code.
@laclaro  If you have some suggestion for the documentation, in regards to possible side effects, this would be great. Maybe we can discuss this in a direct "chat".
Suggestion: Write a log message, if there are duplicate users of *internal admins* and the user in a *resolver*.
This might be problematic, due to the code flow. Check, if we can do this simply if a user_obj is found and quickly check, if a local admin with the same name exists.

Resolving patch:
diff --git a/privacyidea/api/auth.py b/privacyidea/api/auth.py
--- a/privacyidea/api/auth.py
+++ b/privacyidea/api/auth.py
@@ -104,6 +104,16 @@ def before_request():
                         "action_detail": "",
                         "info": ""})
 
+    username = getParam(request.all_data, "username")
+    if username:
+        # We only fill request.User, if we really have a username.
+        # On endpoints like /auth/rights, this is not available
+        loginname, realm = split_user(username)
+        # overwrite the split realm if we have a realm parameter. Default back to default_realm
+        realm = getParam(request.all_data, "realm", default=realm) or realm or get_default_realm()
+        # Prefill the request.User. This is used by some pre-event handlers
+        request.User = User(loginname, realm)
+
 
 @jwtauth.route('', methods=['POST'])
 @prepolicy(pushtoken_disable_wait, request)
@@ -267,6 +277,7 @@ def get_auth_token():
     elif verify_db_admin(username, password):
         role = ROLE.ADMIN
         admin_auth = True
+        log.info("Local admin '{0!s}' successfully logged in.".format(username))
         # This admin is not in the default realm!
         realm = ""
         g.audit_object.log({"success": True,
@@ -289,6 +300,11 @@ def get_auth_token():
                                                     superuser_realms=
                                                     superuser_realms)
         details = details or {}
+        if db_admin_exist(loginname) and realm == get_default_realm():
+            # If there is a local admin with the same login name as the user
+            # in the default realm, we inform about this in the log file.
+            log.warning("A user '{0!s}' exists as local admin and as user in "
+                        "your default realm!".format(loginname))
         if role == ROLE.ADMIN:
             g.audit_object.log({"user": "",
                                 "administrator": user_obj.login,


Test patch:
diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py
--- a/tests/test_api_auth.py
+++ b/tests/test_api_auth.py
@@ -7,6 +7,15 @@
 from privacyidea.lib.policy import (set_policy, SCOPE, ACTION, REMOTE_USER,
                                     delete_policy)
 from privacyidea.lib.auth import create_db_admin
+from privacyidea.lib.resolver import save_resolver
+from privacyidea.lib.realm import set_realm, set_default_realm
+from privacyidea.lib.event import set_event, delete_event
+from privacyidea.lib.eventhandler.base import CONDITION
+from privacyidea.lib.token import get_tokens, remove_token
+from privacyidea.lib.user import User
+
+
+PWFILE = "tests/testdata/passwd-duplicate-name"
 
 
 class AuthApiTestCase(MyApiTestCase):
@@ -530,3 +539,141 @@ def test_04_remote_user_auth(self):
 
         delete_policy(name='remote')
         set_privacyidea_config(SYSCONF.SPLITATSIGN, True)
+
+
+class DuplicateUserApiTestCase(MyApiTestCase):
+
+    def test_01_admin_and_user_same_name(self):
+        # Test the logging, if admin and user have the same name (testamdin/testpw)
+        # Now create a default realm, that contains the used "testadmin"
+        rid = save_resolver({"resolver": self.resolvername1,
+                             "type": "passwdresolver",
+                             "fileName": PWFILE})
+        self.assertTrue(rid > 0, rid)
+
+        (added, failed) = set_realm(self.realm1,
+                                    [self.resolvername1])
+        self.assertTrue(len(failed) == 0)
+        self.assertTrue(len(added) == 1)
+
+        set_default_realm(self.realm1)
+
+        # If the admin logs in, everything is fine
+        with mock.patch("logging.Logger.info") as mock_log:
+            with self.app.test_request_context('/auth',
+                                               method='POST',
+                                               data={"username": "testadmin",
+                                                     "password": "testpw"}):
+                res = self.app.full_dispatch_request()
+                self.assertEqual(200, res.status_code, res)
+                result = res.json.get("result")
+                self.assertTrue(result.get("status"), result)
+                self.assertIn('token', result.get("value"), result)
+                # role should be 'admin'
+                self.assertEqual('admin', result['value']['role'], result)
+            mock_log.assert_called_with("Local admin 'testadmin' successfully logged in.")
+
+        # If a user logs in, with the same name as the admin, this event is logged in warning
+        with mock.patch("logging.Logger.warning") as mock_log:
+            with self.app.test_request_context('/auth',
+                                               method='POST',
+                                               data={"username": "testadmin",
+                                                     "password": "test"}):
+                res = self.app.full_dispatch_request()
+                self.assertEqual(200, res.status_code, res)
+                result = res.json.get("result")
+                self.assertTrue(result.get("status"), result)
+                self.assertIn('token', result.get("value"), result)
+                # role should be 'user'
+                self.assertEqual('user', result['value']['role'], result)
+            # check if we have this log entry
+            mock_log.assert_called_with("A user 'testadmin' exists as local admin and as user "
+                                        "in your default realm!")
+
+
+class AAPreEventHandlerTest(MyApiTestCase):
+
+    def test_01_setup_eventhandlers(self):
+        # This test create an HOTP token with C/R with a pre-event handler
+        # and the user uses this HOTP token to directly login to /auth
+
+        # Setup realm
+        rid = save_resolver({"resolver": self.resolvername1,
+                             "type": "passwdresolver",
+                             "fileName": PWFILE})
+        self.assertTrue(rid > 0, rid)
+
+        (added, failed) = set_realm(self.realm1,
+                                    [self.resolvername1])
+        self.assertTrue(len(failed) == 0)
+        self.assertTrue(len(added) == 1)
+
+        set_default_realm(self.realm1)
+
+        # set a policy to authenticate against privacyIDEA
+        set_policy("piLogin", scope=SCOPE.WEBUI, action="{0!s}=privacyIDEA".format(ACTION.LOGINMODE))
+        # set a policy to for otppin=userstore
+        set_policy("otppin", scope=SCOPE.AUTH, action="{0!s}=userstore".format(ACTION.OTPPIN))
+        # Set a policy to do C/R with HOTP tokens
+        set_policy("crhotp", scope=SCOPE.AUTH, action="{0!s}=hotp".format(ACTION.CHALLENGERESPONSE))
+
+        # Create an event handler, that creates HOTP token on /auth with default OTP key
+        eid = set_event("createtoken", event=["auth"], handlermodule="Token",
+                        action="enroll", position="pre",
+                        conditions={CONDITION.USER_TOKEN_NUMBER: 0},
+                        options={"tokentype": "hotp", "user": "1",
+                                 "additional_params": {
+                                     'otpkey': self.otpkey,
+                                     # We need to set gekey=0, otherwise the Tokenhandler will
+                                     # generate a random otpkey
+                                     'genkey': 0}})
+        # cleanup tokens
+        remove_token(user=User("someuser", self.realm1))
+
+        # user tries to log in with his userstore password and gets a transaction_id
+        with self.app.test_request_context('/auth',
+                                           method='POST',
+                                           data={"username": "someuser",
+                                                 "password": "test"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(401, res.status_code, res)
+            result = res.json.get("result")
+            self.assertFalse(result.get("status"), result)
+            detail = res.json.get("detail")
+            self.assertEqual("please enter otp: ", detail.get("message"))
+            transaction_id = detail.get("transaction_id")
+
+        # Check if the token was enrolled
+        toks = get_tokens(user=User("someuser", self.realm1))
+        self.assertEqual(len(toks), 1)
+        self.assertEqual(toks[0].token.tokentype, "hotp")
+        serial = toks[0].token.serial
+        # Check if the correct otpkey was used
+        hotptoken = toks[0]
+        r = hotptoken.check_otp(self.valid_otp_values[1])
+        self.assertTrue(r >= 0)
+
+        # Now the user logs in with the second step of C/R with OTP value of new token
+        with self.app.test_request_context('/auth',
+                                           method='POST',
+                                           data={"username": "someuser",
+                                                 "transaction_id": transaction_id,
+                                                 "password": self.valid_otp_values[2]}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(200, res.status_code, res)
+            result = res.json.get("result")
+            self.assertTrue(result.get("status"))
+            self.assertTrue(result.get("value"))
+
+        # Check that there is still only one token
+        toks = get_tokens(user=User("someuser", self.realm1))
+        self.assertEqual(len(toks), 1)
+        self.assertEqual(toks[0].token.tokentype, "hotp")
+        self.assertEqual(serial, toks[0].token.serial)
+
+        # cleanup
+        delete_policy("piLogin")
+        delete_policy("otppin")
+        delete_policy("crhotp")
+        delete_event(eid)
+        remove_token(hotptoken.token.serial)
diff --git a/tests/testdata/passwd-duplicate-name b/tests/testdata/passwd-duplicate-name
new file mode 100644
--- /dev/null
+++ b/tests/testdata/passwd-duplicate-name
@@ -0,0 +1,2 @@
+testadmin:H9Y6eYntkrrKo:1004:1004:::
+someuser:H9Y6eYntkrrKo:1005:1005:::