Авторизация для ленивых. Наши грабли

1mbz0bfdafyi4bbkd0eglywfe0q.png


Всем привет!


Недавно мы решали задачу авторизации пользователей мобильного приложения на нашем бекенде. Ну и что, спросите вы, задача-то уже тысячу раз решённая. В этой статье я не буду рассказывать историю успеха. Лучше расскажу про те грабли, которые мы собрали.


Немного про проект


Мы в 2ГИС делаем крутой и точный справочник компаний.
Для обеспечения качества и актуальности данных в 2ГИС есть несколько внутренних систем. Одна из них называется YouLa — нет, не та, где публикуют объявления. Наша YouLa поддерживает процесс выверки данных на местности.


Часть системы — мобильное приложение, с которым ходят пешеходники. Пешеходники — специалисты, которые обходят весь город. Карта разбита на разные участки для проверки.


Посмотрите, как выглядит территориальное деление Московской области. Разные цвета на карте обозначают разные назначения территорий.


cxtk-jup0o_0j6-7ak6ybbhvgyw.png


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


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


На нашем новом бекенде мы хотим знать, что за пользователь к нам пришёл.


К авторизации у нас несколько требований:
 — надежность и безопасность,
 — аутентификация по разным источникам,
 — аутентификация нескольких типов клиентов Web, Mobile, API.


Выбор способа аутентификации


Для реализации аутентификации существует много разных подходов, у каждого есть свои плюсы и минусы. Учитывая, что у нас большое количество точек интеграции,
мы решили не изобретать велосипед и взять провайдера аутентификации и авторизации на базе OpenId Connect. Для авторизации на бекенде используем JWT.


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


Чем хорош JWT и стандарт OpenId Connect в Enterprise?


Сейчас даже в рамках одной компании системы разрабатываются на разных стеках технологий и зачастую их потом тяжело подружить. В рамках одного стека технологий тоже можно поймать много странных и неожиданных эффектов, что уж говорить про ситуацию, когда у вас несколько систем. Для JWT и OpenId Connect список поддерживаемых клиентов и платформ впечатляет.


Схема работы всех компонентов выглядит вот так:
x6noipyobpbq09mw0428f5hmslm.png


В рамках протокола поддерживается динамическое подключение поставщиков аутентификации. Мы рассматривали два источника — Google+ и ADFS. Но в дальнейшем нам бы хотелось просто и быстро расширять аудиторию продукта, например, за счёт подключения к системе других компаний, которые могли бы решать в нашей системе свои задачи.


С помощью JWT можно легко организовать аутентификацию разношёрстных клиентов. Более того, многие облачные клиенты предлагают сразу целый набор библиотек, облегчающих интеграцию провайдера в ваше приложение.


Облачные решения

Первой платформой, которую мы решили попробовать, был Auth0. Платформа очень крутая и для разработчика, и для администратора. В ней есть подробная документация, красивый и понятный Web UI для настройки всех параметров. В наше Java/Kotlin-приложение и на бекенд аутентификация была прикручена за пару часов.


Основные плюсы, которые мы отметили при работе с платформой Auth0:
 — подробная документация и бесконечное количество примеров кода на распространённых языках программирования;
 — возможность использовать для аутентификации не веб, а нативную форму входа.


Для того, чтобы реализовать поддержку JWT аутентификации в бекенде, достаточно написать всего несколько строчек (этот код для разных платформ будет отличаться только параметрами Authority и Audience), в некоторых случаях потребуется ещё указать сертификаты для проверки подписи токенов:


Код бекенда на .NET Core
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();

            // 1. Add Authentication Services
            services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;

            }).AddJwtBearer(options =>
            {
                options.Authority = "https://devday2gis.auth0.com/";
                options.Audience = "https://devday.api";
            });
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            // 2. Add Authentication
            app.UseAuthentication();

            app.UseMvc();
        }


Для того, чтобы прикрутить аутентификацию к мобилке — ещё несколько строчек:


Код логина на мобилке
 private void login() {
        token.setText("Not logged in");
        Auth0 auth0 = new Auth0(this);
        auth0.setOIDCConformant(true);
        WebAuthProvider.init(auth0)
                .withScheme("demo")
                .withAudience(String.format("https://%s/userinfo", getString(R.string.com_auth0_domain)))
                .withScope("openid email profile")
                .start(MainActivity.this, new AuthCallback() {

                    @Override
                    public void onSuccess(@NonNull final Credentials credentials) {
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                idToken = credentials.getIdToken();
                                accessToken = credentials.getAccessToken();
                                Log.d("id token", credentials.getIdToken());
                                Log.d("access token", credentials.getAccessToken());
                                token.setText("Logged in: " + credentials.getIdToken());
                            }
                        });
                    }

                    @Override
                    public void onFailure(@NonNull final Dialog dialog) {
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                dialog.show();
                            }
                        });
                    }

                    @Override
                    public void onFailure(final AuthenticationException exception) {
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                Toast.makeText(MainActivity.this, "Error: " + exception.getMessage(), Toast.LENGTH_SHORT).show();
                            }
                        });
                    }

                });
    }


