Создание сетевого iOS-приложения с автоматизированной JWT-авторизацией

Автор: Лев Бакланов. Перевод: SwiftBook.

17 ноября 2022

Управление JWT в мобильных приложениях стало проще

Эта статья рассматривает JWT-авторизацию в iOS-приложении с помощью токенов обновления (refresh tokens). Мы напишем приложение, взаимодействующее с сервером JWT-авторизации, которое демонстрирует предложенный подход.

Исходный код демо-приложения можно найти в репозитории. Основной код автоматизации JWT-авторизации находится в файле Networking/Requester.swift. Вы можете использовать его для проверки, проверки логов журнала авторизации и т.д.

JWT (JSON Web Token) является стандартом для хранения идентификационных данных в подписанных токенах доступа, основанных на формате JSON. Как правило, после авторизации по логину и паролю сервер генерирует JWT с данными пользователя, подписывает его и возвращает токен доступа клиенту. После этого, клиент может проходить аутентификацию с этим токеном: когда сервер получает JWT, он верифицирует подпись и проверяет, что срок действия токена не истек.

Довольно часто некоторые системы меняют общий подход к работе с JWT, добавляя т.н. токены обновления (refresh tokens). В этом случае, токен доступа действует недолго, а токен обновления остается действующим в течение длительного периода времени, и используется для создания уже нового токена доступа по истечении срока действия старого.

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

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

При создания нашего демо-приложения будет использоваться SwiftUI, но это не принципиально важно, поскольку общая логика JWT-авторизации не будет привязана к пользовательскому интерфейсу.

Бэкенд

Для приложения был реализован простой бэкенд, который обрабатывает четыре вида запросов:
1. Регистрация пользователя. В случае успеха, сервер вернет пользователю объект, содержащий токены доступа и токены обновления, а также срок их действия.

2. Авторизация пользователя по логину и паролю. В случае успеха, ответ сервера будет таким же, как и в первом пункте.

3. Обновление токенов. Возвращает новый набор токенов при успешном выполнении запроса. Для успешного исполнения запроса, при его отправке в заголовке авторизации необходимо вставить токен обновления, а тело запроса должно быть пустым.

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

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

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

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

iOS приложение

Наше приложение будет использовать оптимальный для SwiftUI шаблон проектирования MVVM, который также подходит для написания небольшого приложения с одной ViewModel.

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

struct User: Codable {    

let id: Int64    

let accessToken: String    

let accessTokenExpire: Int64    

let refreshToken: String    

let refreshTokenExpire: Int64

}

 

struct AuthBody: Codable {    

let login: String    

let password: String

}

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

struct TokensInfo: Codable {    

let accessToken: String    

let accessTokenExpire: Int64    

let refreshToken: String    

let refreshTokenExpire: Int64

}

struct TokenInfo {    

let token: String    

let expiresAt: Int64

}

Для всех запросов, на сервере существует обобщающий ErrorResponse. Для него мы создадим ещё одну структуру, как показано ниже:

struct ErrorResponse: Codable {    

let code: Int    

let message: String         

func isAuth() -> Bool {        

return Errors.isAuthError(err: message)    

}

}

В этой структуре есть метод isAuth, который проверяет сообщения и определяет ошибки авторизации (недействительный токен или токен с истёкшим сроком действия). Метод использует класс Errors, который будет обсуждаться ниже.
И ещё создается одна структура для хранения информации о конкретном разработчике:

struct Developer: Codable {    

let id: Int64    

let name: String    

let department: String

}

Чтобы хранить JWT-токены для предстоящей авторизации, необходимо локальное хранилище. В нашем случае, для простоты, я буду использовать UserDefaults. При необходимости, вы можете использовать более защищенное решение.
Давайте напишем небольшой Singleton-класс для хранения/получения токенов в UserDefaults:

