Настройка процедур входа в приложения#

Процедуры входа применяются для настройки правил доступа пользователей к различным приложениям. C помощью процедур можно определить, например, какие приложения должны быть доступны каким пользователям, при каких условиях должна требоваться двухфакторная аутентификация и какие методы подтверждения входа может применять пользователь. Применение процедур входа позволяет организации исполнить принятые в ней политики контроля доступа к приложениям.

Создание процедуры входа#

Управление процедурами входа осуществляется в разделе «Процедуры входа» консоли управления Blitz Identity Provider.

:size=80%

Экран настроек процедур входа)#

Создание процедуры входа включает в себя следующие шаги:

  1. Указание базовых параметров процедуры:

    • идентификатор процесса (процедуры);

    • описание процедуры;

    • приложения – перечень приложений, для которых будет применяться данная процедура.

Важно

Для каждого приложения может быть создана только одна процедура. Если для данного приложения не создано процедуры, к нему будет применяться стандартная процедура входа (процедура входа по умолчанию). Если процедура создана без указания приложений, то она заменит стандартную процедуру входа.

:size=80%

Экран создания новой процедуры входа)#

  1. Написание исходного кода процедуры. Для успешной работы процедуры входа необходимо написать на языке Java класс, реализующий необходимый интерфейс Strategy. Вся контекстная информация о пользователе, о текущем состоянии процедуры аутентификации и т.д. доступна в объекте Context. Процедура состоит из двух блоков, которые определяют:

    • действия, предпринимаемые на начальном этапе процесса аутентификации. В этом блоке, например, можно определить, при каких условиях осуществлять переход в приложение в режиме SSO (если пользователь ранее был аутентифицирован);

    • действия, предпринимаемые после первичной аутентификации пользователя. В этом блоке, например, можно определить, какие методы двухфакторной аутентификации при каких условиях использовать.

  2. После написания кода необходимо нажать на кнопку «Компилировать». При наличии ошибок некорректные фрагменты кода будут выделены цветом и подписаны ошибки.

  3. Если компиляция прошла успешно, можно сохранить процедуру.

  4. Сохраненную процедуру можно активировать – для этого следует нажать на кнопку «Активировать» в шапке соответствующей процедуры.

  5. Можно редактировать как активированную, так и деактивированную процедуру. После редактирования следует компилировать процедуру, после чего – сохранить. Если процедура была активирована, то новая скомпилированная процедура заменит старую.

Предупреждение

Если процедура активирована, то сохранить можно только ту процедуру, которую удается скомпилировать. Иными словами, если при редактировании активированной процедуры была выявлена ошибка, то кнопка «Сохранить» работать не будет, а при перезагрузке страницы изменения будут утеряны.

:size=80%

Экран редактирования исходного кода процедуры входа (фрагмент)#

Примеры процедур входа#

В поставку входят несколько готовых процедур, которые могут быть при необходимости изменены:

  • принудительная двухфакторная аутентификация в приложение (Require2ndFactor);

  • ограничение перечня доступных методов первого фактора при входе в приложение (FFmethods);

  • предоставление доступа к приложению только при определенном значении атрибута (AccessByAttribute);

  • запрет входа в приложение после истечения срока действия учетной записи (AccountExpiresCheck);

  • разрешение входа в приложение только из определенных сетей (AllowedIPs);

  • запрет работы в нескольких одновременных сессиях (RestrictSessions);

  • сохранение в утверждениях (claims) перечня групп пользователя (AddGroupsToToken);

  • отображение пользователю объявления при входе (InfoPipe);

  • запрос ввода пользователем атрибута или актуализации телефона и email (PipeAttrActAdd);

  • регистрация ключа безопасности (WebAuthn, Passkey, FIDO2) при входе (PipeWebAuthn).

Далее приводятся листинги этих процедур. Для удобства отладки можно выводить информацию о состоянии аутентификации в лог, воспользовавшись функцией logger.debug(). Например, следующая команда выведет в лог заданный уровень аутентификации для пользователя:

