diff --git a/Simplified.xcodeproj/project.pbxproj b/Simplified.xcodeproj/project.pbxproj index 5d0e58ede..1d79a0607 100644 --- a/Simplified.xcodeproj/project.pbxproj +++ b/Simplified.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ 03F94CD11DD6288C00CE8F4F /* AccountsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F94CD01DD6288C00CE8F4F /* AccountsManager.swift */; }; 081387571BC574DA003DEA6A /* UILabel+NYPLAppearanceAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 081387561BC574DA003DEA6A /* UILabel+NYPLAppearanceAdditions.m */; }; 0813875A1BC5767F003DEA6A /* UIButton+NYPLAppearanceAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 081387591BC5767F003DEA6A /* UIButton+NYPLAppearanceAdditions.m */; }; + 0826CD2924AA21B2000F4030 /* SamlIDPCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0826CD2824AA21B2000F4030 /* SamlIDPCell.swift */; }; + 0826CD2F24AA2801000F4030 /* LibraryDescriptionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0826CD2E24AA2801000F4030 /* LibraryDescriptionCell.swift */; }; 085640CE1BB99FC30088BDBF /* NSURL+NYPLURLAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 085640CD1BB99FC30088BDBF /* NSURL+NYPLURLAdditions.m */; }; 0857A0F72478337D00C7984E /* NYPLSessionCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0857A0F62478337D00C7984E /* NYPLSessionCredentials.swift */; }; 0857A0FF247835FF00C7984E /* KeychainStoredVariable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0857A0FE247835FF00C7984E /* KeychainStoredVariable.swift */; }; @@ -33,6 +35,8 @@ 086C45D624AE77CA00F5108E /* NYPLBasicAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086C45D524AE77CA00F5108E /* NYPLBasicAuth.swift */; }; 086C45D724AE77CA00F5108E /* NYPLBasicAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086C45D524AE77CA00F5108E /* NYPLBasicAuth.swift */; }; 086C45DC24AE77E600F5108E /* NYPLUserAccountFrontEndValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C481AD247CAEEB003A6723 /* NYPLUserAccountFrontEndValidation.swift */; }; + 089E42C6249A823800310360 /* NYPLCookiesWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089E42C5249A823800310360 /* NYPLCookiesWebViewController.swift */; }; + 089E430C24A2459100310360 /* LoginCellTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089E430B24A2459100310360 /* LoginCellTypes.swift */; }; 08A352201BDE8E410040BF1D /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 08A3521F1BDE8E410040BF1D /* CFNetwork.framework */; }; 08A352221BDE8E560040BF1D /* libicucore.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 08A352211BDE8E560040BF1D /* libicucore.tbd */; }; 08A352241BDE8E640040BF1D /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 08A352231BDE8E640040BF1D /* Security.framework */; }; @@ -567,6 +571,8 @@ 081387561BC574DA003DEA6A /* UILabel+NYPLAppearanceAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UILabel+NYPLAppearanceAdditions.m"; sourceTree = ""; }; 081387581BC5767F003DEA6A /* UIButton+NYPLAppearanceAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIButton+NYPLAppearanceAdditions.h"; sourceTree = ""; }; 081387591BC5767F003DEA6A /* UIButton+NYPLAppearanceAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIButton+NYPLAppearanceAdditions.m"; sourceTree = ""; }; + 0826CD2824AA21B2000F4030 /* SamlIDPCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SamlIDPCell.swift; sourceTree = ""; }; + 0826CD2E24AA2801000F4030 /* LibraryDescriptionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryDescriptionCell.swift; sourceTree = ""; }; 085640CC1BB99FC30088BDBF /* NSURL+NYPLURLAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSURL+NYPLURLAdditions.h"; sourceTree = ""; }; 085640CD1BB99FC30088BDBF /* NSURL+NYPLURLAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSURL+NYPLURLAdditions.m"; sourceTree = ""; }; 0857A0F62478337D00C7984E /* NYPLSessionCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYPLSessionCredentials.swift; sourceTree = ""; }; @@ -578,6 +584,8 @@ 085D31DE1BE3CD3C007F7672 /* NSURLRequest+NYPLURLRequestAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSURLRequest+NYPLURLRequestAdditions.m"; sourceTree = ""; }; 085D31FB1BE7BE86007F7672 /* ReaderClientCert.sig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = ReaderClientCert.sig; sourceTree = ""; }; 086C45D524AE77CA00F5108E /* NYPLBasicAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYPLBasicAuth.swift; sourceTree = ""; }; + 089E42C5249A823800310360 /* NYPLCookiesWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYPLCookiesWebViewController.swift; sourceTree = ""; }; + 089E430B24A2459100310360 /* LoginCellTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginCellTypes.swift; sourceTree = ""; }; 08A3521F1BDE8E410040BF1D /* CFNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = System/Library/Frameworks/CFNetwork.framework; sourceTree = SDKROOT; }; 08A352211BDE8E560040BF1D /* libicucore.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libicucore.tbd; path = usr/lib/libicucore.tbd; sourceTree = SDKROOT; }; 08A352231BDE8E640040BF1D /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -1079,6 +1087,16 @@ name = CardCreator; sourceTree = ""; }; + 089E430724A2456E00310360 /* Login */ = { + isa = PBXGroup; + children = ( + 089E430B24A2459100310360 /* LoginCellTypes.swift */, + 0826CD2824AA21B2000F4030 /* SamlIDPCell.swift */, + 0826CD2E24AA2801000F4030 /* LibraryDescriptionCell.swift */, + ); + name = Login; + sourceTree = ""; + }; 110AF8881961D652004887C3 /* My Books */ = { isa = PBXGroup; children = ( @@ -1321,6 +1339,7 @@ 111E75791A80165C00718AD7 /* NYPLSettingsSplitViewController.m */, E6BA02B71DE4B6F600F76404 /* RemoteHTMLViewController.swift */, 731A5B1124621F2B00B5E663 /* NYPLSignInBusinessLogic.swift */, + 089E42C5249A823800310360 /* NYPLCookiesWebViewController.swift */, ); name = Settings; sourceTree = ""; @@ -1513,6 +1532,7 @@ A823D816192BABA400B55DE2 /* Simplified */ = { isa = PBXGroup; children = ( + 089E430724A2456E00310360 /* Login */, 110AF8891961D66C004887C3 /* Additions */, 110AF8901961D822004887C3 /* Book */, 0345BFEE1DBF031B00398B6F /* CardCreator */, @@ -2261,6 +2281,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0826CD2F24AA2801000F4030 /* LibraryDescriptionCell.swift in Sources */, 2DE514351DC3F0BE005A58BD /* NYPLCirculationAnalytics.swift in Sources */, 171966A924170819007BB87E /* NYPLBookState.swift in Sources */, 03DE7BE91DBF0DE400E89064 /* UpdateCheckShim.swift in Sources */, @@ -2404,6 +2425,8 @@ 113DB8A719C24E54004E1154 /* NYPLIndeterminateProgressView.m in Sources */, 119BEB89198C43A600121439 /* NSString+NYPLStringAdditions.m in Sources */, E6B6E76F1F6859A4007EE361 /* NYPLKeychainManager.swift in Sources */, + 089E42C6249A823800310360 /* NYPLCookiesWebViewController.swift in Sources */, + 0826CD2924AA21B2000F4030 /* SamlIDPCell.swift in Sources */, 5D7CF8B922C3FC06007CAA34 /* NYPLErrorLogger.swift in Sources */, A499BF261B39EFC7002F8B8B /* NYPLOPDSEntryGroupAttributes.m in Sources */, 1196F75B1970727C00F62670 /* NYPLMyBooksDownloadCenter.m in Sources */, @@ -2415,6 +2438,7 @@ 8CE9C471237F84820072E964 /* NYPLBookDetailsProblemDocumentViewController.swift in Sources */, A4BA1D131B43046B006F83DF /* NYPLCatalogGroupedFeed.m in Sources */, 11F3773319E0876F00487769 /* NYPLCatalogFacet.m in Sources */, + 089E430C24A2459100310360 /* LoginCellTypes.swift in Sources */, 1158812E1A894F4E008672C3 /* NYPLAccountSignInViewController.m in Sources */, 5D3A28CC22D3DA850042B3BD /* NYPLUserProfileDocument.swift in Sources */, 03F94CD11DD6288C00CE8F4F /* AccountsManager.swift in Sources */, diff --git a/Simplified/Account.swift b/Simplified/Account.swift index ea433e76b..b1e2a4585 100644 --- a/Simplified/Account.swift +++ b/Simplified/Account.swift @@ -3,6 +3,25 @@ private let userAcceptedEULAKey = "NYPLSettingsUserAcceptedEULA" private let userAboveAgeKey = "NYPLSettingsUserAboveAgeKey" private let accountSyncEnabledKey = "NYPLAccountSyncEnabledKey" + +@objcMembers +class SamlIDP: NSObject, Codable { + let url: URL + + private let displayNames: [String: String]? + private let descriptions: [String: String]? + + var displayName: String? { displayNames?["en"] } + var idpDescription: String? { descriptions?["en"] } + + init?(opdsLink: OPDS2Link) { + guard let url = URL(string: opdsLink.href) else { return nil } + self.url = url + self.displayNames = opdsLink.displayNames?.reduce(into: [String: String]()) { $0[$1.language] = $1.value } + self.descriptions = opdsLink.descriptions?.reduce(into: [String: String]()) { $0[$1.language] = $1.value } + } +} + // MARK: AccountDetails // Extra data that gets loaded from an OPDS2AuthenticationDocument, @objcMembers final class AccountDetails: NSObject { @@ -28,14 +47,20 @@ private let accountSyncEnabledKey = "NYPLAccountSyncEnabledKey" let supportsBarcodeDisplay:Bool let coppaUnderUrl:URL? let coppaOverUrl:URL? - + let oauthIntermediaryUrl:URL? + let methodDescription: String? + + let samlIdps: [SamlIDP]? + init(auth: OPDS2AuthenticationDocument.Authentication) { - authType = AuthType(rawValue: auth.type) ?? .none + let authType = AuthType(rawValue: auth.type) ?? .none + self.authType = authType authPasscodeLength = auth.inputs?.password.maximumLength ?? 99 patronIDKeyboard = LoginKeyboard.init(auth.inputs?.login.keyboard) ?? .standard pinKeyboard = LoginKeyboard.init(auth.inputs?.password.keyboard) ?? .standard patronIDLabel = auth.labels?.login pinLabel = auth.labels?.password + methodDescription = auth.description supportsBarcodeScanner = auth.inputs?.login.barcodeFormat == "Codabar" supportsBarcodeDisplay = supportsBarcodeScanner @@ -43,14 +68,38 @@ private let accountSyncEnabledKey = "NYPLAccountSyncEnabledKey" case .coppa: coppaUnderUrl = URL.init(string: auth.links?.first(where: { $0.rel == "http://librarysimplified.org/terms/rel/authentication/restriction-not-met" })?.href ?? "") coppaOverUrl = URL.init(string: auth.links?.first(where: { $0.rel == "http://librarysimplified.org/terms/rel/authentication/restriction-met" })?.href ?? "") + oauthIntermediaryUrl = nil + samlIdps = nil + + case .oauthIntermediary: + oauthIntermediaryUrl = URL.init(string: auth.links?.first(where: { $0.rel == "authenticate" })?.href ?? "") + coppaUnderUrl = nil + coppaOverUrl = nil + samlIdps = nil + + case .saml: + samlIdps = auth.links?.filter { $0.rel == "authenticate" }.compactMap { SamlIDP(opdsLink: $0) } + oauthIntermediaryUrl = nil + coppaUnderUrl = nil + coppaOverUrl = nil case .none, .basic, .anonymous: + oauthIntermediaryUrl = nil coppaUnderUrl = nil coppaOverUrl = nil + samlIdps = nil } } + var needsAuth:Bool { + return authType == .basic || authType == .oauthIntermediary || authType == .saml + } + + var needsAgeCheck:Bool { + return authType == .coppa + } + var isCatalogSecured: Bool { return authType == .oauthIntermediary || authType == .saml } @@ -76,6 +125,9 @@ private let accountSyncEnabledKey = "NYPLAccountSyncEnabledKey" supportsBarcodeDisplay = authentication.supportsBarcodeDisplay coppaUnderUrl = authentication.coppaUnderUrl coppaOverUrl = authentication.coppaOverUrl + oauthIntermediaryUrl = authentication.oauthIntermediaryUrl + methodDescription = authentication.methodDescription + samlIdps = authentication.samlIdps } } @@ -90,42 +142,6 @@ private let accountSyncEnabledKey = "NYPLAccountSyncEnabledKey" let userProfileUrl:String? let signUpUrl:URL? let loansUrl:URL? - var authType: AuthType { - return auths.first?.authType ?? .none - } - - var authPasscodeLength: UInt { - return auths.first?.authPasscodeLength ?? 99 - } - - var patronIDKeyboard: LoginKeyboard { - return auths.first?.patronIDKeyboard ?? .standard - } - - var pinKeyboard: LoginKeyboard { - return auths.first?.pinKeyboard ?? .standard - } - - var supportsBarcodeScanner: Bool { - return auths.first?.supportsBarcodeScanner ?? false - } - - var supportsBarcodeDisplay: Bool { - return auths.first?.supportsBarcodeDisplay ?? false - } - - var coppaUnderUrl: URL? { - return auths.first?.coppaUnderUrl - } - - var coppaOverUrl: URL? { - return auths.first?.coppaOverUrl - } - - var needsAuth:Bool { - return authType == .basic - } - var defaultAuth: Authentication? { guard auths.count > 1 else { return auths.first } return auths.first(where: { !$0.isCatalogSecured }) ?? auths.first diff --git a/Simplified/AccountsManager.swift b/Simplified/AccountsManager.swift index 26aaed6d5..107e19806 100644 --- a/Simplified/AccountsManager.swift +++ b/Simplified/AccountsManager.swift @@ -71,7 +71,13 @@ private let prodUrlHash = prodUrl.absoluteString.md5().base64EncodedStringUrlSaf object: nil ) - self.loadCatalogs(completion: {_ in }) + // It needs to be done asynchronously, so that init returns prior to calling it + // Otherwise it would try to access itself before intialization is finished + // Network executor will try to access shared accounts manager, as it needs it to get headers data + // Thik of this async block as you would about viewDidLoad which is triggered after a view is loaded + OperationQueue.current?.underlyingQueue?.async { + self.loadCatalogs(completion: {_ in }) + } } let completionHandlerAccessQueue = DispatchQueue(label: "libraryListCompletionHandlerAccessQueue") diff --git a/Simplified/LibraryDescriptionCell.swift b/Simplified/LibraryDescriptionCell.swift new file mode 100644 index 000000000..331b09e88 --- /dev/null +++ b/Simplified/LibraryDescriptionCell.swift @@ -0,0 +1,36 @@ +// +// LibraryDescriptionCell.swift +// SimplyE +// +// Created by Jacek Szyja on 29/06/2020. +// Copyright © 2020 NYPL Labs. All rights reserved. +// + +import UIKit + +@objcMembers +class LibraryDescriptionCell: UITableViewCell { + + let descriptionLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.font = UIFont(name: "AvenirNext-Regular", size: 12) + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + descriptionLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(descriptionLabel) + + descriptionLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor).isActive = true + descriptionLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true + descriptionLabel.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -32).isActive = true + descriptionLabel.heightAnchor.constraint(equalTo: contentView.heightAnchor, constant: -16).isActive = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Simplified/LoginCellTypes.swift b/Simplified/LoginCellTypes.swift new file mode 100644 index 000000000..dbed3c7c6 --- /dev/null +++ b/Simplified/LoginCellTypes.swift @@ -0,0 +1,37 @@ +// +// LoginCellTypes.swift +// SimplyE +// +// Created by Jacek Szyja on 23/06/2020. +// Copyright © 2020 NYPL Labs. All rights reserved. +// + +import Foundation + +@objcMembers +class AuthMethodCellType: NSObject { + let authenticationMethod: AccountDetails.Authentication + + init(authenticationMethod: AccountDetails.Authentication) { + self.authenticationMethod = authenticationMethod + } +} + +@objcMembers +class InfoHeaderCellType: NSObject { + let information: String + + init(information: String) { + self.information = information + } +} + +@objcMembers +class SamlIdpCellType: NSObject { + let idp: SamlIDP + + init(idp: SamlIDP) { + self.idp = idp + } +} + diff --git a/Simplified/NYPLAccountSignInViewController.h b/Simplified/NYPLAccountSignInViewController.h index cca46e3ab..b7346edcd 100644 --- a/Simplified/NYPLAccountSignInViewController.h +++ b/Simplified/NYPLAccountSignInViewController.h @@ -18,4 +18,6 @@ // and pin. + (void)authorizeUsingExistingBarcodeAndPinWithCompletionHandler:(void (^)(void))handler; ++ (void)authorizeUsingIntermediaryWithCompletionHandler:(void (^)(void))handler; + @end diff --git a/Simplified/NYPLAccountSignInViewController.m b/Simplified/NYPLAccountSignInViewController.m index 91b5e10fa..b65d48924 100644 --- a/Simplified/NYPLAccountSignInViewController.m +++ b/Simplified/NYPLAccountSignInViewController.m @@ -56,6 +56,9 @@ @interface NYPLAccountSignInViewController () 0 && self.businessLogic.isSignedIn) { + // sign out from SAML + [section addObject:@(CellKindLogInSignOut)]; + } else if (authenticationMethod.samlIdps.count > 0) { + for (SamlIDP *idp in authenticationMethod.samlIdps) { + SamlIdpCellType *idpCell = [[SamlIdpCellType alloc] initWithIdp:idp]; + [section addObject:idpCell]; + } + } else if (authenticationMethod.pinKeyboard != LoginKeyboardNone) { + [section addObjectsFromArray:@[@(CellKindBarcode), @(CellKindPIN), @(CellKindLogInSignOut)]]; + } else { + //Server expects a blank string. Passes local textfield validation. + self.PINTextField.text = @""; + [section addObjectsFromArray:@[@(CellKindBarcode), @(CellKindLogInSignOut)]]; + } + }; + + NSMutableArray *section0AcctInfo; + if (!self.businessLogic.selectedAuthentication.needsAuth && self.businessLogic.selectedAuthentication) { + section0AcctInfo = @[].mutableCopy; + } else if (self.businessLogic.selectedAuthentication && self.businessLogic.isSignedIn) { + // user already logged in + // show only the selected auth method + section0AcctInfo = @[].mutableCopy; + insertCredentials(self.businessLogic.selectedAuthentication, section0AcctInfo); + } else if (!self.businessLogic.isSignedIn && self.businessLogic.userAccount.needsAuth) { + // user needs to sign in + section0AcctInfo = @[].mutableCopy; + + NSUInteger samlIndex = [self.businessLogic.libraryAccount.details.auths indexOfObjectPassingTest:^BOOL(AccountDetailsAuthentication * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + return obj.samlIdps.count > 0; + }]; + + if (samlIndex != NSNotFound) { + NSString *libraryInfo = [NSString stringWithFormat:@"Log in to %@ required to download materials.", self.businessLogic.libraryAccount.name]; + [section0AcctInfo addObject:[[InfoHeaderCellType alloc] initWithInformation:libraryInfo]]; + } + + if (self.businessLogic.libraryAccount.details.auths.count > 1) { + // multiple authentication methods + for (AccountDetailsAuthentication *authenticationMethod in self.businessLogic.libraryAccount.details.auths) { + // show all possible login methods + AuthMethodCellType *autheticationCell = [[AuthMethodCellType alloc] initWithAuthenticationMethod:authenticationMethod]; + [section0AcctInfo addObject:autheticationCell]; + if (authenticationMethod.methodDescription == self.businessLogic.selectedAuthentication.methodDescription) { + // selected method, unfold + insertCredentials(authenticationMethod, section0AcctInfo); + } + } + } else if (self.businessLogic.libraryAccount.details.auths.count == 1) { + // only 1 authentication method + // no method header needed + insertCredentials(self.businessLogic.libraryAccount.details.auths[0], section0AcctInfo); + } else if (self.businessLogic.selectedAuthentication) { + // only 1 authentication method + // no method header needed + insertCredentials(self.businessLogic.selectedAuthentication, section0AcctInfo); + } } else { - //Server expects a blank string. Passes local textfield validation. - self.PINTextField.text = @""; - section0 = @[@(CellKindBarcode), - @(CellKindLogInSignOut)]; + section0AcctInfo = @[].mutableCopy; + insertCredentials(self.businessLogic.selectedAuthentication, section0AcctInfo); } + NSArray *section1; if ([self.businessLogic registrationIsPossible]) { section1 = @[@(CellKindRegistration)]; } else { section1 = @[]; } - self.tableData = @[section0, section1]; + self.tableData = @[section0AcctInfo, section1]; + [self.tableView reloadData]; } - (void)viewWillAppear:(BOOL)animated @@ -246,7 +305,6 @@ - (void)viewWillAppear:(BOOL)animated } else { self.hiddenPIN = YES; [self accountDidChange]; - [self.tableView reloadData]; [self updateShowHidePINState]; } } @@ -271,6 +329,27 @@ - (void)tableView:(__attribute__((unused)) UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *const)indexPath { NSArray *sectionArray = (NSArray *)self.tableData[indexPath.section]; + + if ([sectionArray[indexPath.row] isKindOfClass:[AuthMethodCellType class]]) { + AuthMethodCellType *methodCell = sectionArray[indexPath.row]; + [self.tableView deselectRowAtIndexPath:indexPath animated:YES]; + + self.businessLogic.selectedIDP = nil; + self.businessLogic.selectedAuthentication = methodCell.authenticationMethod; + [self setupTableData]; + return; + } else if ([sectionArray[indexPath.row] isKindOfClass:[SamlIdpCellType class]]) { + SamlIdpCellType *idpCell = sectionArray[indexPath.row]; + [self.tableView deselectRowAtIndexPath:indexPath animated:YES]; + + self.businessLogic.selectedIDP = idpCell.idp; + [self logIn]; + return; + } else if ([sectionArray[indexPath.row] isKindOfClass:[InfoHeaderCellType class]]) { + [self.tableView deselectRowAtIndexPath:indexPath animated:YES]; + return; + } + CellKind cellKind = (CellKind)[sectionArray[indexPath.row] intValue]; switch(cellKind) { @@ -354,6 +433,27 @@ - (UITableViewCell *)tableView:(__attribute__((unused)) UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *const)indexPath { NSArray *sectionArray = (NSArray *)self.tableData[indexPath.section]; + + if ([sectionArray[indexPath.row] isKindOfClass:[AuthMethodCellType class]]) { + AuthMethodCellType *methodCell = sectionArray[indexPath.row]; + UITableViewCell *cell = [[UITableViewCell alloc] + initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:nil]; + cell.textLabel.font = [UIFont customFontForTextStyle:UIFontTextStyleBody]; + cell.textLabel.text = methodCell.authenticationMethod.methodDescription; + return cell; + } else if ([sectionArray[indexPath.row] isKindOfClass:[SamlIdpCellType class]]) { + SamlIdpCellType *idpCell = sectionArray[indexPath.row]; + SamlIDPCell *cell = [[SamlIDPCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; + cell.idpName.text = idpCell.idp.displayName; + return cell; + } else if ([sectionArray[indexPath.row] isKindOfClass:[InfoHeaderCellType class]]) { + InfoHeaderCellType *infoCell = sectionArray[indexPath.row]; + LibraryDescriptionCell *cell = [[LibraryDescriptionCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; + cell.descriptionLabel.text = infoCell.information; + return cell; + } + CellKind cellKind = (CellKind)[sectionArray[indexPath.row] intValue]; switch(cellKind) { @@ -538,14 +638,22 @@ - (NSString *)pin // Tell |accountViewController| to create its text fields so we can set their properties. [accountViewController view]; - if(useExistingBarcode) { - NSString *const barcode = [NYPLUserAccount sharedAccount].barcode; - if(!barcode) { - @throw NSInvalidArgumentException; + if (NYPLUserAccount.sharedAccount.authDefinition.samlIdps.count > 0) { + if (!useExistingBarcode) { + accountViewController.businessLogic.forceLogIn = true; + accountViewController.businessLogic.selectedAuthentication = nil; + [accountViewController setupTableData]; } - accountViewController.usernameTextField.text = barcode; } else { - accountViewController.usernameTextField.text = @""; + if(useExistingBarcode) { + NSString *const barcode = [NYPLUserAccount sharedAccount].barcode; + if(!barcode) { + @throw NSInvalidArgumentException; + } + accountViewController.usernameTextField.text = barcode; + } else { + accountViewController.usernameTextField.text = @""; + } } accountViewController.PINTextField.text = @""; @@ -568,8 +676,12 @@ - (NSString *)pin completion:nil]; if (authorizeImmediately && [NYPLUserAccount sharedAccount].hasBarcodeAndPIN) { - accountViewController.PINTextField.text = [NYPLUserAccount sharedAccount].PIN; - [accountViewController logIn]; + accountViewController.PINTextField.text = [NYPLUserAccount sharedAccount].PIN; + [accountViewController logIn]; + } else if (authorizeImmediately && NYPLUserAccount.sharedAccount.authDefinition.oauthIntermediaryUrl) { + [accountViewController logIn]; + } else if (NYPLUserAccount.sharedAccount.authDefinition.samlIdps.count > 0) { + } else { if(useExistingBarcode) { [accountViewController.PINTextField becomeFirstResponder]; @@ -591,6 +703,12 @@ + (void)authorizeUsingExistingBarcodeAndPinWithCompletionHandler:(void (^)(void) [self requestCredentialsUsingExistingBarcode:YES authorizeImmediately:YES completionHandler:handler]; } ++ (void)authorizeUsingIntermediaryWithCompletionHandler:(void (^)(void))handler +{ + [self requestCredentialsUsingExistingBarcode:NO authorizeImmediately:YES completionHandler:handler]; +} + + #pragma mark - - (void)textFieldsDidChange @@ -714,8 +832,7 @@ - (void)accountDidChange self.PINTextField.textColor = [UIColor defaultLabelColor]; } - [self.tableView reloadData]; - + [self setupTableData]; [self updateLoginLogoutCellAppearance]; }]; } @@ -743,30 +860,137 @@ - (void)updateLoginLogoutCellAppearance stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]].length; BOOL const pinHasText = [self.PINTextField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]].length; - BOOL const pinIsNotRequired = self.currentAccount.details.pinKeyboard == LoginKeyboardNone; - if((barcodeHasText && pinHasText) || (barcodeHasText && pinIsNotRequired)) { - self.logInSignOutCell.userInteractionEnabled = YES; - self.logInSignOutCell.textLabel.textColor = [NYPLConfiguration mainColor]; + BOOL const pinIsNotRequired = self.businessLogic.selectedAuthentication.pinKeyboard == LoginKeyboardNone; + BOOL const oauthLogin = self.businessLogic.selectedAuthentication.oauthIntermediaryUrl != nil; + + if((barcodeHasText && pinHasText) || (barcodeHasText && pinIsNotRequired) || oauthLogin) { + self.logInSignOutCell.userInteractionEnabled = YES; + self.logInSignOutCell.textLabel.textColor = [NYPLConfiguration mainColor]; } else { - self.logInSignOutCell.userInteractionEnabled = NO; - self.logInSignOutCell.textLabel.textColor = [UIColor lightGrayColor]; + self.logInSignOutCell.userInteractionEnabled = NO; + if (@available(iOS 13.0, *)) { + self.logInSignOutCell.textLabel.textColor = [UIColor systemGray2Color]; + } else { + self.logInSignOutCell.textLabel.textColor = [UIColor lightGrayColor]; + } } } } - (void)logIn { - assert(self.usernameTextField.text.length > 0); - assert(self.PINTextField.text.length > 0 || [self.PINTextField.text isEqualToString:@""]); - [self.usernameTextField resignFirstResponder]; - [self.PINTextField resignFirstResponder]; + if (self.businessLogic.selectedAuthentication.oauthIntermediaryUrl) { + // oauth + NSURL *oauthURL = self.businessLogic.selectedAuthentication.oauthIntermediaryUrl; + + NSURLComponents *urlComponents = [[NSURLComponents alloc] initWithURL:oauthURL resolvingAgainstBaseURL:true]; + + // add params + NSURLQueryItem *redirect_uri = [[NSURLQueryItem alloc] initWithName:@"redirect_uri" value:@"https://skyneck.pl/login"]; + urlComponents.queryItems = [urlComponents.queryItems arrayByAddingObject:redirect_uri]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleRedirectURL:) + name: @"NYPLAppDelegateDidReceiveCleverRedirectURL" + object:nil]; + + [UIApplication.sharedApplication openURL: urlComponents.URL]; + [[UIApplication sharedApplication] beginIgnoringInteractionEvents]; + } else if (self.businessLogic.selectedAuthentication.samlIdps.count > 0) { + // SAML + NSURL *idpURL = self.businessLogic.selectedIDP.url; + + NSURLComponents *urlComponents = [[NSURLComponents alloc] initWithURL:idpURL resolvingAgainstBaseURL:true]; + + // add params + NSURLQueryItem *redirect_uri = [[NSURLQueryItem alloc] initWithName:@"redirect_uri" value:@"https://skyneck.pl/login"]; + urlComponents.queryItems = [urlComponents.queryItems arrayByAddingObject:redirect_uri]; + NSURL *url = urlComponents.URL; + + CookiesWebViewModel *model = [[CookiesWebViewModel alloc] initWithCookies:@[] + request:[[NSURLRequest alloc] initWithURL:url] + loginCompletionHandler:^(NSURL * _Nonnull url, NSArray * _Nonnull cookies) { + self.cookies = cookies; + [self handleRedirectURL:[NSNotification notificationWithName:@"NYPLAppDelegateDidReceiveCleverRedirectURL" + object:url + userInfo:nil]]; + [self dismissViewControllerAnimated:YES completion:nil]; + } + loginCancelHandler:nil + bookFoundHandler:nil + problemFoundHandler:nil + autoPresentIfNeeded:NO]; + NYPLCookiesWebViewController *cookiesVC = [[NYPLCookiesWebViewController alloc] initWithModel:model]; + UINavigationController *navigationWrapper = [[UINavigationController alloc] initWithRootViewController:cookiesVC]; + [self presentViewController:navigationWrapper animated:YES completion:nil]; + } else { + // bar and pin + assert(self.usernameTextField.text.length > 0); + assert(self.PINTextField.text.length > 0 || [self.PINTextField.text isEqualToString:@""]); - [self setActivityTitleWithText:NSLocalizedString(@"Verifying", nil)]; - - [[UIApplication sharedApplication] beginIgnoringInteractionEvents]; - - [self validateCredentials]; + [self.usernameTextField resignFirstResponder]; + [self.PINTextField resignFirstResponder]; + + [self setActivityTitleWithText:NSLocalizedString(@"Verifying", nil)]; + + [[UIApplication sharedApplication] beginIgnoringInteractionEvents]; + + [self validateCredentials]; + } +} + +- (void) handleRedirectURL: (NSNotification *) notification +{ + [NSNotificationCenter.defaultCenter removeObserver: self name: @"NYPLAppDelegateDidReceiveCleverRedirectURL" object: nil]; + + NSURL *url = notification.object; + if (![url.absoluteString hasPrefix:@"https://skyneck.pl/login"] + || !([url.absoluteString containsString:@"error"] || [url.absoluteString containsString:@"access_token"])) + { + [self displayErrorMessage:nil]; + return; + } + + NSMutableDictionary *kvpairs = [[NSMutableDictionary alloc] init]; + NSString *responseData = url.fragment != nil ? url.fragment : url.query; + for (NSString *param in [responseData componentsSeparatedByString:@"&"]) { + NSArray *elts = [param componentsSeparatedByString:@"="]; + if([elts count] < 2) continue; + [kvpairs setObject:[elts lastObject] forKey:[elts firstObject]]; + } + + if (kvpairs[@"error"]) { + NSString *error = [[kvpairs[@"error"] stringByReplacingOccurrencesOfString:@"+" withString:@" "] stringByRemovingPercentEncoding]; + + NSDictionary *parsedError = [error parseJSONString]; + + if (parsedError) { + [self displayErrorMessage:parsedError[@"title"]]; + } + } + + NSString *auth_token = kvpairs[@"access_token"]; + NSString *patron_info = kvpairs[@"patron_info"]; + + if (auth_token != nil && patron_info != nil) { + NSString *patron = [[patron_info stringByReplacingOccurrencesOfString:@"+" withString:@" "] stringByRemovingPercentEncoding]; + + NSDictionary *parsedPatron = [patron parseJSONString]; + if (parsedPatron) { + self.authToken = auth_token; + self.patron = parsedPatron; + [self validateCredentials]; + } + } +} + +- (void)displayErrorMessage:(NSString *)errorMessage { + UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero]; + label.text = errorMessage; + [label sizeToFit]; + label.center = CGPointMake(self.view.frame.size.width / 2, self.view.frame.size.height / 2); + [self.view addSubview:label]; } - (void)setActivityTitleWithText:(NSString *)text @@ -816,7 +1040,15 @@ - (void)validateCredentials [self.currentAccount.details userProfileUrl]]]; request.timeoutInterval = 20.0; - + + if (self.businessLogic.selectedAuthentication.oauthIntermediaryUrl || self.businessLogic.selectedAuthentication.samlIdps.count > 0) { + NSString *authToken = self.authToken; + if (authToken != nil) { + NSString *authenticationValue = [@"Bearer " stringByAppendingString: authToken]; + [request addValue:authenticationValue forHTTPHeaderField:@"Authorization"]; + } + } + self.isCurrentlySigningIn = YES; NSURLSessionDataTask *const task = [self.session @@ -1005,7 +1237,19 @@ - (void)authorizationAttemptDidFinish:(BOOL)success error:(NSError *)error [[UIApplication sharedApplication] endIgnoringInteractionEvents]; if(success) { + self.businessLogic.sessionRefreshed = YES; + if (self.businessLogic.selectedAuthentication.oauthIntermediaryUrl) { + [self.businessLogic.userAccount setAuthToken:self.authToken]; + [self.businessLogic.userAccount setPatron:self.patron]; + } else if (self.businessLogic.selectedAuthentication.samlIdps.count > 0) { + [self.businessLogic.userAccount setAuthToken:self.authToken]; + [self.businessLogic.userAccount setPatron:self.patron]; + if (self.cookies) { + [self.businessLogic.userAccount setCookies:self.cookies]; + } + } else { [self.businessLogic.userAccount setBarcode:self.usernameTextField.text PIN:self.PINTextField.text]; + } self.businessLogic.userAccount.authDefinition = self.businessLogic.selectedAuthentication; diff --git a/Simplified/NYPLAnnotations.swift b/Simplified/NYPLAnnotations.swift index ff437091e..cef7645fb 100644 --- a/Simplified/NYPLAnnotations.swift +++ b/Simplified/NYPLAnnotations.swift @@ -653,12 +653,20 @@ import UIKit return syncIsPossible(NYPLUserAccount.sharedAccount()) && acct?.details?.syncPermissionGranted == true } - class func setDefaultAnnotationHeaders(forRequest request: inout URLRequest) { - for (headerKey, headerValue) in NYPLAnnotations.headers { - request.setValue(headerValue, forHTTPHeaderField: headerKey) + @objc class func addingDefaultAnnotationHeaders(to request: URLRequest) -> URLRequest { + var request = request + for (headerKey, headerValue) in NYPLAnnotations.headers { + request.setValue(headerValue, forHTTPHeaderField: headerKey) + } + return request } + + class func setDefaultAnnotationHeaders(forRequest request: inout URLRequest) { + for (headerKey, headerValue) in NYPLAnnotations.headers { + request.setValue(headerValue, forHTTPHeaderField: headerKey) + } } - + class var headers: [String:String] { if let barcode = NYPLUserAccount.sharedAccount().barcode, let pin = NYPLUserAccount.sharedAccount().PIN { let authenticationString = "\(barcode):\(pin)" @@ -669,8 +677,12 @@ import UIKit } else { Log.error(#file, "Error formatting auth headers.") } + } else if let authToken = NYPLUserAccount.sharedAccount().authToken { + let authenticationValue = "Bearer \(authToken)" + return ["Authorization" : "\(authenticationValue)", + "Content-Type" : "application/json"] } else { - Log.error(#file, "Attempted to create authorization header without a barcode or pin.") + Log.error(#file, "Attempted to create authorization header with neither an oauth token nor a barcode and pin pair.") } return ["Authorization" : "", "Content-Type" : "application/json"] diff --git a/Simplified/NYPLAppDelegate.m b/Simplified/NYPLAppDelegate.m index 3c15f229e..87378c981 100644 --- a/Simplified/NYPLAppDelegate.m +++ b/Simplified/NYPLAppDelegate.m @@ -103,10 +103,24 @@ - (void)application:(__attribute__((unused)) UIApplication *)application } } +- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray> * _Nullable))restorationHandler +{ + if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb] && [userActivity.webpageURL.host isEqualToString:@"skyneck.pl"]) { + [[NSNotificationCenter defaultCenter] + postNotificationName:@"NYPLAppDelegateDidReceiveCleverRedirectURL" + object:userActivity.webpageURL]; + + return YES; + } + + return NO; +} + - (BOOL)application:(__unused UIApplication *)app openURL:(NSURL *)url options:(__unused NSDictionary *)options { + // URLs should be a permalink to a feed URL NSURL *entryURL = [url URLBySwappingForScheme:@"http"]; NSData *data = [NSData dataWithContentsOfURL:entryURL]; diff --git a/Simplified/NYPLBookButtonsView.m b/Simplified/NYPLBookButtonsView.m index e515722df..b6dae9eeb 100644 --- a/Simplified/NYPLBookButtonsView.m +++ b/Simplified/NYPLBookButtonsView.m @@ -336,6 +336,7 @@ - (void)didSelectReturn switch([[NYPLBookRegistry sharedRegistry] stateForIdentifier:self.book.identifier]) { case NYPLBookStateUsed: + case NYPLBookStateSAMLStarted: case NYPLBookStateDownloading: case NYPLBookStateUnregistered: case NYPLBookStateDownloadFailed: diff --git a/Simplified/NYPLBookCell.m b/Simplified/NYPLBookCell.m index 62e476840..e6504e4f5 100644 --- a/Simplified/NYPLBookCell.m +++ b/Simplified/NYPLBookCell.m @@ -83,6 +83,7 @@ void NYPLBookCellRegisterClassesForCollectionView(UICollectionView *const collec cell.state = NYPLBookButtonsStateDownloadSuccessful; return cell; } + case NYPLBookStateSAMLStarted: case NYPLBookStateDownloading: { NYPLBookDownloadingCell *const cell = diff --git a/Simplified/NYPLBookDetailButtonsView.m b/Simplified/NYPLBookDetailButtonsView.m index dbd193e9e..e02a5bd2f 100644 --- a/Simplified/NYPLBookDetailButtonsView.m +++ b/Simplified/NYPLBookDetailButtonsView.m @@ -339,6 +339,7 @@ - (void)didSelectReturn switch([[NYPLBookRegistry sharedRegistry] stateForIdentifier:self.book.identifier]) { case NYPLBookStateUsed: + case NYPLBookStateSAMLStarted: case NYPLBookStateDownloading: case NYPLBookStateUnregistered: case NYPLBookStateDownloadFailed: @@ -403,6 +404,7 @@ - (void)didSelectDownload - (void)didSelectCancel { switch([[NYPLBookRegistry sharedRegistry] stateForIdentifier:self.book.identifier]) { + case NYPLBookStateSAMLStarted: case NYPLBookStateDownloading: { [self.downloadingDelegate didSelectCancelForBookDetailDownloadingView:self]; break; diff --git a/Simplified/NYPLBookDetailView.m b/Simplified/NYPLBookDetailView.m index d12ac895f..05542efeb 100644 --- a/Simplified/NYPLBookDetailView.m +++ b/Simplified/NYPLBookDetailView.m @@ -557,6 +557,9 @@ - (void)setState:(NYPLBookState)state self.normalView.state = NYPLBookButtonsStateDownloadNeeded; self.buttonsView.state = NYPLBookButtonsStateDownloadNeeded; break; + case NYPLBookStateSAMLStarted: + self.downloadingView.downloadProgress = 0; + self.downloadingView.downloadStarted = false; case NYPLBookStateDownloading: self.downloadFailedView.hidden = YES; [self hideDownloadingView:NO]; diff --git a/Simplified/NYPLBookRegistry.m b/Simplified/NYPLBookRegistry.m index 190d50f12..a8dec9fde 100644 --- a/Simplified/NYPLBookRegistry.m +++ b/Simplified/NYPLBookRegistry.m @@ -178,7 +178,7 @@ - (void)loadWithoutBroadcastingForAccount:(NSString *)account continue; } // If a download was still in progress when we quit, it must now be failed. - if(record.state == NYPLBookStateDownloading) { + if(record.state == NYPLBookStateDownloading || record.state == NYPLBookStateSAMLStarted) { self.identifiersToRecords[record.book.identifier] = [record recordWithState:NYPLBookStateDownloadFailed]; } else { @@ -797,6 +797,7 @@ - (NSArray *)myBooks { return [self booksMatchingStates:@[@(NYPLBookStateDownloadNeeded), @(NYPLBookStateDownloading), + @(NYPLBookStateSAMLStarted), @(NYPLBookStateDownloadFailed), @(NYPLBookStateDownloadSuccessful), @(NYPLBookStateUsed)]]; diff --git a/Simplified/NYPLBookState.swift b/Simplified/NYPLBookState.swift index 1e14f1d54..eec5f83c5 100644 --- a/Simplified/NYPLBookState.swift +++ b/Simplified/NYPLBookState.swift @@ -8,6 +8,7 @@ let UnregisteredKey = "unregistered" let HoldingKey = "holding" let UsedKey = "used" let UnsupportedKey = "unsupported" +let SAMLStartedKey = "saml-started" @objc public enum NYPLBookState : Int, CaseIterable { case Unregistered = 0 @@ -18,7 +19,8 @@ let UnsupportedKey = "unsupported" case Holding case Used case Unsupported - + case SAMLStarted + init?(_ stringValue: String) { switch stringValue { case DownloadingKey: @@ -37,6 +39,8 @@ let UnsupportedKey = "unsupported" self = .Used case UnsupportedKey: self = .Unsupported + case SAMLStartedKey: + self = .SAMLStarted default: return nil } @@ -60,6 +64,8 @@ let UnsupportedKey = "unsupported" return UsedKey; case .Unsupported: return UnsupportedKey; + case .SAMLStarted: + return SAMLStartedKey; } } } diff --git a/Simplified/NYPLCatalogNavigationController.m b/Simplified/NYPLCatalogNavigationController.m index ad8dfa2e3..4951a4de1 100644 --- a/Simplified/NYPLCatalogNavigationController.m +++ b/Simplified/NYPLCatalogNavigationController.m @@ -3,6 +3,7 @@ #import "NYPLCatalogNavigationController.h" +#import "NYPLAccountSignInViewController.h" #import "NYPLBookRegistry.h" #import "NYPLRootTabBarController.h" #import "NYPLMyBooksNavigationController.h" @@ -205,6 +206,13 @@ - (void)updateFeedAndRegistryOnAccountChange completion(); }); }]; + } else if (NYPLUserAccount.sharedAccount.isCatalogSecured && !NYPLUserAccount.sharedAccount.hasCredentials) { + // sign in + [NYPLAccountSignInViewController authorizeUsingIntermediaryWithCompletionHandler:^{ + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + }]; } else { if (![NSThread isMainThread]) { dispatch_async(dispatch_get_main_queue(), ^{ diff --git a/Simplified/NYPLCookiesWebViewController.swift b/Simplified/NYPLCookiesWebViewController.swift new file mode 100644 index 000000000..c14a69ff9 --- /dev/null +++ b/Simplified/NYPLCookiesWebViewController.swift @@ -0,0 +1,211 @@ +// +// NYPLCookiesWebViewController.swift +// SimplyE +// +// Created by Jacek Szyja on 17/06/2020. +// Copyright © 2020 NYPL Labs. All rights reserved. +// + +import UIKit +import WebKit + +@objcMembers +class CookiesWebViewModel: NSObject { + let cookies: [HTTPCookie] + let request: URLRequest + let loginCompletionHandler: ((URL, [HTTPCookie]) -> Void)? + let loginCancelHandler: (() -> Void)? + let bookFoundHandler: ((URLRequest?, [HTTPCookie]) -> Void)? + let problemFound: (((NYPLProblemDocument?)) -> Void)? + let autoPresentIfNeeded: Bool + + init(cookies: [HTTPCookie], request: URLRequest, loginCompletionHandler: ((URL, [HTTPCookie]) -> Void)?, loginCancelHandler: (() -> Void)?, bookFoundHandler: ((URLRequest?, [HTTPCookie]) -> Void)?, problemFoundHandler: ((NYPLProblemDocument?) -> Void)?, autoPresentIfNeeded: Bool = false) { + self.cookies = cookies + self.request = request + self.loginCompletionHandler = loginCompletionHandler + self.loginCancelHandler = loginCancelHandler + self.bookFoundHandler = bookFoundHandler + self.problemFound = problemFoundHandler + self.autoPresentIfNeeded = autoPresentIfNeeded + super.init() + } +} + +@objcMembers +class NYPLCookiesWebViewController: UIViewController, WKNavigationDelegate { + private let uuid: String = UUID().uuidString + private static var automaticBrowserStroage: [String: NYPLCookiesWebViewController] = [:] + var model: CookiesWebViewModel! // must be set before view loads + private var domainCookies: [String: [HTTPCookie]] = [:] + private let webView = WKWebView() + private var previousRequest: URLRequest? + + init() { + super.init(nibName: nil, bundle: nil) + webView.configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() + } + + init(model: CookiesWebViewModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + + webView.configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = webView + } + + override func viewDidLoad() { + super.viewDidLoad() + + if model.autoPresentIfNeeded { + NYPLCookiesWebViewController.automaticBrowserStroage[uuid] = self + } + +// if #available(iOS 13.0, *) { +// // iOS 13 brings new page like presentation for modals, this prevents the interactive dismiss gesture +// isModalInPresentation = true +// } + + navigationItem.leftBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Cancel", comment: ""), style: .plain, target: self, action: #selector(didSelectCancel)) + + webView.navigationDelegate = self + if !model.cookies.isEmpty { + var cookiesLeft = model.cookies.count + for cookie in model.cookies { + if #available(iOS 11.0, *) { + webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { [model, webView] in + cookiesLeft -= 1 + if cookiesLeft == 0, let request = model?.request { + webView.load(request) + } + } + } else { + // Fallback on earlier versions + // load cookies in old ios + } + } + } else { + webView.load(model.request) + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + NYPLCookiesWebViewController.automaticBrowserStroage[uuid] = nil + } + + @objc private func didSelectCancel() { + (navigationController?.presentingViewController ?? presentingViewController)?.dismiss(animated: true, completion: { [model] in model?.loginCancelHandler?() }) + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + + previousRequest = navigationAction.request + + if let loginHandler = model.loginCompletionHandler { + // if want to receive a login callback + if #available(iOS 11.0, *) { + if let destination = navigationAction.request.url, destination.absoluteString.hasPrefix("https://skyneck.pl/login") { + decisionHandler(.cancel) + + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [weak self] (cookies) in + loginHandler(destination, cookies) + NYPLCookiesWebViewController.automaticBrowserStroage[self?.uuid ?? ""] = nil + } + + } else { + decisionHandler(.allow) + } + } else { + if let destination = navigationAction.request.url?.absoluteString { + if destination.hasPrefix("https://skyneck.pl/login") { + } + } + + decisionHandler(.allow) + } + } else { + decisionHandler(.allow) + } + } + + private var wasBookFound = false + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + + if let bookHandler = model.bookFoundHandler { + // if want to receive a handle when book is found + let supportedTypes = NYPLBookAcquisitionPath.supportedTypes() + + if let responseType = navigationResponse.response.mimeType, supportedTypes.contains(responseType) { + wasBookFound = true + + if #available(iOS 11.0, *) { + decisionHandler(.cancel) + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [weak self] cookies in + bookHandler(self?.previousRequest, cookies) + NYPLCookiesWebViewController.automaticBrowserStroage[self?.uuid ?? ""] = nil + if self?.model.autoPresentIfNeeded == true { + (self?.navigationController?.presentingViewController ?? self?.presentingViewController)?.dismiss(animated: true, completion: nil) + } + } + } else { + decisionHandler(.allow) + } + + return + } + } + + if let problemHandler = model.problemFound { + if let responseType = navigationResponse.response.mimeType, responseType == "application/problem+json" || responseType == "application/api-problem+json" { + + decisionHandler(.cancel) + let presenter = navigationController?.presentingViewController ?? presentingViewController + if let presentingVC = presenter, model.autoPresentIfNeeded { + presentingVC.dismiss(animated: true, completion: { [uuid] in + problemHandler(nil) + NYPLCookiesWebViewController.automaticBrowserStroage[uuid] = nil + }) + } else { + problemHandler(nil) + NYPLCookiesWebViewController.automaticBrowserStroage[uuid] = nil + } + + return + } + } + + decisionHandler(.allow) + } + + private var loginScreenHandlerOnceOnly = true + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + + if model.autoPresentIfNeeded { + // delay is needed in case IDP will want to do a redirect after initial load (from within the page) + OperationQueue.current?.underlyingQueue?.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self else { return } + guard !self.webView.isLoading else { return } + guard !self.wasBookFound else { return } + guard self.loginScreenHandlerOnceOnly else { return } + self.loginScreenHandlerOnceOnly = false + + let navigationWrapper = UINavigationController(rootViewController: self) + NYPLRootTabBarController.shared()?.safelyPresentViewController(navigationWrapper, animated: true, completion: nil) + NYPLCookiesWebViewController.automaticBrowserStroage[self.uuid] = nil + } + } + } +} + +extension NYPLCookiesWebViewController: UIAdaptivePresentationControllerDelegate { + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + model?.loginCancelHandler?() + } +} diff --git a/Simplified/NYPLMyBooksDownloadCenter.m b/Simplified/NYPLMyBooksDownloadCenter.m index 72b416acf..8cf8f58fa 100644 --- a/Simplified/NYPLMyBooksDownloadCenter.m +++ b/Simplified/NYPLMyBooksDownloadCenter.m @@ -132,7 +132,8 @@ - (void)URLSession:(__attribute__((unused)) NSURLSession *)session self.bookIdentifierToDownloadInfo[book.identifier] = [[self downloadInfoForBookIdentifier:book.identifier] withRightsManagement:NYPLMyBooksDownloadRightsManagementSimplifiedBearerTokenJSON]; - } else { + } else if ([NYPLBookAcquisitionPath.supportedTypes containsObject:downloadTask.response.MIMEType]) { + // if response type represents supported type of book, proceed NYPLLOG_F(@"Presuming no DRM for unrecognized MIME type \"%@\".", downloadTask.response.MIMEType); NYPLMyBooksDownloadInfo *info = [[self downloadInfoForBookIdentifier:book.identifier] @@ -140,6 +141,12 @@ - (void)URLSession:(__attribute__((unused)) NSURLSession *)session if (info) { self.bookIdentifierToDownloadInfo[book.identifier] = info; } + } else { + NYPLLOG(@"Authentication might be needed after all"); + [downloadTask cancel]; + [[NYPLBookRegistry sharedRegistry] setState:NYPLBookStateDownloadFailed forIdentifier:book.identifier]; + [self broadcastUpdate]; + return; } } @@ -172,7 +179,8 @@ - (void)URLSession:(__attribute__((unused)) NSURLSession *)session [self.taskIdentifierToRedirectAttempts removeObjectForKey:@(downloadTask.taskIdentifier)]; - BOOL success = YES; + BOOL success = YES; + BOOL needsAuth = NO; NYPLProblemDocument *problemDocument = nil; if ([downloadTask.response.MIMEType isEqualToString:@"application/problem+json"] || [downloadTask.response.MIMEType isEqualToString:@"application/api-problem+json"]) { @@ -186,12 +194,18 @@ - (void)URLSession:(__attribute__((unused)) NSURLSession *)session [[NSFileManager defaultManager] removeItemAtURL:location error:NULL]; success = NO; } + + if (![NYPLBookAcquisitionPath.supportedTypes containsObject:downloadTask.response.MIMEType]) { + [[NSFileManager defaultManager] removeItemAtURL:location error:NULL]; + success = NO; + needsAuth = YES; + } if (success) { switch([self downloadInfoForBookIdentifier:book.identifier].rightsManagement) { case NYPLMyBooksDownloadRightsManagementUnknown: @throw NSInternalInconsistencyException; - + case NYPLMyBooksDownloadRightsManagementAdobe: { @@ -287,19 +301,36 @@ - (void)URLSession:(__attribute__((unused)) NSURLSession *)session if (!success) { dispatch_async(dispatch_get_main_queue(), ^{ - NSString *formattedMessage = [NSString stringWithFormat:NSLocalizedString(@"DownloadCouldNotBeCompletedFormat", nil), book.title]; - UIAlertController *alert = [NYPLAlertUtils - alertWithTitle:@"DownloadFailed" - message:formattedMessage]; if (problemDocument) { - [[NYPLProblemDocumentCacheManager sharedInstance] cacheProblemDocument:problemDocument key:book.identifier]; - [NYPLAlertUtils setProblemDocumentWithController:alert document:problemDocument append:YES]; - - if ([problemDocument.type isEqualToString:NYPLProblemDocument.TypeNoActiveLoan]) { - [[NYPLBookRegistry sharedRegistry] removeBookForIdentifier:book.identifier]; + if ([problemDocument.type isEqualToString:NYPLProblemDocument.TypeInvalidCredentials]) { + NYPLLOG(@"Invalid credentials problem, present sign in VC"); + [NYPLAccountSignInViewController + requestCredentialsUsingExistingBarcode:NO + completionHandler:^{ + [[NYPLMyBooksDownloadCenter sharedDownloadCenter] startDownloadForBook:book]; + }]; + } else { + NSString *formattedMessage = [NSString stringWithFormat:NSLocalizedString(@"DownloadCouldNotBeCompletedFormat", nil), book.title]; + UIAlertController *alert = [NYPLAlertUtils + alertWithTitle:@"DownloadFailed" + message:formattedMessage]; + [[NYPLProblemDocumentCacheManager sharedInstance] cacheProblemDocument:problemDocument key:book.identifier]; + [NYPLAlertUtils setProblemDocumentWithController:alert document:problemDocument append:YES]; + + if ([problemDocument.type isEqualToString:NYPLProblemDocument.TypeNoActiveLoan]) { + [[NYPLBookRegistry sharedRegistry] removeBookForIdentifier:book.identifier]; + } + + [NYPLAlertUtils presentFromViewControllerOrNilWithAlertController:alert viewController:nil animated:YES completion:nil]; } + } else if (needsAuth) { + NYPLLOG(@"Present sign in VC"); + [NYPLAccountSignInViewController + requestCredentialsUsingExistingBarcode:NO + completionHandler:^{ + [[NYPLMyBooksDownloadCenter sharedDownloadCenter] startDownloadForBook:book]; + }]; } - [NYPLAlertUtils presentFromViewControllerOrNilWithAlertController:alert viewController:nil animated:YES completion:nil]; }); [[NYPLBookRegistry sharedRegistry] @@ -472,7 +503,7 @@ - (void)returnBookWithIdentifier:(NSString *)identifier // Process Adobe Return #if defined(FEATURE_DRM_CONNECTOR) NSString *fulfillmentId = [[NYPLBookRegistry sharedRegistry] fulfillmentIdForIdentifier:identifier]; - if (fulfillmentId && [[AccountsManager sharedInstance] currentAccount].details.needsAuth) { + if (fulfillmentId && NYPLUserAccount.sharedAccount.authDefinition.needsAuth) { NYPLLOG_F(@"Return attempt for book. userID: %@",[[NYPLUserAccount sharedAccount] userID]); [[NYPLADEPT sharedInstance] returnLoan:fulfillmentId userID:[[NYPLUserAccount sharedAccount] userID] @@ -514,6 +545,13 @@ - (void)returnBookWithIdentifier:(NSString *)identifier [self deleteLocalContentForBookIdentifier:identifier]; } [[NYPLBookRegistry sharedRegistry] removeBookForIdentifier:identifier]; + } else if ([error[@"type"] isEqualToString:NYPLProblemDocument.TypeInvalidCredentials]) { + NYPLLOG(@"Invalid credentials problem, present sign in VC"); + [NYPLAccountSignInViewController + requestCredentialsUsingExistingBarcode:NO + completionHandler:^{ + [[NYPLMyBooksDownloadCenter sharedDownloadCenter] returnBookWithIdentifier:identifier]; + }]; } else { [[NSOperationQueue mainQueue] addOperationWithBlock:^{ NSString *formattedMessage = [NSString stringWithFormat:NSLocalizedString(@"ReturnCouldNotBeCompletedFormat", nil), bookTitle]; @@ -621,6 +659,14 @@ - (void)startBorrowForBook:(NYPLBook *)book formattedMessage = [NSString stringWithFormat:NSLocalizedString(@"You have already checked out this loan. You may need to refresh your My Books list to download the title.", comment: @"When book is already checked out on patron's other device(s), they will get this message"), book.title]; alert = [NYPLAlertUtils alertWithTitle:@"BorrowFailed" message:formattedMessage]; + } if ([error[@"type"] isEqualToString:NYPLProblemDocument.TypeInvalidCredentials]) { + NYPLLOG(@"Invalid credentials problem, present sign in VC"); + [NYPLAccountSignInViewController + requestCredentialsUsingExistingBarcode:NO + completionHandler:^{ + [[NYPLMyBooksDownloadCenter sharedDownloadCenter] startDownloadForBook:book]; + }]; + return; } else { [NYPLAlertUtils setProblemDocumentWithController:alert document:[NYPLProblemDocument fromDictionary:error] append:YES]; } @@ -680,6 +726,11 @@ - (void)startBorrowForBook:(NYPLBook *)book } - (void)startDownloadForBook:(NYPLBook *const)book +{ + [self startDownloadForBook:book withRequest:nil]; +} + +- (void)startDownloadForBook:(NYPLBook *const)book withRequest:(NSURLRequest *)initedRequest { NYPLBookState state = [[NYPLBookRegistry sharedRegistry] stateForIdentifier:book.identifier]; @@ -709,6 +760,8 @@ - (void)startDownloadForBook:(NYPLBook *const)book break; case NYPLBookStateHolding: break; + case NYPLBookStateSAMLStarted: + break; case NYPLBookStateDownloadSuccessful: // fallthrough case NYPLBookStateUsed: @@ -725,8 +778,14 @@ - (void)startDownloadForBook:(NYPLBook *const)book } else { // Actually download the book. NSURL *URL = book.defaultAcquisition.hrefURL; - NSURLRequest *const request = [NSURLRequest requestWithURL:URL]; - + + NSURLRequest *request; + if (initedRequest) { + request = initedRequest; + } else { + request = [[NYPLNetworkExecutor bearerAuthorizedWithRequest:[NSURLRequest requestWithURL:URL]] mutableCopy]; + } + if(!request.URL) { // Originally this code just let the request fail later on, but apparently resuming an // NSURLSessionDownloadTask created from a request with a nil URL pathetically results in a @@ -735,33 +794,81 @@ - (void)startDownloadForBook:(NYPLBook *const)book [self failDownloadForBook:book]; return; } - - NSURLSessionDownloadTask *const task = [self.session downloadTaskWithRequest:request]; - - self.bookIdentifierToDownloadInfo[book.identifier] = + + if (NYPLUserAccount.sharedAccount.cookies && state != NYPLBookStateSAMLStarted) { + [[NYPLBookRegistry sharedRegistry] setState:NYPLBookStateSAMLStarted forIdentifier:book.identifier]; + + NSMutableArray *someCookies = NYPLUserAccount.sharedAccount.cookies.mutableCopy; + NSMutableURLRequest *mutableRequest = request.mutableCopy; + + dispatch_async(dispatch_get_main_queue(), ^{ + __weak NYPLMyBooksDownloadCenter *weakSelf = self; + + mutableRequest.cachePolicy = NSURLRequestReloadIgnoringCacheData; + + CookiesWebViewModel *model = [[CookiesWebViewModel alloc] initWithCookies:someCookies + request:mutableRequest + loginCompletionHandler:nil + loginCancelHandler:^{ + [[NYPLBookRegistry sharedRegistry] setState:NYPLBookStateDownloadNeeded forIdentifier:book.identifier]; + [weakSelf cancelDownloadForBookIdentifier:book.identifier]; + } + bookFoundHandler:^(NSURLRequest * _Nullable request, NSArray * _Nonnull cookies) { + [NYPLUserAccount.sharedAccount setCookies:cookies]; + [weakSelf startDownloadForBook:book withRequest:request]; + } + problemFoundHandler:^(NYPLProblemDocument * _Nullable problemDocument) { + [[NYPLBookRegistry sharedRegistry] setState:NYPLBookStateDownloadNeeded forIdentifier:book.identifier]; + [NYPLAccountSignInViewController + requestCredentialsUsingExistingBarcode:NO + completionHandler:^{ + [[NYPLMyBooksDownloadCenter sharedDownloadCenter] startDownloadForBook:book]; + }]; + } + autoPresentIfNeeded:YES]; // <- this will cause a web view to retain a cycle + + NYPLCookiesWebViewController *cookiesVC = [[NYPLCookiesWebViewController alloc] initWithModel:model]; + [cookiesVC loadViewIfNeeded]; + }); + } else { + // clear all cookies + NSHTTPCookieStorage *cookieStorage = self.session.configuration.HTTPCookieStorage; + for (NSHTTPCookie *each in cookieStorage.cookies) { + [cookieStorage deleteCookie:each]; + } + + // set new cookies + for (NSHTTPCookie *cookie in NYPLUserAccount.sharedAccount.cookies) { + [self.session.configuration.HTTPCookieStorage setCookie:cookie]; + } + + NSURLSessionDownloadTask *const task = [self.session downloadTaskWithRequest:request]; + + self.bookIdentifierToDownloadInfo[book.identifier] = [[NYPLMyBooksDownloadInfo alloc] initWithDownloadProgress:0.0 downloadTask:task rightsManagement:NYPLMyBooksDownloadRightsManagementUnknown]; - - self.taskIdentifierToBook[@(task.taskIdentifier)] = book; - - [task resume]; - - [[NYPLBookRegistry sharedRegistry] - addBook:book - location:nil - state:NYPLBookStateDownloading - fulfillmentId:nil - readiumBookmarks:nil - genericBookmarks:nil]; - - // It is important to issue this immediately because a previous download may have left the - // progress for the book at greater than 0.0 and we do not want that to be temporarily shown to - // the user. As such, calling |broadcastUpdate| is not appropriate due to the delay. - [[NSNotificationCenter defaultCenter] - postNotificationName:NYPLMyBooksDownloadCenterDidChangeNotification - object:self]; + + self.taskIdentifierToBook[@(task.taskIdentifier)] = book; + + [task resume]; + + [[NYPLBookRegistry sharedRegistry] + addBook:book + location:nil + state:NYPLBookStateDownloading + fulfillmentId:nil + readiumBookmarks:nil + genericBookmarks:nil]; + + // It is important to issue this immediately because a previous download may have left the + // progress for the book at greater than 0.0 and we do not want that to be temporarily shown to + // the user. As such, calling |broadcastUpdate| is not appropriate due to the delay. + [[NSNotificationCenter defaultCenter] + postNotificationName:NYPLMyBooksDownloadCenterDidChangeNotification + object:self]; + } } } else { diff --git a/Simplified/NYPLProblemDocument.swift b/Simplified/NYPLProblemDocument.swift index 79de114af..7941eed84 100644 --- a/Simplified/NYPLProblemDocument.swift +++ b/Simplified/NYPLProblemDocument.swift @@ -8,7 +8,9 @@ import Foundation "http://librarysimplified.org/terms/problem/no-active-loan"; static let TypeLoanAlreadyExists = "http://librarysimplified.org/terms/problem/loan-already-exists"; - + static let TypeInvalidCredentials = + "http://librarysimplified.org/terms/problem/credentials-invalid"; + let type: String? let title: String? let status: Int? diff --git a/Simplified/NYPLSettingsAccountDetailViewController.m b/Simplified/NYPLSettingsAccountDetailViewController.m index 695ed090b..dc8c077ca 100644 --- a/Simplified/NYPLSettingsAccountDetailViewController.m +++ b/Simplified/NYPLSettingsAccountDetailViewController.m @@ -70,6 +70,9 @@ @interface NYPLSettingsAccountDetailViewController () 0 && self.businessLogic.userAccount.hasCredentials) { + [section addObject:@(CellKindLogInSignOut)]; + } else if (authenticationMethod.samlIdps.count > 0) { + for (SamlIDP *idp in authenticationMethod.samlIdps) { + SamlIdpCellType *idpCell = [[SamlIdpCellType alloc] initWithIdp:idp]; + [section addObject:idpCell]; + } + } else if (authenticationMethod.pinKeyboard != LoginKeyboardNone) { + [section addObjectsFromArray:@[@(CellKindBarcode), @(CellKindPIN), @(CellKindLogInSignOut)]]; + } else { + //Server expects a blank string. Passes local textfield validation. + self.PINTextField.text = @""; + [section addObjectsFromArray:@[@(CellKindBarcode), @(CellKindLogInSignOut)]]; + } + }; + + if (self.businessLogic.selectedAuthentication.needsAgeCheck) { section0AcctInfo = @[@(CellKindAgeCheck)].mutableCopy; - } else if (!self.selectedAccount.details.needsAuth) { - section0AcctInfo = [NSMutableArray new]; - } else if (self.selectedAccount.details.pinKeyboard != LoginKeyboardNone) { - section0AcctInfo = @[@(CellKindBarcode), @(CellKindPIN), @(CellKindLogInSignOut)].mutableCopy; + } else if (!self.businessLogic.selectedAuthentication.needsAuth && self.businessLogic.selectedAuthentication) { + section0AcctInfo = @[].mutableCopy; + } else if (self.businessLogic.userAccount.hasCredentials && self.businessLogic.selectedAuthentication) { + // user already logged in + // show only the selected auth method + section0AcctInfo = @[].mutableCopy; + insertCredentials(self.businessLogic.selectedAuthentication, section0AcctInfo); + } else if (!self.businessLogic.userAccount.hasCredentials && self.businessLogic.userAccount.needsAuth) { + // user needs to sign in + section0AcctInfo = @[].mutableCopy; + + NSUInteger samlIndex = [self.businessLogic.libraryAccount.details.auths indexOfObjectPassingTest:^BOOL(AccountDetailsAuthentication * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + return obj.samlIdps.count > 0; + }]; + + if (samlIndex != NSNotFound) { + NSString *libraryInfo = [NSString stringWithFormat:@"Log in to %@ required to download materials.", self.businessLogic.libraryAccount.name]; + [section0AcctInfo addObject:[[InfoHeaderCellType alloc] initWithInformation:libraryInfo]]; + } + + if (self.businessLogic.libraryAccount.details.auths.count > 1) { + // multiple authentication methods + for (AccountDetailsAuthentication *authenticationMethod in self.businessLogic.libraryAccount.details.auths) { + // show all possible login methods + AuthMethodCellType *autheticationCell = [[AuthMethodCellType alloc] initWithAuthenticationMethod:authenticationMethod]; + [section0AcctInfo addObject:autheticationCell]; + if (authenticationMethod.methodDescription == self.businessLogic.selectedAuthentication.methodDescription) { + // selected method, unfold + insertCredentials(authenticationMethod, section0AcctInfo); + } + } + } else if (self.businessLogic.selectedAuthentication) { + // only 1 authentication method + // no header needed + insertCredentials(self.businessLogic.selectedAuthentication, section0AcctInfo); + } } else { - //Server expects a blank string. Passes local textfield validation. - self.PINTextField.text = @""; - section0AcctInfo = @[@(CellKindBarcode), @(CellKindLogInSignOut)].mutableCopy; + section0AcctInfo = @[].mutableCopy; + insertCredentials(self.businessLogic.selectedAuthentication, section0AcctInfo); } if ([self.businessLogic librarySupportsBarcodeDisplay]) { [section0AcctInfo insertObject:@(CellKindBarcodeImage) atIndex: 0]; @@ -353,6 +406,7 @@ - (void)setupTableData } } self.tableData = finalTableContents; + [self.tableView reloadData]; } - (void)viewWillAppear:(BOOL)animated @@ -411,19 +465,113 @@ - (NSString *)pin - (void)logIn { - assert(self.usernameTextField.text.length > 0); - assert(self.PINTextField.text.length > 0 || [self.PINTextField.text isEqualToString:@""]); - - [self.usernameTextField resignFirstResponder]; - [self.PINTextField resignFirstResponder]; - - [self setActivityTitleWithText:NSLocalizedString(@"Verifying", nil)]; - - [[UIApplication sharedApplication] beginIgnoringInteractionEvents]; - - [self validateCredentials]; + if (self.businessLogic.selectedAuthentication.oauthIntermediaryUrl) { + // oauth + NSURL *oauthURL = self.businessLogic.selectedAuthentication.oauthIntermediaryUrl; + + NSURLComponents *urlComponents = [[NSURLComponents alloc] initWithURL:oauthURL resolvingAgainstBaseURL:true]; + + // add params + NSURLQueryItem *redirect_uri = [[NSURLQueryItem alloc] initWithName:@"redirect_uri" value:@"https://skyneck.pl/login"]; + urlComponents.queryItems = [urlComponents.queryItems arrayByAddingObject:redirect_uri]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleRedirectURL:) + name: @"NYPLAppDelegateDidReceiveCleverRedirectURL" + object:nil]; + + [UIApplication.sharedApplication openURL: urlComponents.URL]; + [[UIApplication sharedApplication] beginIgnoringInteractionEvents]; + } else if (self.businessLogic.selectedAuthentication.samlIdps.count > 0) { + // SAML + NSURL *idpURL = self.businessLogic.selectedIDP.url; + + NSURLComponents *urlComponents = [[NSURLComponents alloc] initWithURL:idpURL resolvingAgainstBaseURL:true]; + + // add params + NSURLQueryItem *redirect_uri = [[NSURLQueryItem alloc] initWithName:@"redirect_uri" value:@"https://skyneck.pl/login"]; + urlComponents.queryItems = [urlComponents.queryItems arrayByAddingObject:redirect_uri]; + NSURL *url = urlComponents.URL; + + CookiesWebViewModel *model = [[CookiesWebViewModel alloc] initWithCookies:@[] + request:[[NSURLRequest alloc] initWithURL:url] + loginCompletionHandler:^(NSURL * _Nonnull url, NSArray * _Nonnull cookies) { + self.cookies = cookies; + [self handleRedirectURL:[NSNotification notificationWithName:@"NYPLAppDelegateDidReceiveCleverRedirectURL" + object:url + userInfo:nil]]; + [self dismissViewControllerAnimated:YES completion:nil]; + } + loginCancelHandler:nil + bookFoundHandler:nil + problemFoundHandler:nil + autoPresentIfNeeded:NO]; + NYPLCookiesWebViewController *cookiesVC = [[NYPLCookiesWebViewController alloc] initWithModel:model]; + UINavigationController *navigationWrapper = [[UINavigationController alloc] initWithRootViewController:cookiesVC]; + [self presentViewController:navigationWrapper animated:YES completion:nil]; + } else { + // bar and pin + assert(self.usernameTextField.text.length > 0); + assert(self.PINTextField.text.length > 0 || [self.PINTextField.text isEqualToString:@""]); + + [self.usernameTextField resignFirstResponder]; + [self.PINTextField resignFirstResponder]; + + [self setActivityTitleWithText:NSLocalizedString(@"Verifying", nil)]; + + [[UIApplication sharedApplication] beginIgnoringInteractionEvents]; + + [self validateCredentials]; + } +} + +- (void) handleRedirectURL: (NSNotification *) notification +{ + [NSNotificationCenter.defaultCenter removeObserver: self name: @"NYPLAppDelegateDidReceiveCleverRedirectURL" object: nil]; + + NSURL *url = notification.object; + if (![url.absoluteString hasPrefix:@"https://skyneck.pl/login"] + || !([url.absoluteString containsString:@"error"] || [url.absoluteString containsString:@"access_token"])) + { + [self displayErrorMessage:nil]; + return; + } + + NSMutableDictionary *kvpairs = [[NSMutableDictionary alloc] init]; + NSString *responseData = url.fragment != nil ? url.fragment : url.query; + for (NSString *param in [responseData componentsSeparatedByString:@"&"]) { + NSArray *elts = [param componentsSeparatedByString:@"="]; + if([elts count] < 2) continue; + [kvpairs setObject:[elts lastObject] forKey:[elts firstObject]]; + } + + if (kvpairs[@"error"]) { + NSString *error = [[kvpairs[@"error"] stringByReplacingOccurrencesOfString:@"+" withString:@" "] stringByRemovingPercentEncoding]; + + NSDictionary *parsedError = [error parseJSONString]; + + if (parsedError) { + [self displayErrorMessage:parsedError[@"title"]]; + } + } + + NSString *auth_token = kvpairs[@"access_token"]; + NSString *patron_info = kvpairs[@"patron_info"]; + + if (auth_token != nil && patron_info != nil) { + NSString *patron = [[patron_info stringByReplacingOccurrencesOfString:@"+" withString:@" "] stringByRemovingPercentEncoding]; + + NSDictionary *parsedPatron = [patron parseJSONString]; + if (parsedPatron) { + self.authToken = auth_token; + self.patron = parsedPatron; + [self validateCredentials]; + } + } } + + - (void)logOut { @@ -507,8 +655,8 @@ - (void)logOut [[NYPLMyBooksDownloadCenter sharedDownloadCenter] reset:self.selectedAccountId]; [[NYPLBookRegistry sharedRegistry] reset:self.selectedAccountId]; [self.businessLogic.userAccount removeAll]; + self.businessLogic.selectedIDP = nil; [self setupTableData]; - [self.tableView reloadData]; [self removeActivityTitle]; [[UIApplication sharedApplication] endIgnoringInteractionEvents]; } @@ -529,8 +677,8 @@ - (void)deauthorizeDevice [[NYPLBookRegistry sharedRegistry] reset:self.selectedAccountId]; [self.businessLogic.userAccount removeAll]; + self.businessLogic.selectedIDP = nil; [self setupTableData]; - [self.tableView reloadData]; }; NSDictionary *licensor = [self.selectedUserAccount licensor]; @@ -583,6 +731,14 @@ - (void)validateCredentials request.timeoutInterval = self.businessLogic.requestTimeoutInterval; + if (self.businessLogic.selectedAuthentication.oauthIntermediaryUrl || self.businessLogic.selectedAuthentication.samlIdps.count > 0) { + NSString *authToken = self.authToken; + if (authToken != nil) { + NSString *authenticationValue = [@"Bearer " stringByAppendingString: authToken]; + [request addValue:authenticationValue forHTTPHeaderField:@"Authorization"]; + } + } + __weak __auto_type weakSelf = self; NSURLSessionDataTask *const task = [self.session @@ -737,7 +893,18 @@ - (void)authorizationAttemptDidFinish:(BOOL)success error:(NSError *)error [[UIApplication sharedApplication] endIgnoringInteractionEvents]; if (success) { - [self.selectedUserAccount setBarcode:self.usernameTextField.text PIN:self.PINTextField.text]; + if (self.businessLogic.selectedAuthentication.oauthIntermediaryUrl) { + [self.businessLogic.userAccount setAuthToken:self.authToken]; + [self.businessLogic.userAccount setPatron:self.patron]; + } else if (self.businessLogic.selectedAuthentication.samlIdps.count > 0) { + [self.businessLogic.userAccount setAuthToken:self.authToken]; + [self.businessLogic.userAccount setPatron:self.patron]; + if (self.cookies) { + [self.businessLogic.userAccount setCookies:self.cookies]; + } + } else { + [self.businessLogic.userAccount setBarcode:self.usernameTextField.text PIN:self.PINTextField.text]; + } self.businessLogic.userAccount.authDefinition = self.businessLogic.selectedAuthentication; @@ -763,7 +930,28 @@ - (void)authorizationAttemptDidFinish:(BOOL)success error:(NSError *)error - (void)tableView:(__attribute__((unused)) UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *const)indexPath { + NSArray *sectionArray = (NSArray *)self.tableData[indexPath.section]; + if ([sectionArray[indexPath.row] isKindOfClass:[AuthMethodCellType class]]) { + AuthMethodCellType *methodCell = sectionArray[indexPath.row]; + [self.tableView deselectRowAtIndexPath:indexPath animated:YES]; + + self.businessLogic.selectedIDP = nil; + self.businessLogic.selectedAuthentication = methodCell.authenticationMethod; + [self setupTableData]; + return; + } else if ([sectionArray[indexPath.row] isKindOfClass:[SamlIdpCellType class]]) { + SamlIdpCellType *idpCell = sectionArray[indexPath.row]; + [self.tableView deselectRowAtIndexPath:indexPath animated:YES]; + + self.businessLogic.selectedIDP = idpCell.idp; + [self logIn]; + return; + } else if ([sectionArray[indexPath.row] isKindOfClass:[InfoHeaderCellType class]]) { + [self.tableView deselectRowAtIndexPath:indexPath animated:YES]; + return; + } + CellKind cellKind = (CellKind)[sectionArray[indexPath.row] intValue]; switch(cellKind) { @@ -1046,6 +1234,27 @@ - (UITableViewCell *)tableView:(__attribute__((unused)) UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *const)indexPath { NSArray *sectionArray = (NSArray *)self.tableData[indexPath.section]; + + if ([sectionArray[indexPath.row] isKindOfClass:[AuthMethodCellType class]]) { + AuthMethodCellType *methodCell = sectionArray[indexPath.row]; + UITableViewCell *cell = [[UITableViewCell alloc] + initWithStyle:UITableViewCellStyleDefault + reuseIdentifier:nil]; + cell.textLabel.font = [UIFont customFontForTextStyle:UIFontTextStyleBody]; + cell.textLabel.text = methodCell.authenticationMethod.methodDescription; + return cell; + } else if ([sectionArray[indexPath.row] isKindOfClass:[SamlIdpCellType class]]) { + SamlIdpCellType *idpCell = sectionArray[indexPath.row]; + SamlIDPCell *cell = [[SamlIDPCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; + cell.idpName.text = idpCell.idp.displayName; + return cell; + } else if ([sectionArray[indexPath.row] isKindOfClass:[InfoHeaderCellType class]]) { + InfoHeaderCellType *infoCell = sectionArray[indexPath.row]; + LibraryDescriptionCell *cell = [[LibraryDescriptionCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; + cell.descriptionLabel.text = infoCell.information; + return cell; + } + CellKind cellKind = (CellKind)[sectionArray[indexPath.row] intValue]; switch(cellKind) { @@ -1229,9 +1438,6 @@ - (UITableViewCell *)tableView:(__attribute__((unused)) UITableView *)tableView cell.textLabel.text = NSLocalizedString(@"Advanced", nil); return cell; } - default: { - return nil; - } } } @@ -1369,6 +1575,7 @@ - (UIView *)tableView:(__unused UITableView *)tableView viewForHeaderInSection:( - (UIView *)tableView:(UITableView *)__unused tableView viewForFooterInSection:(NSInteger)section { + // something's wrong, it gets called every refresh cycle when scrolling if ((section == sSection0AccountInfo && [self.businessLogic shouldShowEULALink]) || (section == sSection1Sync && [self.businessLogic shouldShowSyncButton])) { @@ -1505,8 +1712,7 @@ - (void)accountDidChange } [self setupTableData]; - [self.tableView reloadData]; - + [self updateLoginLogoutCellAppearance]; }]; } @@ -1526,7 +1732,8 @@ - (void)updateLoginLogoutCellAppearance BOOL const pinHasText = [self.PINTextField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]].length; BOOL const pinIsNotRequired = self.businessLogic.selectedAuthentication.pinKeyboard == LoginKeyboardNone; - if((barcodeHasText && pinHasText) || (barcodeHasText && pinIsNotRequired)) { + BOOL const oauthLogin = self.businessLogic.selectedAuthentication.oauthIntermediaryUrl != nil; + if((barcodeHasText && pinHasText) || (barcodeHasText && pinIsNotRequired) || oauthLogin) { self.logInSignOutCell.userInteractionEnabled = YES; self.logInSignOutCell.textLabel.textColor = [NYPLConfiguration mainColor]; } else { diff --git a/Simplified/NYPLSignInBusinessLogic.swift b/Simplified/NYPLSignInBusinessLogic.swift index be8167016..6a86e93fd 100644 --- a/Simplified/NYPLSignInBusinessLogic.swift +++ b/Simplified/NYPLSignInBusinessLogic.swift @@ -33,6 +33,9 @@ class NYPLSignInBusinessLogic: NSObject { return NYPLSignInBusinessLogic.sharedLibraryAccount(libraryAccountID) } + var selectedIDP: SamlIDP? + var forceLogIn: Bool = false + var sessionRefreshed: Bool = false private var _selectedAuthentication: AccountDetails.Authentication? var selectedAuthentication: AccountDetails.Authentication? { get { @@ -60,8 +63,8 @@ class NYPLSignInBusinessLogic: NSObject { (selectedAuthentication?.supportsBarcodeDisplay ?? false) } - @objc func isSignedIn() -> Bool { - return userAccount.hasBarcodeAndPIN() + func isSignedIn() -> Bool { + return (!forceLogIn || sessionRefreshed) && userAccount.hasCredentials() } func registrationIsPossible() -> Bool { @@ -296,3 +299,19 @@ class NYPLSignInBusinessLogic: NSObject { } } } + +extension NSString { + + @objc var parseJSONString: AnyObject? { + + let data = self.data(using: String.Encoding.utf8.rawValue, allowLossyConversion: false) + + if let jsonData = data { + // Will return an object or nil if JSON decoding fails + return try! JSONSerialization.jsonObject(with: jsonData, options: JSONSerialization.ReadingOptions.mutableContainers) as AnyObject? + } else { + // Lossless conversion of the string was not possible + return nil + } + } +} diff --git a/Simplified/OPDS2Link.swift b/Simplified/OPDS2Link.swift index 900f586cf..ce707443b 100644 --- a/Simplified/OPDS2Link.swift +++ b/Simplified/OPDS2Link.swift @@ -13,4 +13,12 @@ struct OPDS2Link: Codable { let type: String? let rel: String? let templated: Bool? + + let displayNames: [OPDS2InternationalVariable]? + let descriptions: [OPDS2InternationalVariable]? +} + +struct OPDS2InternationalVariable: Codable { + let language: String + let value: String } diff --git a/Simplified/SamlIDPCell.swift b/Simplified/SamlIDPCell.swift new file mode 100644 index 000000000..1a9e2b19c --- /dev/null +++ b/Simplified/SamlIDPCell.swift @@ -0,0 +1,37 @@ +// +// SamlIDPCell.swift +// SimplyE +// +// Created by Jacek Szyja on 29/06/2020. +// Copyright © 2020 NYPL Labs. All rights reserved. +// + +import UIKit + +@objcMembers +class SamlIDPCell: UITableViewCell { + + let idpName: UILabel = { + let label = UILabel() + label.textAlignment = .right + label.font = UIFont.customFont(forTextStyle: .subheadline) + label.textColor = UIColor.systemBlue + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + idpName.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(idpName) + + idpName.centerXAnchor.constraint(equalTo: contentView.centerXAnchor).isActive = true + idpName.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true + idpName.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -32).isActive = true + idpName.heightAnchor.constraint(equalTo: contentView.heightAnchor, constant: -16).isActive = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Simplified/SimplyE.entitlements b/Simplified/SimplyE.entitlements index b88ac2098..ac780943d 100644 --- a/Simplified/SimplyE.entitlements +++ b/Simplified/SimplyE.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.associated-domains + + applinks:skyneck.pl + keychain-access-groups 7262U6ST2R.org.nypl.labs.SharedKeychainGroup