class UserDefaultsWorker {       

static let shared = UserDefaultsWorker()    

private static let KEY_ACCESS_TOKEN = "auth_token"    

private static let KEY_ACCESS_TOKEN_EXPIRE = "auth_token_expire"    

private static let KEY_REFRESH_TOKEN = "refresh_token"    

private static let KEY_REFRESH_TOKEN_EXPIRE = "refresh_token_expire"         

func saveAuthTokens(tokens: TokensInfo) {        

let defaults = UserDefaults.standard        

defaults.set(tokens.accessToken, forKey: UserDefaultsWorker.KEY_ACCESS_TOKEN)        

defaults.set(tokens.accessTokenExpire, forKey: UserDefaultsWorker.KEY_ACCESS_TOKEN_EXPIRE)        

defaults.set(tokens.refreshToken, forKey: UserDefaultsWorker.KEY_REFRESH_TOKEN)        

defaults.set(tokens.refreshTokenExpire, forKey: UserDefaultsWorker.KEY_REFRESH_TOKEN_EXPIRE)    

}         

func getAccessToken() -> TokenInfo {        

let defaults = UserDefaults.standard        

let token = defaults.object(forKey: UserDefaultsWorker.KEY_ACCESS_TOKEN) as? String ?? ""        

let expiresAt = defaults.object(forKey: UserDefaultsWorker.KEY_ACCESS_TOKEN_EXPIRE) as? Int64 ?? 0        

return TokenInfo(token: token, expiresAt: expiresAt)    

}         

func getRefreshToken() -> TokenInfo {        

let defaults = UserDefaults.standard        

let token = defaults.object(forKey: UserDefaultsWorker.KEY_REFRESH_TOKEN) as? String ?? ""        

let expiresAt = defaults.object(forKey: UserDefaultsWorker.KEY_REFRESH_TOKEN_EXPIRE) as? Int64 ?? 0        

return TokenInfo(token: token, expiresAt: expiresAt)    

}         

func haveAuthTokens() -> Bool {        

return !getAccessToken().token.isEmpty && !getRefreshToken().token.isEmpty    

}         

func dropTokens() {        

let defaults = UserDefaults.standard        

defaults.set("", forKey: UserDefaultsWorker.KEY_ACCESS_TOKEN)        

defaults.set(0 as Int64, forKey: UserDefaultsWorker.KEY_ACCESS_TOKEN_EXPIRE)        

defaults.set("", forKey: UserDefaultsWorker.KEY_REFRESH_TOKEN)        

defaults.set(0 as Int64, forKey: UserDefaultsWorker.KEY_REFRESH_TOKEN_EXPIRE)    

}

}

Для удобства взаимодействия с сервером и для генерации URL-адресов, давайте создадим перечисление Endpoint. Вот код:

enum Endpoint {         

static let baseURL: String  = "https://lev.customapp.tech/"    

case register    

case login    

case refreshTokens    

case getDevelopers         

func path() -> String {        

switch self {        

case .register:            

return "api/register"        

case .login:            

return "api/login"        

case .refreshTokens:            

return "api/refresh_tokens"        

case .getDevelopers:            

return "api/get_devs"        

}    

}         

var absoluteURL: URL {        

URL(string: Endpoint.baseURL + self.path())!    

}

}

Чтобы вернуть из обработчика запросов значение универсального типа (generic), мы создадим перечисление Result, которое будет определять тип ответа от сервера.

enum Result<T> {    

case success(_ response: T)    

case serverError(_ err: ErrorResponse)    

case authError(_ err: ErrorResponse)    

case networkError(_ err: String)

}

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

Реализация отправителя запроса

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

Итак, мы видим следующее:

  • Запросы, не требующие JWT-авторизации (в нашем случае регистрация и авторизация по логину и паролю), отправляются напрямую без дополнительных проверок.
  • Для запросов, требующих авторизации JWT, перед отправкой проверяется статус срока действия токена доступа. Если срок действия токена истек или скоро истечет (время до истечения срока действия меньше указанного порогового значения), то перед отправкой самого запроса токены обновляются.
  • При ошибке обновления токенов, необходимо вернуть ошибку авторизации, чтобы наша ViewModel переместила пользователя на экран авторизации, поскольку мы не можем авторизоваться с помощью JWT.

Давайте начнем реализацию класса Requester с работы с UserDefaults.