logger.debug("requiredFactor="+ctx.userProps("requiredFactor"));

Принудительная двухфакторная аутентификация в приложение#

Процедура Require2ndFactor требует двухфакторной аутентификации для доступа к приложению. Если пользователь переходит в приложение в рамках единой сессии, то при наличии одного пройденного фактора у него будет дополнительно проверен второй фактор, т.е. SSO в этом случае не сработает.

public class Require2ndFactor implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
        if(ctx.claims("subjectId") != null){
            if (ctx.sessionTrack().split(",").length < 2)
                return StrategyState.MORE(new String[]{});
            else
                return StrategyState.ENOUGH();
        }
        else {
            return StrategyState.MORE(new String[]{});
        }
    }

    @Override public StrategyState next(final Context ctx) {
        if(ctx.justCompletedFactor() == 1)
            return StrategyState.MORE(new String[]{});
        else
            return StrategyState.ENOUGH();
    }
}

Ограничение перечня доступных методов первого фактора#

Процедура FFmethods позволяет при входе в приложение предлагать пользователю только определенные методы идентификации и аутентификации (аналогичную процедуру с иным перечнем методов, можно назначить другому приложению). Для обозначения методов аутентификации первого фактора в процедуре используются следующие идентификаторы:

  • password – вход по логину и паролю;

  • x509 – вход по электронной подписи;

  • externalIdps – вход через внешние поставщики идентификации (социальные сети, ЕСИА);

  • spnego – вход по сеансу операционной системы;

  • sms – вход по коду подтверждения в SMS-сообщении.

  • knownDevice – вход по известному устройству;

  • qrCode – вход по QR-коду;

  • webAuthn – вход с помощью ключей безопасности (WebAuthn, Passkey, FIDO2);

  • tls – вход на основе переданного HTTP-заголовка.

public class FFmethods implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
        if(ctx.claims("subjectId") != null)
            return StrategyState.ENOUGH();
        else
            return StrategyState.MORE(new String[]{"password","x509"});
    }

    @Override public StrategyState next(final Context ctx) {
        Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
        if(reqFactor == null || reqFactor == 0)
            return StrategyState.ENOUGH();
        else {
            if(reqFactor == ctx.justCompletedFactor())
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }
}

Разрешение входа в приложение только при определенном значении атрибута у пользователя#

Процедура AccessByAttribute использует атрибут appList для принятия решения о доступе пользователя к приложению. Для работы этой процедуры необходимо создать атрибут appList в виде массива (Array of strings). В качестве значений элементов этого массива следует использовать идентификаторы приложений. В результате доступ к приложению будет предоставлен, если среди значений appList у данного пользователя будет идентификатор этого приложения. Такая архитектура процедуры позволяет назначить ее сразу нескольким приложениям и регулировать доступ к ним при помощи одного атрибута.

public class AccessByAttribute implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
        if(ctx.claims("subjectId") != null){
            int appListIdx = 0;
            boolean hasAccess = false;
            while (appListIdx > -1) {
                String app = ctx.claims("appList.[" + appListIdx + "]");
                logger.debug("app [" + appListIdx + "] = " + app);
                if (app == null){ appListIdx = -1; }
                else if (app.equals(ctx.appId())) { appListIdx = -1; hasAccess = true; }
                else { appListIdx ++; logger.debug("AppList index = " + appListIdx); }
            }
            if(hasAccess)
                return StrategyState.ENOUGH();
            else
                return StrategyState.DENY;
        }
        else
            return StrategyState.MORE(new String[]{});
    }

    @Override public StrategyState next(final Context ctx) {
        int appListIdx = 0;
        boolean hasAccess = false;
        while (appListIdx > -1) {
            String app = ctx.claims("appList.[" + appListIdx + "]");
            logger.debug("app [" + appListIdx + "] = " + app);
            if (app == null){ appListIdx = -1; }
            else if (app.equals(ctx.appId())) { appListIdx = -1; hasAccess = true; }
            else { appListIdx ++; logger.debug("AppList index = " + appListIdx); }
        }
        if(!hasAccess)
            return StrategyState.DENY;
        Integer reqFactor = 0;
        if (ctx.user() != null) {
            reqFactor = ctx.user().requiredFactor();
        }
        if (reqFactor == 0)
            return StrategyState.ENOUGH();
        else {
            if (reqFactor == ctx.justCompletedFactor())
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }
}

