Пошаговое руководство по использованию OAuth 2.0 при доступе к защищенным API из iOS‑приложения на Swift с Auth0.
Как Swift‑разработчику, в какой‑то момент вам, скорее всего, понадобится добавить в свое приложение аутентификацию пользователей или, как ее еще называют, логин и логаут. Скорее всего, вы уже интегрировали эту функцию с Auth0. Но если нет или если вы все еще сомневаетесь, нужна ли вам аутентификация пользователей, я бы настоятельно рекомендовал вам начать с изучения статей «Get Started with iOS Authentication using SwiftUI, Part 1: Login and Logout» или «Get Started with iOS Authentication using Swift and UIKit» (что вам больше понравится), прежде чем двигаться дальше. В этой статье я покажу вам, как настроить Auth0 для получения токена доступа OAuth 2.0, который позволит вам безопасно, надежно и от имени пользователя вызывать защищенный API из вашего приложения. Сразу предупрежу вас, что это будет какой‑нибудь пользовательский защищенный API, который вы сами создаете или являетесь его владельцем, а не сторонний API, предоставляемый Facebook, Google, Microsoft и т. п. — о них мы поговорим в другой раз.
Что такое токен доступа OAuth 2.0?
OAuth 2.0 позволяет генерировать токен доступа (access token), который используется в качестве части вызова защищенной конечной точки (например, API) от имени пользователя и с его согласия. Это часто называют процессом делегированной авторизации и обычно подразумевают использование сервера авторизации, например Auth0. Токен доступа (распространенной вариацией которого является Bearer‑токен, обычно поставляемый в виде заголовка Authorization: Bearer
) выступает в качестве сертификата безопасности для вызова REST API, обеспечивая более безопасный и проверяемый механизм, чем базовая аутентификация или ключ API — особенно для нативных приложений или SPA (Single Page Application), где угрозы могут быть куда сложнее, чем у обычных веб‑приложений. Токен доступа OAuth 2.0 отличается от OIDC ID токена, созданного в рамках OpenID Connect, предназначенного для использования в приложениях, хотя они могут иметь ряд сходств, например, они оба в формате JWT в Auth0. Подробнее об этом можно прочитать здесь.
Настройка Auth0
В этой статье я покажу вам, как использовать Auth0 (выступающий в роли сервера авторизации OAuth 2.0) для генерации токена доступа, который позволит вашему Swift‑приложению безопасно вызывать ваш пользовательский API. Для этого я буду опираться на статью Get Started with iOS Authentication using SwiftUI, Part 1: Login and Logout, о которой я упоминал в самом начале. Я также продолжу использовать в этой статье символ ?, благодаря которому вы можете пролистать большую часть содержания, сосредоточившись на шагах сборки и выполнения.
Почему именно SwiftUI? На данный момент нас не очень волнует пользовательский интерфейс, поэтому используете ли вы SwiftUI или Swift с UIKit — это является делом вкуса. Для генерации токенов доступа в качестве обязательного условия требуется аутентификация, и использование IdP (он же Identity Provider) в Auth0 обычно является хорошим отправной точкой.
? Давайте начнем с использования дашборда для настройки Auth0. Я собираюсь зарегистрировать новый API, нажав кнопку Create API в правом верхнем углу страницы, показанной ниже:
Примечание: тенант (tenant — «арендатор», логическая единица изоляции) Auth0, которого я использую, — это тенант класса Production, но в вашем случае, вероятно, будет указано Development. В рамках темы этой статьи классификация тенантов не имеет значения.
Откроется следующее диалоговое окно, в котором я могу создать свое определение API:
Это диалоговое окно состоит из нескольких частей, поэтому давайте рассмотрим их по порядку:
Name
— это символическое имя для вашего API, которое вы можете изменить позже, если захотите.Identifier
(идентификатор) — это значение в формате URI, которое не может быть изменено в дальнейшем. Этот идентификатор представляет собой (пользовательское) значение audience, которое мы будем использовать в дальнейшем. Будучи URI, он имеет структуру, схожую с URL, но является символическим идентификатором API, а не локацией, где API находится.Signing Algorithm
(алгоритм подписи) — определяет алгоритм, используемый для подписи токена доступа. В большинстве случаев рекомендовано оставить дефолтный RS256. В Auth0 токен доступа, как и ID‑токен, обычно имеет формат JWT и также содержит компонент подписи. Хотя Auth0 поддерживает асимметричную подпись (RS256) с использованием пары публичного и приватного ключей и симметричную подпись (HS256 с использованием одного общего ключа), с точки зрения безопасности и удобства первая почти всегда предпочтительнее второй.
? Я не буду вдаваться в подробности реализации API — оставлю это вам, уважаемый читатель. Вы можете самостоятельно на досуге изучить примеры из Auth0 Developer Center Backend API или чего‑нибудь подобного. Здесь же я буду использовать типичные значения, которые можно встретить в реальных примерах. Вы можете смело заменить эти значения чем‑то более подходящим для вашего конкретного случая:
Name:
MyAPI
identifier:
https://myapi.com
Signing Algorithm:
RS256 (по умолчанию)
? Нажмите кнопку Create, и в итоге должна появиться страница конфигурации (пользовательского) API для приложения. Как правило, страница начинается с раздела Quickstart, где приведены примеры настройки самых распространенных типов API. Например, здесь показан пример настройки для Node.js с Express:
Настройки
Здесь я хочу остановиться на двух конкретных аспектах настройки API в Auth0, и начнем мы с деталей на вкладке Settings. Перейдите на вкладку Settings, и если вы прокрутите страницу немного вниз, то увидите страницу, приведенную ниже. Поля, которые вы прокрутили, чтобы добраться до показанного ниже экрана, будут показывать неизменяемую информацию о конфигурации API, такую как внутренний Id и (пользовательский) идентификатор audience, а также дает вам возможность изменить символическое имя API (поле Name):
Token Expiration (Seconds): пока оставим значение по умолчанию. Тем не менее, это значение, к которому вы, вероятно, захотите в какой‑то момент вернуться, поскольку оно контролирует истечение срока действия токена доступа, выданного Auth0 для данного (пользовательского) идентификатора audience. Это важно, особенно для операций, требующих повышенной безопасности, таких как финансовые транзакции, где вы, скорее всего, захотите установить это значение как можно более низким, что будет означать, что срока действия токена доступа будет хватать только на совершение одной операции и, таким образом, уменьшит угрозу в случае случайной утечки токена.
Allow Skipping User Consent позволит приложению, определенному в Auth0 (по умолчанию это first‑party приложение, подробнее см. здесь), пропустить интерактивное согласие пользователя при запросе токена доступа. Спецификация OAuth 2.0 позволяет пользователю явно выразить согласие на то, что приложение может делать от его имени. Подробнее об этом я расскажу ниже в разделе «Разрешения». Для first‑party приложений (то есть приложений, для которых вы также являетесь владельцем и которые создаете в дополнение к вашему API) согласие часто подразумевается по умолчанию, поэтому с помощью этой настройки Auth0 дает вам возможность обойтись без лишнего взаимодействия с пользователем.
Все остальные настройки мы можем пока пропустить.
Разрешения
Еще один аспект, который я хочу обсудить, когда речь заходит о конфигурации API в Auth0, — это разрешения (Permissions), доступ к которым осуществляется через вкладку Permissions, как показано на скриншоте ниже. Термин Permission может быть немного запутанным, так как он имеет несколько значений в контексте Auth0 — особенно когда для API включен Auth0 RBAC (т. е. контроль доступа на основе ролей). Я не буду вдаваться в подробности здесь, а только оговорюсь, что мы определим по крайней мере один scope — значение по умолчанию для Permission в Auth0, когда для API не включен RBAC. Если вы хотите узнать об этом больше, эта статья в блоге Auth0 может быть хорошей отправной точкой.
scope
является частью делегированной авторизации OAuth 2.0 и определяет, что приложение может делать от имени пользователя и с его согласия. Хотя определение scope вполне возможно опустить (спецификация OAuth 2.0 не делает его обязательным) — это может привести к проблемным ситуациям, например:
Использование токена доступа без scope, по сути, нарушает принцип наименьших привилегий, поскольку такой токен будет действителен для любого маршрута API.
Не существует механизма, позволяющего пользователю отменить согласие по одному или нескольким конкретным scope»ам. Это может сделать проблематичным соблюдение GDPR или других нормативных требований.
Если API открыт для сторонних приложений, становится невозможным обеспечить соответствие спецификации OAuth 2.0 без возвращения и рефакторинга как API, так и (пользовательского) определения API в Auth0.
? Итак, нам нужно определить хотя бы одно разрешение. В поле Permission введите что‑то вроде read:events, а в поле Description введите подходящее описание, например, Read access to defined Events. Нажмите + Add, и страница должна обновиться в соответствии с изображением ниже:
Хорошо. Это все, что нам нужно сделать в Auth0 на данный момент, поэтому давайте рассмотрим, как запрашивать токен доступа для использования при вызове вашего API.
Запрос токена доступа
В этом разделе мы рассмотрим, как запросить токен доступа у Auth0. Как уже говорилось ранее, я буду опираться на статью Get Started with iOS Authentication using SwiftUI, Part 1: Login and Logout, о которой я упоминал в самом начале. Я также продолжу использовать в этой статье символ ?, благодаря которому вы можете пролистать большую часть содержания, сосредоточившись на шагах сборки и выполнения.
Обновление проекта
? Из кода из статьи Get Started with iOS Authentication using SwiftUI, Part 1: Login and Logout (либо кода, который вы сами написали в рамках этого руководства, либо найденного в каталоге iOS SwiftUI Login (completed)
) откройте проект в Xcode (если вы еще не сделали это) и обновите функцию login()
так, как показано ниже. Остальной код должен остаться без изменений. Вы также можете загрузить весь код, который мы будем здесь использовать, из этого GitHub‑репозитория:
// [ ? ContentView.swift ]
func login() {
Auth0
.webAuth()
.audience("https://myapi.com") // 1
.scope("openid profile email read:events") // 2
.start { result in
switch result {
case .failure(let error):
print("Failed with: \(error)")
Ниже мы рассмотрим строки из кода выше, отмеченные пронумерованными комментариями:
Добавляем параметр
audience
, указывающий идентификатор из (пользовательского) API, который мы создали в разделе Настройки выше.Добавляем scope, чтобы включить
read:events
, определенную в разделе Разрешения выше. Поскольку мы будем переопределять значение по умолчанию, нам также нужно будет явно указать scope OIDC, обычно включаемые по умолчанию, а именноopenid
,profile
иemail
. Подробнее о них вы можете прочитать здесь.Добавляем строку
print
для отображения возвращаемого токена доступа в XCode. Это поможет при отладке, особенно для разбора возвращаемого токена доступа.
Запуск приложения
? Теперь запустите приложение (либо в симуляторе, который поставляется с XCode, либо на реальном устройстве) и посмотрите, что произойдет, когда вы нажмете кнопку Log in. Ничего особо не изменилось, верно?
Помните опцию Allow Skipping User Consent в разделе Настройки (выше), которая включена по умолчанию? Так вот, она запрещает Auth0 отображать диалог согласия, поскольку используемое определение в Auth0 предназначено для first‑party приложения (по умолчанию, когда Application определяется через Auth0 Dashboard). Если бы эта опция была отключена, или определение было бы для стороннего приложения, то при первом запуске вы бы увидели экран, похожий на тот, что показан ниже:
Возвращаемая информация
Хотя вы не увидите ничего на экране, в информации, полученной от Auth0, все‑таки есть некоторые изменения. Во‑первых, в XCode вы увидите, что оператор print в приведенном выше коде показывает в отладочной консоли строку accessToken: «<REDACTED>»
, значение expiresIn
, а также scope: Optional(«openid profile email read:events»)
. Мы самостоятельно выводим значение accessToken, о чем я расскажу через пару секунд. scope теперь повторяет значения из запрашиваемых scope, а expiresIn теперь рассчитывается на основе Token Expiration, о котором говорилось ранее в разделе Настройки.
Возвращаемый accessToken
— это то, что мы будем использовать при вызове нашего API. Как правило, токен доступа не должен быть читаемым с точки зрения приложения. Однако, поскольку в Auth0 токен доступа передается в формате JWT (по крайней мере, если указано audience
нашего пользовательского API; если не указано, то по умолчанию используется формат JWE) мы можем взять значение и декодировать его с помощью [jwt.io] (https://jwt.io/). В результате должно получиться что‑то похожее на следующее (некоторые части отредактированы в целях безопасности):
В частности, вы увидите идентификатор https://myapi.com
, включенный в утверждение audience
, и указанные нами scope
, включенные в утверждение scope
. Вы также можете заметить утверждение sub
, содержащее внутренний идентификатор Auth0
для пользователя, от имени которого был сгенерирован токен доступа. Все это стандартные утверждения, определенные спецификацией OAuth 2.0, и все они обычно используются сервером ресурсов (т. е. API) для определения валидности запроса. Дополнительную информацию см. в документации Auth0.
Использование токена доступа
Мы наконец переходим к использованию токена доступа.
? Вернитесь к проекту XCode и обновите код, как показано на рисунке ниже, сохранив учетные данные, полученные из запроса Auth0 webAuth()
. Скорее всего, нам понадобится использовать эти учетные данные в разных местах приложения, поэтому для их безопасного и надежного хранения мы можем использовать CredentialsManager
, предоставляемый Auth0. Я не буду слишком подробно описывать этот процесс здесь, вы можете прочитать о нем подробнее в документации Auth0 SDK. Или, в качестве альтернативы, перейдите по этой ссылке в раздел примеров в репозитории SDK на GitHub.
// [ ? ContentView.swift ]
func login() {
let credentialsManager = CredentialsManager(authentication: Auth0.authentication()) // 1
Auth0
.webAuth()
.audience("https://myapi.com")
.scope("openid profile email read:events")
.start { result in
switch result {
case .failure(let error):
print("Failed with: \(error)")
case .success(let credentials):
self.isAuthenticated = true
self.userProfile = Profile.from(credentials.idToken)
// Передаем учетные данные менеджеру
let didStore = credentialsManager.store(credentials: credentials) // 2
print("Credentials: \(credentials)")
print("ID token: \(credentials.idToken)")
print("Access token: \(credentials.accessToken)")
}
}
}
Разъяснения по строкам кода с пронумерованным комментариям:
Создаем ссылку на
CredentialsManager
Auth0.Сохраняем полученные учетные данные для последующего использования.
? Теперь нам нужно сделать вызов фактической реализации API, передав токен доступа в качестве Authorization: Bearer
в заголовке HTTP‑запроса. Я создал отдельный модуль, чтобы изолировать этот функционал и тем самым (надеюсь) облегчить процесс. Ниже я также добавил несколько заметок, на которые, по моему мнению, вам следует обратить внимание:
// [ ? APICallView.swift ]
import SwiftUI
import Auth0
struct Event: Identifiable, Codable { // 1
let id: Int
let title: String
let body: String
}
enum NetworkError: Error { // 2
case badUrl
case invalidRequest
case badResponse
case badStatus
case failedToDecodeResponse
}
class WebService: Codable {
func credentials() async throws -> Credentials { // 3
let credentialsManager = CredentialsManager(authentication: Auth0.authentication())
return try await withCheckedThrowingContinuation { continuation in
credentialsManager.credentials { result in
switch result {
case .success(let credentials):
continuation.resume(returning: credentials)
break
case .failure(let reason):
continuation.resume(throwing: reason)
break
}
}
}
}
func downloadData<T: Codable>(fromURL: String) async -> T? {
do {
guard let url = URL(string: fromURL) else { throw NetworkError.badUrl }
var request = URLRequest(url: url)
let credentials = try await credentials();
/* По правилам токен доступа должен быть указан в качестве Authorization Bearer в заголовке HTTP‑запроса. Документация Apple несколько неоднозначна, когда речь заходит о том, как это сделать, поэтому в данном примере я буду следовать совету, предложенному на сайте https://ampersandsoftworks.com/posts/bearer‑authentication‑nsurlsession/.
*/
request.setValue("Bearer \(credentials.accessToken)", forHTTPHeaderField: "Authorization") // 4
let (data, result) = try await URLSession.shared.data(for: request)
guard let response = result as? HTTPURLResponse else { throw NetworkError.badResponse }
guard response.statusCode >= 200 && response.statusCode < 300 else { throw NetworkError.badStatus }
guard let decodedResponse = try? JSONDecoder().decode(T.self, from: data) else { throw NetworkError.failedToDecodeResponse }
return decodedResponse
} catch NetworkError.badUrl {
print("There was an error creating the URL")
} catch NetworkError.badResponse {
print("Did not get a valid response")
} catch NetworkError.badStatus {
print("Did not get a 2xx status code from the response")
} catch NetworkError.failedToDecodeResponse {
print("Failed to decode response into the given type")
} catch {
print("An error occurred downloading the data")
}
return nil
}
}
class EventViewModel: ObservableObject {
@Published var eventData = [Event]()
func fetchData() async {
guard let downloadedEvents: [Event] = await WebService().downloadData(fromURL: "<API URL goes here>") else {return} // 5
DispatchQueue.main.async {
self.eventData = downloadedEvents
}
}
}
struct APICallView: View {
@StateObject var vm = EventViewModel()
var body: some View { // 6
List(vm.eventData) { event in
HStack {
Text("\(event.id)")
.padding()
.overlay(Circle().stroke(.blue))
VStack(alignment: .leading) {
Text(event.title)
.bold()
.lineLimit(1)
Text(event.body)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
}
.onAppear {
if vm.eventData.isEmpty {
Task {
await vm.fetchData()
}
}
}
}
}
Разъяснения по строкам кода с пронумерованным комментариям:
Пример структуры объекта
Event
для данных, получаемых из API.Пример перечисления потенциальных ошибок, возникающих в результате вызова API.
Обертка Auth0
CredentialsManager
для работы в режиме Async/Await.Добавление токена доступа в качестве заголовка
Authorization: Bearer
.Получение URL‑адрес API из записи в файле .plist.
Отображение событий, полученных от API.
UX вызова API
Чтобы упростить доступ к API, я обновил существующую функцию, добавив кнопку, которая запускает вызов API. В приведенном ниже коде показаны изменения, внесенные в объявление struct ContentView
, с примечаниями, добавленными для объяснения происходящего:
// [ ? ContentView.swift ]
@State private var isAuthenticated = false
@State private var isAPICall = false // 1
@State var userProfile = Profile.empty
var body: some View {
if isAuthenticated {
if isAPICall {
APICallView() // 2
} else {
// Экран “Logged in”
// ------------------
// Когда пользователь залогинился, он должен увидеть:
//
// - Текст заголовка “You’re logged in!”
// - Его фото
// - Его имя
// - Адрес электронной почты
// - Кнопку "Log out”
VStack {
Text("You’re logged in!")
.modifier(TitleStyle())
UserImage(urlString: userProfile.picture)
VStack {
Text("Name: \(userProfile.name)")
Text("Email: \(userProfile.email)")
}
.padding()
HStack {
Button("Log out") {
logout()
}
.buttonStyle(MyButtonStyle())
Button("Call API") { // 3
isAPICall = true;
}
.buttonStyle(MyButtonStyle())
} // HStack
} // VStack
}
} else {
// Экран “Logged out”
// ------------------
// Когда пользователь выходит из системы, он должен увидеть:
//
// - Текст заголовка "SwiftUI Login Demo"
// - Кнопку ”Log in”
VStack {
Text("SwiftUI Login demo")
.modifier(TitleStyle())
Button("Log in") {
login()
}
.buttonStyle(MyButtonStyle())
} // VStack
} // if isAuthenticated
} // body
Добавлен новый флаг состояния.
Триггер рендеринга результатов вызова API.
Кнопка для обновления триггера флага добавленного состояния.
Создание примера API
Как уже говорилось выше, здесь я не буду вдаваться в подробности реализации API. Однако для личного SaaS‑проекта, над которым я работаю, я экспериментировал с использованием Amazon API Gateway в рамках этой статьи статьей в блоге Amazon. Вам, вероятно, не понадобится использовать какие‑либо аспекты SaaS (например, функцию Auth0 Organizations), однако в этой статье довольно хорошо описан процесс валидации токена доступа... по крайней мере, в контексте AWS. Ниже приведены несколько скриншотов моей реализации (простого API, возвращающего список событий), которые могут помочь вам, если вы решите пойти по аналогичному пути.
Моя простенькая функция API реализована на Node.js, но вы можете реализовать API, используя любой бэкенд‑язык (например, Python, PHP, Ruby и т. д.) в связке с любой серверной (или бессерверной) платформой. Но придерживаясь темы Apple и не отходя от AWS, вот отличный пример того, как можно использовать серверный Swift (в бессерверной среде) для создания API:
Узнайте, как создать и развернуть бессерверный HTTP API с помощью Swift и AWS Lambda.
Обновление токена доступа
Рекомендуемым подходом в среде мобильных приложений обычно является использование Refresh Token, поэтому вам обязательно стоит ознакомиться с моей последующей статьей на эту тему, когда у вас появится такая возможность.
Пошаговое руководство по использованию OAuth 2.0 Refresh Tokens в приложении для iOS, созданном на Swift с интеграцией Auth0.
Что дальше?
Не забудьте взглянуть на код, который мы создали, посетив этот GitHub‑репозиторий. И, конечно, не стесняйтесь оставлять комментарии ниже и рассказывать нам, что вы думаете — мы всегда рады услышать отзывы, положительные или нет, так как это помогает нам улучшать наш контент! Спасибо. Кроме того, вот несколько дополнительных ресурсов, которые могут помочь вам в вашем обучении. Ну а на этом все. Удачи вам и до новых встреч!
Статья подготовлена в рамках набора на курс "iOS Developer. Professional". Ознакомиться с полной программой, а также посмотреть записи открытых уроков можно на странице курса (сейчас для просмотра доступны уроки по темам «Написание клиента для музыкального сервиса на SwiftUI» и «Навигация на SwiftUI без UIKit». Чтобы открыть доступ к остальным записям, пройдите вступительное тестирование).