Requester должен получать после обновления и сохранять токены в хранилище, чтобы использовать эти токены для следующего запуска приложения. Мы также подготовим метод для сброса текущих токенов в случае выхода из системы.

class Requester {         

static let shared = Requester()    

static private let ACCESS_TOKEN_LIFE_THRESHOLD_SECONDS: Int64 = 10         

private var accessToken = UserDefaultsWorker.shared.getAccessToken()    

private var refreshToken = UserDefaultsWorker.shared.getRefreshToken()         

private init() {}         

private func onTokensRefreshed(tokens: TokensInfo) {        

UserDefaultsWorker.shared.saveAuthTokens(tokens: tokens)        

accessToken = TokenInfo(token: tokens.accessToken, expiresAt: tokens.accessTokenExpire)        

refreshToken = TokenInfo(token: tokens.refreshToken, expiresAt: tokens.refreshTokenExpire)    

}         

func dropTokens() {        

accessToken = TokenInfo(token: "", expiresAt: 0)        

refreshToken = TokenInfo(token: "", expiresAt: 0)    

}

}

Здесь мы объявили приватный инициализатор, а также свойство класса shared для доступа к единственному экземпляру класса Requester, тем самым реализуя шаблон Singleton. Это необходимо, поскольку запросы API могут быть вызваны не только из одного места в приложении. Например, при использовании подхода MVVM у вас обычно есть несколько ViewModels, которые могут независимо отправлять запросы.

Для выполнения запросов я буду использовать Swift-класс URLSession. Давайте напишем универсальный метод для создания URLRequest, который будем использовать для всех наших запросов, и пару вспомогательных методов:

class Requester {         

...         

private func formRequest(url: URL,                              

data: Data = Data(),                              

method: String = "POST",                              

contentType: String = "application/json",                              

refreshTokens: Bool = false,                              

ignoreJwtAuth: Bool = false) -> URLRequest {        

var request = URLRequest(            

url: url,            

cachePolicy: .reloadIgnoringLocalCacheData        

)        

request.httpMethod = method        

request.addValue(contentType, forHTTPHeaderField: "Content-Type")        

request.httpBody = data        

if refreshTokens {            

request.addValue("Bearer \(refreshToken.token)", forHTTPHeaderField: "Authorization")        

}

else if !accessToken.token.isEmpty && !ignoreJwtAuth {            

request.addValue("Bearer \(accessToken.token)", forHTTPHeaderField: "Authorization")        

}        

return request    

}         

private func formRefreshTokensRequest() -> URLRequest {        

return formRequest(url: Endpoint.refreshTokens.absoluteURL, refreshTokens: true)    

}         

private func renewAuthHeader(request: URLRequest) -> URLRequest {        

var newRequest = request        

newRequest.setValue("Bearer \(accessToken.token)", forHTTPHeaderField: "Authorization")        

return newRequest    

}

}

В методе formRequest, помимо остальных параметров запроса, есть два флага:

- refreshTokens, сигнализирующий о том, что формируется запрос на обновление токенов, значит Requester должен поместить токен обновления в заголовок авторизации, - ignoreJwtAuth, сигнализирующий об отсутствии необходимости использовать JWT- авторизацию (токен доступа) в запросе.

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

Метод renewAuthHeader заменяет токен доступа в заголовке авторизации на текущий. Метод выполняется когда необходимо обновить токены, после генерации запроса, требующего JWT. Иначе, заголовок авторизации будет содержать старый недействительный токен доступа.
Давайте перейдем к логике отправки запросов. Следующий блок кода будет довольно большим из-за множества проверок, но он не содержит очень сложных для понимания вещей, которые я опишу ниже:

class Requester {         

...         

private var needReAuth: Bool {        

let current = Date().timestampMillis()        

let expires = accessToken.expiresAt        

return current + Requester.ACCESS_TOKEN_LIFE_THRESHOLD_SECONDS > expires    

}         

func request<T: Decodable>(request: URLRequest, onResult: @escaping (Result<T>) -> Void) {        

if (needReAuth && !refreshToken.token.isEmpty) {            

authAndDoRequest(request: request, onResult: onResult)        

} else {            

doRequest(request: request, onResult: onResult)        

}    

}         

func authAndDoRequest<T: Decodable>(request: URLRequest, onResult: @escaping (Result<T>) -> Void) {        

let refreshRequest = formRefreshTokensRequest()        

let task = URLSession.shared.dataTask(with: refreshRequest) { [self] (data, response, error) -> Void in            

if let error = error {                

DispatchQueue.main.async { onResult(.networkError(error.localizedDescription)) }                

return            

}            

guard let httpResponse = response as? HTTPURLResponse else {                

//should never happen                

DispatchQueue.main.async {                    

onResult(.authError(ErrorResponse(code: 0, message: Errors.ERR_CONVERTING_TO_HTTP_RESPONSE)))                

}                

return            

}            

guard let data = data else {                

//should never happen                

DispatchQueue.main.async {                    

onResult(.authError(ErrorResponse(code: httpResponse.statusCode, message: Errors.ERR_NIL_BODY)))                

}                

return            

}            

if httpResponse.isSuccessful() {                

do {                    

let response = try JSONDecoder().decode(TokensInfo.self, from: data)                    

onTokensRefreshed(tokens: response)                    

let newRequest = renewAuthHeader(request: request)                    

doRequest(request: newRequest, onResult: onResult)                    

return                

} catch {                    

//should never happen                    

DispatchQueue.main.async {                        

onResult(.authError(ErrorResponse(code: 0, message: Errors.ERR_PARSE_RESPONSE)))                    

}                    

return                

}            

} else {                

do {                    

let errorResponse = try JSONDecoder().decode(ErrorResponse.self, from: data)                    

DispatchQueue.main.async {                        

onResult(.authError(errorResponse))                    

}                    

return                

} catch {                    

DispatchQueue.main.async {                        

onResult(.authError(ErrorResponse(code: 0, message: error.localizedDescription)))                    

}                    

return                

}            

}        

}        

task.resume()    

}         

func doRequest<T: Decodable>(request: URLRequest, onResult: @escaping (Result<T>) -> Void) {        

let task = URLSession.shared.dataTask(with: request) { (data, response, error) -> Void in            

if let error = error {                

DispatchQueue.main.async { onResult(.networkError(error.localizedDescription)) }                

return            

}            

guard let httpResponse = response as? HTTPURLResponse else {                

//should never happen                

DispatchQueue.main.async { onResult(.networkError(Errors.ERR_CONVERTING_TO_HTTP_RESPONSE)) }                

return            

}                         

guard let data = data else {                

//should never happen                

DispatchQueue.main.async {                    

onResult(.serverError(ErrorResponse(code: httpResponse.statusCode, message: Errors.ERR_NIL_BODY)))                

}                

return            

}            

if httpResponse.isSuccessful() {                

let responseBody: Result<T> = self.parseResponse(data: data)                

DispatchQueue.main.async { onResult(responseBody) }            

} else {                

let responseBody: Result<T> = self.parseError(data: data)                

DispatchQueue.main.async { onResult(responseBody) }            

}        

}        

task.resume()    

}         

private func parseResponse<T: Decodable>(data: Data) -> Result<T> {        

do {            

return .success(try JSONDecoder().decode(T.self, from: data))        

} catch {            

return parseError(data: data)        

}    

}         

private func parseError<T>(data: Data) -> Result<T> {        

do {            

let errorResponse = try JSONDecoder().decode(ErrorResponse.self, from: data)            

if (errorResponse.isAuth()) {                

return .authError(errorResponse)            

} else {                

return .serverError(errorResponse)            

}        

} catch {            

return .serverError(ErrorResponse(code: 0, message: Errors.ERR_PARSE_ERROR_RESPONSE))        

}    

}

}

Основной подход к написанию методов класса Requester заключается в следующем: сам запрос и его @escaping-обработчик передаются в виде параметров методу отправки запроса, который вернет Result, где Type — это тип данных, который должен содержаться в ответе, если запрос выполнен успешно. В зависимости от результата запроса будет возвращен Result.success или один из трёх остальных кейсов Result, содержащих ошибки.