Пример упрощенного варианта процедуры – допуск пользователя в приложение при условии, что адрес его электронной почты равен ivanov@company.ru:

@Override public StrategyBeginState begin(final Context ctx) {
  if(ctx.claims("subjectId") != null){
    if("ivanov@company.ru".equals(ctx.claims("email")))
      return StrategyState.ENOUGH();
    else
      return StrategyState.DENY;
  }
  else
    return StrategyState.MORE(new String[]{});
}

@Override public StrategyState next(final Context ctx) {
  if(!"ivanov@company.ru".equals(ctx.claims("email")))
    return StrategyState.DENY;
  Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
  if(reqFactor == null)
    return StrategyState.ENOUGH();
  else {
    if(reqFactor == ctx.justCompletedFactor())
      return StrategyState.ENOUGH();
    else
      return StrategyState.MORE(new String[]{});
  }
}

Запрет входа в приложение после истечения срока действия учетной записи#

Процедура AccountExpiresCheck использует атрибут accountExpires для принятия решения о доступе пользователя к приложению. Для работы этой процедуры необходимо создать атрибут accountExpires с типом строка (String). В этот атрибут необходимо сохранить дату (в формате гггг-ММ-дд ЧЧ:мм, например 2021-09-23 13:58), после наступления которой доступ в приложение будет заблокирован для данного пользователя. Если значение атрибута не указано, то пользователь будет допущен в приложение.

public class AccountExpiresCheck implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

@Override public StrategyBeginState begin(final Context ctx) {
  if ("login".equals(ctx.prompt())){
    List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
    methods.remove("cls");
    return StrategyState.MORE(methods.toArray(new String[0]), true);
  } else {
    if(ctx.claims("subjectId") != null)
      return StrategyState.ENOUGH();
    else
      return StrategyState.MORE(new String[]{});
  }
}

@Override public StrategyState next(final Context ctx) {
  if (ctx.claims("accountExpires") != null && isExpired(ctx.claims("accountExpires")))
    return StrategyState.DENY("account_expired", true);
  Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
  if(reqFactor == null || reqFactor == ctx.justCompletedFactor())
    return StrategyState.ENOUGH();
  else
    return StrategyState.MORE(new String[]{});
}

public static boolean isExpired(String strData) {
  try {
    Date now = new Date();
            Date date = new SimpleDateFormat("yyyy-M-d HH:mm").parse(strData);
            return now.after(date);
        } catch (ParseException e) {
            throw new RuntimeException(e);
        }
    }
}

Разрешение входа в приложение только из определенных сетей#

Процедура AllowedIPs использует константу ALLOW_IP для принятия решения о доступе пользователя к приложению. В данной константе необходимо прописать перечень сетей, из которых возможен доступ в приложение, допустимо указать несколько сетей. При входе в приложение будет проверен IP адрес пользователя на предмет его соответствия одному из значений, включенных в константу. Если он соответствует, то пользователь будет допущен в приложение, если не соответствует – в доступе будет отказано.

