diff --git a/yobble/Network/APIModels.swift b/yobble/Network/APIModels.swift new file mode 100644 index 0000000..f784726 --- /dev/null +++ b/yobble/Network/APIModels.swift @@ -0,0 +1,27 @@ +import Foundation + +struct APIResponse: Decodable { + let status: String + let data: T + let detail: String? +} + +struct TokenPairPayload: Decodable { + let access_token: String + let refresh_token: String + let user_id: String? +} + +struct RegisterPayload: Decodable { + let message: String +} + +struct ErrorPayload: Decodable { + let message: String? +} + +struct ErrorResponse: Decodable { + let status: String? + let data: ErrorPayload? + let detail: String? +} diff --git a/yobble/Network/AuthService.swift b/yobble/Network/AuthService.swift index 12783f3..2e9e30d 100644 --- a/yobble/Network/AuthService.swift +++ b/yobble/Network/AuthService.swift @@ -7,296 +7,245 @@ import Foundation -class AuthService { - +final class AuthService { + func autoLogin(completion: @escaping (Bool, String?) -> Void) { - // 1️⃣ Проверяем наличие текущего пользователя if let currentUser = UserDefaults.standard.string(forKey: "currentUser"), let _ = KeychainService.shared.get(forKey: "access_token", service: currentUser), let _ = KeychainService.shared.get(forKey: "refresh_token", service: currentUser) { - if AppConfig.DEBUG{ print("AutoLogin: найден текущий пользователь — \(currentUser)")} + if AppConfig.DEBUG { print("AutoLogin: найден текущий пользователь — \(currentUser)") } completion(true, nil) return } - // 2️⃣ Текущий пользователь не найден или токены отсутствуют - if AppConfig.DEBUG{ print("AutoLogin: текущий пользователь не найден или токены отсутствуют. Пробуем найти другого пользователя...")} - + if AppConfig.DEBUG { print("AutoLogin: текущий пользователь не найден или токены отсутствуют. Пробуем найти другого пользователя...") } + let allUsers = KeychainService.shared.getAllServices() - for user in allUsers { let hasAccessToken = KeychainService.shared.get(forKey: "access_token", service: user) != nil let hasRefreshToken = KeychainService.shared.get(forKey: "refresh_token", service: user) != nil - + if hasAccessToken && hasRefreshToken { - // Нашли пользователя с токенами — назначаем как currentUser UserDefaults.standard.set(user, forKey: "currentUser") - if AppConfig.DEBUG{ print("AutoLogin: переключились на пользователя \(user)")} + if AppConfig.DEBUG { print("AutoLogin: переключились на пользователя \(user)") } completion(true, nil) return } } - - // 3️⃣ Если никто не найден -// completion(false, "Не найден авторизованный пользователь. Пожалуйста, войдите снова.") + completion(false, nil) } - func login(username: String, password: String, completion: @escaping (Bool, String?) -> Void) { - let url = URL(string: "\(AppConfig.API_SERVER)/v1/auth/login")! - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("\(AppConfig.USER_AGENT)", forHTTPHeaderField: "User-Agent") - - let payload: [String: String] = [ - "login": username, - "password": password - ] - - do { - let jsonData = try JSONEncoder().encode(payload) - request.httpBody = jsonData - } catch { - DispatchQueue.main.async { - completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: "")) - } + let payload = LoginRequest(login: username, password: password) + guard let body = try? JSONEncoder().encode(payload) else { + completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: "")) return } - let task = URLSession.shared.dataTask(with: request) { data, response, error in - DispatchQueue.main.async { - if let error = error { - let errorMessage = String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), error.localizedDescription) - completion(false, errorMessage) - return - } - - guard let httpResponse = response as? HTTPURLResponse else { - completion(false, NSLocalizedString("Некорректный ответ от сервера.", comment: "")) - return - } - - guard (200...299).contains(httpResponse.statusCode) else { - if httpResponse.statusCode == 401{ - completion(false, NSLocalizedString("Неверный логин или пароль.", comment: "")) - } else if httpResponse.statusCode == 502{ - completion(false, NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")) - } else if httpResponse.statusCode == 429 { - completion(false, NSLocalizedString("Слишком много запросов.", comment: "")) - } else { - let errorMessage = String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(httpResponse.statusCode)") - completion(false, errorMessage) - } - return - } - - guard let data = data else { - completion(false, NSLocalizedString("Пустой ответ от сервера.", comment: "")) - return - } - + NetworkClient.shared.request( + path: "/v1/auth/login", + method: .post, + body: body, + requiresAuth: false + ) { result in + switch result { + case .success(let response): do { let decoder = JSONDecoder() - let loginResponse = try decoder.decode(APIResponse.self, from: data).data - - // Сохраняем токены в Keychain - KeychainService.shared.save(loginResponse.access_token, forKey: "access_token", service: username) - KeychainService.shared.save(loginResponse.refresh_token, forKey: "refresh_token", service: username) - KeychainService.shared.save(loginResponse.user_id, forKey: "userId", service: username) + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) + + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Неизвестная ошибка", comment: "") + completion(false, message) + return + } + + let tokens = apiResponse.data + KeychainService.shared.save(tokens.access_token, forKey: "access_token", service: username) + KeychainService.shared.save(tokens.refresh_token, forKey: "refresh_token", service: username) + if let userId = tokens.user_id { + KeychainService.shared.save(userId, forKey: "userId", service: username) + } UserDefaults.standard.set(username, forKey: "currentUser") - + completion(true, nil) } catch { completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: "")) } + + case .failure(let error): + let message = self.loginErrorMessage(for: error) + completion(false, message) } } - task.resume() } func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) { - let url = URL(string: "\(AppConfig.API_SERVER)/v1/auth/register")! - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(AppConfig.USER_AGENT, forHTTPHeaderField: "User-Agent") - - let payload: [String: Any] = [ - "login": username, - "password": password, - "invite": invite ?? NSNull() - ] - - do { - let jsonData = try JSONSerialization.data(withJSONObject: payload) - request.httpBody = jsonData - } catch { - DispatchQueue.main.async { - completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: "")) - } + let payload = RegisterRequest(login: username, password: password, invite: invite) + guard let body = try? JSONEncoder().encode(payload) else { + completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: "")) return } - let task = URLSession.shared.dataTask(with: request) { data, response, error in - DispatchQueue.main.async { - if let error = error { - let errorMessage = String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), error.localizedDescription) - completion(false, errorMessage) - return - } - - guard let httpResponse = response as? HTTPURLResponse else { - completion(false, NSLocalizedString("Некорректный ответ от сервера.", comment: "")) - return - } - - guard let data = data else { - completion(false, NSLocalizedString("Пустой ответ от сервера.", comment: "")) - return - } - - let decoder = JSONDecoder() - - if (200...299).contains(httpResponse.statusCode) { - do { - let _ = try decoder.decode(APIResponse.self, from: data) - if AppConfig.DEBUG{ print("Регистрация успешна. Пытаемся сразу войти...")} + NetworkClient.shared.request( + path: "/v1/auth/register", + method: .post, + body: body, + requiresAuth: false + ) { result in + switch result { + case .success(let response): + do { + let decoder = JSONDecoder() + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) - // Сразу логинимся - self.login(username: username, password: password) { loginSuccess, loginMessage in - if loginSuccess { - completion(true, "Регистрация и вход выполнены успешно.") - } else { - // Регистрация успешна, но логин не удался — покажем сообщение - completion(false, loginMessage ?? NSLocalizedString("Регистрация выполнена, но вход не удался.", comment: "")) - } - } - } catch { - completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: "")) + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Неизвестная ошибка", comment: "") + completion(false, message) + return } - } else { - // Ошибка сервера — пробуем распарсить message - if let errorResponseMessage = try? decoder.decode(ErrorResponse.self, from: data), - let message = errorResponseMessage.data?.message { - - if let jsonString = String(data: data, encoding: .utf8) { - if AppConfig.DEBUG{ print("Raw JSON:", jsonString)} - } - if AppConfig.DEBUG{ print("message:", message)} - - if httpResponse.statusCode == 400 { - if message.contains("Invalid invitation code") { - completion(false, NSLocalizedString("Неверный код приглашения.", comment: "")) - } else if message.contains("This invitation is not active") { - completion(false, NSLocalizedString("Приглашение не активно.", comment: "")) - } else if message.contains("This invitation has reached its usage limit") { - completion(false, NSLocalizedString("Приглашение достигло лимита использования.", comment: "")) - } else if message.contains("This invitation has expired") { - completion(false, NSLocalizedString("Приглашение истекло.", comment: "")) - } else if message.contains("Login already registered") { - completion(false, NSLocalizedString("Логин уже занят.", comment: "")) - } else { - completion(false, message) - } - } else if httpResponse.statusCode == 403 { - if message.contains("Registration is currently disabled") { - completion(false, NSLocalizedString("Регистрация временно недоступна.", comment: "")) - } else { - completion(false, message) - } - } else if httpResponse.statusCode == 429 { - completion(false, NSLocalizedString("Слишком много запросов.", comment: "")) - } else if httpResponse.statusCode == 502{ - completion(false, NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")) + + if AppConfig.DEBUG { print("Регистрация успешна. Пытаемся сразу войти...") } + + self.login(username: username, password: password) { loginSuccess, loginMessage in + if loginSuccess { + completion(true, NSLocalizedString("Регистрация и вход выполнены успешно.", comment: "")) } else { - let errorMessage = String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(httpResponse.statusCode)") - completion(false, errorMessage) - } - } else { - // Не удалось распарсить JSON — fallback - if httpResponse.statusCode == 400 { - completion(false, NSLocalizedString("Неверный запрос (400).", comment: "")) - } else if httpResponse.statusCode == 403 { - completion(false, NSLocalizedString("Регистрация запрещена.", comment: "")) - } else if httpResponse.statusCode == 429 { - completion(false, NSLocalizedString("Слишком много запросов.", comment: "")) - } else if httpResponse.statusCode == 502{ - completion(false, NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")) - } else { - let errorMessage = String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(httpResponse.statusCode)") - completion(false, errorMessage) + completion(false, loginMessage ?? NSLocalizedString("Регистрация выполнена, но вход не удался.", comment: "")) } } + } catch { + completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: "")) } + + case .failure(let error): + let message = self.registerErrorMessage(for: error) + completion(false, message) } } - task.resume() } - - - func logoutCurrentUser(completion: @escaping (Bool, String?) -> Void) { guard let currentUser = UserDefaults.standard.string(forKey: "currentUser") else { completion(false, "Не найден текущий пользователь.") return } - - // Удаляем токены текущего пользователя + KeychainService.shared.delete(forKey: "access_token", service: currentUser) KeychainService.shared.delete(forKey: "refresh_token", service: currentUser) KeychainService.shared.delete(forKey: "userId", service: currentUser) - - // Сбрасываем текущего пользователя + UserDefaults.standard.removeObject(forKey: "currentUser") - - // Пробуем переключиться на другого пользователя + let allUsers = KeychainService.shared.getAllServices() for user in allUsers { let hasAccessToken = KeychainService.shared.get(forKey: "access_token", service: user) != nil let hasRefreshToken = KeychainService.shared.get(forKey: "refresh_token", service: user) != nil - + if hasAccessToken && hasRefreshToken { UserDefaults.standard.set(user, forKey: "currentUser") - if AppConfig.DEBUG{ print("Logout: переключились на пользователя \(user)")} + if AppConfig.DEBUG { print("Logout: переключились на пользователя \(user)") } completion(true, nil) return } } - // Если пользователей больше нет -// completion(false, "Нет доступных пользователей. Пожалуйста, войдите снова.") completion(false, nil) } + + private func loginErrorMessage(for error: NetworkError) -> String { + switch error { + case .network(let err): + return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), err.localizedDescription) + case .server(let statusCode, _): + switch statusCode { + case 401: + return NSLocalizedString("Неверный логин или пароль.", comment: "") + case 429: + return NSLocalizedString("Слишком много запросов.", comment: "") + case 502: + return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "") + default: + return String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(statusCode)") + } + case .unauthorized: + return NSLocalizedString("Неверный логин или пароль.", comment: "") + case .invalidURL, .noResponse: + return NSLocalizedString("Некорректный ответ от сервера.", comment: "") + } + } + + private func registerErrorMessage(for error: NetworkError) -> String { + switch error { + case .network(let err): + return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), err.localizedDescription) + case .server(let statusCode, let data): + if let data, + let response = try? JSONDecoder().decode(ErrorResponse.self, from: data), + let message = response.data?.message { + return mappedRegistrationMessage(for: message, statusCode: statusCode) + } + + switch statusCode { + case 400: + return NSLocalizedString("Неверный запрос (400).", comment: "") + case 403: + return NSLocalizedString("Регистрация запрещена.", comment: "") + case 429: + return NSLocalizedString("Слишком много запросов.", comment: "") + case 502: + return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "") + default: + return String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(statusCode)") + } + case .unauthorized: + return NSLocalizedString("Регистрация запрещена.", comment: "") + case .invalidURL, .noResponse: + return NSLocalizedString("Некорректный ответ от сервера.", comment: "") + } + } + + private func mappedRegistrationMessage(for message: String, statusCode: Int) -> String { + if statusCode == 400 { + if message.contains("Invalid invitation code") { + return NSLocalizedString("Неверный код приглашения.", comment: "") + } else if message.contains("This invitation is not active") { + return NSLocalizedString("Приглашение не активно.", comment: "") + } else if message.contains("This invitation has reached its usage limit") { + return NSLocalizedString("Приглашение достигло лимита использования.", comment: "") + } else if message.contains("This invitation has expired") { + return NSLocalizedString("Приглашение истекло.", comment: "") + } else if message.contains("Login already registered") { + return NSLocalizedString("Логин уже занят.", comment: "") + } + } + + if statusCode == 403 { + if message.contains("Registration is currently disabled") { + return NSLocalizedString("Регистрация временно недоступна.", comment: "") + } + } + + if statusCode == 429 { + return NSLocalizedString("Слишком много запросов.", comment: "") + } + + if statusCode == 502 { + return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "") + } + + return message + } } -struct APIResponse: Decodable { - let status: String - let data: T +private struct LoginRequest: Encodable { + let login: String + let password: String } -struct LoginPayload: Decodable { - let access_token: String - let refresh_token: String - let user_id: String -} - -struct TokenRefreshPayload: Decodable { - let access_token: String - let token_type: String -} - -struct RegisterPayload: Decodable { - let message: String -} - -struct ErrorPayload: Decodable { - let message: String? -} - -struct ErrorResponse: Decodable { - let status: String? - let data: ErrorPayload? +private struct RegisterRequest: Encodable { + let login: String + let password: String + let invite: String? } diff --git a/yobble/Network/NetworkClient.swift b/yobble/Network/NetworkClient.swift new file mode 100644 index 0000000..185b2ae --- /dev/null +++ b/yobble/Network/NetworkClient.swift @@ -0,0 +1,291 @@ +import Foundation + +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" +} + +struct NetworkResponse { + let statusCode: Int + let headers: [AnyHashable: Any] + let data: Data +} + +enum NetworkError: Error { + case invalidURL + case unauthorized + case network(Error) + case noResponse + case server(statusCode: Int, data: Data?) +} + +final class NetworkClient { + static let shared = NetworkClient() + + private let session: URLSession + private let refreshQueue = DispatchQueue(label: "org.yobble.network.refresh") + private var isRefreshing = false + private var refreshCompletions: [(Bool) -> Void] = [] + + init(session: URLSession = .shared) { + self.session = session + } + + func request( + path: String, + method: HTTPMethod = .get, + query: [String: String?]? = nil, + headers: [String: String] = [:], + body: Data? = nil, + contentType: String? = nil, + requiresAuth: Bool = false, + callbackQueue: DispatchQueue = .main, + completion: @escaping (Result) -> Void + ) { + performRequest( + path: path, + method: method, + query: query, + headers: headers, + body: body, + contentType: contentType, + requiresAuth: requiresAuth, + callbackQueue: callbackQueue, + allowRefreshRetry: true, + isRetry: false, + completion: completion + ) + } + + private func performRequest( + path: String, + method: HTTPMethod, + query: [String: String?]?, + headers: [String: String], + body: Data?, + contentType: String?, + requiresAuth: Bool, + callbackQueue: DispatchQueue, + allowRefreshRetry: Bool, + isRetry: Bool, + completion: @escaping (Result) -> Void + ) { + guard let url = buildURL(path: path, query: query) else { + callbackQueue.async { + completion(.failure(.invalidURL)) + } + return + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.httpBody = body + + var allHeaders: [String: String] = [ + "User-Agent": AppConfig.USER_AGENT, + "Accept": "application/json" + ] + + if let contentType = contentType ?? (body != nil ? "application/json" : nil), + headers["Content-Type"] == nil { + allHeaders["Content-Type"] = contentType + } + + headers.forEach { allHeaders[$0.key] = $0.value } + + if requiresAuth { + guard let tokenInfo = currentTokenInfo() else { + callbackQueue.async { + completion(.failure(.unauthorized)) + } + return + } + allHeaders["Authorization"] = "Bearer \(tokenInfo.access)" + } + + for (key, value) in allHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + + let task = session.dataTask(with: request) { [weak self] data, response, error in + guard let self else { return } + + if let error = error { + callbackQueue.async { + completion(.failure(.network(error))) + } + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + callbackQueue.async { + completion(.failure(.noResponse)) + } + return + } + + let responseData = data ?? Data() + + if (200...299).contains(httpResponse.statusCode) { + let payload = NetworkResponse( + statusCode: httpResponse.statusCode, + headers: httpResponse.allHeaderFields, + data: responseData + ) + callbackQueue.async { + completion(.success(payload)) + } + return + } + + if httpResponse.statusCode == 401, + requiresAuth, + allowRefreshRetry, + !isRetry { + self.refreshAccessToken { success in + if success { + self.performRequest( + path: path, + method: method, + query: query, + headers: headers, + body: body, + contentType: contentType, + requiresAuth: requiresAuth, + callbackQueue: callbackQueue, + allowRefreshRetry: false, + isRetry: true, + completion: completion + ) + } else { + callbackQueue.async { + completion(.failure(.unauthorized)) + } + } + } + return + } + + callbackQueue.async { + completion(.failure(.server(statusCode: httpResponse.statusCode, data: responseData))) + } + } + + task.resume() + } + + private func buildURL(path: String, query: [String: String?]?) -> URL? { + guard let baseURL = URL(string: AppConfig.API_SERVER) else { + return nil + } + + var cleanedPath = path + if cleanedPath.hasPrefix("/") { + cleanedPath.removeFirst() + } + + let url = cleanedPath.isEmpty ? baseURL : baseURL.appendingPathComponent(cleanedPath) + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + + if let query { + let items = query.compactMap { key, value -> URLQueryItem? in + guard let value else { return nil } + return URLQueryItem(name: key, value: value) + } + if !items.isEmpty { + components.queryItems = items + } + } + + return components.url + } + + private func currentTokenInfo() -> (login: String, access: String, refresh: String)? { + guard + let login = UserDefaults.standard.string(forKey: "currentUser"), + let access = KeychainService.shared.get(forKey: "access_token", service: login), + let refresh = KeychainService.shared.get(forKey: "refresh_token", service: login) + else { + return nil + } + return (login, access, refresh) + } + + private func refreshAccessToken(completion: @escaping (Bool) -> Void) { + refreshQueue.async { + if self.isRefreshing { + self.refreshCompletions.append(completion) + return + } + + guard let tokenInfo = self.currentTokenInfo() else { + completion(false) + return + } + + self.isRefreshing = true + self.refreshCompletions.append(completion) + + let payload = RefreshRequest(access_token: tokenInfo.access, refresh_token: tokenInfo.refresh) + guard let body = try? JSONEncoder().encode(payload) else { + self.completeRefresh(success: false) + return + } + + self.performRequest( + path: "/v1/auth/token/refresh", + method: .post, + query: nil, + headers: [:], + body: body, + contentType: "application/json", + requiresAuth: false, + callbackQueue: self.refreshQueue, + allowRefreshRetry: false, + isRetry: false + ) { result in + switch result { + case .success(let response): + do { + let decoder = JSONDecoder() + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) + guard apiResponse.status == "fine" else { + self.completeRefresh(success: false) + return + } + + let data = apiResponse.data + KeychainService.shared.save(data.access_token, forKey: "access_token", service: tokenInfo.login) + KeychainService.shared.save(data.refresh_token, forKey: "refresh_token", service: tokenInfo.login) + if let userId = data.user_id { + KeychainService.shared.save(userId, forKey: "userId", service: tokenInfo.login) + } + + self.completeRefresh(success: true) + } catch { + self.completeRefresh(success: false) + } + case .failure: + self.completeRefresh(success: false) + } + } + } + } + + private func completeRefresh(success: Bool) { + let completions = refreshCompletions + refreshCompletions.removeAll() + isRefreshing = false + completions.forEach { $0(success) } + } +} + +private struct RefreshRequest: Encodable { + let access_token: String + let refresh_token: String +} diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 1448be3..b81aaea 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -432,9 +432,6 @@ }, "Публичная информация" : { - }, - "Пустой ответ от сервера." : { - }, "Регистрация" : { "comment" : "Регистрация" @@ -447,6 +444,9 @@ }, "Регистрация запрещена." : { + }, + "Регистрация и вход выполнены успешно." : { + }, "Редактировать профиль" : {