Как видно из примера, после аутентификации к нам приходит два токена + ещё один (RefreshToken) не показан в коде.


Для чего они нужны?

IdToken — содержит учетные данные пользователя
AccessToken — для авторизации на API
RefreshToken — для обновления AccessToken


Вопрос на засыпку: зачем необходимы два токена Access и Refresh?


Ответ

Рассмотрим два случая кражи ключей:


  1. Негодяй украл только AccessToken. Тогда он будет валиден только до того момента, пока вы не воспользуетесь своим RefreshToken.
  2. Негодяй украл оба токена. Тогда, как только он воспользуется RefreshToken, ваши токены перестанут действовать и вас разлогинит из приложения. Если вы воспользуетесь своими учётными данными, то токены атакующего перестанут действовать.
    Использование двух токенов ограничивает время, на которое атакующий будет иметь доступ к вашим API.


Сам JWT-IdToken токен выглядит так:
utfb-re2jfkrtphr2g2z7gurodq.png


Из этого токена мобильное приложение получает информацию об аутентифицированном пользователе. Соответственно, IdToken мы используем для отрисовки ФИО пользователя и его аватарки.


AccessToken мы прикрепляем к header запросов:


Вызов API из Android приложения
private void makeApiCall()
    {
        DevDayApi api = CreateApi("http://rnd-123.2gis.local/", accessToken);

        api.getUserProfile().enqueue(new Callback() {
            @Override
            public void onResponse(Call call, Response response) {
                runOnUiThread(() -> {

                    if(response.body() != null)
                        apiAnswer.setText(response.body().Answer);
                });
            }

            @Override
            public void onFailure(Call call, Throwable t) {
                apiAnswer.setText(t.getMessage());
            }
        });
    }

    private DevDayApi CreateApi(String baseUrl, String authToken)
    {
        HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
        logging.setLevel(HttpLoggingInterceptor.Level.HEADERS);

        OkHttpClient client = new OkHttpClient.Builder()
                .addInterceptor(chain -> {
                    Request newRequest = chain.request().newBuilder()
                            .addHeader("Authorization", "Bearer " + authToken)//tokenProvider.getAuthToken())
                            .build();

                    return chain.proceed(newRequest);
                })
                .addInterceptor(logging)
                .build();

        Retrofit retrofit = new Retrofit.Builder()
                .client(client)
                .baseUrl(baseUrl)
                .addConverterFactory(GsonConverterFactory.create())
                .build();

        return retrofit.create(DevDayApi.class);
    }


Для аутентификации web-клиента также достаточно просто выполнить интерактивный вход через IdenitityProvider.
Ниже пример из официальной документации, как это прикрутить к Angular4-приложению.


Аутентификация на бекенде в веб-приложении
import { Injectable } from '@angular/core';
import { AUTH_CONFIG } from './auth0-variables';
import { Router } from '@angular/router';
import * as auth0 from 'auth0-js';

@Injectable()
export class AuthService {

  auth0 = new auth0.WebAuth({
    clientID: AUTH_CONFIG.clientID,
    domain: AUTH_CONFIG.domain,
    responseType: 'token id_token',
    audience: `https://${AUTH_CONFIG.domain}/userinfo`,
    redirectUri: AUTH_CONFIG.callbackURL,
    scope: 'openid'
  });

  constructor(public router: Router) {}

  public login(): void {
    this.auth0.authorize();
  }

  public handleAuthentication(): void {
    this.auth0.parseHash((err, authResult) => {
      if (authResult && authResult.accessToken && authResult.idToken) {
        this.setSession(authResult);
        this.router.navigate(['/home']);
      } else if (err) {
        this.router.navigate(['/home']);
        console.log(err);
        alert(`Error: ${err.error}. Check the console for further details.`);
      }
    });
  }
}


Как видно из примеров, никто не ушёл обиженным — реализация для клиентов получается простой и понятной.


Для полного счастья нам не хватало аутентификации пользователей через нашу локальную Active Directory.


Для настройки синхронизации между Auth0 и локальной Active Directory, Auth0 предоставляет powershell-скрипт.


z_z49ck9epv2w3x-bus9kj5nife.png