public class AllowedIPs implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");
    private final static String[] ALLOW_IP = {"179.218","180.219"};

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override public StrategyState next(final Context ctx) {
        if (!_allowed_ip(ctx.ip())) {
            return StrategyState.DENY("ip_not_allowed", true);
        }
        Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
        if(reqFactor == null || reqFactor == ctx.justCompletedFactor()) {
            return StrategyState.ENOUGH_BUILDER()
                .build();
        } else
            return StrategyState.MORE(new String[]{});
    }

    private Boolean _allowed_ip(final String IP) {
      int IpListIdx = 0;
      boolean ipAllowed = false;
      while (IpListIdx > -1) {
        String ip_part = ALLOW_IP[IpListIdx];
        if (IP.startsWith(ip_part)) {
            ipAllowed = true;
            IpListIdx = -1;
        } else if (ALLOW_IP.length == (IpListIdx + 1)) {
            IpListIdx = -1;
        } else {
            IpListIdx ++;
        }
      }
        return ipAllowed;
    }
}

Запрет работы в нескольких одновременных сессиях#

Процедура RestrictSessions запрещает работу в нескольких сессиях.

public class RestrictSessions implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
      List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
      if ("login".equals(ctx.prompt())){
        methods.remove("cls");
        return StrategyState.MORE(methods.toArray(new String[0]), true);
      } else {
        if(ctx.claims("subjectId") != null)
          return StrategyState.ENOUGH();
        else {
          methods.remove("cls");
          return StrategyState.MORE(methods.toArray(new String[0]));
        }
      }
    }

    @Override public StrategyState next(final Context ctx) {
      Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
      if(reqFactor == null || reqFactor == ctx.justCompletedFactor()) {
        return StrategyState.ENOUGH_BUILDER().singleSession(true).build();
      } else
        return StrategyState.MORE(new String[]{});
    }
}

Сохранение в утверждениях (claims) перечня групп пользователя#

Процедура AddGroupsToToken сохраняет в утверждение grps перечень групп пользователя. Чтобы эта процедура работала, должны быть выполнены условия:

При входе в приложение будет проверено наличие групп у пользователя в атрибуте memberOf, и если они там присутствуют, то они будут добавлены в утверждение grps.

public class AddGroupsToToken implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override public StrategyState next(final Context ctx) {
        Integer reqFactor = (ctx.user() == null) ? null : ctx.user().requiredFactor();
        if(reqFactor == null || reqFactor == ctx.justCompletedFactor()) {
            List<String> grps = new ArrayList<String>();
            int groupListIdx = 0;
            while (groupListIdx > -1) {
              String group = ctx.claims("memberOf.[" + groupListIdx + "]");
              logger.debug("### group [" + groupListIdx + "] = " + group);
              if (group == null) {
                groupListIdx = -1;
              } else {
                grps.add(ctx.claims("memberOf.[" + groupListIdx + "]"));
                groupListIdx ++;
              }
            }
            LClaimsBuilder  claimsBuilder = ctx.claimsBuilder();
            if (grps.size() > 0) {
                claimsBuilder.addClaim("grps", grps);
            }
            LClaims claims = claimsBuilder.build();
            return StrategyState.ENOUGH_BUILDER()
                .withClaims(claims)
                .build();
        } else
            return StrategyState.MORE(new String[]{});
    }
}

Отображение пользователю объявления при входе#

Процедура InfoPipe позволяет с периодичностью в 30 дней показывать пользователю при входе объявления. Перед использованием в процедуру нужно внести следующие изменения:

  • в функции requiredNews() скорректировать критерии отображения объявления – например, в примере настроено, что показывать раз в 30 дней в случае если в прошлый раз пользователь при показе объявления нажал кнопку отказа;

  • в функции showNews() вместо <BLITZ-HOST> указать URI, по которому из браузера пользователя доступен Blitz Identity Provider, а вместо <CLIENT_ID> указать идентификатор приложения (с правами на scope openid), от имени которого будет выполняться вспомогательное приложение;

  • настроить в конфигурационном файле тип уведомления – см. Вспомогательные приложения (pipes);

  • настроить в сообщениях текст уведомления и названия кнопок – см. Сообщения вспомогательных приложений (pipes).

