Repository: rucio/rucio

Issue:
Make X-RUCIO-ACCOUNT header optional in auth server
Motivation
----------
In the rucio server, `/auth/userpass` endpoint has a hard requirement for the `account` header.
https://github.com/rucio/rucio/blob/5c665be7891e76a2c31d3d50d5f51ca38fecfa4c/lib/rucio/web/rest/flaskapi/v1/auth.py#L110-L111

This complicates the login workflow for the new webui. The login page asks the user for the identity i.e. `username' and `password` but if the user does not specify the account, the login will fail.

In order to make the account field optional,  this endpoint must be modified


Modification
------------

- if the header is present, use that account
- if the header is not present, list the accounts associated with the identity.
    - if 0 accounts are found, raise appropriate error
    - if a single account is found, use that
    - if multiple accounts are found
        - if default account is specified, use that
        - otherwise, return a list of accounts that the UI can render and ask the person to pick one explicitly
 
This must be done in a way that other endpoints can re-use the logic





Discussion:

Resolving patch:
diff --git a/lib/rucio/api/identity.py b/lib/rucio/api/identity.py
--- a/lib/rucio/api/identity.py
+++ b/lib/rucio/api/identity.py
@@ -17,6 +17,7 @@
 Interface for identity abstraction layer
 """
 
+import typing
 from rucio.api import permission
 from rucio.common import exception
 from rucio.common.types import InternalAccount
@@ -82,6 +83,18 @@ def add_account_identity(identity_key, id_type, account, email, issuer, default=
                                          email=email, account=account, password=password, session=session)
 
 
+@read_session
+def verify_identity(identity_key: str, id_type: str, password: typing.Union[str, None] = None, session=None) -> bool:
+    """
+    Verifies a user identity.
+    :param identity_key: The identity key name. For example x509 DN, or a username.
+    :param id_type: The type of the authentication (x509, gss, userpass, ssh, saml)
+    :param password: If type==userpass, verifies the identity_key, .
+    :param session: The database session in use.
+    """
+    return identity.verify_identity(identity_key, IdentityType[id_type.upper()], password=password, session=session)
+
+
 @transactional_session
 def del_account_identity(identity_key, id_type, account, issuer, vo='def', session=None):
     """
diff --git a/lib/rucio/core/identity.py b/lib/rucio/core/identity.py
--- a/lib/rucio/core/identity.py
+++ b/lib/rucio/core/identity.py
@@ -25,10 +25,12 @@
 from rucio.db.sqla import models
 from rucio.db.sqla.constants import IdentityType
 from rucio.db.sqla.session import read_session, transactional_session
+from rucio.common.types import InternalAccount
+from typing import Union
 
 
 @transactional_session
-def add_identity(identity, type_, email, password=None, session=None):
+def add_identity(identity: str, type_: IdentityType, email: str, password: Union[str, None] = None, session=None):
     """
     Creates a user identity.
 
@@ -58,8 +60,38 @@ def add_identity(identity, type_, email, password=None, session=None):
         raise exception.DatabaseException(str(e))
 
 
+@read_session
+def verify_identity(identity: str, type_: IdentityType, password: Union[str, None] = None, session=None) -> bool:
+    """
+    Verifies a user identity.
+    :param identity: The identity key name. For example x509 DN, or a username.
+    :param type_: The type of the authentication (x509, gss, userpass, ssh, saml, oidc)
+    :param password: If type==userpass, verifies the identity_key, .
+    :param session: The database session in use.
+    :returns: True if the identity is valid, raises IdentityNotFound otherwise.
+    :raises IdentityNotFound: If the identity is not valid.
+    :raises IdentityError: If the identity is not valid.
+    :raises NotImplementedError: If the identity type is not implemented. i.e. x509, gss, ssh, saml, oidc
+    """
+
+    if type_ == IdentityType.USERPASS and password is None:
+        raise exception.IdentityError('You must provide a password!')
+
+    id_ = session.query(models.Identity).filter_by(identity=identity, identity_type=type_).first()
+    if id_ is None:
+        raise exception.IdentityError('Identity pair \'%s\',\'%s\' does not exist!' % (identity, type_))
+    if type_ == IdentityType.USERPASS:
+        salted_password = id_.salt + password.encode()
+        password = hashlib.sha256(salted_password).hexdigest()
+        if password != id_.password:
+            raise exception.IdentityNotFound('Password does not match for userpass identity \'%s\'!' % identity)
+        return True
+    else:
+        raise NotImplementedError('Identity type \'%s\' is not implemented!' % type_)
+
+
 @transactional_session
-def del_identity(identity, type_, session=None):
+def del_identity(identity: str, type_: IdentityType, session=None):
     """
     Deletes a user identity.
 
@@ -75,7 +107,7 @@ def del_identity(identity, type_, session=None):
 
 
 @transactional_session
-def add_account_identity(identity, type_, account, email, default=False, password=None, session=None):
+def add_account_identity(identity: str, type_: IdentityType, account: InternalAccount, email: str, default: bool = False, password: str = None, session=None):
     """
     Adds a membership association between identity and account.
 
@@ -111,7 +143,7 @@ def add_account_identity(identity, type_, account, email, default=False, passwor
 
 
 @read_session
-def exist_identity_account(identity, type_, account, session=None):
+def exist_identity_account(identity: str, type_: IdentityType, account: InternalAccount, session=None):
     """
     Check if an identity is mapped to an account.
 
@@ -128,7 +160,7 @@ def exist_identity_account(identity, type_, account, session=None):
 
 
 @read_session
-def get_default_account(identity, type_, oldest_if_none=False, session=None):
+def get_default_account(identity: str, type_: IdentityType, oldest_if_none: bool = False, session=None):
     """
     Retrieves the default account mapped to an identity.
 
@@ -158,7 +190,7 @@ def get_default_account(identity, type_, oldest_if_none=False, session=None):
 
 
 @transactional_session
-def del_account_identity(identity, type_, account, session=None):
+def del_account_identity(identity: str, type_: IdentityType, account: InternalAccount, session=None):
     """
     Removes a membership association between identity and account.
 
@@ -192,7 +224,7 @@ def list_identities(session=None, **kwargs):
 
 
 @read_session
-def list_accounts_for_identity(identity, type_, session=None):
+def list_accounts_for_identity(identity: str, type_: IdentityType, session=None):
     """
     Returns a list of all accounts for an identity.
 
diff --git a/lib/rucio/web/rest/flaskapi/v1/auth.py b/lib/rucio/web/rest/flaskapi/v1/auth.py
--- a/lib/rucio/web/rest/flaskapi/v1/auth.py
+++ b/lib/rucio/web/rest/flaskapi/v1/auth.py
@@ -16,6 +16,7 @@
 import base64
 import logging
 import time
+import json
 from re import search
 from typing import TYPE_CHECKING
 
@@ -27,9 +28,10 @@
     get_auth_token_ssh, get_ssh_challenge_token, validate_auth_token, get_auth_oidc, redirect_auth_oidc, \
     get_token_oidc, refresh_cli_auth_token, get_auth_token_saml
 from rucio.common.config import config_get
-from rucio.common.exception import AccessDenied, IdentityError, CannotAuthenticate, CannotAuthorize
+from rucio.common.exception import AccessDenied, IdentityError, IdentityNotFound, CannotAuthenticate, CannotAuthorize
 from rucio.common.extra import import_extras
 from rucio.common.utils import date_to_str
+from rucio.api.identity import list_accounts_for_identity, get_default_account, verify_identity
 from rucio.web.rest.flaskapi.v1.common import check_accept_header_wrapper_flask, error_headers, \
     extract_vo, generate_http_error_flask, ErrorHandlingMethodView
 
@@ -55,7 +57,7 @@ def get_headers(self) -> "Optional[HeadersType]":
         headers['Access-Control-Allow-Headers'] = request.environ.get('HTTP_ACCESS_CONTROL_REQUEST_HEADERS')
         headers['Access-Control-Allow-Methods'] = '*'
         headers['Access-Control-Allow-Credentials'] = 'true'
-        headers['Access-Control-Expose-Headers'] = 'X-Rucio-Auth-Token'
+        headers['Access-Control-Expose-Headers'] = 'X-Rucio-Auth-Token, X-Rucio-Auth-Account, X-Rucio-Auth-Accounts'
         return headers
 
     def options(self):
@@ -155,6 +157,18 @@ def get(self):
               X-Rucio-Auth-Token-Expires:
                 schema:
                   type: string
+              X-Rucio-Auth-Account:
+                schema:
+                  type: string
+                description: The rucio account used for authentication
+          206:
+            description: Partial content containing X-Rucio-Auth-Accounts header
+            headers:
+              X-Rucio-Auth-Accounts:
+                schema:
+                  type: string
+                description: The rucio accounts corresponding to the provided identity as a csv string
+
           401:
             description: Cannot authenticate
         """
@@ -172,17 +186,37 @@ def get(self):
         appid = request.headers.get('X-Rucio-AppID', default='unknown')
         ip = request.headers.get('X-Forwarded-For', default=request.remote_addr)
 
-        if not account or not username or not password:
+        if not username or not password:
             return generate_http_error_flask(401, CannotAuthenticate.__name__, 'Cannot authenticate without passing all required arguments', headers=headers)
 
         try:
-            result = get_auth_token_user_pass(account, username, password, appid, ip, vo=vo)
+            verify_identity(identity_key=username, id_type='USERPASS', password=password)
+        except IdentityNotFound:
+            return generate_http_error_flask(401, IdentityNotFound.__name__, 'Cannot authenticate. Username/Password pair does not exist.', headers=headers)
+        except IdentityError:
+            return generate_http_error_flask(401, IdentityError.__name__, 'Cannot authenticate. The identity does not exist.', headers=headers)
+
+        if not account:
+            accounts = list_accounts_for_identity(identity_key=username, id_type='USERPASS')
+            if len(accounts) == 0 or accounts is None:
+                return generate_http_error_flask(401, CannotAuthenticate.__name__, 'Cannot authenticate with provided username or password. Identity is not mapped to any accounts.', headers=headers)
+            if len(accounts) > 1:
+                try:
+                    account = get_default_account(identity_key=username, id_type='USERPASS')
+                except IdentityError:
+                    headers['X-Rucio-Auth-Accounts'] = ','.join(accounts)
+                    return json.dumps(accounts), 206, headers
+            else:
+                account = accounts[0]
+        account_name = account.external if type(account) is not str else account
+        try:
+            result = get_auth_token_user_pass(account_name, username, password, appid, ip, vo=vo)
         except AccessDenied:
             return generate_http_error_flask(401, CannotAuthenticate.__name__, f'Cannot authenticate to account {account} with given credentials', headers=headers)
 
         if not result:
             return generate_http_error_flask(401, CannotAuthenticate.__name__, f'Cannot authenticate to account {account} with given credentials', headers=headers)
-
+        headers['X-Rucio-Auth-Account'] = account_name
         headers['X-Rucio-Auth-Token'] = result['token']
         headers['X-Rucio-Auth-Token-Expires'] = date_to_str(result['expires_at'])
         return '', 200, headers


Test patch:
diff --git a/lib/rucio/tests/test_identity.py b/lib/rucio/tests/test_identity.py
--- a/lib/rucio/tests/test_identity.py
+++ b/lib/rucio/tests/test_identity.py
@@ -14,6 +14,8 @@
 # limitations under the License.
 
 import unittest
+import random
+import string
 
 import pytest
 
@@ -21,10 +23,11 @@
 from rucio.common.types import InternalAccount
 from rucio.common.utils import generate_uuid as uuid
 from rucio.core.account import add_account, del_account
-from rucio.core.identity import add_identity, del_identity, add_account_identity, del_account_identity, list_identities
+from rucio.core.identity import add_identity, del_identity, add_account_identity, del_account_identity, list_identities, verify_identity
 from rucio.db.sqla.constants import AccountType, IdentityType
 from rucio.tests.common import account_name_generator, headers, hdrdict, auth
 from rucio.tests.common_server import get_vo
+from rucio.common.exception import IdentityNotFound, IdentityError
 
 
 @pytest.mark.noparallel(reason='adds/removes entities with non-unique names')
@@ -87,3 +90,33 @@ def test_userpass(rest_client, auth_token):
     headers_dict = {'X-Rucio-Username': username, 'X-Rucio-Password': 'secret', 'X-Rucio-Email': 'email'}
     response = rest_client.put('/identities/root/userpass', headers=headers(auth(auth_token), hdrdict(headers_dict)))
     assert response.status_code == 201
+
+
+def test_verify_identity():
+    """ Test if an idenity exists in the db, mapping to at least one account. """
+    if config_get_bool('common', 'multi_vo', raise_exception=False, default=False):
+        vo = {'vo': get_vo()}
+    else:
+        vo = {}
+    account_name = account_name_generator()
+    account = InternalAccount(account_name, **vo)
+    username = ''.join(random.choice(string.ascii_letters) for i in range(10))
+    email = username + '@email.com'
+
+    add_account(account, AccountType.USER, email)
+
+    password = ''.join(random.choice(string.ascii_letters) for i in range(10))
+    add_identity(username, IdentityType.USERPASS, email=email, password=password)
+    add_account_identity(username, IdentityType.USERPASS, account, email=username + '@email.com', password=password)
+
+    with pytest.raises(IdentityError):
+        verify_identity(username, IdentityType.X509, password=password)
+
+    assert verify_identity(username, IdentityType.USERPASS, password=password) is True
+
+    with pytest.raises(IdentityNotFound):
+        verify_identity(username, IdentityType.USERPASS, password=password + 'wrong')
+
+    del_account_identity(username, IdentityType.USERPASS, account)
+    del_identity(username, IdentityType.USERPASS)
+    del_account(account)