Когда мы уже обрадовались, что всё отлично работает, и пошли к админам с просьбой настроить синхронизацию между нашим AD и Auth0, то получили отказ. Ребята сказали, что максимум, куда они готовы лить наши данные, — это Azure. Также на решение повлияло то, что у нас уже использовалась подписка Office 365 и часть учёток уже была залита в Azure.


Окей, сказали мы.


Azure Active Directory B2C


У Microsoft есть сервис, который называется Azure Active Directory B2C.
С помощью админов удалось настроить синхронизацию нашей AD с инстансом Azure AD и настроить вход через наш Active Directory Federation Services (ADFS).


Настройка политик входа в Azure B2C


На момент написания статьи сервис находится в превью версии, поэтому через UI можно настроить только самые примитивные сценарии, вроде входа через Google+ или Facebook. Вход через Active Directory производится через загрузку xml-файлов через Identity Experience Framework. На отладку сценариев входа ушло около восьми часов + ещё день на рефакторинг входа мобилки и прикручивание провайдера аутентификации от Microsoft.
На бекенде потребовалось только указать новый IdentityProvider и Audience.


Для того, чтобы настроить вход, потребуется скачать репозиторий и пройти процедуру настройки, описанную в статье. Всего несколько часов вы программируете на xml — и вуаля! Ваш клиент аутентифицируется через серверы Azure.