public class InfoPipe implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override public StrategyState next(Context ctx) {
        if (ctx.user() == null || ctx.user().requiredFactor() == null ||
            ctx.user().requiredFactor().equals(ctx.justCompletedFactor()))
            if (requiredNews("user_agreement", ctx)) return showNews("user_agreement", ctx);
            else return StrategyState.ENOUGH();
        else
            return StrategyState.MORE(new String[] {});
    }

    private boolean requiredNews(final String pipeId, final Context ctx) {
        Long readOn = ctx.user().userProps().numProp("pipes.info." + pipeId + ".disagreedOn");
        return (readOn == null || Instant.now().getEpochSecond() - readOn > 30*86400);
    }

    private StrategyState showNews(final String pipeId, final Context ctx) {
        String uri = "https://<BLITZ-HOST>/blitz/pipes/info/start?&pipeId=" + pipeId;
        Set<String> claims = new HashSet<String>(){{
            add("instanceId");
        }};
        Set<String> scopes = new HashSet<String>(){{
            add("openid");
       }};
       return StrategyState.ENOUGH_BUILDER()
         .withPipe(uri, "<CLIENT_ID>", scopes, claims)
         .build();
    }
}

Запрос ввода пользователем атрибута или актуализации телефона и email#

Процедура PipeAttrActAdd позволяет запросить у пользователя ввод значения атрибута. Для мобильного телефона и для email реализована периодическая актуализация контакта. Для обычного атрибута (в примере используется family_name) разовое заполнение атрибута. В случае если пользователь не захотел заполнять атрибут, то следующий запрос ввода атрибута реализован спустя определенное время.

Перед использованием в процедуру нужно внести следующие изменения:

  • в константе DOMAIN указать URI, по которому из браузера пользователя доступен Blitz Identity Provider;

  • в константах MOBILE_ATTR, EMAIL_ATTR, COMMON_ATTR указать имена заполняемых атрибутов;

  • в константе SKIP_TIME_IN_SEC указать время, не чаще которого пользователю будут предлагать заполнить атрибут;

  • в константе ACT_TIME_IN_SEC указать время, не чаще которого пользователю будут предлагать актуализировать телефон или email;

  • в константе ASK_AT_1ST_LOGIN изменить значение, если запрос заполнения контакта нужно выполнять при первом же входе (обычно первый вход происходит сразу после регистрации учетной записи, потому сделана настройка, чтобы пользователю при первом входе не предлагали сразу заполнить данные);

  • в теле процедуры вместо _blitz_profile указать идентификатор другого приложения, если изменение атрибутов должно делаться от приложения, отличного от Личного кабинета;

  • настроить в сообщениях тексты для атрибута из COMMON_ATTR (для email и телефона также можно скорректировать тексты по умолчанию) – см. Сообщения вспомогательных приложений (pipes).

