From 0f16b164d646f47cd2b4ece18cd1fc291c64abe1 Mon Sep 17 00:00:00 2001 From: Vicnet <35202538+VincentMeilinger@users.noreply.github.com> Date: Tue, 19 Sep 2023 12:04:59 +0200 Subject: [PATCH] Nextcloud login flow v2 support (not in working state yet) --- .../Data/DataModels.swift | 2 + .../Data/DataStore.swift | 5 +- .../Network/NetworkController.swift | 104 +++++++++++- .../Network/NetworkRequests.swift | 20 ++- .../ViewModels/MainViewModel.swift | 2 +- .../Views/OnboardingView.swift | 149 ++++++++++++++---- 6 files changed, 233 insertions(+), 49 deletions(-) diff --git a/Nextcloud Cookbook iOS Client/Data/DataModels.swift b/Nextcloud Cookbook iOS Client/Data/DataModels.swift index 33ba8c5..74fd09c 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataModels.swift @@ -67,3 +67,5 @@ struct RecipeImage { var thumb: UIImage? var full: UIImage? } + + diff --git a/Nextcloud Cookbook iOS Client/Data/DataStore.swift b/Nextcloud Cookbook iOS Client/Data/DataStore.swift index 39b721b..0b1525c 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataStore.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataStore.swift @@ -59,10 +59,7 @@ class DataStore { func recipeDetailExists(recipeId: Int) -> Bool { let filePath = "recipe\(recipeId).data" guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false } - let exists = fileManager.fileExists(atPath: folderPath + filePath) - print("Path: ", folderPath + filePath) - print("Recipe detail with id \(recipeId)", exists ? "exists" : "does not exist") - return exists + return fileManager.fileExists(atPath: folderPath + filePath) } func clearAll() -> Bool { diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkController.swift b/Nextcloud Cookbook iOS Client/Network/NetworkController.swift index d0fa5d7..25b728e 100644 --- a/Nextcloud Cookbook iOS Client/Network/NetworkController.swift +++ b/Nextcloud Cookbook iOS Client/Network/NetworkController.swift @@ -22,14 +22,14 @@ public enum NetworkError: String, Error { class NetworkController { var userSettings: UserSettings var authString: String - var urlString: String + var cookBookUrlString: String let apiVersion = "1" init() { print("Initializing NetworkController.") self.userSettings = UserSettings() - self.urlString = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)" + self.cookBookUrlString = "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v\(apiVersion)/" let loginString = "\(userSettings.username):\(userSettings.token)" let loginData = loginString.data(using: String.Encoding.utf8)! @@ -38,7 +38,7 @@ class NetworkController { func fetchData(path: String) async throws -> Data? { - let url = URL(string: "\(urlString)/\(path)")! + let url = URL(string: "\(cookBookUrlString)/\(path)")! var request = URLRequest(url: url) @@ -61,9 +61,9 @@ class NetworkController { } } - func sendHTTPRequest(path: String, _ requestWrapper: RequestWrapper) async throws -> (Data?, NetworkError?) { - print("Sending \(requestWrapper.method.rawValue) request (path: \(path)) ...") - let urlStringSanitized = "\(urlString)/\(path)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + func sendHTTPRequest(_ requestWrapper: RequestWrapper) async throws -> (Data?, NetworkError?) { + print("Sending \(requestWrapper.method.rawValue) request (path: \(requestWrapper.prepend(cookBookPath: cookBookUrlString))) ...") + let urlStringSanitized = requestWrapper.prepend(cookBookPath: cookBookUrlString).addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) let url = URL(string: urlStringSanitized!)! var request = URLRequest(url: url) request.setValue( @@ -123,7 +123,7 @@ class NetworkController { func sendDataRequest(_ request: RequestWrapper) async -> (D?, Error?) { do { - let (data, error) = try await sendHTTPRequest(path: request.path, request) + let (data, error) = try await sendHTTPRequest(request) if let data = data { return (decodeData(data), error) } @@ -136,7 +136,7 @@ class NetworkController { func sendRequest(_ request: RequestWrapper) async -> Error? { do { - return try await sendHTTPRequest(path: request.path, request).1 + return try await sendHTTPRequest(request).1 } catch { print("An unknown network error occured.") } @@ -158,3 +158,91 @@ class NetworkController { +struct NetworkHandler { + static func sendHTTPRequest(_ requestWrapper: RequestWrapper, authString: String? = nil) async throws -> (Data?, NetworkError?) { + print("Sending \(requestWrapper.method.rawValue) request (path: \(requestWrapper.path)) ...") + let urlStringSanitized = requestWrapper.path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + let url = URL(string: urlStringSanitized!)! + var request = URLRequest(url: url) + request.setValue( + "true", + forHTTPHeaderField: "OCS-APIRequest" + ) + if let authString = authString { + request.setValue( + "Basic \(authString)", + forHTTPHeaderField: "Authorization" + ) + } + request.setValue( + requestWrapper.accept.rawValue, + forHTTPHeaderField: "Accept" + ) + + request.httpMethod = requestWrapper.method.rawValue + + switch requestWrapper.method { + case .GET: break + case .POST, .PUT: + guard let httpBody = requestWrapper.body else { break } + do { + print("Encoding request ...") + request.httpBody = try JSONEncoder().encode(httpBody) + print("Request body: \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "nil")") + } catch { + throw error + } + case .DELETE: throw NotImplementedError.notImplemented + } + + var data: Data? = nil + var response: URLResponse? = nil + do { + (data, response) = try await URLSession.shared.data(for: request) + print("Response: ", response) + return (data, nil) + } catch { + return (nil, decodeURLResponse(response: response as? HTTPURLResponse)) + } + } + + static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? { + guard let response = response else { + return NetworkError.unknownError + } + switch response.statusCode { + case 200...299: return (nil) + case 300...399: return (NetworkError.redirectionError) + case 400...499: return (NetworkError.clientError) + case 500...599: return (NetworkError.serverError) + case 600: return (NetworkError.invalidRequest) + default: return (NetworkError.unknownError) + } + } + + static func sendDataRequest(_ request: RequestWrapper) async -> (D?, Error?) { + do { + let (data, error) = try await sendHTTPRequest(request) + if let data = data { + print(String(data: data, encoding: .utf8)) + return (decodeData(data), error) + } + return (nil, error) + } catch { + print("An unknown network error occured.") + } + return (nil, NetworkError.unknownError) + } + + private static func decodeData(_ data: Data) -> D? { + let decoder = JSONDecoder() + do { + print("Decoding type ", D.self, " ...") + return try decoder.decode(D.self, from: data) + } catch (let error) { + print("DataController - decodeData(): Failed to decode data.") + print("Error: ", error) + return nil + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift b/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift index a04ce12..a2d44b5 100644 --- a/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift +++ b/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift @@ -21,7 +21,7 @@ enum AcceptHeader: String { struct RequestWrapper { let method: RequestMethod - let path: String + var path: String let accept: AcceptHeader let body: Codable? @@ -31,6 +31,24 @@ struct RequestWrapper { self.body = body self.accept = accept } + + func prepend(cookBookPath: String) -> String { + return cookBookPath + self.path + } } +struct LoginV2Request: Codable { + let poll: LoginV2Poll + let login: String +} +struct LoginV2Poll: Codable { + let token: String + let endpoint: String +} + +struct LoginV2Response: Codable { + let server: String + let loginName: String + let appPassword: String +} diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift index 2f218ee..a0a0722 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -233,7 +233,7 @@ extension MainViewModel { do { let networkPath = networkImagePath(recipeId, full) let request = RequestWrapper(method: .GET, path: networkPath, accept: .IMAGE) - let (data, _): (Data?, Error?) = try await networkController.sendHTTPRequest(path: request.path, request) + let (data, _): (Data?, Error?) = try await networkController.sendHTTPRequest(request) guard let data = data else { print("Error receiving or decoding data.") return nil diff --git a/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift b/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift index f856c1e..68e4344 100644 --- a/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift +++ b/Nextcloud Cookbook iOS Client/Views/OnboardingView.swift @@ -54,6 +54,12 @@ struct WelcomeTab: View { struct LoginTab: View { @ObservedObject var userSettings: UserSettings + enum LoginMethod { + case v2, token + } + @State var selectedLoginMethod: LoginMethod = .v2 + @State var loginRequest: LoginV2Request? = nil + enum Field { case server case username @@ -73,43 +79,96 @@ struct LoginTab: View { .padding() Spacer() } - LoginLabel(text: "Server address") - LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress) - .focused($focusedField, equals: .server) - .textContentType(.URL) - .submitLabel(.next) - .padding(.bottom) - - LoginLabel(text: "User name") - LoginTextField(example: "username", text: $userSettings.username) - .focused($focusedField, equals: .username) - .textContentType(.username) - .submitLabel(.next) - .padding(.bottom) + Picker("Login Method", selection: $selectedLoginMethod) { + Text("Nextcloud Login").tag(LoginMethod.v2) + Text("App Token Login").tag(LoginMethod.token) + } + .pickerStyle(.segmented) + .foregroundColor(.white) + if selectedLoginMethod == .token { + LoginLabel(text: "Server address") + LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress) + .focused($focusedField, equals: .server) + .textContentType(.URL) + .submitLabel(.next) + .padding(.bottom) - - LoginLabel(text: "App Token") - LoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token) - .focused($focusedField, equals: .token) - .textContentType(.password) - .submitLabel(.join) - HStack{ - Spacer() - Button { - userSettings.onboarding = false - } label: { - Text("Submit") - .foregroundColor(.white) - .font(.headline) - .padding() - .background( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.white, lineWidth: 2) - .foregroundColor(.clear) - ) + LoginLabel(text: "User name") + LoginTextField(example: "username", text: $userSettings.username) + .focused($focusedField, equals: .username) + .textContentType(.username) + .submitLabel(.next) + .padding(.bottom) + + + LoginLabel(text: "App Token") + LoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token) + .focused($focusedField, equals: .token) + .textContentType(.password) + .submitLabel(.join) + HStack{ + Spacer() + Button { + userSettings.onboarding = false + } label: { + Text("Submit") + .foregroundColor(.white) + .font(.headline) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white, lineWidth: 2) + .foregroundColor(.clear) + ) + } + .padding() + Spacer() + } + } + else if selectedLoginMethod == .v2 { + LoginLabel(text: "Server address") + LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress) + .focused($focusedField, equals: .server) + .textContentType(.URL) + .submitLabel(.done) + .padding(.bottom) + .onSubmit { + if userSettings.serverAddress == "" { return } + Task { + await sendLoginV2Request() + if let loginRequest = loginRequest { + await UIApplication.shared.open(URL(string: loginRequest.login)!) + } + } + } + Text("Submitting will open a web browser. Please follow the login instructions provided there.\nAfter a successfull login, return to this application and press 'Done'.") + .font(.subheadline) + .padding(.bottom) + .tint(.white) + HStack{ + Spacer() + + Button { + // fetch login v2 response + Task { + guard let res = await fetchLoginV2Response() else { return } + print(res.loginName) + } + } label: { + Text("Done") + .foregroundColor(.white) + .font(.headline) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white, lineWidth: 2) + .foregroundColor(.clear) + ) + } + .disabled(loginRequest == nil ? true : false) + .padding() + Spacer() } - .padding() - Spacer() } Spacer() } @@ -127,6 +186,26 @@ struct LoginTab: View { .padding() } } + + func sendLoginV2Request() async { + let request = RequestWrapper( + method: .POST, + path: "https://\(userSettings.serverAddress)/index.php/login/v2" + ) + let (loginReq, _): (LoginV2Request?, Error?) = await NetworkHandler.sendDataRequest(request) + self.loginRequest = loginReq + } + + func fetchLoginV2Response() async -> LoginV2Response? { + guard let loginRequest = loginRequest else { return nil } + let request = RequestWrapper( + method: .POST, + path: loginRequest.poll.endpoint, + body: "token=\(loginRequest.poll.token)" + ) + let (loginRes, _): (LoginV2Response?, Error?) = await NetworkHandler.sendDataRequest(request) + return loginRes + } } struct LoginLabel: View {