def import_remote_user(uid, description, provider_type, provider_id): """ Import and return user. Raises: EntityExists: user with that uid already exists. """ log.debug('Attempt to create remote user with uid `%s`', uid) try: user = User( uid=uid, utype=UserType.regular, description=description, provider_type=provider_type, provider_id=provider_id, ) except bouncer.app.exceptions.UidValidationError: _raise_cannot_import_user('Cannot import user: invalid uid `%s`' % (uid, )) # This might raise `bouncer.app.exceptions.EntityExists`. Let the caller of # this function handle it. Ususally, it has just checked before if the user # exists or not. User.add(user) return user
def on_patch(self, req, resp, uid): pw_given = req.context['idata'].get('password', None) update_description = req.context['idata'].get('description', None) if pw_given is None and update_description is None: raise falcon.HTTPBadRequest( description= 'One of `description` and `password` must be provided.', code='ERR_INVALID_DATA', ) user = User.get_or_terminate_request(uid, self.log) if pw_given is not None: if user.is_service: raise falcon.HTTPBadRequest( description= 'Password update is not available for service user accounts.', code='ERR_INVALID_DATA', ) update_pw_hashed = self._validate_hash_password(pw_given) user.passwordhash = update_pw_hashed if update_description is not None: user.description = update_description dbsession.commit() resp.status = falcon.HTTP_NO_CONTENT
def on_put(self, req, resp, uid): pw_given = req.context['idata'].get('password', None) key_given = req.context['idata'].get('public_key', None) description = req.context['idata'].get('description', '') provider_type_str = req.context['idata'].get('provider_type', None) # Note(JP): why is the default of this not None? provider_id = req.context['idata'].get('provider_id', '') # Check if this is upstream Bouncer. if hasattr(oidcidtokenlogin, 'ISSUER_WHITELIST'): # Yes, it is. Implement a fallback to support inserting users with a # uid that looks like an email address, and where the HTTP request # lacks meaningful data in the request body. This is what the DC/OS # UI actually does up to version 1.12, and what other external # tooling might also do. What such an HTTP request actually means # is: insert user record with 'provider_type': 'oidc', # 'provider_id': 'https://dcos.auth0.com/' if not provider_id and provider_type_str is None: if not pw_given and not key_given: if re.match(r'[^@]+@[^@]+\.[^@]+', uid): # The email check really is meant to be very liberal. # See https://stackoverflow.com/a/8022584/145400 provider_type_str = 'oidc' # Expect a single key in the whitelist dict, get it. provider_id = 'https://dcos.auth0.com/' # Basic validation: do not allow empty strings for password and public # key, under no circumstances. if pw_given == '': self.raise_invalid_data( '`password` must not be empty when provided') if key_given == '': self.raise_invalid_data( '`public_key` must not be empty when provided') ptype = self._parse_provider_type_str(provider_type_str) # Basic validation for non-internal providers:no password or public key # must be provided. if ptype is not ProviderType.internal: if pw_given: self.raise_invalid_data( 'external provider: `password` is unexpected') if key_given: self.raise_invalid_data( 'external provider: `public_key` is unexpected') # Assume that the user is a regular user account. utype = UserType.regular # Set defaults. publickey = None pw_hashed = None if ptype is ProviderType.internal: if len([_ for _ in (pw_given, key_given) if _]) != 1: self.raise_invalid_data( 'One of `password` or `public_key` must be provided') if pw_given: pw_hashed = self._validate_hash_password(pw_given) else: # Service user account. As long as there is no distinct notion # of service accounts in our data model, piggyback `pw_hashed` # for storing its public key. Note(JP): clean this up: # https://jira.mesosphere.com/browse/DCOS-43663 self._validate_public_key(key_given) publickey = key_given # This is an internal user that is using key-based # authentication. This implies that the it is a service account. utype = UserType.service # Todo(jp): add validation logic to the User object creation. try: user = User( uid=uid, passwordhash=pw_hashed, publickey=publickey, utype=utype, description=description, provider_type=ptype, provider_id=provider_id, ) except UidValidationError: raise falcon.HTTPBadRequest(description='Invalid user ID: %s' % uid, code='ERR_INVALID_USER_ID') except ProviderTypeValidationError as exc: self.raise_invalid_data('Invalid provider_type: %s' % str(exc)) except ProviderIdValidationError as exc: self.raise_invalid_data('Invalid provider_id: %s' % str(exc)) try: User.add(user) except EntityExists: raise falcon.HTTPConflict( description='User with id `%s` already exists.' % uid, code='ERR_USER_EXISTS') self.log.info('User with uid `%s` added to database.', uid) resp.status = falcon.HTTP_201
def on_get(self, req, resp): show_services = req.params.get('type', None) == 'service' utype = UserType.service if show_services else UserType.regular users = User.get_all(utype) req.context['odata'] = {'array': [u.jsonobj() for u in users]}
def on_delete(self, req, resp, uid): user = User.get_or_terminate_request(uid, self.log) ensure_at_least_one_superuser_will_be_left(user) dbsession.delete(user) dbsession.commit() resp.status = falcon.HTTP_NO_CONTENT
def on_get(self, req, resp, uid): user = User.get_or_terminate_request(uid, self.log) user_json_obj = user.jsonobj() req.context['odata'] = user_json_obj
def on_post(self, req, resp): """Authenticate against cluster-local user DB or against directory back-end (via LDAP, if configured). Create internal representation for remote user if it does not yet exist. This auto-populate concept is also used in: - http://www.roundup-tracker.org/cgi-bin/moin.cgi/LDAPLogin2 - https://pythonhosted.org/django-auth-ldap/users.html Treat different combinations of local/remote user/service account and error out early. """ login_params = self._validate_req_params(req, resp) if login_params.uid is None: # Rely on login_params.oidc_id_token to be set (that's a guarantee # `_validate_req_params()` has to give. It means initiation of an # OIDC ID token-based login (legacy for (open) DC/OS). assert login_params.oidc_id_token self._oidc_id_token_login(req, resp, login_params.oidc_id_token) # Make it explicit that the request handling must terminate here. return self.log.info('Trigger login procedure for uid `%s`', login_params.uid) try: user = User.get(login_params.uid) except bouncer.app.exceptions.EntityNotFound: if login_params.service_login_token is not None: # Do not fall back to an external username/password login system # for an attempted service account login. self._raise_local_nonauth_error() try: self._unknown_user_external_login_fallback( req, resp, login_params) except AttributeError: # The `uid` is not known, and an AttributeError means that there # is no login fallback to an external system. Emit a 401 # response. Note(JP): a cleaner plugin interface between this # auth module and and an external username/password login system # is required. self._raise_local_nonauth_error() # Terminate request processing after external username/password # login system fallback. return # Prepare expressive booleans to mitigate logic bugs. is_service = user.utype is UserType.service is_remote = user.is_remote local_regular_user = not is_service and not is_remote remote_user = not is_service and is_remote if is_service and login_params.service_login_token is None: # We know for a fact that this is a service user account, but the # request did not send a service login token along. Treat as bad # credentials. self._raise_local_nonauth_error() if remote_user: # POSTing credentials to the login endpoint plus known regular user # with `is_remote` set means: delegate the login to the external # login system. self.log.info( 'User login: uid `%s` refers to a known remote user.', login_params.uid) self._external_login_user(req, resp, user, login_params) return elif local_regular_user: # Regular user account login. self.log.info('User login: uid `%s` refers to a known local user.', login_params.uid) self._login_local_regular_user(req, resp, user, login_params) return elif is_service: self.log.info( 'Service login: uid `%s` refers to a known service account.', login_params.uid) self._login_service(req, resp, user, login_params) return raise BouncerException("Unexpected account setup")
def _oidc_id_token_login(self, req, resp, oidc_id_token): # Hand off the ID Token validation business logic to the # `oidcidtokenlogin` module. issuer, email = oidcidtokenlogin.verify_id_token_or_terminate( req, resp, oidc_id_token) uid = sanitize_remote_uid(email) regular_user_count = dbsession.query(User).filter_by( utype=UserType.regular).count() if regular_user_count == 0: log.info('There is no regular user account yet. Create one.') # Add user to database. Rely on that we have just checked that no # user is there, i.e. a conflict is unexpected. Technically, there # is race condition and if a separate party was faster adding the # same user, `import_remote_user()` below could raise # `bouncer.app.exceptions.EntityExists`. In practice, that requires # the same user to log in multiple times via the external login # method on a sub-second timescale through different Bouncer # instances. Leave this unhandled (one request will succeed, the # others will see a 500 Internal Server Error response). Store # issuer as provider_id so that we keep record of which identity # provider precisely emitted the data. descr = f'User added through OIDC ID Token login. Issuer: {issuer}' user = import_remote_user(uid=uid, description=descr, provider_type=ProviderType.oidc, provider_id=issuer) else: try: user = User.get(uid) except bouncer.app.exceptions.EntityNotFound: log.info( "I know %s user(s), but `%s` ain't one of them. Emit 401.", regular_user_count, uid) # Note(JP): 403 is more appropriate because this is effectively # our coarse-grained authorization mechanism hitting in, but 401 # I think should be maintained for legacy reasons. raise falcon.HTTPUnauthorized( description='ID Token login failed: user unknown', ) # Make sure that provider ID and type are matching. That is if a # user is known in the database with the same uid as presented by # the current ID Token but stemming from a different provider type # or from a different issuer than recorded in the database then # reject the login request. if user.provider_type != ProviderType.oidc: raise falcon.HTTPUnauthorized( description='ID Token login failed: provider type mismatch', ) if user.provider_id != issuer: raise falcon.HTTPUnauthorized( description='ID Token login failed: provider ID mismatch', ) generate_authtoken_json_response(crypt.generate_auth_token(user.uid), req, resp, user.uid, user.description)