public class PipeAttrActAdd implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");
    private final static String DOMAIN = "example.com";
    private final static String MOBILE_ATTR = "phone_number";
    private final static String EMAIL_ATTR = "email";
    private final static String COMMON_ATTR = "family_name";
    private final static Integer SKIP_TIME_IN_SEC = 30*86400;
    private final static Integer ACT_TIME_IN_SEC = 30*86400;
    private final static Boolean ASK_AT_1ST_LOGIN = false;

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override public StrategyState next(final Context ctx) {
        Instant instant = Instant.now();
        Boolean new_device = false;
        if (ctx.ua().getNewlyCreated() && ctx.justCompletedFactor() == 1 && !ASK_AT_1ST_LOGIN){
            logger.debug("User with sub={} is signing in, pid={}, on a new device",
                ctx.claims("subjectId"), ctx.id());
            new_device = true;
        }
        Integer reqFactor = ctx.user().requiredFactor();
        if(reqFactor == null || reqFactor == ctx.justCompletedFactor()) {
            Enough.Builder en_builder = StrategyState.ENOUGH_BUILDER();
            if (MOBILE_ATTR !=null && !new_device && requireActualizeAttr(MOBILE_ATTR, ctx)) {
                String uri = "https://"+DOMAIN+"/blitz/pipes/attr/act?attr="
                    +MOBILE_ATTR+"&canSkip=true&appId=_blitz_profile&verified=true";
                Set<String> clms = new HashSet<String>(){{
                    add("instanceId");
                    add(MOBILE_ATTR);
                }};
                Set<String> scps = new HashSet<String>(){{
                    add("openid");
                }};
                logger.debug("User has no {} or a non-actualzed {}, so opening pipe",
                    MOBILE_ATTR, MOBILE_ATTR);
                en_builder = en_builder.withPipe(uri, "_blitz_profile", scps, clms);
            } else if (EMAIL_ATTR !=null && !new_device && requireActualizeAttr(EMAIL_ATTR, ctx)) {
                String uri = "https://"+DOMAIN+"/blitz/pipes/attr/act?attr="
                    +EMAIL_ATTR+"&canSkip=true&appId=_blitz_profile&verified=true";
                Set<String> clms = new HashSet<String>(){{
                    add("instanceId");
                    add(EMAIL_ATTR);
                }};
                Set<String> scps = new HashSet<String>(){{
                    add("openid");
                }};
                logger.debug("User has no {} or a non-actualzed {}, so opening pipe",
                    EMAIL_ATTR, EMAIL_ATTR);
                en_builder = en_builder.withPipe(uri, "_blitz_profile", scps, clms);
            } else if (COMMON_ATTR !=null && !new_device &&
                       requireActualizeAttr(COMMON_ATTR, ctx)) {
                String uri = "https://"+DOMAIN+"/blitz/pipes/attr/act?attr="
                    +COMMON_ATTR+"&canSkip=true&appId=_blitz_profile";
                Set<String> clms = new HashSet<String>(){{
                    add("instanceId");
                    add(COMMON_ATTR);
                }};
                Set<String> scps = new HashSet<String>(){{
                    add("openid");
                }};
                logger.debug("User has no {}, so opening pipe", COMMON_ATTR);
                en_builder = en_builder.withPipe(uri, "_blitz_profile", scps, clms);
            }
            return en_builder.build();
        } else {
            return StrategyState.MORE(new String[]{});
        }
    }

    private Boolean requireActualizeAttr(final String attrName, final Context ctx) {
        if (attrName.equals(MOBILE_ATTR) && (ctx.passedTrack().startsWith("1:sms") ||
            ctx.passedTrack().endsWith("sms"))) {
            logger.debug("User subjectId = {}, pid = {} used SMS, so no actualization needed",
                ctx.claims("subjectId"), ctx.id());
            return false;
        }
        if (attrName.equals(EMAIL_ATTR) && ctx.passedTrack().endsWith("email")) {
            logger.debug(
                "User subjectId = {}, pid = {} used EMAIL while auth, so no actualization needed",
                ctx.claims("subjectId"), ctx.id());
            return false;
        }
        Long skpTime = null;
        Long actTime = null;
        long now = Instant.now().getEpochSecond();
        if (ctx.user().userProps().numProp("pipes.act."+attrName+".skippedOn") != null) {
            skpTime = ctx.user().userProps().numProp("pipes.act."+attrName+".skippedOn");
        }
        if (skpTime != null && ((now - skpTime) < SKIP_TIME_IN_SEC)) {
            logger.debug(
                "User subjectId = {}, pid = {} has skipped update '{}' only '{}' seconds ago, no actualization needed", ctx.claims("subjectId"), ctx.id(), attrName, (now - skpTime));
            return false;
        }
        if (ctx.claims(attrName) == null) return true;
        else {
            if (ctx.user().attrsCfmTimes() != null) {
                actTime = ctx.user().attrsCfmTimes().get(attrName);
            }
            if (actTime == null) return true;
            else {
                logger.debug(
                    "User subjectId = {}, pid = {} has updated '{}' '{}' seconds ago, actualization needed = {}", ctx.claims("subjectId"), ctx.id(), attrName, (now - actTime), ((now - actTime) > ACT_TIME_IN_SEC));
                return ((now - actTime) > ACT_TIME_IN_SEC);
            }
        }
    }
}

