Авторизация для ленивых. Наши грабли
Всем привет!
Недавно мы решали задачу авторизации пользователей мобильного приложения на нашем бекенде. Ну и что, спросите вы, задача-то уже тысячу раз решённая. В этой статье я не буду рассказывать историю успеха. Лучше расскажу про те грабли, которые мы собрали.
Немного про проект
Мы в 2ГИС делаем крутой и точный справочник компаний.
Для обеспечения качества и актуальности данных в 2ГИС есть несколько внутренних систем. Одна из них называется YouLa — нет, не та, где публикуют объявления. Наша YouLa поддерживает процесс выверки данных на местности.
Часть системы — мобильное приложение, с которым ходят пешеходники. Пешеходники — специалисты, которые обходят весь город. Карта разбита на разные участки для проверки.
Посмотрите, как выглядит территориальное деление Московской области. Разные цвета на карте обозначают разные назначения территорий.
Пешеходники приносят нам данные об организациях, в которые невозможно позвонить или у которых нет сайта. Например, шашлычные, киоски с овощами. Кроме того, бывает так, что у организации изменился телефон и мы не можем туда дозвониться. Во всех упомянутых случаях в организацию приходит наш специалист и сверяет информацию на местности.
Буквально несколько дней назад мы зарелизили новое мобильное приложение, для которого написали бекенд для синхронизации данных.
На нашем новом бекенде мы хотим знать, что за пользователь к нам пришёл.
К авторизации у нас несколько требований:
— надежность и безопасность,
— аутентификация по разным источникам,
— аутентификация нескольких типов клиентов Web, Mobile, API.
Выбор способа аутентификации
Для реализации аутентификации существует много разных подходов, у каждого есть свои плюсы и минусы. Учитывая, что у нас большое количество точек интеграции,
мы решили не изобретать велосипед и взять провайдера аутентификации и авторизации на базе OpenId Connect. Для авторизации на бекенде используем JWT.
Подробнее можно прочитать в статье «Аутентификация и авторизация в микросервисных приложениях».
Чем хорош JWT и стандарт OpenId Connect в Enterprise?
Сейчас даже в рамках одной компании системы разрабатываются на разных стеках технологий и зачастую их потом тяжело подружить. В рамках одного стека технологий тоже можно поймать много странных и неожиданных эффектов, что уж говорить про ситуацию, когда у вас несколько систем. Для JWT и OpenId Connect список поддерживаемых клиентов и платформ впечатляет.
Схема работы всех компонентов выглядит вот так:
В рамках протокола поддерживается динамическое подключение поставщиков аутентификации. Мы рассматривали два источника — Google+ и ADFS. Но в дальнейшем нам бы хотелось просто и быстро расширять аудиторию продукта, например, за счёт подключения к системе других компаний, которые могли бы решать в нашей системе свои задачи.
С помощью JWT можно легко организовать аутентификацию разношёрстных клиентов. Более того, многие облачные клиенты предлагают сразу целый набор библиотек, облегчающих интеграцию провайдера в ваше приложение.
Облачные решения
Первой платформой, которую мы решили попробовать, был Auth0. Платформа очень крутая и для разработчика, и для администратора. В ней есть подробная документация, красивый и понятный Web UI для настройки всех параметров. В наше Java/Kotlin-приложение и на бекенд аутентификация была прикручена за пару часов.
Основные плюсы, которые мы отметили при работе с платформой Auth0:
— подробная документация и бесконечное количество примеров кода на распространённых языках программирования;
— возможность использовать для аутентификации не веб, а нативную форму входа.
Для того, чтобы реализовать поддержку JWT аутентификации в бекенде, достаточно написать всего несколько строчек (этот код для разных платформ будет отличаться только параметрами Authority и Audience), в некоторых случаях потребуется ещё указать сертификаты для проверки подписи токенов:
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?
Рассмотрим два случая кражи ключей:
- Негодяй украл только AccessToken. Тогда он будет валиден только до того момента, пока вы не воспользуетесь своим RefreshToken.
- Негодяй украл оба токена. Тогда, как только он воспользуется RefreshToken, ваши токены перестанут действовать и вас разлогинит из приложения. Если вы воспользуетесь своими учётными данными, то токены атакующего перестанут действовать.
Использование двух токенов ограничивает время, на которое атакующий будет иметь доступ к вашим API.
Сам JWT-IdToken токен выглядит так:
Из этого токена мобильное приложение получает информацию об аутентифицированном пользователе. Соответственно, IdToken мы используем для отрисовки ФИО пользователя и его аватарки.
AccessToken мы прикрепляем к header запросов:
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-скрипт.
Когда мы уже обрадовались, что всё отлично работает, и пошли к админам с просьбой настроить синхронизацию между нашим 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