Вспомогательная функция needReAuth позволяет узнать, нужно ли обновлять токены перед отправкой запроса. Она проверяет, что срок действия токена не истек, а до истечения срока его действия осталось больше времени, чем установленный нами порог (10 секунд). Она возвращает временную метку UNIX в миллисекундах.

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

Метод request используется запросами, требующими JWT-авторизации. Он определяет, нужно ли обновлять токены перед запросом и вызывать authAndDoRequest, или просто вызывать doRequest.

В методе authAndDoRequest мы формируем и отправляем запрос на обновление токенов. Мы выполняем классическую проверку того, что у нас нет сетевой ошибки, и что ответ был успешно приведен к типу HTTPURLResponse, а также, что мы получили непустое тело ответа. Далее мы проверяем, что запрос прошел успешно (проверка кода HTTP-ответа на значение 2XX).

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

Метод doRequest выполняет запрос без проверки авторизации. Он вызывается из метода authAndDoRequest, либо напрямую, для выполнения запросов, не требующих авторизации.

Метод parseResponse пытается декодировать JSON-ответ в ожидаемый нами тип данных . Если это не удается, то вызывается метод parseError, который пытается декодировать тело ошибки в нашу структуру ErrorResponse. Внутри parseError выполняется проверка, является ли ошибка, которую мы получили, ошибкой авторизации или другой ошибкой.

Затем остается только добавить public-методы API (регистрация, авторизация, получение списка разработчиков).

class Requester {         

...         

private func handleAuthResponse(response: Result<User>, onResult: @escaping (Result<User>) -> Void) {        

if case .success(let user) = response {            

self.onTokensRefreshed(tokens: user.getTokensInfo())        

}        

onResult(response)    

}         

func register(authBody: AuthBody, onResult: @escaping (Result<User>) -> Void) {        

let url = Endpoint.register.absoluteURL        

let body = try! JSONEncoder().encode(authBody)        

let request = formRequest(url: url, data: body, method: "POST", ignoreJwtAuth: true)        

self.doRequest(request: request) { [self] result in            

self.handleAuthResponse(response: result, onResult: onResult)        

}    

}         

func login(authBody: AuthBody, onResult: @escaping (Result<User>) -> Void) {        

let url = Endpoint.login.absoluteURL        

let body = try! JSONEncoder().encode(authBody)        

let request = formRequest(url: url, data: body, method: "POST", ignoreJwtAuth: true)        

self.doRequest(request: request) { [self] result in            

self.handleAuthResponse(response: result, onResult: onResult)        

}    

}         

func getDevelopers(onResult: @escaping (Result<[Developer]>) -> Void) {        

let url = Endpoint.getDevelopers.absoluteURL        

let request = formRequest(url: url, data: Data(), method: "GET")        

self.request(request: request, onResult: onResult)    

}

}

Пожалуйста, обратите внимание, что для обработки результатов запросов на регистрацию и вход в систему (register и login) мы используем отдельную функцию handleAuthResponse, и внутри нее мы вызываем @escaping-обработчик onResult уже после сохранения нами токенов. Это необходимо для того, чтобы инкапсулировать всё управление токенами внутри запросчика, и внешние экземпляры (например, ViewModel) не будут знать об их существовании. Таким образом, вся автоматизированная работа с токенами происходит внутри запросчика, а ViewModel использует только методы из состава API.

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

Важное примечание — этот запросчик будет работать с последовательно отправляемыми запросами. Иначе, если срок действия токена доступа истек, то при одновременной отправке запросов метод обновления токенов будет вызываться несколько раз одновременно, что приведет к ошибкам. Если вам нужно отправить несколько запросов параллельно, то потребуется небольшая модификация запросчик. В любом случае вам придется обновить токены и только после этого отправлять запросы. Я предложу два наиболее логичных решения:

1. Храните все запросы и обратные вызовы до тех пор, пока токены не будут обновлены, а затем отправьте их. Может быть, полезен какой-либо mutex или DispathSemaphore для блокировки потока до завершения обновления.2. Создайте очередь запросов, требующих JWT-авторизации, и отправляйте их по очереди один за другим.

Добавление остальной части приложения

Самая интересная сетевая логика уже написана. Теперь мы перейдем к написанию блока с остальной частью приложения. Мы не будем заострять внимание на конкретном описании пользовательского интерфейса (UI), потому что это довольно просто. Давайте рассмотрим лишь некоторые важные моменты.

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

class MainViewModel: ObservableObject {         

@Published    

var showAuthContainer = true         

@Published    

var loginPending = false    

@Published    

var registerPending = false         

@Published    

var devsProgress: LoadingState = .notStarted    

@Published    

var developers: [Developer] = []         

@Published    

var alert: IdentifiableAlert?         

init() {        

let refreshToken = UserDefaultsWorker.shared.getRefreshToken()        

if !refreshToken.token.isEmpty && refreshToken.expiresAt > Date().timestampMillis() {            

showAuthContainer = false        

}    

}         

func logout() {        

UserDefaultsWorker.shared.dropTokens()        

Requester.shared.dropTokens()        

withAnimation {            

showAuthContainer = true        

}    

}         

func login(login: String, password: String) {        

withAnimation {            

loginPending = true        

}        

DispatchQueue.global(qos: .userInitiated).async {            

Requester.shared.login(authBody: AuthBody(login: login, password: password)) { [self] result in                

withAnimation {                    

loginPending = false                

}                

switch result {                

case .success(let user):                    

// do something with user                    

withAnimation {                        

self.showAuthContainer = false                    

}                

case .serverError(let err):                    

alert = IdentifiableAlert.buildForError(id: "login_server_err", message: Errors.messageFor(err: err.message))                

case .networkError(_):                    

alert = IdentifiableAlert.networkError()                

case .authError(let err):                    

alert = IdentifiableAlert.buildForError(id: "login_err", message: Errors.messageFor(err: err.message))                

}            

}        

}    

}       

func register(login: String, password: String) {      

//almost the same as login      

...    

}         

func getDevelopers() {        

withAnimation {            

devsProgress = .loading        

}        

DispatchQueue.global(qos: .userInitiated).async {            

Requester.shared.getDevelopers() { [self] result in                

withAnimation {                    

registerPending = false                

}                

switch result {                

case .success(let devs):                    

withAnimation {                        

developers = devs                        

devsProgress = .finished                    

}                

case .serverError(let err):                    

withAnimation {                        

devsProgress = .error                    

}                    

alert = IdentifiableAlert.buildForError(id: "devs_server_err", message: Errors.messageFor(err: err.message))                

case .networkError(_):                    

withAnimation {                        

devsProgress = .error                    

}                    

alert = IdentifiableAlert.networkError()                

case .authError(let err):                    

withAnimation {                        

self.showAuthContainer = true                    

}                

}            

}        

}    

}

}

При инициализации ViewModel (при запуске приложения) мы проверяем, есть ли у нас валидный токен обновления, и если он есть, то пропускаем экран авторизации, изменяя значение showAuthContainer, которое отвечает за то, будет ли отображаться экран авторизации/регистрации или же внутренний экран со списком разработчиков.

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

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

В коде корневого представления ContentView нам нужно реализовать описанную выше навигацию, а также создать свою ViewModel, а затем передать её дочерним представлениям как объект EnvironmentObject.

На экранах входа и регистрации у нас будут только поля для ввода логина и пароля и кнопка авторизации/регистрации. Кроме того, экран регистрации будет содержать навигационную ссылку на экран входа в систему.

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

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

Желаете подключиться? Полный исходный код приложения | Компания Custom app | Общение со мной в Twitter.

Если Вас интересует разработка web3, есть классная статья моего коллеги о реализации некоторых фичей web2 в блокчейне.

Благодарность Анупаму Чангу.

Оригинал статьи

Подписывайся на наши соцсети: Telegram / VKontakte
Вступай в открытый чат для iOS-разработчиков: t.me/swiftbook_chat

Содержание