Регистрация ключа безопасности (WebAuthn, Passkey, FIDO2) при входе#

Процедура PipeWebAuthn позволяет запросить у пользователя регистрацию ключа безопасности (WebAuthn, Passkey, FIDO2) при входе.

Перед использованием в процедуру нужно внести следующие изменения:

  • в константе DOMAIN указать URI, по которому из браузера пользователя доступен Blitz Identity Provider;

  • в константе SKIP_TIME_IN_SEC указать время, не чаще которого пользователю будут предлагать заполнить атрибут;

  • в константе ASK_AT_1ST_LOGIN изменить значение, если запрос выпуска ключа безопасности нужно выполнять при первом же входе (обычно первый вход происходит сразу после регистрации учетной записи, потому сделана настройка, чтобы пользователю при первом входе не предлагали сразу заполнить данные);

  • в теле процедуры вместо _blitz_profile указать идентификатор другого приложения, если изменение атрибутов должно делаться от приложения, отличного от Личного кабинета.

public class PipeWebAuthn implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");
    private final static String DOMAIN = "example.com";
    private final static Integer SKIP_TIME_IN_SEC = 30*86400;
    private final static Boolean ASK_AT_1ST_LOGIN = true;

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override
    public StrategyState next(Context ctx) {
        Boolean new_device = false;
        if (ctx.ua().getNewlyCreated() && ctx.justCompletedFactor() == 1 && !ASK_AT_1ST_LOGIN){
            logger.debug("User with sub={} is signing in, pid={}, on a new device",
                ctx.claims("subjectId"), ctx.id());
            new_device = true;
        }
        if (ctx.user() == null || ctx.user().requiredFactor() == null ||
            ctx.user().requiredFactor().equals(ctx.justCompletedFactor()))
            if (!new_device && requiredWebAuthn(ctx))
                return webAuthn(ctx);
            else
                return StrategyState.ENOUGH();
        else
            return StrategyState.MORE(new String[] {});
    }

    private boolean requiredWebAuthn(final Context ctx) {
        LBrowser br = ctx.ua().asBrowser();
        String deviceType = br.getDeviceType();
        String os = br.getOsName();
        List<WakMeta> keyList = null;
        logger.trace("User subjectId = {}, pid = {} is logging using device '{}' and OS '{}', checking configured webAuthn keys", ctx.claims("subjectId"), ctx.id(), deviceType, os);
        ListResult<WakMeta> keys = ctx.dataSources().webAuthn().keysOfCurrentSubject();
        if (keys != null) {
            keyList = keys.filter(k -> deviceType.equals(k.addedOnUA().deviceType()))
                .filter(k -> os.equals(k.addedOnUA().osName())).list();
        }
        if (keys != null && keyList.size() > 0) {
            logger.debug("User subjectId = {}, pid = {} has '{}' webAuthn keys for device '{}' and OS '{}'", ctx.claims("subjectId"), ctx.id(), keyList.size(), deviceType, os);
            return false;
        } else {
            logger.debug("User subjectId = {}, pid = {} has no configured webAuthn keys for device '{}' and OS '{}'", ctx.claims("subjectId"), ctx.id(), deviceType, os);
        }
        Long disagreedOn = ctx.user().userProps().numProp("pipes.addKey." + deviceType + "." + os + ".disagreedOn");
        if (disagreedOn == null) {
            return true;
        } else if (Instant.now().getEpochSecond() - disagreedOn > SKIP_TIME_IN_SEC) {
            logger.debug("User subjectId = {}, pid = {} has skipped Webauthn '{}' seconds ago, so open webAuthn pipe", ctx.claims("subjectId"), ctx.id(), (Instant.now().getEpochSecond() - disagreedOn));
            return true;
        } else {
            logger.debug("User subjectId = {}, pid = {} has skipped Webauthn '{}' seconds ago, no need to open webAuthn pipe", ctx.claims("subjectId"), ctx.id(), (Instant.now().getEpochSecond() - disagreedOn));
            return false;
        }
    }

    private StrategyState webAuthn(final Context ctx) {
        String uri = "https://"+DOMAIN+"/blitz/pipes/conf/webAuthn/start?&canSkip=true&appId=_blitz_profile";
        Set<String> claims = new HashSet<String>(){{
            add("instanceId");
        }};
        Set<String> scopes = new HashSet<String>(){{
            add("openid");
        }};
        Map<String, Object> urParams = new HashMap<String, Object>();
        return StrategyState.ENOUGH_BUILDER()
            .withPipe(uri, "_blitz_profile", scopes, claims).build();
    }
}

