diff --git a/cloudfile_ext/base.py b/cloudfile_ext/base.py index b4a36c8eda..d0a08cbb0f 100644 --- a/cloudfile_ext/base.py +++ b/cloudfile_ext/base.py @@ -35,9 +35,14 @@ def register(registry): # to its explicitly enabled administrator control plane. from cloudfile_ext.identity import provisioning_enabled if provisioning_enabled(settings): - from cloudfile_ext.identity_api import AdminExternalIdentityView + # Both registrations must reach the same Etech UID authority. Keep + # the generic CE extension's provider behavior outside this deployment. + if getattr(settings, 'CF_DIRECTORY_URL', ''): + from cloudfile_ext.v2.http import IdentityResolve as identity_view + else: + from cloudfile_ext.identity_api import AdminExternalIdentityView as identity_view registry.register_urls([ path('api/v2.1/admin/cloudfile/sso/identities/resolve/', - AdminExternalIdentityView.as_view(), + identity_view.as_view(), name='cloudfile-admin-external-identity'), ]) diff --git a/cloudfile_ext/identity_api.py b/cloudfile_ext/identity_api.py index 2df25391c4..46de6a00df 100644 --- a/cloudfile_ext/identity_api.py +++ b/cloudfile_ext/identity_api.py @@ -64,6 +64,13 @@ def post(self, request): The trusted caller has already resolved the user in its own directory. No password is supplied and the account is never chosen by display name. """ + # Etech has exactly one administrator provisioning authority, even + # when a legacy extension registration is still enabled. + if (getattr(settings, 'CF_DIRECTORY_URL', '') + or request.data.get('identity_source') == 'etech-directory'): + from cloudfile_ext.v2.http import IdentityResolve + return IdentityResolve().post(request) + from seahub.auth.models import SocialAuthUser from seahub.base.accounts import User from seahub.profile.models import Profile, DuplicatedContactEmailError diff --git a/cloudfile_ext/v2/ACL_CONTRACT.md b/cloudfile_ext/v2/ACL_CONTRACT.md index 7d2aefc886..8b4babec38 100644 --- a/cloudfile_ext/v2/ACL_CONTRACT.md +++ b/cloudfile_ext/v2/ACL_CONTRACT.md @@ -288,7 +288,13 @@ binding, an inactive account or any check error takes the locked path, which behaves as before. Readiness lifetime and revocation timing are unchanged. Staff identity resolution pre-creates a native CE User/Profile only after a fresh -active Directory UID proof. It creates no OAuth subject; first OAuth/OIDC login +active Directory UID proof. New Etech users require the trusted Directory account +(`employee_no`, currently Etech `account_`) and use `account@auth.local`. Numeric +and named accounts, including `admin`, are ordinary non-staff users. New native +names follow Seafile RPC lowercase normalization; UIDs remain byte-exact; `cfadmin`, +missing/invalid accounts and native name collisions reject creation. Existing +UID owners retain their exact historical native names, independent of contact +email. It creates no OAuth subject; first OAuth/OIDC login reuses that UID's native account. Existing UID collation aliases and inactive accounts reject. Local CE users without a Profile UID use a disjoint native principal and do not inherit external group claims. @@ -299,3 +305,13 @@ Optional issuer/audience defaults match EAP (`cloudfile-sso` / `eap-directory`). `CF_DIRECTORY_GROUP_OWNER` is an existing active native service administrator that owns newly created flat Groups. Redis comes from native `CACHES.default` LOCATION. These are connection/identity inputs, not feature switches. + + +OIDC-only login provisioning uses `cloudfile_ext.v2.jit.SQLJITProvisioner` after +ID Token verification and exact signed/UserInfo `userId` agreement, with fresh +active Directory proof. It uses only native User/Profile/SocialAuthUser facts; +no legacy job, audit, outbox or extension schema is required. Staff preparation +never invokes this login adapter. OAuth-only callbacks retain existing-UID +reuse; a second provider adds its own subject binding to the same UID owner. +A native RPC success followed by SQL failure can leave a precreated/unbound +account, as with staff preparation; no password or session is issued on failure. diff --git a/cloudfile_ext/v2/account_names.py b/cloudfile_ext/v2/account_names.py new file mode 100644 index 0000000000..fdf9a23701 --- /dev/null +++ b/cloudfile_ext/v2/account_names.py @@ -0,0 +1,12 @@ +"""Pure account naming shared by trusted login and administrator provisioning.""" +import re + + +def account_username(account): + """Follow CE's lowercase account naming; never select by contact email.""" + if (not isinstance(account, str) or not re.fullmatch(r'[A-Za-z0-9._-]{1,200}', account) + or account.lower() == 'cfadmin'): + raise ValueError('valid non-reserved Directory account required') + # ccnet_user_manager_add_emailuser lowercases usernames in native RPC. + # Normalize creation in SQL JIT too; stored UID owners are never renamed. + return account.lower() + '@auth.local' diff --git a/cloudfile_ext/v2/etech_directory.py b/cloudfile_ext/v2/etech_directory.py index 78aea5b270..fe6572d5c2 100644 --- a/cloudfile_ext/v2/etech_directory.py +++ b/cloudfile_ext/v2/etech_directory.py @@ -138,13 +138,29 @@ def create_group(name): conn.close() +def verified_account(directory, uid): + """Prove UID/status; NativeIdentity validates account names on creation only.""" + subject = directory.subject(uid) + login, _ = validate_subject(subject, uid) + if subject['status'] != 'active': + raise IdentityUnavailable('Directory account disabled') + # employee_no carries Etech account_, including nonnumeric accounts. + # Only the trusted Directory supplies it; request aliases are not proof. + account = subject['attributes'].get('employee_no') + return login, account + + def prepare_user(uid): with runtime() as (directory, identity, catalog, memberships): - subject = directory.subject(uid) - login, _ = validate_subject(subject, uid) - if subject['status'] != 'active': - raise IdentityUnavailable('Directory account disabled') - return identity.prepare(uid=uid, login_name=login) + login, account = verified_account(directory, uid) + return identity.prepare(uid=uid, login_name=login, account=account) + + +def provision_oidc_user(*, uid, provider, subject): + """Internal callback only, after verified ID Token/UserInfo UID equality.""" + from .jit import SQLJITProvisioner + with runtime() as (directory, identity, catalog, memberships): + return SQLJITProvisioner(directory, identity).ensure(uid=uid, provider=provider, subject=subject) def lookup_group(external): diff --git a/cloudfile_ext/v2/http.py b/cloudfile_ext/v2/http.py index 88f04af451..04171fc724 100644 --- a/cloudfile_ext/v2/http.py +++ b/cloudfile_ext/v2/http.py @@ -18,9 +18,15 @@ def post(self, request): # Native staff authentication authorizes lookup; Directory proves new UIDs. uid = request.data.get('external_user_id') if (not isinstance(uid, str) or not uid or uid != uid.strip() or len(uid) > 225 + or any(ord(c) < 32 or ord(c) == 127 for c in uid) or request.data.get('login_id') != uid): return api_error(status.HTTP_400_BAD_REQUEST, 'Stable UID required.') - username = Profile.objects.get_username_by_login_id(uid) + # Check exact stored values after native collation matching; never + # choose an arbitrary owner for a duplicate UID or case alias. + rows = list(Profile.objects.filter(login_id=uid).values_list('user', 'login_id')) + if rows and (len(rows) != 1 or rows[0][1] != uid): + return api_error(status.HTTP_409_CONFLICT, 'Native UID binding conflict.') + username = rows[0][0] if rows else None if username is None: # A staff request is not identity proof: independently read the # scoped Etech Directory before creating any native account. @@ -33,12 +39,15 @@ def post(self, request): except Exception: return api_error(status.HTTP_503_SERVICE_UNAVAILABLE, 'Trusted user preparation unavailable; sign in through SSO or restore Directory.') profile = Profile.objects.get_profile_by_user(username) - if profile is None or profile.login_id != uid: + if (profile is None or profile.login_id != uid + or username.lower() in ('cfadmin@auth.local', 'cfadmin@etech.com')): return api_error(status.HTTP_409_CONFLICT, 'Native UID binding conflict.') try: user = User.objects.get(email=username) except User.DoesNotExist: return api_error(status.HTTP_404_NOT_FOUND, 'Native user not found.') + if user.username != username or not user.is_active: + return api_error(status.HTTP_409_CONFLICT, 'Native user inactive or changed.') return Response({'external_user_id': uid, 'login_id': uid, 'email': user.username, 'is_active': bool(user.is_active)}) diff --git a/cloudfile_ext/v2/identity.py b/cloudfile_ext/v2/identity.py index f55da11583..b3db6825ba 100644 --- a/cloudfile_ext/v2/identity.py +++ b/cloudfile_ext/v2/identity.py @@ -1,7 +1,7 @@ """Stable UID binding using CE's native account/Profile/OAuth facts only. -Ported invariants: exact provider/sub/UID matching, no email/name merging, -and opaque native accounts. No legacy provisioning worker is imported. +Ported invariants: exact provider/sub/UID matching and no email/name merging. +Trusted account naming affects creation only. No legacy worker is imported. """ from contextlib import ExitStack import hashlib @@ -10,6 +10,10 @@ from .errors import IdentityConflict, IdentityUnavailable from .schema import database_lock, identifier +from cloudfile_ext.v2.account_names import account_username + + +_UNSPECIFIED_ACCOUNT = object() def text(value, maximum=225): @@ -32,6 +36,8 @@ def __init__(self, connection, *, account_schema, hub_schema, issuer, create_acc self.create_account = create_account def active(self, username, uid, *, locked=False): + if username.lower() in ('cfadmin@auth.local', 'cfadmin@etech.com'): + raise IdentityConflict('reserved native account') suffix = ' FOR UPDATE' if locked else '' with self.connection.cursor() as cursor: cursor.execute('SELECT user,login_id FROM ' + self.profiles + ' WHERE user=%s OR login_id=%s' + suffix, (username, uid)) @@ -41,9 +47,9 @@ def active(self, username, uid, *, locked=False): if cursor.fetchall() != ((username, 1),): raise IdentityUnavailable('native account inactive') - def prepare(self, *, uid, login_name): + def prepare(self, *, uid, login_name, account=_UNSPECIFIED_ACCOUNT): """Pre-create only a Directory-verified UID; never invent an OAuth sub.""" - uid, login_name = text(uid), text(login_name, 200) + uid = text(uid) with database_lock(self.connection, 'identity:uid:' + uid): with self.connection.cursor() as cursor: cursor.execute('SELECT user,login_id FROM ' + self.profiles + ' WHERE login_id=%s', (uid,)) @@ -56,22 +62,38 @@ def prepare(self, *, uid, login_name): return username if self.create_account is None: raise IdentityUnavailable('native account creation unavailable') - username = uuid4().hex + '@auth.local' - self.create_account(username) - self.connection.begin() try: + # Naming metadata must never block reuse of an exact UID owner. + # An explicitly missing Directory account cannot request UUID fallback. + username = (uuid4().hex + '@auth.local' if account is _UNSPECIFIED_ACCOUNT + else account_username(account)) + except ValueError: + raise IdentityConflict('invalid Directory account') from None + login_name = text(login_name, 200) + # Serialize case variants too: CE account lookup is case insensitive. + # Never adopt an unbound native account after a failed RPC/SQL attempt. + with database_lock(self.connection, 'identity:account:' + username.lower()): with self.connection.cursor() as cursor: - cursor.execute('SELECT email,is_active FROM ' + self.accounts + ' WHERE email=%s FOR UPDATE', (username,)) - if cursor.fetchall() != ((username, 1),): - raise IdentityUnavailable('native account creation failed') - # Contact mail and employee number are display attributes; - # only the exact immutable UID owns this native account. - cursor.execute('INSERT INTO ' + self.profiles + - '(user,login_id,nickname,intro,lang_code,contact_email,is_manually_set_contact_email,institution,list_in_address_book) ' - "VALUES(%s,%s,%s,'',NULL,NULL,0,'',0)", (username, uid, login_name[:64])) - self.connection.commit() - finally: - self.connection.rollback() + cursor.execute('SELECT user,login_id FROM ' + self.profiles + ' WHERE user=%s', (username,)) + if cursor.fetchall(): + raise IdentityConflict('native account name already bound') + cursor.execute('SELECT email FROM ' + self.accounts + ' WHERE email=%s', (username,)) + if cursor.fetchall(): + raise IdentityConflict('native account name already exists') + self.create_account(username) + self.connection.begin() + try: + with self.connection.cursor() as cursor: + cursor.execute('SELECT email,is_active FROM ' + self.accounts + ' WHERE email=%s FOR UPDATE', (username,)) + if cursor.fetchall() != ((username, 1),): + raise IdentityUnavailable('native account creation failed') + # UID is the binding; the account only names a new user. + cursor.execute('INSERT INTO ' + self.profiles + + '(user,login_id,nickname,intro,lang_code,contact_email,is_manually_set_contact_email,institution,list_in_address_book) ' + "VALUES(%s,%s,%s,'',NULL,NULL,0,'',0)", (username, uid, login_name[:64])) + self.connection.commit() + finally: + self.connection.rollback() self.active(username, uid) return username diff --git a/cloudfile_ext/v2/jit.py b/cloudfile_ext/v2/jit.py new file mode 100644 index 0000000000..d6d4c5ca49 --- /dev/null +++ b/cloudfile_ext/v2/jit.py @@ -0,0 +1,57 @@ +"""Verified OIDC JIT using only CE User, Profile and SocialAuthUser facts. + +This internal callback adapter does not authenticate an HTTP request. The caller +must verify issuer, audience, nonce and matching signed/UserInfo business UID. +Administrator preparation never calls this login authority. +""" +import json + +from .errors import IdentityConflict +from .identity import text +from .oauth_identity import UIDBindingError, choose_uid_owner +from .schema import database_lock + + +class SQLJITProvisioner: + def __init__(self, directory, identity): + self.directory, self.identity = directory, identity + + def ensure(self, *, uid, provider, subject): + from .etech_directory import verified_account + uid, provider, subject = text(uid), text(provider, 32), text(subject, 255) + identity, connection = self.identity, self.identity.connection + # Serialize the provider subject before any account creation. A subject + # already owned by a different UID cannot leave another new user behind. + with database_lock(connection, 'identity:oauth:' + provider + ':' + subject): + with connection.cursor() as cursor: + cursor.execute('SELECT username,provider,uid FROM ' + identity.social + + ' WHERE provider=%s AND uid=%s', (provider, subject)) + bindings = cursor.fetchall() + cursor.execute('SELECT user,login_id FROM ' + identity.profiles + ' WHERE login_id=%s', (uid,)) + profiles = cursor.fetchall() + try: + if bindings: + username = choose_uid_owner(uid, profiles, bindings, provider=provider, subject=subject) + identity.active(username, uid) + login, account = verified_account(self.directory, uid) + # Native RPC creates non-staff accounts. This primitive shares + # UID/name locks and collision checks with staff preparation. + username = identity.prepare(uid=uid, login_name=login, account=account) + connection.begin() + try: + identity.active(username, uid, locked=True) + with connection.cursor() as cursor: + cursor.execute('SELECT username,provider,uid FROM ' + identity.social + + ' WHERE provider=%s AND uid=%s FOR UPDATE', (provider, subject)) + bindings = cursor.fetchall() + choose_uid_owner(uid, [(username, uid)], bindings, provider=provider, subject=subject) + if not bindings: + cursor.execute('INSERT INTO ' + identity.social + + '(username,provider,uid,extra_data) VALUES(%s,%s,%s,%s)', + (username, provider, subject, json.dumps({'userId': uid}, sort_keys=True))) + connection.commit() + finally: + connection.rollback() + return username + except UIDBindingError: + raise IdentityConflict('provider subject or UID conflict') from None diff --git a/cloudfile_ext/v2/memberships.py b/cloudfile_ext/v2/memberships.py index 68a5699b4e..2b56edb968 100644 --- a/cloudfile_ext/v2/memberships.py +++ b/cloudfile_ext/v2/memberships.py @@ -17,10 +17,15 @@ def validate_subject(value, uid, *, now=None): if generated.tzinfo is None or not -60 <= (now - generated).total_seconds() <= 60: raise ValueError() attrs = value['attributes'] + if not isinstance(attrs, dict): + raise ValueError() login = attrs.get('login_name', attrs.get('employee_no')) if attrs.get('login_name', login) != attrs.get('employee_no', login): raise ValueError() - text(login, 200) + # Account metadata names new users only. Existing UID owners and their + # memberships remain valid when that metadata is missing or noncanonical. + if login is not None and not isinstance(login, str): + raise ValueError() text(value['etag']) desired = set() for field, kind in (('organizations', 'org'), ('organization_ancestors', 'org'), ('roles', 'role')): diff --git a/cloudfile_ext/v2/oauth_identity.py b/cloudfile_ext/v2/oauth_identity.py new file mode 100644 index 0000000000..8b08c5f903 --- /dev/null +++ b/cloudfile_ext/v2/oauth_identity.py @@ -0,0 +1,81 @@ +"""V2 legacy-OAuth adapter: attach a trusted Etech UID to its native CE account. + +The existing OAuth protocol stays upstream. This adapter is only used when +the configured IdP maps a trusted userId claim to Profile.login_id. Unmapped +legacy OAuth logins remain on the CE compatibility path. +""" +import json + + +class UIDBindingError(ValueError): + """Untrusted, missing, disabled or conflicting UID / subject binding.""" + + +def trusted_uid_mapping(attribute_map): + """Recognize an explicitly configured business UID claim, not an email.""" + return isinstance(attribute_map, dict) and attribute_map.get("userId") in ( + (True, "login_id"), (False, "login_id")) + + +def _text(value, maximum): + if (not isinstance(value, str) or not 1 <= len(value) <= maximum + or value != value.strip() + or any(ord(c) < 32 or ord(c) == 127 for c in value)): + raise UIDBindingError("invalid identity") + return value + + +def choose_uid_owner(uid, profiles, bindings, *, provider, subject): + """No email/employee fallback; an existing subject cannot change owners.""" + _text(uid, 225) + _text(provider, 32) + _text(subject, 255) + if len(profiles) != 1: + raise UIDBindingError("missing or ambiguous canonical UID") + username, stored_uid = profiles[0] + if (not isinstance(username, str) or not username or stored_uid != uid + or username.lower() in ("cfadmin@auth.local", "cfadmin@etech.com")): + raise UIDBindingError("canonical UID mismatch") + if len(bindings) > 1: + raise UIDBindingError("ambiguous provider subject") + if bindings and bindings[0] != (username, provider, subject): + raise UIDBindingError("provider subject belongs to a different account") + return username + + +def bind_existing_uid(*, uid, provider, subject): + """Use the existing UID owner and add only a missing provider/sub mapping. + + OAuth state and UserInfo must be verified by the caller, and userId must be + provisioned by a trusted Etech/IdP source. Never create/migrate native users. + """ + uid, provider, subject = (_text(uid, 225), _text(provider, 32), + _text(subject, 255)) + from django.db import IntegrityError, transaction + from seahub.auth.models import SocialAuthUser + from seahub.base.accounts import User + from seahub.profile.models import Profile + + try: + with transaction.atomic(): + profiles = list(Profile.objects.select_for_update().filter( + login_id=uid).values_list("user", "login_id")) + bindings = list(SocialAuthUser.objects.select_for_update().filter( + provider=provider, uid=subject).values_list("username", "provider", "uid")) + username = choose_uid_owner(uid, profiles, bindings, + provider=provider, subject=subject) + try: + user = User.objects.get(email=username) + except User.DoesNotExist: + raise UIDBindingError("native UID account missing") from None + if (user.username != username or type(user.is_active) not in (bool, int) + or user.is_active != 1): + raise UIDBindingError("native UID account inactive or changed") + if not bindings: + # CE Manager.add() swallows integrity errors; use the ORM directly. + SocialAuthUser.objects.create( + username=username, provider=provider, uid=subject, + extra_data=json.dumps({"userId": uid}, sort_keys=True)) + return username + except IntegrityError: + raise UIDBindingError("concurrent provider/UID conflict") from None diff --git a/cloudfile_ext/v2/tests/native_identity_facts.py b/cloudfile_ext/v2/tests/native_identity_facts.py new file mode 100644 index 0000000000..be0274679b --- /dev/null +++ b/cloudfile_ext/v2/tests/native_identity_facts.py @@ -0,0 +1,60 @@ +"""Native SQL-boundary fixture; no real database or RPC service is accessed.""" +from copy import deepcopy + + +class NativeFacts: + """Model CE's case-insensitive lookup while returning exact stored values.""" + def __init__(self): + self.accounts, self.profiles, self.social = {}, [], [] + self.rows, self.snapshot = (), None + self.created = [] + + def get_autocommit(self): return True + def cursor(self): return self + def __enter__(self): return self + def __exit__(self, *args): pass + def fetchall(self): return tuple(self.rows) + def begin(self): self.snapshot = deepcopy((self.accounts, self.profiles, self.social)) + def commit(self): self.snapshot = None + def rollback(self): + if self.snapshot is not None: + self.accounts, self.profiles, self.social = self.snapshot + self.snapshot = None + + def create(self, username): + username = username.lower() + if any(name.lower() == username.lower() for name in self.accounts): + raise AssertionError('RPC must never adopt an existing account') + self.created.append(username) + self.accounts[username] = 1 + + def execute(self, sql, args=()): + self.rows = () + sql = sql.replace("`", "") + if sql.startswith('SELECT'): + if '.profile_profile' in sql: + if 'WHERE user=%s OR login_id=%s' in sql: + rows = [r for r in self.profiles if r[0].lower() == args[0].lower() or r[1].lower() == args[1].lower()] + elif 'WHERE login_id=%s' in sql: + rows = [r for r in self.profiles if r[1].lower() == args[0].lower()] + else: + rows = [r for r in self.profiles if r[0].lower() == args[0].lower()] + self.rows = rows + elif '.EmailUser' in sql: + rows = [(name, active) for name, active in self.accounts.items() if name.lower() == args[0].lower()] + self.rows = rows if 'email,is_active' in sql else [(r[0],) for r in rows] + elif '.social_auth_usersocialauth' in sql: + self.rows = [r if "extra_data" in sql else r[:3] for r in self.social if r[1:3] == args] + else: + raise AssertionError(sql) + elif sql.startswith('INSERT INTO'): + if '.EmailUser' in sql: + self.create(args[0]) + elif '.profile_profile' in sql: + self.profiles.append(tuple(args[:2])) + elif '.social_auth_usersocialauth' in sql: + self.social.append(tuple(args)) + else: + raise AssertionError(sql) + else: + raise AssertionError(sql) diff --git a/cloudfile_ext/v2/tests/test_etech_directory.py b/cloudfile_ext/v2/tests/test_etech_directory.py index f606f3e179..c1fa7798ab 100644 --- a/cloudfile_ext/v2/tests/test_etech_directory.py +++ b/cloudfile_ext/v2/tests/test_etech_directory.py @@ -1,5 +1,5 @@ """Runtime integration failures never become native membership readiness.""" -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from datetime import datetime, timezone from types import SimpleNamespace from unittest import TestCase @@ -8,7 +8,9 @@ from django.test import override_settings from cloudfile_ext.v2.etech_directory import Directory, prepare_user, prepare_group, ensure_subject -from cloudfile_ext.v2.errors import IdentityUnavailable +from cloudfile_ext.v2.errors import IdentityUnavailable, IdentityConflict +from cloudfile_ext.v2.identity import NativeIdentity +from cloudfile_ext.v2.tests.native_identity_facts import NativeFacts def subject(status='active'): @@ -31,7 +33,7 @@ def test_preparation_proves_directory_uid_and_preserves_native_name(self): with patch('cloudfile_ext.v2.etech_directory.runtime',self.runtime): self.assertEqual(prepare_user('uid'),'opaque@auth.local') self.directory.subject.assert_called_once_with('uid') - self.identity.prepare.assert_called_once_with(uid='uid',login_name='E001') + self.identity.prepare.assert_called_once_with(uid='uid',login_name='E001',account='E001') self.memberships.ensure.assert_not_called() def test_disabled_or_changed_uid_cannot_create_native_account(self): @@ -45,6 +47,43 @@ def runtime(): with self.assertRaises(IdentityUnavailable):prepare_user('uid') self.identity.prepare.assert_not_called() + def test_missing_reserved_or_invalid_directory_account_cannot_precreate(self): + for account in (None, '', 'cfadmin', 'CFADMIN', 'a b', 'a@contact.test'): + @contextmanager + def runtime(): + with self.runtime(): + facts = NativeFacts() + native = NativeIdentity(facts, account_schema='accounts', hub_schema='hub', + issuer='https://directory.test', create_account=facts.create) + value = subject() + value['attributes'] = {'login_name': 'display-only'} if account is None else {'employee_no': account} + self.directory.subject.return_value = value + yield self.directory, native, self.catalog, self.memberships + with patch('cloudfile_ext.v2.etech_directory.runtime', runtime), \ + patch('cloudfile_ext.v2.identity.database_lock', side_effect=lambda *args: nullcontext()), \ + self.assertRaises((IdentityUnavailable, IdentityConflict)): + prepare_user('uid') + + def test_prepared_uid_reuses_native_user_with_missing_or_invalid_account_metadata(self): + for account in (None, '', '中文', 'a@contact.test', ' x ', 'x' * 201): + facts = NativeFacts() + facts.profiles.append(('historical@auth.local', 'uid')) + facts.accounts['historical@auth.local'] = 1 + native = NativeIdentity(facts, account_schema='accounts', hub_schema='hub', + issuer='https://directory.test', create_account=facts.create) + @contextmanager + def runtime(): + with self.runtime(): + value = subject() + value['attributes'] = {'employee_no': account} + self.directory.subject.return_value = value + yield self.directory, native, self.catalog, self.memberships + with self.subTest(account=account), \ + patch('cloudfile_ext.v2.etech_directory.runtime', runtime), \ + patch('cloudfile_ext.v2.identity.database_lock', side_effect=lambda *args: nullcontext()): + self.assertEqual(prepare_user('uid'), 'historical@auth.local') + self.assertEqual(facts.created, []) + def test_group_mapping_uses_exact_type_namespace_and_external_id(self): with patch('cloudfile_ext.v2.etech_directory.runtime',self.runtime): self.assertEqual(prepare_group('o1'),42) diff --git a/cloudfile_ext/v2/tests/test_etech_http.py b/cloudfile_ext/v2/tests/test_etech_http.py index d7236744f5..4568f9d84f 100644 --- a/cloudfile_ext/v2/tests/test_etech_http.py +++ b/cloudfile_ext/v2/tests/test_etech_http.py @@ -15,7 +15,7 @@ def request(self, data, staff=True): return IdentityResolve.as_view()(request) def test_existing_uid_keeps_opaque_native_username(self): - with patch('cloudfile_ext.v2.http.Profile.objects.get_username_by_login_id', return_value='opaque@auth.local'), \ + with patch('cloudfile_ext.v2.http.Profile.objects.filter', return_value=Mock(values_list=Mock(return_value=[('opaque@auth.local', '417137619324108800')]))), \ patch('cloudfile_ext.v2.http.User.objects.get', return_value=SimpleNamespace(username='opaque@auth.local', is_active=True)), \ patch('cloudfile_ext.v2.http.Profile.objects.get_profile_by_user', return_value=SimpleNamespace(login_id='417137619324108800')): response = self.request({'external_user_id': '417137619324108800', 'login_id': '417137619324108800'}) @@ -24,7 +24,7 @@ def test_existing_uid_keeps_opaque_native_username(self): self.assertEqual(response.data['login_id'], '417137619324108800') def test_uid_mismatch_is_rejected_before_lookup(self): - with patch('cloudfile_ext.v2.http.Profile.objects.get_username_by_login_id') as lookup: + with patch('cloudfile_ext.v2.http.Profile.objects.filter') as lookup: self.assertEqual(self.request({'external_user_id': 'uid', 'login_id': 'other'}).status_code, 400) lookup.assert_not_called() @@ -33,7 +33,7 @@ def test_non_admin_cannot_resolve_other_users(self): def test_unknown_uid_requires_trusted_directory_preparation(self): from cloudfile_ext.v2.errors import IdentityUnavailable - with patch('cloudfile_ext.v2.http.Profile.objects.get_username_by_login_id', return_value=None), \ + with patch('cloudfile_ext.v2.http.Profile.objects.filter', return_value=Mock(values_list=Mock(return_value=[]))), \ patch('cloudfile_ext.v2.etech_directory.prepare_user', side_effect=IdentityUnavailable()) as prepare, \ patch('cloudfile_ext.v2.http.User.objects.get') as user: self.assertEqual(self.request({'external_user_id': 'uid', 'login_id': 'uid'}).status_code, 503) @@ -41,7 +41,7 @@ def test_unknown_uid_requires_trusted_directory_preparation(self): user.assert_not_called() def test_prepared_user_is_read_back_and_uid_alias_is_rejected(self): - with patch('cloudfile_ext.v2.http.Profile.objects.get_username_by_login_id', return_value=None), \ + with patch('cloudfile_ext.v2.http.Profile.objects.filter', return_value=Mock(values_list=Mock(return_value=[]))), \ patch('cloudfile_ext.v2.etech_directory.prepare_user', return_value='new@auth.local'), \ patch('cloudfile_ext.v2.http.Profile.objects.get_profile_by_user', return_value=SimpleNamespace(login_id='uid')) as profile, \ patch('cloudfile_ext.v2.http.User.objects.get', return_value=SimpleNamespace(username='new@auth.local', is_active=True)): @@ -49,6 +49,29 @@ def test_prepared_user_is_read_back_and_uid_alias_is_rejected(self): profile.return_value.login_id = 'UID' self.assertEqual(self.request({'external_user_id': 'uid', 'login_id': 'uid'}).status_code, 409) + def test_ambiguous_or_case_aliased_uid_cannot_prepare_or_select_a_user(self): + for rows in ([('a@auth.local', 'uid'), ('b@auth.local', 'uid')], [('a@auth.local', 'UID')]): + with patch('cloudfile_ext.v2.http.Profile.objects.filter', return_value=Mock(values_list=Mock(return_value=rows))), \ + patch('cloudfile_ext.v2.etech_directory.prepare_user') as prepare: + self.assertEqual(self.request({'external_user_id': 'uid', 'login_id': 'uid'}).status_code, 409) + prepare.assert_not_called() + + def test_disabled_reserved_or_changed_native_account_is_rejected(self): + for username, active, returned in (('old@auth.local', False, 'old@auth.local'), + ('cfadmin@auth.local', True, 'cfadmin@auth.local'), + ('old@auth.local', True, 'OLD@auth.local')): + with patch('cloudfile_ext.v2.http.Profile.objects.filter', return_value=Mock(values_list=Mock(return_value=[(username, 'uid')]))), \ + patch('cloudfile_ext.v2.http.Profile.objects.get_profile_by_user', return_value=SimpleNamespace(login_id='uid')), \ + patch('cloudfile_ext.v2.http.User.objects.get', return_value=SimpleNamespace(username=returned, is_active=active)): + self.assertEqual(self.request({'external_user_id': 'uid', 'login_id': 'uid'}).status_code, 409) + + def test_request_alias_is_ignored(self): + from cloudfile_ext.v2.errors import IdentityConflict + with patch('cloudfile_ext.v2.http.Profile.objects.filter', return_value=Mock(values_list=Mock(return_value=[]))), \ + patch('cloudfile_ext.v2.etech_directory.prepare_user', side_effect=IdentityConflict()) as prepare: + self.assertEqual(self.request({'external_user_id': 'uid', 'login_id': 'uid', 'login_email': 'attacker@auth.local'}).status_code, 409) + prepare.assert_called_once_with('uid') + class EtechOAuthBindingTests(TestCase): def test_oidc_login_adds_nonce_and_native_oauth_clears_oidc_mode(self): @@ -69,7 +92,7 @@ def test_oidc_login_adds_nonce_and_native_oauth_clears_oidc_mode(self): self.assertNotIn('oauth_nonce', request.session) def callback(self, oauth_binding, profile_username, profile_uid, *, oidc=False, invalid_token=False, - userinfo_sub='trusted-sub'): + userinfo_sub='trusted-sub', missing_uid=False, generic=False, id_uid='business-uid'): request = APIRequestFactory().get('/cfile/oauth/callback/?code=test&state=expected') request.session = {'oauth_state': 'expected'} if oidc: @@ -79,17 +102,19 @@ def callback(self, oauth_binding, profile_username, profile_uid, *, oidc=False, session.fetch_token.return_value = {} session.get.return_value.status_code = 200 session.get.return_value.json.return_value = {'sub': userinfo_sub, 'userId': 'business-uid'} + if missing_uid: + session.get.return_value.json.return_value = {'sub': userinfo_sub} profile = SimpleNamespace(login_id=profile_uid, nickname='', contact_email='', save=Mock()) - from cloudfile_extensions.identity.legacy_oauth import UIDBindingError + from cloudfile_ext.v2.oauth_identity import UIDBindingError conflicting = (oauth_binding is not None and oauth_binding.username != profile_username) conflicting = conflicting or profile_uid != 'business-uid' from cloudfile_ext.v2.errors import IdentityUnavailable with patch('cloudfile_ext.v2.oidc.configuration', return_value=('https://idp.example.test/', 'https://idp.example.test/jwks')), \ - patch('cloudfile_ext.v2.oidc.verify_id_token', return_value={'sub': 'trusted-sub'}, + patch('cloudfile_ext.v2.oidc.verify_id_token', return_value=({'sub': 'trusted-sub'} if id_uid is None else {'sub': 'trusted-sub', 'userId': id_uid}), side_effect=IdentityUnavailable('rejected') if invalid_token else None) as verify, \ patch.object(views, 'OAuth2Session', return_value=session), \ - patch.object(views, 'OAUTH_ATTRIBUTE_MAP', {'sub': (True, 'uid'), 'userId': (True, 'login_id')}), \ - patch('cloudfile_extensions.identity.legacy_oauth.bind_existing_uid', + patch.object(views, 'OAUTH_ATTRIBUTE_MAP', ({'sub': (True, 'uid')} if generic else {'sub': (True, 'uid'), 'userId': (False, 'login_id')})), \ + patch('cloudfile_ext.v2.oauth_identity.bind_existing_uid', return_value=profile_username, side_effect=UIDBindingError('UID conflict') if conflicting else None) as bind, \ patch.object(views.Profile.objects, 'get_or_create', return_value=(profile, False)), \ @@ -97,7 +122,7 @@ def callback(self, oauth_binding, profile_username, profile_uid, *, oidc=False, username='existing@auth.local', is_active=True)) as authenticate, \ patch.object(views.auth, 'login'), \ patch.object(views, 'get_api_token', return_value=SimpleNamespace(key='test-token')), \ - patch.object(views, 'get_service_url', return_value='https://files.example.test/cfile'), \ + patch.object(views, 'get_service_url', return_value='https://files.example.test'), \ patch.object(views, 'render_error', return_value='binding-error'): response = views.oauth_callback(request) self.assertEqual(session.fetch_token.call_args.kwargs['authorization_response'], @@ -152,6 +177,44 @@ def test_native_uid_collation_alias_cannot_be_claimed(self): authenticate.assert_not_called() bind.assert_called_once() + def test_missing_optional_trusted_uid_cannot_fall_back_to_ce_signup(self): + response, authenticate, bind = self.callback(None, 'existing@auth.local', 'business-uid', missing_uid=True) + self.assertEqual(response, 'binding-error') + authenticate.assert_not_called() + + def test_directory_config_cannot_enter_employee_claim_compatibility_path(self): + with patch.object(views.settings, 'CF_DIRECTORY_URL', 'https://directory.test', create=True): + response, authenticate, bind = self.callback(None, 'existing@auth.local', 'business-uid', generic=True) + self.assertEqual(response, 'binding-error') + authenticate.assert_not_called() + bind.assert_not_called() + + def test_generic_ce_oauth_without_directory_keeps_existing_provider_resolution(self): + with patch.object(views.settings, 'CF_DIRECTORY_URL', '', create=True), \ + patch.object(views.SocialAuthUser.objects, 'get_by_provider_and_uid', return_value=SimpleNamespace(username='existing@auth.local')): + response, authenticate, bind = self.callback(None, 'existing@auth.local', 'business-uid', generic=True) + self.assertEqual(response.status_code, 302) + authenticate.assert_called_once_with(remote_user='existing@auth.local') + bind.assert_not_called() + + def test_signed_oidc_identity_uses_v2_jit_and_oauth_remains_reuse_only(self): + with patch.object(views.settings, 'CF_DIRECTORY_URL', 'https://directory.test', create=True), \ + patch('cloudfile_ext.v2.etech_directory.provision_oidc_user', return_value='existing@auth.local') as provision, \ + patch('cloudfile_ext.v2.etech_directory.ensure_subject'): + response, authenticate, bind = self.callback(None, 'existing@auth.local', 'business-uid', oidc=True) + self.assertEqual(response.status_code, 302) + provision.assert_called_once_with(uid='business-uid', provider=views.OAUTH_PROVIDER, subject='trusted-sub') + bind.assert_not_called() + + def test_missing_signed_business_uid_cannot_provision_even_with_userinfo_uid(self): + with patch.object(views.settings, 'CF_DIRECTORY_URL', 'https://directory.test', create=True), \ + patch('cloudfile_ext.v2.etech_directory.provision_oidc_user') as provision: + response, authenticate, bind = self.callback(None, 'existing@auth.local', 'business-uid', oidc=True, id_uid=None) + self.assertEqual(response, 'binding-error') + provision.assert_not_called() + authenticate.assert_not_called() + bind.assert_not_called() + def test_missing_state_cannot_fetch_tokens(self): for saved, supplied in (({}, 'expected'), ({'oauth_state': 'expected'}, '错误-state')): request = APIRequestFactory().get('/cfile/oauth/callback/', {'code': 'test', 'state': supplied}) diff --git a/cloudfile_ext/v2/tests/test_oidc_jit.py b/cloudfile_ext/v2/tests/test_oidc_jit.py new file mode 100644 index 0000000000..691920037d --- /dev/null +++ b/cloudfile_ext/v2/tests/test_oidc_jit.py @@ -0,0 +1,144 @@ +"""V2 login-first and staff-first creation execute without the legacy framework.""" +from contextlib import nullcontext +from datetime import datetime, timezone +import unittest +from unittest.mock import Mock, patch + +from cloudfile_ext.v2.errors import IdentityConflict, IdentityUnavailable +from cloudfile_ext.v2.identity import NativeIdentity +from cloudfile_ext.v2.jit import SQLJITProvisioner +from cloudfile_ext.v2.tests.native_identity_facts import NativeFacts + + +class OIDCProvisioningTests(unittest.TestCase): + def setUp(self): + self.facts = NativeFacts() + self.native = NativeIdentity(self.facts, account_schema='accounts', hub_schema='hub', + issuer='https://directory.test', create_account=self.facts.create) + self.account, self.status, self.uid = '10280993', 'active', None + self.directory = Mock() + self.directory.subject.side_effect = self.source + self.jit = SQLJITProvisioner(self.directory, self.native) + for target in ('cloudfile_ext.v2.jit.database_lock', 'cloudfile_ext.v2.identity.database_lock'): + mocked = patch(target, side_effect=lambda *args, **kwargs: nullcontext()) + mocked.start() + self.addCleanup(mocked.stop) + + def source(self, uid): + return dict(userId=self.uid or uid, status=self.status, + attributes={'employee_no': self.account, 'email': 'shared@contact.test'}, + organizations=[], organization_ancestors=[], roles=[], etag='revision', + generated_at=datetime.now(timezone.utc).isoformat()) + + def login(self, uid='uid', provider='idp-one', subject='sub-one'): + return self.jit.ensure(uid=uid, provider=provider, subject=subject) + + def test_login_first_then_staff_and_repeat_login_keep_one_native_user(self): + self.assertEqual(self.login(), '10280993@auth.local') + self.assertEqual(self.native.prepare(uid='uid', login_name='renamed', account='renamed'), '10280993@auth.local') + self.assertEqual(self.login(), '10280993@auth.local') + self.assertEqual(self.facts.created, ['10280993@auth.local']) + self.assertEqual(len(self.facts.social), 1) + + def test_staff_first_and_second_provider_bind_to_the_same_uid(self): + self.native.prepare(uid='uid', login_name=self.account, account=self.account) + self.assertEqual(self.login(), '10280993@auth.local') + self.account = 'renamed' + self.assertEqual(self.login(provider='idp-two', subject='another-sub'), '10280993@auth.local') + self.assertEqual(self.facts.created, ['10280993@auth.local']) + self.assertEqual(len(self.facts.social), 2) + + def test_shared_contact_email_and_nonnumeric_accounts_never_merge(self): + for uid, account in (('one', '10280993'), ('two', 'zhangsan'), ('three', 'admin'), ('four', 'EMP-A')): + self.account = account + self.assertEqual(self.login(uid=uid, subject=uid), account.lower() + '@auth.local') + self.assertEqual(len(self.facts.accounts), 4) + self.assertEqual(len(self.facts.profiles), 4) + self.account = 'emp-a' + with self.assertRaises(IdentityConflict): self.login(uid='case-collision', subject='case-collision') + self.assertEqual(len(self.facts.accounts), 4) + + def test_missing_invalid_reserved_or_disabled_directory_identity_cannot_create(self): + for account in (None, '', 'cfadmin', 'CFADMIN', 'a b', 'a@contact.test', '中文', ' x ', 'x' * 201): + self.account = account + with self.assertRaises((IdentityConflict, IdentityUnavailable)): self.login() + self.account, self.status = 'admin', 'disabled' + with self.assertRaises(IdentityUnavailable): self.login() + self.status, self.uid = 'active', 'different-uid' + with self.assertRaises(IdentityUnavailable): self.login() + self.assertEqual(self.facts.created, []) + + def test_existing_historical_native_account_is_preserved(self): + self.facts.profiles.append(('historical-uuid@auth.local', 'uid')) + self.facts.accounts['historical-uuid@auth.local'] = 1 + self.assertEqual(self.login(), 'historical-uuid@auth.local') + self.assertEqual(self.facts.created, []) + + def test_existing_uid_reuse_and_additional_binding_ignore_new_account_format(self): + self.facts.profiles.append(('historical-uuid@auth.local', 'uid')) + self.facts.accounts['historical-uuid@auth.local'] = 1 + # A name is not identity proof: missing/renamed/invalid metadata must + # neither split an existing UID nor prevent a second IdP binding. + for account in (None, '', '中文', 'a@contact.test', ' x ', 'x' * 201): + with self.subTest(account=account): + self.account = account + self.assertEqual(self.login(), 'historical-uuid@auth.local') + self.assertEqual(self.login(provider='idp-two', subject='sub-two'), 'historical-uuid@auth.local') + self.assertEqual(self.facts.created, []) + self.assertEqual(len(self.facts.social), 2) + + def test_existing_binding_still_requires_current_status_and_exact_uid(self): + self.login() + self.account = None + self.status = 'disabled' + with self.assertRaises(IdentityUnavailable): self.login() + self.status, self.uid = 'active', 'other-uid' + with self.assertRaises(IdentityUnavailable): self.login() + self.uid = None + self.facts.accounts['10280993@auth.local'] = 0 + with self.assertRaises(IdentityUnavailable): self.login() + self.assertEqual(len(self.facts.social), 1) + + def test_generic_preparation_retains_opaque_naming_but_missing_etech_account_cannot_request_it(self): + username = self.native.prepare(uid='generic', login_name='Generic user') + self.assertRegex(username, r'^[a-f0-9]{32}@auth\.local$') + with self.assertRaises(IdentityConflict): + self.native.prepare(uid='etech', login_name='Etech user', account=None) + self.assertEqual(self.facts.created, [username]) + + def test_provider_subject_conflict_is_rejected_before_any_new_account(self): + self.login() + self.account = 'zhangsan' + with self.assertRaises(IdentityConflict): self.login(uid='other-uid') + self.assertEqual(self.facts.created, ['10280993@auth.local']) + self.assertEqual(len(self.facts.social), 1) + + def test_rpc_failure_never_publishes_profile_or_authentication_binding(self): + self.native.create_account = Mock(side_effect=IdentityUnavailable('RPC unavailable')) + with self.assertRaises(IdentityUnavailable): self.login() + self.assertEqual(self.facts.profiles, []) + self.assertEqual(self.facts.social, []) + + def test_failed_binding_is_retried_on_the_same_precreated_native_user(self): + execute = self.facts.execute + def fail_binding(sql, args=()): + if sql.startswith('INSERT INTO') and 'social_auth_usersocialauth' in sql: + raise RuntimeError('binding unavailable') + return execute(sql, args) + with patch.object(self.facts, 'execute', side_effect=fail_binding): + with self.assertRaises(RuntimeError): self.login() + self.assertEqual(self.facts.social, []) + self.assertEqual(self.login(), '10280993@auth.local') + self.assertEqual(self.facts.created, ['10280993@auth.local']) + + def test_name_uid_case_and_inactive_conflicts_do_not_create_or_bind(self): + for profiles, accounts in ( + ([('old@auth.local', 'uid')], {'old@auth.local': 0}), + ([('old@auth.local', 'UID')], {'old@auth.local': 1}), + ([('a@auth.local', 'uid'), ('b@auth.local', 'uid')], {'a@auth.local': 1, 'b@auth.local': 1}), + ([('10280993@auth.local', 'another-uid')], {'10280993@auth.local': 1}), + ([], {'10280993@AUTH.local': 1})): + self.facts.accounts, self.facts.profiles = accounts, profiles + with self.assertRaises((IdentityConflict, IdentityUnavailable)): self.login() + self.assertEqual(self.facts.created, []) + self.assertEqual(self.facts.social, []) diff --git a/cloudfile_extensions/identity/jit.py b/cloudfile_extensions/identity/jit.py index 3e93e59b86..f862155f2c 100644 --- a/cloudfile_extensions/identity/jit.py +++ b/cloudfile_extensions/identity/jit.py @@ -5,6 +5,7 @@ steps, so this result is not a login session or file authorization. """ import json +from contextlib import ExitStack, nullcontext import secrets import time from datetime import datetime, timezone @@ -19,6 +20,8 @@ from ..schema.runner import SchemaRunner from .bindings import IdentityBindings, conflict from .sql_bindings import SQLIdentityBindings +from cloudfile_ext.v2.account_names import account_username +from cloudfile_ext.v2.schema import database_lock class SQLJITProvisioner: @@ -74,23 +77,20 @@ def ensure(self, identity, *, assert_transaction=None, request_id=None): return existing # Coherent primary fetch outside the short SQL authority transaction. source = self.directory.fetch(user_id) + if source["userId"] != user_id: + raise conflict() if source["status"] != "active": raise ContractError("SUBJECT_DISABLED", "Business subject is disabled", 403) - # Employee numbers name new EAP accounts only; login_id remains the UID. - # Generic directories without this attribute retain opaque account names. - employee = source["attributes"].get("employee_no") - if employee is not None: - if (not isinstance(employee, str) or not employee or len(employee) > 200 - or not all(c.isascii() and (c.isalnum() or c in "._-") for c in employee) - or employee.lower() == "cfadmin"): - raise conflict() - default_username = employee + "@auth.local" if employee else secrets.token_hex(16) + "@auth.local" connection = self.bindings.connection scopes = [dict(type="provider", provider=self.bindings.provider, external_id=self.bindings.provider), dict(type="user", provider=self.bindings.provider, external_id=user_id), dict(type="subject", provider=provider, namespace="oidc", external_id=subject)] try: - with scope_locks(connection, scopes): + # Etech preparation and login serialize the same UID, while their + # authorization proofs and native creation mechanisms stay separate. + uid_lock = (database_lock(connection, 'identity:uid:' + user_id) + if self.bindings.provider == 'etech' else nullcontext()) + with scope_locks(connection, scopes), uid_lock, ExitStack() as names: if self.state.barrier_active(self.bindings.provider, user_id): raise ContractError("SUBJECT_UNAVAILABLE", "Identity provisioning is fenced", 503) existing = self.bindings.resolve(issuer=self.issuer, subject=subject, user_id=user_id) @@ -114,7 +114,18 @@ def ensure(self, identity, *, assert_transaction=None, request_id=None): raise conflict() self.bindings._account(cursor, username, locked=True) else: - username = default_username + # Naming applies only to new users. Existing UID + # owners retain their historical native username. + employee = source["attributes"].get("employee_no") + try: + username = (account_username(employee) if employee is not None + or self.bindings.provider == 'etech' + else secrets.token_hex(16) + "@auth.local") + except ValueError: + raise conflict() from None + if self.bindings.provider == 'etech': + names.enter_context(database_lock(connection, + 'identity:account:' + username.lower())) cursor.execute("SELECT user,login_id FROM " + self.bindings.profiles + " WHERE user=%s FOR UPDATE", (username,)) if cursor.fetchall(): # A name collision or changed UID requires explicit prebinding. diff --git a/cloudfile_extensions/identity/legacy_oauth.py b/cloudfile_extensions/identity/legacy_oauth.py index 8b08c5f903..ed941f1d6a 100644 --- a/cloudfile_extensions/identity/legacy_oauth.py +++ b/cloudfile_extensions/identity/legacy_oauth.py @@ -1,81 +1,3 @@ -"""V2 legacy-OAuth adapter: attach a trusted Etech UID to its native CE account. - -The existing OAuth protocol stays upstream. This adapter is only used when -the configured IdP maps a trusted userId claim to Profile.login_id. Unmapped -legacy OAuth logins remain on the CE compatibility path. -""" -import json - - -class UIDBindingError(ValueError): - """Untrusted, missing, disabled or conflicting UID / subject binding.""" - - -def trusted_uid_mapping(attribute_map): - """Recognize an explicitly configured business UID claim, not an email.""" - return isinstance(attribute_map, dict) and attribute_map.get("userId") in ( - (True, "login_id"), (False, "login_id")) - - -def _text(value, maximum): - if (not isinstance(value, str) or not 1 <= len(value) <= maximum - or value != value.strip() - or any(ord(c) < 32 or ord(c) == 127 for c in value)): - raise UIDBindingError("invalid identity") - return value - - -def choose_uid_owner(uid, profiles, bindings, *, provider, subject): - """No email/employee fallback; an existing subject cannot change owners.""" - _text(uid, 225) - _text(provider, 32) - _text(subject, 255) - if len(profiles) != 1: - raise UIDBindingError("missing or ambiguous canonical UID") - username, stored_uid = profiles[0] - if (not isinstance(username, str) or not username or stored_uid != uid - or username.lower() in ("cfadmin@auth.local", "cfadmin@etech.com")): - raise UIDBindingError("canonical UID mismatch") - if len(bindings) > 1: - raise UIDBindingError("ambiguous provider subject") - if bindings and bindings[0] != (username, provider, subject): - raise UIDBindingError("provider subject belongs to a different account") - return username - - -def bind_existing_uid(*, uid, provider, subject): - """Use the existing UID owner and add only a missing provider/sub mapping. - - OAuth state and UserInfo must be verified by the caller, and userId must be - provisioned by a trusted Etech/IdP source. Never create/migrate native users. - """ - uid, provider, subject = (_text(uid, 225), _text(provider, 32), - _text(subject, 255)) - from django.db import IntegrityError, transaction - from seahub.auth.models import SocialAuthUser - from seahub.base.accounts import User - from seahub.profile.models import Profile - - try: - with transaction.atomic(): - profiles = list(Profile.objects.select_for_update().filter( - login_id=uid).values_list("user", "login_id")) - bindings = list(SocialAuthUser.objects.select_for_update().filter( - provider=provider, uid=subject).values_list("username", "provider", "uid")) - username = choose_uid_owner(uid, profiles, bindings, - provider=provider, subject=subject) - try: - user = User.objects.get(email=username) - except User.DoesNotExist: - raise UIDBindingError("native UID account missing") from None - if (user.username != username or type(user.is_active) not in (bool, int) - or user.is_active != 1): - raise UIDBindingError("native UID account inactive or changed") - if not bindings: - # CE Manager.add() swallows integrity errors; use the ORM directly. - SocialAuthUser.objects.create( - username=username, provider=provider, uid=subject, - extra_data=json.dumps({"userId": uid}, sort_keys=True)) - return username - except IntegrityError: - raise UIDBindingError("concurrent provider/UID conflict") from None +"""Compatibility import for the V2 trusted-UID adapter; no separate authority.""" +from cloudfile_ext.v2.oauth_identity import ( + UIDBindingError, bind_existing_uid, choose_uid_owner, trusted_uid_mapping) diff --git a/cloudfile_extensions/tests/test_etech_accounts.py b/cloudfile_extensions/tests/test_etech_accounts.py new file mode 100644 index 0000000000..61fbe4c80b --- /dev/null +++ b/cloudfile_extensions/tests/test_etech_accounts.py @@ -0,0 +1,130 @@ +"""Account/UID state transitions with SQL-boundary doubles; no database I/O. + +These tests execute both provisioning implementations and native binding +selection. They do not claim RPC, collation or transactional integration proof. +""" +from contextlib import nullcontext +import unittest +from unittest.mock import Mock, patch + +from cloudfile_ext.v2.account_names import account_username +from cloudfile_ext.v2.identity import NativeIdentity +from cloudfile_ext.v2.errors import IdentityConflict, IdentityUnavailable +from cloudfile_extensions.common.errors import ContractError +from cloudfile_extensions.directory.provider import DirectoryProvider +from cloudfile_extensions.identity.jit import SQLJITProvisioner +from cloudfile_extensions.identity.sql_bindings import SQLIdentityBindings + + +from cloudfile_ext.v2.tests.native_identity_facts import NativeFacts + +class AccountProvisioningTests(unittest.TestCase): + def setUp(self): + self.facts = NativeFacts() + self.native = NativeIdentity(self.facts, account_schema='accounts', hub_schema='hub', + issuer='https://directory.test', create_account=self.facts.create) + for target in ('cloudfile_ext.v2.identity.database_lock', + 'cloudfile_extensions.identity.jit.database_lock', + 'cloudfile_extensions.identity.jit.scope_locks', + 'cloudfile_extensions.identity.sql_bindings.scope_locks'): + mocked = patch(target, side_effect=lambda *args, **kwargs: nullcontext()) + mocked.start() + self.addCleanup(mocked.stop) + for target in ('cloudfile_extensions.identity.jit.SchemaRunner.require_current', + 'cloudfile_extensions.identity.sql_bindings.SQLIdentityBindings._engines', + 'cloudfile_extensions.identity.jit.EventWriter.append'): + mocked = patch(target) + mocked.start() + self.addCleanup(mocked.stop) + + def jit(self, account='10280993', *, provider='etech', issuer='https://idp.test', status='active'): + directory = Mock(spec=DirectoryProvider) + attributes = {} if account is None else {'employee_no': account} + directory.fetch.return_value = dict(userId='uid', status=status, attributes=attributes) + bindings = SQLIdentityBindings(self.facts, native_schema='accounts', identity_schema='hub', + directory_provider=provider, authorize=Mock(), audit=Mock()) + jit = SQLJITProvisioner(bindings, issuer=issuer, directory=directory, enabled=True, request_id='test') + jit.state = Mock() + jit.state.barrier_active.return_value = False + return jit, dict(issuer=issuer, sub='sub', userId='uid') + + def test_both_entry_orders_converge_without_renaming_or_extra_users(self): + for first in ('prepare', 'login'): + with self.subTest(first=first): + self.facts.accounts.clear(); self.facts.profiles.clear(); self.facts.social.clear(); self.facts.created.clear() + jit, claims = self.jit() + if first == 'prepare': + self.assertEqual(self.native.prepare(uid='uid', login_name='10280993', account='10280993'), '10280993@auth.local') + username = jit.ensure(claims) + self.assertEqual(self.native.prepare(uid='uid', login_name='changed', account='changed'), username) + self.assertEqual(jit.ensure(claims), username) + self.assertEqual(self.facts.created, ['10280993@auth.local']) + + def test_nonnumeric_accounts_and_admin_are_normal_nonstaff_names(self): + for account in ('10280993', 'zhangsan', 'admin', 'EMP-A', '001'): + with self.subTest(account=account): + self.facts.accounts.clear(); self.facts.profiles.clear(); self.facts.social.clear() + jit, claims = self.jit(account) + self.assertEqual(jit.ensure(claims), account_username(account)) + + def test_missing_invalid_or_reserved_new_etech_account_never_falls_back(self): + for account in (None, '', 'cfadmin', 'CFADMIN', ' a', 'a b', 'a@x', '张三', 'a'*201): + with self.subTest(account=account), self.assertRaises(ContractError): + jit, claims = self.jit(account) + jit.ensure(claims) + self.assertEqual(self.facts.created, []) + + def test_existing_historical_uid_owner_accepts_another_idp_only(self): + self.facts.accounts['historical-uuid@auth.local'] = 1 + self.facts.profiles.append(('historical-uuid@auth.local', 'uid')) + for issuer in ('https://idp-one.test', 'https://idp-two.test'): + jit, claims = self.jit(None, issuer=issuer) + self.assertEqual(jit.ensure(claims), 'historical-uuid@auth.local') + self.assertEqual(self.native.prepare(uid='uid', login_name='new', account='new'), 'historical-uuid@auth.local') + self.assertEqual(len(self.facts.social), 2) + self.assertEqual(self.facts.created, []) + + def test_second_provider_links_even_when_new_account_jit_is_disabled(self): + self.facts.accounts['historical@auth.local'] = 1 + self.facts.profiles.append(('historical@auth.local', 'uid')) + jit, claims = self.jit(issuer='https://second.test') + jit.enabled = False + jit.state.username.return_value = 'historical@auth.local' + self.assertEqual(jit.link_existing(claims), 'historical@auth.local') + self.assertEqual(len(self.facts.social), 1) + self.assertEqual(self.facts.created, []) + + def test_collisions_and_inactive_or_ambiguous_uid_cannot_merge(self): + for profiles, accounts in ( + ([('other@auth.local', 'UID')], {'other@auth.local': 1}), + ([('old@auth.local', 'uid')], {'old@auth.local': 0}), + ([('a@auth.local', 'uid'), ('b@auth.local', 'uid')], {'a@auth.local': 1, 'b@auth.local': 1}), + ([('10280993@auth.local', 'other-uid')], {'10280993@auth.local': 1}), + ([], {'10280993@AUTH.local': 1})): + with self.subTest(profiles=profiles, accounts=accounts): + self.facts.profiles, self.facts.accounts = profiles, accounts + jit, claims = self.jit() + with self.assertRaises(ContractError): jit.ensure(claims) + with self.assertRaises((IdentityConflict, IdentityUnavailable)): + self.native.prepare(uid='uid', login_name='10280993', account='10280993') + self.assertEqual(self.facts.created, []) + + def test_generic_directory_keeps_opaque_naming_and_disabled_directory_denies(self): + jit, claims = self.jit(None, provider='generic') + self.assertRegex(jit.ensure(claims), r'^[0-9a-f]{32}@auth.local$') + self.facts.accounts.clear(); self.facts.profiles.clear(); self.facts.social.clear() + jit, claims = self.jit(status='disabled') + with self.assertRaises(ContractError): jit.ensure(claims) + + +class LegacyProvisioningDelegationTests(unittest.TestCase): + def test_legacy_creation_uses_v2_uid_authority(self): + from types import SimpleNamespace + from django.test import override_settings + from cloudfile_ext.identity_api import AdminExternalIdentityView + from cloudfile_ext.v2.http import IdentityResolve + with override_settings(CF_DIRECTORY_URL='https://directory.test'), \ + patch.object(IdentityResolve, 'post', return_value='delegated') as resolve: + request = SimpleNamespace(data={'external_user_id': 'uid', 'login_id': 'uid'}) + self.assertEqual(AdminExternalIdentityView().post(request), 'delegated') + resolve.assert_called_once_with(request) diff --git a/seahub/oauth/views.py b/seahub/oauth/views.py index c4a157b784..d88e9c3c57 100644 --- a/seahub/oauth/views.py +++ b/seahub/oauth/views.py @@ -192,6 +192,7 @@ def oauth_callback(request): nonce = request.session.pop('oauth_nonce', None) if (not isinstance(expected_state, str) or not expected_state or not isinstance(state, str) or not state + or not expected_state.isascii() or not state.isascii() or not secrets.compare_digest(expected_state, state) or protocol not in ('oauth2', 'oidc') or (protocol == 'oidc' and (not isinstance(nonce, str) or not nonce))): @@ -267,19 +268,34 @@ def oauth_callback(request): # A specifically mapped, trusted Etech userId is the canonical login_id. # Generic legacy OAuth mappings (including preferred_username as login_id) # retain their native CE compatibility path; do not mistake them for UID. - from cloudfile_extensions.identity.legacy_oauth import ( + from cloudfile_ext.v2.oauth_identity import ( trusted_uid_mapping, bind_existing_uid) - strict_uid = (trusted_uid_mapping(OAUTH_ATTRIBUTE_MAP) - and bool(oauth_user_info.get('login_id'))) + # An optional claim in a trusted UID mapping is still mandatory for + # identity selection: its absence must not enter CE's generic signup path. + strict_uid = trusted_uid_mapping(OAUTH_ATTRIBUTE_MAP) + if getattr(settings, 'CF_DIRECTORY_URL', '') and not strict_uid: + # Etech Directory deployments cannot reuse a legacy employee-number + # mapping: that would overwrite the UID after a successful login. + logger.warning('Directory login requires userId mapped to login_id') + return render_error(request, _('Error, please contact administrator.')) if strict_uid: if (not isinstance(user_info_json.get('sub'), str) or not user_info_json.get('sub') or oauth_user_info.get('uid') != user_info_json['sub']): logger.warning('Trusted OAuth UID has no matching stable provider sub') return render_error(request, _('Error, please contact administrator.')) try: - email = bind_existing_uid( - uid=oauth_user_info['login_id'], - provider=OAUTH_PROVIDER, subject=oauth_user_info['uid']) + if protocol == 'oidc' and getattr(settings, 'CF_DIRECTORY_URL', ''): + # Creation requires the business UID in the verified ID Token + # as well as UserInfo; OAuth-only callbacks remain reuse-only. + if claims.get('userId') != oauth_user_info['login_id']: + raise ValueError('signed business UID required for OIDC JIT') + from cloudfile_ext.v2.etech_directory import provision_oidc_user + email = provision_oidc_user(uid=oauth_user_info['login_id'], + provider=OAUTH_PROVIDER, subject=oauth_user_info['uid']) + else: + email = bind_existing_uid( + uid=oauth_user_info['login_id'], + provider=OAUTH_PROVIDER, subject=oauth_user_info['uid']) except Exception: logger.warning('Trusted OAuth UID cannot resolve its native account') return render_error(request, _('Error, please contact administrator.'))