Узреть и ужаснуться



  
    
      

      

      

      
        Username
        string
        
        TextBox
        
          
        
      

      
        User's Object's Tenant ID
        string
        
          
          
          
        
        Tenant identifier (ID) of the user object in Azure AD.
      

      
        User's Object ID
        string
        
          
          
          
        
        Object identifier (ID) of the user object in Azure AD.
      

      
      
        Sign in name
        string
        
        TextBox
      

      
        Email Address
        string
        Email address to use for signing in.
        TextBox
      

      
        Password
        string
        Enter password
        Password
      

      
      
        New Password
        string
        Enter new password
        Password
        
          
        
      
      

      
        Confirm New Password
        string
        Confirm new password
        Password
        
          
        
      

      
        Password Policies
        string
        Password policies used by Azure AD to determine password strength, expiry etc.
      

      
        client_id
        string
        Special parameter passed to EvoSTS.
        Special parameter passed to EvoSTS.
      

      
        resource_id
        string
        Special parameter passed to EvoSTS.
        Special parameter passed to EvoSTS.
      

      
        Subject
        string
        
          
        
        
      

      
        AlternativeSecurityId
        string
        
      

      
        MailNickName
        string
        Your mail nick name as stored in the Azure Active Directory.
      

      
        Identity Provider
        string
        
          
          
          
        
        
      

      
        Display Name
        string
        
          
          
          
        
        Your display name.
        TextBox
      

      
        Phone Number
        string
        XXX-XXX-
        Your telephone number
      

      
        Verified Phone Number
        string
        
          
        
        XXX-XXX-
        Your office phone number that has been verified
      

      
        New Phone Number Entered
        boolean
      

      
        UserId for MFA
        string
      

      
        Email Address
        string
        
          
        
        Email address that can be used to contact you.
        TextBox
        
          
        
      

      
        Alternate Email Addresses
        stringCollection
        Email addresses that can be used to contact the user.
      

      
        UserPrincipalName
        string
        
          
          
          
        
        Your user name as stored in the Azure Active Directory.
      

      
        UPN User Name
        string
        The user name for creating user principal name.
      

      
        User is new
        boolean
        
      

      
        Executed-SelfAsserted-Input
        string
        A claim that specifies whether attributes were collected from the user.
      

      
        AuthenticationSource
        string
        Specifies whether the user was authenticated at Social IDP or local account.
      

      

      
        nca
        string
        Special parameter passed for local account authentication to login.microsoftonline.com.
      

      
        grant_type
        string
        Special parameter passed for local account authentication to login.microsoftonline.com.
      

      
        scope
        string
        Special parameter passed for local account authentication to login.microsoftonline.com.
      

      
        objectIdFromSession
        boolean
        Parameter provided by the default session management provider to indicate that the object id has been retrieved from an SSO session.
      

      
        isActiveMFASession
        boolean
        Parameter provided by the MFA session management to indicate that the user has an active MFA session.
      

      

      
        Given Name
        string
        
          
          
          
        
        Your given name (also known as first name).
        TextBox
      

      
        Surname
        string
        
          
          
          
        
        Your surname (also known as family name or last name).
        TextBox
      

    

    
      
        
          
          
        
        
          
        
      

      
        
          
        
        
          
        
      

      
        
          
        
        
          
        
        
          
        
      

  
        
          
          
        
        
          
        
      

      
        
          
        
        
          
        
        
          
        
      

    
        
          
        
        
          
        
    

    

    
      
        LineMarkers, MetaRefresh
      
    

    

      
      
        ~/tenant/default/exception.cshtml
        ~/common/default_page_error.html
        urn:com:microsoft:aad:b2c:elements:globalexception:1.1.0
        
          Error page
        
      

      
        ~/tenant/default/idpSelector.cshtml
        ~/common/default_page_error.html
        urn:com:microsoft:aad:b2c:elements:idpselection:1.0.0
        
          Idp selection page
          Sign in
        
      

      
        ~/tenant/default/idpSelector.cshtml
        ~/common/default_page_error.html
        urn:com:microsoft:aad:b2c:elements:idpselection:1.0.0
        
          Idp selection page
          Sign up
        
      

      
        ~/tenant/default/unified.cshtml
        ~/common/default_page_error.html
        urn:com:microsoft:aad:b2c:elements:unifiedssp:1.0.0
        
          Signin and Signup
        
      

      
        ~/tenant/default/multifactor-1.0.0.cshtml
        ~/common/default_page_error.html
        urn:com:microsoft:aad:b2c:elements:multifactor:1.1.0
        
          Multi-factor authentication page
        
      

      
        ~/tenant/default/selfAsserted.cshtml
        ~/common/default_page_error.html
        urn:com:microsoft:aad:b2c:elements:selfasserted:1.1.0
        
          Collect information from user page
        
      

      
        ~/tenant/default/updateProfile.cshtml
        ~/common/default_page_error.html
        urn:com:microsoft:aad:b2c:elements:selfasserted:1.1.0
        
          Collect information from user page
        
      

      
        ~/tenant/default/selfAsserted.cshtml
        ~/common/default_page_error.html
        urn:com:microsoft:aad:b2c:elements:selfasserted:1.1.0
        
          Local account sign up page
        
      

      
        ~/tenant/default/selfAsserted.cshtml
        ~/common/default_page_error.html
        urn:com:microsoft:aad:b2c:elements:selfasserted:1.1.0
        
          Local account change password page
        
      

    
  

  
  

        
      
      facebook.com
      Facebook
      
        
          
          Facebook
          
          
            facebook
            https://www.facebook.com/dialog/oauth
            https://graph.facebook.com/oauth/access_token
            GET
            0

            
            json
          
          
            
          
          
          
            
            
            
            
            
            
            
          
          
            
            
            
          
          
        
      
    

    
      Local Account SignIn
      
        
          Local Account SignIn
          
          
            We can't seem to find your account
            Your password is incorrect
            Looks like you used an old password

            https://sts.windows.net/
            https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration
            https://login.microsoftonline.com/{tenant}/oauth2/token
            id_token
            query
            email openid

            
            false
            POST
          
          
            
            
            
            
            
          
          
            
            
            
            
            
            
            
          
        
      
    

    
      PhoneFactor
      
        
          PhoneFactor
          
          
            api.phonefactor
            true
          
          
            
          
          
            
          
          
            
            
          
          
            
            
          
          
        

      
    

    
      Azure Active Directory
      

        
          Azure Active Directory
          

          
            
          

          
          false
          
        

        

        
          
            Write
            true
            You are already registered, please press the back button and sign in instead.
          
          false
          
            
          
          
            
          
          
            
            
            
            
            

            
            
            
            
          
          
            
            
            
            
          
          
          
        

        
          
            Read
            true
            User does not exist. Please sign up before you can sign in.
          
          
            
          
          
            

            

            
            
            
            
            
            
          
          
        

        
          
            false
          
          
        

        

        
          
            Write
            true
          
          false
          
            
          
          
            
            
            
            
            

            

            
            
            
          
          
            
            
            
            
            
          
          
          
        

        
          
            Read
            true
            An account could not be found for the provided user ID.
          
          false
          
            
          
          
            
            
            

            

            
            
            
            
            
          
          
        

        
          
            Write
            true
          
          false
          
            
          
          
            
            

            
            

          
          
        

        

        
          
            Write
            false
            true
          
          false
          
            
          
          
            
            

            
            

            
            
            
          
          
        

        
        
          
            Read
            true
          
          false
          
            
          
          

            
            

            
            
            
            
            
            
          
          
        

        
          
            Write
            false
            true
          
          false
          
            
          
          
            
            
          
          
        

      
    

    
      Self Asserted
      

        
          User ID signup
          
          
            api.selfasserted
          
          
            
          
          
            
            
            
            
          
          
            
            
            
            

            
            
            
            
          
          
            
          
          © Habrahabr.ru