Отображение пользователю списка выбора значений при входе#

Процедура ChoicePipe позволяет показывать пользователю при входе страницы выбора списка значений. Перед использованием в процедуру нужно внести следующие изменения:

  • в константе DOMAIN вместо <BLITZ-HOST> указать URI, по которому из браузера пользователя доступен Blitz Identity Provider, а в константе CLIENT_ID вместо <CLIENT_ID> указать идентификатор приложения (с правами на scope openid), от имени которого будет выполняться вспомогательное приложение;

  • настроить в конфигурационном файле тип уведомления – см. Вспомогательные приложения (pipes);

  • настроить в сообщениях текст уведомления и названия кнопок – см. Сообщения вспомогательных приложений (pipes).

public class ChoicePipe implements Strategy {

    private final Logger logger = LoggerFactory.getLogger("com.identityblitz.idp.flow.dynamic");

    private final static String DOMAIN = "<BLITZ-HOST>";
    private final static String CLIENT_ID = "<CLIENT_ID>";

    @Override public StrategyBeginState begin(final Context ctx) {
        if ("login".equals(ctx.prompt())){
            List<String> methods = new ArrayList<String>(Arrays.asList(ctx.availableMethods()));
            methods.remove("cls");
            return StrategyState.MORE(methods.toArray(new String[0]), true);
        } else {
            if(ctx.claims("subjectId") != null)
                return StrategyState.ENOUGH();
            else
                return StrategyState.MORE(new String[]{});
        }
    }

    @Override
    public StrategyState next(Context ctx) {
        List<List<String>> choice = new ArrayList<List<String>>(){};
        choice.add(Arrays.asList("Value 1"));
        choice.add(Arrays.asList("Value 2"));
        try {
            if (ctx.user() == null || ctx.user().requiredFactor() == null
                    || ctx.user().requiredFactor().equals(ctx.justCompletedFactor())) {
                String res = new ObjectMapper().writeValueAsString(choice);
                String choiceJson = Base64.getUrlEncoder().encodeToString(res.getBytes("UTF-8"));
                return choice(ctx, choiceJson);
            }
            else
                return StrategyState.MORE(new String[] {});
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    private StrategyState choice(final Context ctx, final String choiceJson) {
        String uri = "https://" + DOMAIN + "/blitz/pipes/choice/start?appId=" + CLIENT_ID + "&pipeId=select_value&choices=" + choiceJson;
        Set<String> claims = new HashSet<String>(){{
            add("instanceId");
        }};
        Set<String> scopes = new HashSet<String>(){{
            add("openid");
        }};
       return StrategyState.ENOUGH_BUILDER()
         .withPipe(uri, CLIENT_ID, scopes, claims)
         .build();
    }
}