def _check_login_to_app_allowed(self, ctx): # type: (LoginCtx) -> bool if ctx.input['current_app'] not in self.sso_conf.apps.login_allowed: if self.sso_conf.apps.inform_if_app_invalid: raise ValidationError(status_code.app_list.invalid, True) else: raise ValidationError(status_code.auth.not_allowed, True) else: return True
def _validate_app_list(self, session, sso_conf, current_app, app_list): """ Raises ValidationError if input app_list is invalid, e.g. includes an unknown one. """ # All of input apps must have been already defined in configuration for app in app_list: if app not in sso_conf.apps.all: raise ValidationError(status_code.app_list.invalid, sso_conf.signup.inform_if_app_invalid) # Current app, the one the user is signed up through, must allow user signup if current_app not in sso_conf.apps.signup_allowed: raise ValidationError(status_code.app_list.no_signup, sso_conf.signup.inform_if_app_invalid)
def _validate_username(self, session, sso_conf, username): """ Raises ValidationError if username is invalid, e.g. is not too long. """ # Username must not be too long if len(username) > sso_conf.signup.max_length_username: raise ValidationError(status_code.username.too_long, sso_conf.signup.inform_if_user_invalid) # Username must not contain whitespace if self._has_whitespace(username): raise ValidationError(status_code.username.has_whitespace, sso_conf.signup.inform_if_user_invalid) # Username must not contain restricted keywords for elem in sso_conf.user_validation.reject_username: if elem in username: raise ValidationError(status_code.username.invalid, sso_conf.signup.inform_if_user_invalid)
def create(self, name, value, expiration=None, encrypt=False, user_id=None): """ Creates a new named attribute, raising an exception if it already exists. """ # Audit comes first audit_pii.info(self.cid, 'attr.create', self.current_user_id, user_id, extra={ 'current_app': self.current_app, 'remote_addr': self.remote_addr }) # Check access permissions to that user's attributes self._require_correct_user('create', user_id) with closing(self.odb_session_func()) as session: try: return self._create(session, name, value, expiration, encrypt, user_id) except IntegrityError: logger.warn(format_exc()) raise ValidationError(status_code.attr.already_exists)
def _require_correct_user(self, op, target_user_id): """ Makes sure that during current operation self.current_user_id is the same as target_user_id (which means that a person accesses his or her own attribute) or that current operation is performed by a super-user. """ if self.is_super_user or self.current_user_id == target_user_id: result = status_code.ok log_func = audit_pii.info else: result = status_code.error log_func = audit_pii.warn log_func(self.cid, '_require_correct_user', self.current_user_id, target_user_id, result, extra={ 'current_app': self.current_app, 'remote_addr': self.remote_addr, 'op': op, 'is_super_user': self.is_super_user }) if result != status_code.ok: raise ValidationError(status_code.auth.not_allowed)
def get_list(self, cid, ust, target_ust, current_ust, current_app, remote_addr, _unused_user_agent=None): """ Returns a list of sessions. Regular users may receive basic information about their own sessions only whereas super-users may look up any other user's session list. """ # PII audit comes first audit_pii.info(cid, 'session.get_list', extra={'current_app':current_app, 'remote_addr':remote_addr}) # Local aliases has_ust = bool(ust) current_ust_elem = ust if has_ust else current_ust current_session = self.get_current_session(cid, current_ust_elem, current_app, remote_addr, False) # We return a list of sessions for currently logged in user if has_ust: return self._get_session_list_by_user_id(current_session.user_id) else: # If we are to return a list of sessions for another UST, we need to be a super-user if not current_session.is_super_user: logger.warn( 'Current UST does not belong to a super-user, cannot continue (session.get_list), current user is ' \ '`%s` `%s`', current_session.user_id, current_session.username) raise ValidationError(status_code.auth.not_allowed, True) # If we are here, it means that we are a super-user and we are to return sessions # by another person's UST but there is still a chance that this other person is actually us. if current_ust == target_ust: return self._get_session_list_by_user_id(current_session.user_id) else: return self._get_session_list_by_ust(target_ust)
def check_remote_app_exists(current_app, apps_all, logger): if current_app not in apps_all: logger.warn('Invalid current_app `%s`, not among `%s', current_app, apps_all) raise ValidationError(status_code.app_list.invalid) else: return True
def _check_is_approved(self, user): if not user.approval_status == const.approval_status.approved: if self.sso_conf.login.inform_if_not_approved: raise ValidationError(status_code.auth.invalid_signup_status, True) else: return True
def _check_signup_status(self, user): if user.sign_up_status != const.signup_status.final: if self.sso_conf.login.inform_if_not_confirmed: raise ValidationError(status_code.auth.invalid_signup_status, True) else: return True
def _check_login_metadata_allowed(self, ctx): if ctx.has_remote_addr or ctx.has_user_agent: if ctx.input[ 'current_app'] not in self.sso_conf.apps.login_metadata_allowed: raise ValidationError(status_code.password.must_send_new, False) return True
def _check_login_metadata_allowed(self, ctx): # type: (LoginCtx) -> bool if ctx.has_remote_addr or ctx.has_user_agent: if ctx.input['current_app'] not in self.sso_conf.apps.login_metadata_allowed: raise ValidationError(status_code.metadata.not_allowed, False) return True
def _check_password_expired(self, user, _now=datetime.utcnow): # type: (SSOUser, datetime) -> bool if _now() > user.password_expiry: if self.sso_conf.password.inform_if_expired: raise ValidationError(status_code.password.expired, True) else: return True
def _check_must_send_new_password(self, ctx, user): # type: (LoginCtx, SSOUser) -> bool if user.password_must_change and not ctx.input.get('new_password'): if self.sso_conf.password.inform_if_must_be_changed: raise ValidationError(status_code.password.must_send_new, True) else: return True
def _check_user_not_locked(self, user): # type: (SSOUser) -> bool if user.is_locked: if self.sso_conf.login.inform_if_locked: raise ValidationError(status_code.auth.locked, True) else: return True
def _run_user_checks(self, ctx, user, check_if_password_expired=True): """ Runs a series of checks for incoming request and user. """ # Input application must have been previously defined if not self._check_remote_app_exists(ctx): raise ValidationError(status_code.auth.not_allowed, True) # If applicable, requests must originate in a white-listed IP address if not self._check_remote_ip_allowed(ctx, user): raise ValidationError(status_code.auth.not_allowed, True) # User must not have been locked out of the auth system if not self._check_user_not_locked(user): raise ValidationError(status_code.auth.not_allowed, True) # If applicable, user must be fully signed up, including account creation's confirmation if not self._check_signup_status(user): raise ValidationError(status_code.auth.not_allowed, True) # If applicable, user must be approved by a super-user if not self._check_is_approved(user): raise ValidationError(status_code.auth.not_allowed, True) # Password must not have expired, but only if input flag tells us to, # it may be possible that a user's password has already expired # and that person wants to change it in this very call, in which case # we cannot reject it on the basis that it is expired - no one would be able # to change expired passwords then. if check_if_password_expired: if not self._check_password_expired(user): raise ValidationError(status_code.auth.not_allowed, True) # Current application must be allowed to send login metadata if not self._check_login_metadata_allowed(ctx): raise ValidationError(status_code.auth.not_allowed, True)
def _validate_password(self, session, sso_conf, password): """ Raises ValidationError if password is invalid, e.g. it is too simple. """ # Password may not be too short if len(password) < sso_conf.password.min_length: raise ValidationError(status_code.password.too_short, sso_conf.password.inform_if_invalid) # Password may not be too long if len(password) > sso_conf.password.max_length: raise ValidationError(status_code.password.too_long, sso_conf.password.inform_if_invalid) # Password's default complexity is checked case-insensitively password = password.lower() # Password may not contain most commonly used ones for elem in sso_conf.password.reject_list: if elem in password: raise ValidationError(status_code.password.invalid, sso_conf.password.inform_if_invalid)
def _handle_sso_POST(self, ctx): """ Verifies whether an input session exists or not. """ # Make sure target UST actually was given on input if ctx.input.target_ust == _invalid: raise ValidationError(status_code.session.no_such_session) self.response.payload.is_valid = self.sso.user.session.verify(self.cid, ctx.input.target_ust, ctx.input.current_ust, ctx.input.current_app, ctx.remote_addr)
def _validate_email(self, session, sso_conf, email): """ Raises ValidationError if email is invalid, e.g. already exists. """ # E-mail may be required if sso_conf.signup.is_email_required and not email: raise ValidationError(status_code.email.missing, sso_conf.signup.inform_if_email_invalid) # E-mail must not be too long if len(email) > sso_conf.signup.max_length_email: raise ValidationError(status_code.email.too_long, sso_conf.signup.inform_if_email_invalid) # E-mail must not contain whitespace if self._has_whitespace(email): raise ValidationError(status_code.email.has_whitespace, sso_conf.signup.inform_if_email_invalid) # E-mail must not contain restricted keywords for elem in sso_conf.user_validation.reject_email: if elem in email: raise ValidationError(status_code.email.invalid, sso_conf.signup.inform_if_email_invalid)
def get_current_session(self, cid, current_ust, current_app, remote_addr, needs_super_user): """ Returns current session info or raises an exception if it could not be found. Optionally, requires that a super-user be owner of current_ust. """ # PII audit comes first audit_pii.info(cid, 'session.get_current_session', extra={'current_app':current_app, 'remote_addr':remote_addr}) # Verify current session's very existence first .. current_session = self._get_session(current_ust, current_app, remote_addr, 'get_current_session') if not current_session: logger.warn('Could not verify session `%s` `%s` `%s` `%s`', current_ust, current_app, remote_addr, format_exc()) raise ValidationError(status_code.auth.not_allowed, True) # .. the session exists but it may be still the case that we require a super-user on input. if needs_super_user: if not current_session.is_super_user: logger.warn( 'Current UST does not belong to a super-user, cannot continue (session.get_current_session), ' \ 'current user is `%s` `%s`', current_session.user_id, current_session.username) raise ValidationError(status_code.auth.not_allowed, True) return current_session
def _get(self, session, ust, current_app, remote_addr, ctx_source, needs_decrypt=True, renew=False, needs_attrs=False, user_agent=None, check_if_password_expired=True, _now=datetime.utcnow, _opaque=GENERIC.ATTR_NAME, skip_sec=False): """ Verifies if input user session token is valid and if the user is allowed to access current_app. On success, if renew is True, renews the session. Returns all session attributes or True, depending on needs_attrs's value. """ # type: (object, unicode, unicode, bool, bool, bool, bool, datetime, unicode) -> object now = _now() ctx = VerifyCtx(self.decrypt_func(ust) if needs_decrypt else ust, remote_addr, current_app) # Look up user and raise exception if not found by input UST sso_info = self._get_session_by_ust(session, ctx.ust, now) # Invalid UST or the session has already expired but in either case # we can not access it. if not sso_info: raise ValidationError(status_code.session.no_such_session, False) if skip_sec: return sso_info if needs_attrs else True else: # Common auth checks self._run_user_checks(ctx, sso_info, check_if_password_expired) # Everything is validated, we can renew the session, if told to. if renew: # Update current interaction details for this session opaque = getattr(sso_info, _opaque) or {} session_state_change_list = self._extract_session_state_change_list(sso_info) self.update_session_state_change_list(session_state_change_list, remote_addr, user_agent, ctx_source, now) opaque['session_state_change_list'] = session_state_change_list # Set a new expiration time expiration_time = now + timedelta(minutes=self.sso_conf.session.expiry) session.execute( SessionModelUpdate().values({ 'expiration_time': expiration_time, GENERIC.ATTR_NAME: dumps(opaque), }).where( SessionModelTable.c.ust==ctx.ust )) return expiration_time else: # Indicate success return sso_info if needs_attrs else True
def delete(self, data, user_id=None, _utcnow=_utcnow): """ Deletes one or more names attributes. """ # Audit comes first audit_pii.info(self.cid, 'attr.delete/delete_many', self.current_user_id, user_id, extra={ 'current_app': self.current_app, 'remote_addr': self.remote_addr, 'data': data, 'is_super_user': self.is_super_user }) # Check access permissions to that user's attributes self._require_correct_user('delete', user_id) now = _utcnow() data = [data] if isinstance(data, basestring) else data and_condition = [ AttrModelTable.c.user_id == (user_id or self.user_id), AttrModelTable.c.ust == self.ust, AttrModelTable.c.name.in_(data), AttrModelTable.c.expiration_time > now, ] with closing(self.odb_session_func()) as session: if self.ust: # Check comment in self._update for a comment why the below is needed if self.is_sqlite: result = self._ensure_ust_is_not_expired( session, self.ust, now) if not result: raise ValidationError( status_code.session.no_such_session) else: and_condition.extend([ AttrModelTable.c.ust == SSOSessionTable.c.ust, SSOSessionTable.c.expiration_time > now ]) session.execute( AttrModelTableDelete().\ where(and_(*and_condition))) session.commit()
def _handle_sso_GET(self, ctx): """ Returns details of a particular session. """ # Make sure target UST actually was given on input if ctx.input.target_ust == _invalid: raise ValidationError(status_code.session.no_such_session) # Get result result = self.sso.user.session.get( self.cid, ctx.input.target_ust, ctx.input.current_ust, ctx.input.current_app, ctx.remote_addr, self.wsgi_environ.get('HTTP_USER_AGENT')) # Return output self.response.payload = result.to_dict()
def _handle_sso_GET(self, ctx): """ Returns details of a particular session. """ # Make sure target UST actually was given on input if ctx.input.target_ust == _invalid: raise ValidationError(status_code.session.no_such_session) # Get result result = self.sso.user.session.get(self.cid, ctx.input.target_ust, ctx.input.current_ust, ctx.input.current_app, ctx.remote_addr) # Serialize datetime objects to string result['creation_time'] = result['creation_time'].isoformat() result['expiration_time'] = result['expiration_time'].isoformat() # Return output self.response.payload = result
def _get(self, session, ust, current_app, remote_addr, needs_decrypt=True, renew=False, needs_attrs=False, check_if_password_expired=True, _now=datetime.utcnow): """ Verifies if input user session token is valid and if the user is allowed to access current_app. On success, if renew is True, renews the session. Returns all session attributes or True, depending on needs_attrs's value. """ now = _now() ctx = VerifyCtx( self.decrypt_func(ust) if needs_decrypt else ust, remote_addr, current_app) # Look up user and raise exception if not found by input UST sso_info = self._get_session_by_ust(session, ctx.ust, now) # Invalid UST or the session has already expired but in either case # we can not access it. if not sso_info: raise ValidationError(status_code.session.no_such_session, False) # Common auth checks self._run_user_checks(ctx, sso_info, check_if_password_expired) # Everything is validated, we can renew the session, if told to. if renew: expiration_time = now + timedelta( minutes=self.sso_conf.session.expiry) session.execute(SessionModelUpdate().values({ 'expiration_time': expiration_time, }).where(SessionModelTable.c.ust == ctx.ust)) return expiration_time else: # Indicate success return sso_info if needs_attrs else True
def _call_many(self, func, data, expiration=None, encrypt=False, user_id=None): """ A reusable method for manipulation of multiple attributes at a time. """ # Audit comes first audit_pii.info(self.cid, 'attr._call_many', self.current_user_id, user_id, extra={ 'current_app': self.current_app, 'remote_addr': self.remote_addr, 'is_super_user': self.is_super_user, 'func': func.__func__.__name__ }) with closing(self.odb_session_func()) as session: for item in data: # Check access permissions to that user's attributes _user_id = item.get('user_id', user_id) self._require_correct_user('_call_many', _user_id) func(session, item['name'], item['value'], item.get('expiration', expiration), item.get('encrypt', encrypt), _user_id, needs_commit=False) # Commit now everything added to session thus far try: session.commit() except IntegrityError: logger.warn(format_exc()) raise ValidationError(status_code.attr.already_exists)
def _validate_username_email(self, session, sso_conf, username, email, check_email): """ Validation common to usernames and emails. """ # Check if user exists either by username or email user = user_exists(session, username, email, check_email) if user: if check_email: if user.username == username and user.email == email: sub_status = [status_code.username.exists, status_code.email.exists] return_status = sso_conf.signup.inform_if_user_exists and sso_conf.signup.inform_if_email_exists elif user.username == username: sub_status = status_code.username.exists return_status = sso_conf.signup.inform_if_user_exists elif user.email == email: sub_status = status_code.email.exists return_status = sso_conf.signup.inform_if_email_exists raise ValidationError(sub_status, return_status)
def login(self, ctx, _ok=status_code.ok, _now=datetime.utcnow, _timedelta=timedelta): """ Logs a user in, returning session info on success or raising ValidationError on any error. """ # Look up user and raise exception if not found by username with closing(self.odb_session_func()) as session: user = get_user_by_username(session, ctx.input['username']) if not user: raise ValidationError(status_code.auth.not_allowed, False) # Check credentials first to make sure that attackers do not learn about any sort # of metadata (e.g. is the account locked) if they do not know username and password. if not self._check_credentials(ctx, user): raise ValidationError(status_code.auth.not_allowed, False) # It must be possible to log into the application requested (CRM above) self._check_login_to_app_allowed(ctx) # Common auth checks self._run_user_checks(ctx, user) # If applicable, password may not be about to expire (this must be after checking that it has not already). # Note that it may return a specific status to return (warning or error) _about_status = self._check_password_about_to_expire(user) if _about_status is not True: if _about_status == status_code.warning: _status_code = status_code.password.w_about_to_exp inform = True else: _status_code = status_code.password.e_about_to_exp inform = False raise ValidationError(_status_code, inform, _about_status) # If password is marked as as requiring a change upon next login but a new one was not sent, reject the request. self._check_must_send_new_password(ctx, user) # If new password is required, we need to validate and save it before session can be created. # Note that at this point we already know that the old password was correct so it is safe to set the new one # if it is confirmed to be valid. We also know that there is some new password on input because otherwise # the check above would have raised a ValidationError. if user.password_must_change: try: validate_password(self.sso_conf, ctx.input['new_password']) except ValidationError as e: if e.return_status: raise ValidationError(e.sub_status, e.return_status, e.status) else: set_password(self.odb_session_func, self.encrypt_func, self.hash_func, self.sso_conf, user.user_id, ctx.input['new_password'], False) # All validated, we can create a session object now creation_time = _now() expiration_time = creation_time + timedelta( minutes=self.sso_conf.session.expiry) ust = new_user_session_token() session.execute(SessionModelInsert().values({ 'ust': ust, 'creation_time': creation_time, 'expiration_time': expiration_time, 'user_id': user.id, 'remote_addr': ', '.join(str(elem) for elem in ctx.remote_addr), 'user_agent': ctx.user_agent, })) session.commit() info = SessionInfo() info.username = user.username info.user_id = user.user_id info.ust = self.encrypt_func(ust.encode('utf8')) info.creation_time = creation_time info.expiration_time = expiration_time return info
def login(self, ctx, _ok=status_code.ok, _now=datetime.utcnow, _timedelta=timedelta, _dummy_password=uuid4().hex, is_logged_in_ext=True): """ Logs a user in, returning session info on success or raising ValidationError on any error. """ # type: (LoginCtx, unicode, datetime, timedelta, unicode, bool) -> SessionInfo # Look up user and raise exception if not found by username with closing(self.odb_session_func()) as session: if ctx.input.get('username'): user = get_user_by_username( session, ctx.input['username']) # type: SSOUser else: user = get_user_by_id(session, ctx.input['user_id']) # type: SSOUser # If the user is already logged in externally, this flag will be True, # in which case we do not check the credentials - we already know they are valid # because they were checked externally and user_id is the SSO user linked to the # already validated external credentials. if not is_logged_in_ext: # Check credentials first to make sure that attackers do not learn about any sort # of metadata (e.g. is the account locked) if they do not know username and password. if not self._check_credentials( ctx, user.password if user else _dummy_password): raise ValidationError(status_code.auth.not_allowed, False) # Check input TOTP key if two-factor authentication is enabled if user.is_totp_enabled: input_totp_code = ctx.input.get('totp_code') if not input_totp_code: logger.warn('Missing TOTP code; user `%s`', user.username) raise ValidationError(status_code.auth.not_allowed, False) else: user_totp_key = self.decrypt_func(user.totp_key) if not CryptoManager.verify_totp_code( user_totp_key, input_totp_code): logger.warn('Invalid TOTP code; user `%s`', user.username) raise ValidationError(status_code.auth.not_allowed, False) # It must be possible to log into the application requested (CRM above) self._check_login_to_app_allowed(ctx) # Common auth checks self._run_user_checks(ctx, user) # We assume that we will not have to warn about an approaching password expiry has_w_about_to_exp = False # If applicable, password may be about to expire (this must be after checking that it has not already). # Note that it may return a specific status to return (warning or error) _about_status = self._check_password_about_to_expire(user) if _about_status is not True: if _about_status == status_code.warning: has_w_about_to_exp = True else: raise ValidationError(status_code.password.e_about_to_exp, False, _about_status) # If password is marked as requiring a change upon next login but a new one was not sent, reject the request. self._check_must_send_new_password(ctx, user) # If new password is required, we need to validate and save it before session can be created. # Note that at this point we already know that the old password was correct so it is safe to set the new one # if it is confirmed to be valid. We also know that there is some new password on input because otherwise # the check above would have raised a ValidationError. if user.password_must_change: try: validate_password(self.sso_conf, ctx.input.get('new_password')) except ValidationError as e: if e.return_status: raise ValidationError(e.sub_status, e.return_status, e.status) else: set_password(self.odb_session_func, self.encrypt_func, self.hash_func, self.sso_conf, user.user_id, ctx.input['new_password'], False) # All validated, we can create a session object now creation_time = _now() expiration_time = creation_time + timedelta( minutes=self.sso_conf.session.expiry) ust = new_user_session_token() # Create current interaction details for this session session_state_change_list = [] self.update_session_state_change_list(session_state_change_list, ctx.remote_addr, ctx.user_agent, 'login', creation_time) opaque = {'session_state_change_list': session_state_change_list} session.execute(SessionModelInsert().values({ 'ust': ust, 'creation_time': creation_time, 'expiration_time': expiration_time, 'user_id': user.id, 'auth_type': const.auth_type.default, 'auth_principal': user.username, 'remote_addr': ', '.join(str(elem) for elem in ctx.remote_addr), 'user_agent': ctx.user_agent, 'ext_session_id': ctx.ext_session_id, GENERIC.ATTR_NAME: dumps(opaque) })) session.commit() info = SessionInfo() info.username = user.username info.user_id = user.user_id info.ust = self.encrypt_func(ust.encode('utf8')) info.creation_time = creation_time info.expiration_time = expiration_time info.has_w_about_to_exp = has_w_about_to_exp return info
def _update(self, session, name, value=None, expiration=None, encrypt=False, user_id=None, needs_commit=True, _utcnow=_utcnow): """ A low-level implementation of self.update which expects an SQL session on input. """ # Audit comes first audit_pii.info(self.cid, 'attr._update', self.current_user_id, user_id, extra={ 'current_app': self.current_app, 'remote_addr': self.remote_addr, 'name': name, 'expiration': expiration, 'encrypt': encrypt, 'is_super_user': self.is_super_user }) # Check access permissions to that user's attributes self._require_correct_user('_update', user_id) now = _utcnow() values = {'last_modified': now} if value: values['value'] = dumps( self.encrypt_func(value.encode('utf8')) if encrypt else value) if expiration: values['expiration_time'] = now + timedelta(seconds=expiration) and_condition = [ AttrModelTable.c.user_id == (user_id or self.user_id), AttrModelTable.c.ust == self.ust, AttrModelTable.c.name == name, AttrModelTable.c.expiration_time > now, ] if self.ust: # SQLite needs to be treated in a special way, otherwise we get an exception from SQLAlchemy # NotImplementedError: This backend does not support multiple-table criteria within UPDATE # which means that on SQLite we need an additional query. if self.is_sqlite: result = self._ensure_ust_is_not_expired( session, self.ust, now) if not result: raise ValidationError(status_code.session.no_such_session) else: and_condition.extend([ AttrModelTable.c.ust == SSOSessionTable.c.ust, SSOSessionTable.c.expiration_time > now ]) session.execute( AttrModelTableUpdate().\ values(values).\ where(and_(*and_condition))) if needs_commit: session.commit()