Persist authenticator items to a database (#18)

This commit is contained in:
Katherine Bertelsen 2024-04-10 11:59:43 -05:00 committed by GitHub
parent 798abea0e6
commit b99af3c566
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 2240 additions and 545 deletions

View File

@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.bitwarden.authenticator</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.bitwarden.authenticator</string>

View File

@ -0,0 +1,35 @@
import Foundation
extension Bundle {
/// Return's the app's action extension identifier.
var appExtensionIdentifier: String {
"\(bundleIdentifier!).find-login-action-extension"
}
/// Returns the app's name.
var appName: String {
infoDictionary?["CFBundleName"] as? String ?? ""
}
/// Returns the app's version string (e.g. "2023.8.0").
var appVersion: String {
infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
}
/// Returns the app's build number (e.g. "123").
var buildNumber: String {
infoDictionary?["CFBundleVersion"] as? String ?? ""
}
/// Return's the app's app identifier.
var appIdentifier: String {
infoDictionary?["BitwardenAppIdentifier"] as? String
?? bundleIdentifier
?? "com.x8bit.bitwarden"
}
/// Return's the app's app group identifier.
var groupIdentifier: String {
"group." + appIdentifier
}
}

View File

@ -0,0 +1,52 @@
import CoreData
extension NSManagedObjectContext {
/// Executes the batch delete request and/or batch insert request and merges any changes into
/// the current context plus any additional contexts.
///
/// - Parameters:
/// - batchDeleteRequest: The batch delete request to execute.
/// - batchInsertRequest: The batch insert request to execute.
/// - additionalContexts: Any additional contexts other than the current to merge the changes into.
///
func executeAndMergeChanges(
batchDeleteRequest: NSBatchDeleteRequest? = nil,
batchInsertRequest: NSBatchInsertRequest? = nil,
additionalContexts: [NSManagedObjectContext] = []
) throws {
var changes: [AnyHashable: Any] = [:]
if let batchDeleteRequest {
batchDeleteRequest.resultType = .resultTypeObjectIDs
if let deleteResult = try execute(batchDeleteRequest) as? NSBatchDeleteResult {
changes[NSDeletedObjectsKey] = deleteResult.result as? [NSManagedObjectID] ?? []
}
}
if let batchInsertRequest {
batchInsertRequest.resultType = .objectIDs
if let insertResult = try execute(batchInsertRequest) as? NSBatchInsertResult {
changes[NSInsertedObjectsKey] = insertResult.result as? [NSManagedObjectID] ?? []
}
}
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self] + additionalContexts)
}
/// Performs the closure on the context's queue and saves the context if there are any changes.
///
/// - Parameter closure: The closure to perform.
///
func performAndSave(closure: @escaping () throws -> Void) async throws {
try await perform {
try closure()
try self.saveIfChanged()
}
}
/// Saves the context if there are changes.
func saveIfChanged() throws {
guard hasChanges else { return }
try save()
}
}

View File

@ -0,0 +1,40 @@
import CoreData
import OSLog
/// A protocol for a `NSManagedObject` which persists a data model as JSON encoded data. The model
/// can be set via the `model` property which encodes the model to the data property, which should
/// be a `@NSManaged` property of the `NSManagedObject`. When the managed object is populated from
/// the database, the `model` property can be read to decode the data.
///
protocol CodableModelData: AnyObject, NSManagedObject {
associatedtype Model: Codable
/// A `@NSManaged` property of the manage object for storing the encoded model as data.
var modelData: Data? { get set }
}
extension CodableModelData {
/// Encodes or decodes the model to/from the data instance.
var model: Model? {
get {
guard let modelData else { return nil }
do {
return try JSONDecoder().decode(Model.self, from: modelData)
} catch {
Logger.application.error("Error decoding \(String(describing: Model.self)): \(error)")
return nil
}
}
set {
guard let newValue else {
modelData = nil
return
}
do {
modelData = try JSONEncoder().encode(newValue)
} catch {
Logger.application.error("Error encoding \(String(describing: Model.self)): \(error)")
}
}
}
}

View File

@ -0,0 +1,78 @@
import CoreData
/// A protocol for an `NSManagedObject` data model that adds some convenience methods for working
/// with Core Data.
///
protocol ManagedObject: AnyObject {
/// The name of the entity of the managed object, as defined in the data model.
static var entityName: String { get }
}
extension ManagedObject where Self: NSManagedObject {
static var entityName: String {
String(describing: self)
}
/// Returns a `NSBatchInsertRequest` for batch inserting an array of objects.
///
/// - Parameters:
/// - objects: The objects (or objects that can be converted to managed objects) to insert.
/// - handler: A handler that is called for each object to set the properties on the
/// `NSManagedObject` to insert.
/// - Returns: A `NSBatchInsertRequest` for batch inserting an array of objects.
///
static func batchInsertRequest<T>(
objects: [T],
handler: @escaping (Self, T) throws -> Void
) throws -> NSBatchInsertRequest {
var index = 0
var errorToThrow: Error?
let insertRequest = NSBatchInsertRequest(entityName: entityName) { (managedObject: NSManagedObject) -> Bool in
guard index < objects.count else { return true }
defer { index += 1 }
if let managedObject = (managedObject as? Self) {
do {
try handler(managedObject, objects[index])
} catch {
// The error can't be thrown directly in this closure, so capture it, return
// from the closure, and then throw it.
errorToThrow = error
return true
}
}
return false
}
if let errorToThrow {
throw errorToThrow
}
return insertRequest
}
/// Returns a `NSFetchRequest` for fetching instances of the managed object.
///
/// - Parameter predicate: An optional predicate to apply to the fetch request.
/// - Returns: A `NSFetchRequest` used to fetch instances of the managed object.
///
static func fetchRequest(predicate: NSPredicate? = nil) -> NSFetchRequest<Self> {
let fetchRequest = NSFetchRequest<Self>(entityName: entityName)
fetchRequest.predicate = predicate
return fetchRequest
}
/// Returns a `NSFetchRequest` for fetching a generic `NSFetchRequestResult` instances of the
/// managed object.
///
/// - Parameter predicate: An optional predicate to apply to the fetch request.
/// - Returns: A `NSFetchRequest` used to fetch generic `NSFetchRequestResult` instances of the
/// managed object.
///
static func fetchResultRequest(predicate: NSPredicate? = nil) -> NSFetchRequest<NSFetchRequestResult> {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
fetchRequest.predicate = predicate
return fetchRequest
}
}

View File

@ -0,0 +1,24 @@
import CoreData
import XCTest
@testable import AuthenticatorShared
class ManagedObjectTests: AuthenticatorTestCase {
// MARK: Tests
/// `fetchRequest()` returns a `NSFetchRequest` for the entity.
func test_fetchRequest() {
let fetchRequest = TestManagedObject.fetchRequest()
XCTAssertEqual(fetchRequest.entityName, "TestManagedObject")
}
/// `fetchResultRequest()` returns a `NSFetchRequest` for the entity.
func test_fetchResultRequest() {
let fetchRequest = TestManagedObject.fetchResultRequest()
XCTAssertEqual(fetchRequest.entityName, "TestManagedObject")
}
}
private class TestManagedObject: NSManagedObject, ManagedObject {
static var entityName = "TestManagedObject"
}

View File

@ -0,0 +1,74 @@
import CoreData
/// A protocol for a `ManagedObject` data model associated with a user that adds some convenience
/// methods for building `NSPersistentStoreRequest` for common CRUD operations.
///
protocol ManagedUserObject: ManagedObject {
/// The value type (struct) associated with the managed object that is persisted in the database.
associatedtype ValueType
/// Returns a `NSPredicate` used for filtering by a user's ID.
///
/// - Parameter userId: The user ID associated with the managed object.
///
static func userIdPredicate(userId: String) -> NSPredicate
/// Returns a `NSPredicate` used for filtering by a user and managed object ID.
///
/// - Parameter userId: The user ID associated with the managed object.
///
static func userIdAndIdPredicate(userId: String, id: String) -> NSPredicate
/// Updates the managed object from its associated value type object and user ID.
///
/// - Parameters:
/// - value: The value type object used to update the managed object.
/// - userId: The user ID associated with the object.
///
func update(with value: ValueType, userId: String) throws
}
extension ManagedUserObject where Self: NSManagedObject {
/// A `NSBatchInsertRequest` that inserts objects for the specified user.
///
/// - Parameters:
/// - objects: The list of objects to insert.
/// - userId: The user associated with the objects to insert.
/// - Returns: A `NSBatchInsertRequest` that inserts the objects for the user.
///
static func batchInsertRequest(objects: [ValueType], userId: String) throws -> NSBatchInsertRequest {
try batchInsertRequest(objects: objects) { object, value in
try object.update(with: value, userId: userId)
}
}
/// A `NSBatchDeleteRequest` that deletes all objects for the specified user.
///
/// - Parameter userId: The user associated with the objects to delete.
/// - Returns: A `NSBatchDeleteRequest` that deletes all objects for the user.
///
static func deleteByUserIdRequest(userId: String) -> NSBatchDeleteRequest {
let fetchRequest = fetchResultRequest(predicate: userIdPredicate(userId: userId))
return NSBatchDeleteRequest(fetchRequest: fetchRequest)
}
/// A `NSFetchRequest` that fetches objects for the specified user matching an ID.
///
/// - Parameters:
/// - id: The ID of the object to fetch.
/// - userId: The user associated with the object to fetch.
/// - Returns: A `NSFetchRequest` that fetches all objects for the user.
///
static func fetchByIdRequest(id: String, userId: String) -> NSFetchRequest<Self> {
fetchRequest(predicate: userIdAndIdPredicate(userId: userId, id: id))
}
/// A `NSFetchRequest` that fetches all objects for the specified user.
///
/// - Parameter userId: The user associated with the objects to delete.
/// - Returns: A `NSFetchRequest` that fetches all objects for the user.
///
static func fetchByUserIdRequest(userId: String) -> NSFetchRequest<Self> {
fetchRequest(predicate: userIdPredicate(userId: userId))
}
}

View File

@ -18,12 +18,18 @@ public class ServiceContainer: Services {
/// The application instance (i.e. `UIApplication`), if the app isn't running in an extension.
let application: Application?
/// The service used for managing items
let authenticatorItemRepository: AuthenticatorItemRepository
/// The service used by the application to manage camera use.
let cameraService: CameraService
/// The service used by the application to handle encryption and decryption tasks.
let clientService: ClientService
/// The service used by the application to encrypt and decrypt items
let cryptographyService: CryptographyService
/// The service used by the application to report non-fatal errors.
let errorReporter: ErrorReporter
@ -33,9 +39,6 @@ public class ServiceContainer: Services {
/// Provides the present time for TOTP Code Calculation.
let timeProvider: TimeProvider
/// The repository for managing tokens
let tokenRepository: TokenRepository
/// The service used by the application to validate TOTP keys and produce TOTP values.
let totpService: TOTPService
@ -45,31 +48,34 @@ public class ServiceContainer: Services {
///
/// - Parameters:
/// - application: The application instance.
/// - authenticatorItemRepository: The service to manage items
/// - cameraService: The service used by the application to manage camera use.
/// - clientService: The service used by the application to handle encryption and decryption tasks.
/// - cryptographyService: The service used by the application to encrypt and decrypt items
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - pasteboardService: The service used by the application for sharing data with other apps.
/// - timeProvider: Provides the present time for TOTP Code Calculation.
/// - tokenRepository: The service to manage tokens.
/// - totpService: The service used by the application to validate TOTP keys and produce TOTP values.
///
init(
application: Application?,
authenticatorItemRepository: AuthenticatorItemRepository,
cameraService: CameraService,
cryptographyService: CryptographyService,
clientService: ClientService,
errorReporter: ErrorReporter,
pasteboardService: PasteboardService,
timeProvider: TimeProvider,
tokenRepository: TokenRepository,
totpService: TOTPService
) {
self.application = application
self.authenticatorItemRepository = authenticatorItemRepository
self.cameraService = cameraService
self.clientService = clientService
self.cryptographyService = cryptographyService
self.errorReporter = errorReporter
self.pasteboardService = pasteboardService
self.timeProvider = timeProvider
self.tokenRepository = tokenRepository
self.totpService = totpService
}
@ -85,26 +91,36 @@ public class ServiceContainer: Services {
) {
let cameraService = DefaultCameraService()
let clientService = DefaultClientService()
let cryptographyService = DefaultCryptographyService()
let dataStore = DataStore(errorReporter: errorReporter)
let timeProvider = CurrentTime()
let totpService = DefaultTOTPService()
let pasteboardService = DefaultPasteboardService(
errorReporter: errorReporter
)
let tokenRepository = DefaultTokenRepository(
let totpService = DefaultTOTPService(
clientVault: clientService.clientVault(),
errorReporter: errorReporter,
timeProvider: timeProvider
)
let pasteboardService = DefaultPasteboardService(
errorReporter: errorReporter
)
let authenticatorItemService = DefaultAuthenticatorItemService(
authenticatorItemDataStore: dataStore
)
let authenticatorItemRepository = DefaultAuthenticatorItemRepository(
authenticatorItemService: authenticatorItemService,
cryptographyService: cryptographyService
)
self.init(
application: application,
authenticatorItemRepository: authenticatorItemRepository,
cameraService: cameraService,
cryptographyService: cryptographyService,
clientService: clientService,
errorReporter: errorReporter,
pasteboardService: pasteboardService,
timeProvider: timeProvider,
tokenRepository: tokenRepository,
totpService: totpService
)
}

View File

@ -1,12 +1,20 @@
import BitwardenSdk
/// The services provided by the `ServiceContainer`.
typealias Services = HasCameraService
typealias Services = HasAuthenticatorItemRepository
& HasCameraService
& HasCryptographyService
& HasErrorReporter
& HasPasteboardService
& HasTOTPService
& HasTimeProvider
& HasTokenRepository
/// Protocol for an object that provides an `AuthenticatorItemRepository`
///
protocol HasAuthenticatorItemRepository {
/// The service used to interact with the data layer for items
var authenticatorItemRepository: AuthenticatorItemRepository { get }
}
/// Protocol for an object that provides a `CameraService`.
///
@ -15,6 +23,13 @@ protocol HasCameraService {
var cameraService: CameraService { get }
}
/// Protocol for an object that provides a `CryptographyService`
///
protocol HasCryptographyService {
/// The service used by the application to encrypt and decrypt items
var cryptographyService: CryptographyService { get }
}
/// Protocol for an object that provides an `ErrorReporter`.
///
protocol HasErrorReporter {
@ -42,8 +57,3 @@ protocol HasTimeProvider {
/// Provides the present time for TOTP Code Calculation.
var timeProvider: TimeProvider { get }
}
protocol HasTokenRepository {
/// The service that interacts with the data layer for tokens
var tokenRepository: TokenRepository { get }
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23E224" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AuthenticatorItemData" representedClassName=".AuthenticatorItemData" syncable="YES">
<attribute name="id" attributeType="String"/>
<attribute name="modelData" attributeType="Binary"/>
<attribute name="userId" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="userId"/>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>

View File

@ -0,0 +1,108 @@
import BitwardenSdk
import CoreData
// MARK: - StoreType
/// A type of data store.
///
enum StoreType {
/// The data store is stored only in memory and isn't persisted to the device. This is used for
/// unit testing.
case memory
/// The data store is persisted to the device.
case persisted
}
// MARK: - DataStore
/// A data store that manages persisting data across app launches in Core Data.
///
class DataStore {
// MARK: Properties
/// A managed object context which executes on a background queue.
private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = persistentContainer.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return context
}()
/// The service used by the application to report non-fatal errors.
let errorReporter: ErrorReporter
/// The Core Data persistent container.
let persistentContainer: NSPersistentContainer
// MARK: Initialization
/// Initialize a `DataStore`.
///
/// - Parameters:
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - storeType: The type of store to create.
///
init(errorReporter: ErrorReporter, storeType: StoreType = .persisted) {
self.errorReporter = errorReporter
let modelURL = Bundle(for: type(of: self)).url(forResource: "Authenticator", withExtension: "momd")!
let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL)!
persistentContainer = NSPersistentContainer(name: "Authenticator", managedObjectModel: managedObjectModel)
let storeDescription: NSPersistentStoreDescription
switch storeType {
case .memory:
storeDescription = NSPersistentStoreDescription(url: URL(fileURLWithPath: "/dev/null"))
case .persisted:
let storeURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: Bundle.main.groupIdentifier)!
.appendingPathComponent("Authenticator.sqlite")
storeDescription = NSPersistentStoreDescription(url: storeURL)
}
persistentContainer.persistentStoreDescriptions = [storeDescription]
persistentContainer.loadPersistentStores { _, error in
if let error {
errorReporter.log(error: error)
}
}
persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
}
// MARK: Methods
/// Deletes all data stored in the store
///
func deleteData() async throws {
try await deleteAllAuthenticatorItems(userId: "local")
}
/// Executes a batch delete request and merges the changes into the background and view contexts.
///
/// - Parameter request: The batch delete request to perform.
///
func executeBatchDelete(_ request: NSBatchDeleteRequest) async throws {
try await backgroundContext.perform {
try self.backgroundContext.executeAndMergeChanges(
batchDeleteRequest: request,
additionalContexts: [self.persistentContainer.viewContext]
)
}
}
/// Executes a batch delete and batch insert request and merges the changes into the background
/// and view contexts.
///
/// - Parameters:
/// - deleteRequest: The batch delete request to perform.
/// - insertRequest: The batch insert request to perform.
///
func executeBatchReplace(deleteRequest: NSBatchDeleteRequest, insertRequest: NSBatchInsertRequest) async throws {
try await backgroundContext.perform {
try self.backgroundContext.executeAndMergeChanges(
batchDeleteRequest: deleteRequest,
batchInsertRequest: insertRequest,
additionalContexts: [self.persistentContainer.viewContext]
)
}
}
}

View File

@ -6,22 +6,24 @@ import Networking
extension ServiceContainer {
static func withMocks(
application: Application? = nil,
authenticatorItemRepository: AuthenticatorItemRepository = MockAuthenticatorItemRepository(),
cameraService: CameraService = MockCameraService(),
clientService: ClientService = MockClientService(),
cryptographyService: CryptographyService = MockCryptographyService(),
errorReporter: ErrorReporter = MockErrorReporter(),
pasteboardService: PasteboardService = MockPasteboardService(),
timeProvider: TimeProvider = MockTimeProvider(.currentTime),
tokenRepository: TokenRepository = MockTokenRepository(),
totpService: TOTPService = MockTOTPService()
) -> ServiceContainer {
ServiceContainer(
application: application,
authenticatorItemRepository: authenticatorItemRepository,
cameraService: cameraService,
cryptographyService: cryptographyService,
clientService: clientService,
errorReporter: errorReporter,
pasteboardService: pasteboardService,
timeProvider: timeProvider,
tokenRepository: tokenRepository,
timeProvider: timeProvider,
totpService: totpService
)
}

View File

@ -0,0 +1,145 @@
import Combine
import CoreData
// MARK: - FetchedResultsPublisher
/// A Combine publisher that publishes the initial result set and any future data changes for a
/// Core Data fetch request.
///
/// Adapted from https://gist.github.com/darrarski/28d2f5a28ef2c5669d199069c30d3d52
///
class FetchedResultsPublisher<ResultType>: Publisher where ResultType: NSFetchRequestResult {
// MARK: Types
typealias Output = [ResultType]
typealias Failure = Error
// MARK: Properties
/// The managed object context that the fetch request is executed against.
let context: NSManagedObjectContext
/// The fetch request used to get the objects.
let request: NSFetchRequest<ResultType>
// MARK: Initialization
/// Initialize a `FetchedResultsPublisher`.
///
/// - Parameters:
/// - context: The managed object context that the fetch request is executed against.
/// - request: The fetch request used to get the objects.
///
init(context: NSManagedObjectContext, request: NSFetchRequest<ResultType>) {
self.context = context
self.request = request
}
// MARK: Publisher
func receive<S>(subscriber: S) where S: Subscriber, S.Failure == Failure, S.Input == Output {
subscriber.receive(subscription: FetchedResultsSubscription(
context: context,
request: request,
subscriber: subscriber
))
}
}
// MARK: - FetchedResultsSubscription
/// A `Subscription` to a `FetchedResultsPublisher` which fetches results from Core Data via a
/// `NSFetchedResultsController` and notifies the subscriber of any changes to the data.
///
private final class FetchedResultsSubscription<SubscriberType, ResultType>: NSObject, Subscription,
NSFetchedResultsControllerDelegate
where SubscriberType: Subscriber,
SubscriberType.Input == [ResultType],
SubscriberType.Failure == Error,
ResultType: NSFetchRequestResult {
// MARK: Properties
/// The fetched results controller to manage the results of a Core Data fetch request.
private var controller: NSFetchedResultsController<ResultType>?
/// The current demand from the subscriber.
private var demand: Subscribers.Demand = .none
/// Whether the subscription has changes to send to the subscriber.
private var hasChangesToSend = false
/// The subscriber to the subscription that is notified of the fetched results.
private var subscriber: SubscriberType?
// MARK: Initialization
/// Initialize a `FetchedResultsSubscription`.
///
/// - Parameters:
/// - context: The managed object context that the fetch request is executed against.
/// - request: The fetch request used to get the objects.
/// - subscriber: The subscriber to the subscription that is notified of the fetched results.
///
init(
context: NSManagedObjectContext,
request: NSFetchRequest<ResultType>,
subscriber: SubscriberType
) {
controller = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: context,
sectionNameKeyPath: nil,
cacheName: nil
)
self.subscriber = subscriber
super.init()
controller?.delegate = self
do {
try controller?.performFetch()
if controller?.fetchedObjects != nil {
hasChangesToSend = true
fulfillDemand()
}
} catch {
subscriber.receive(completion: .failure(error))
}
}
// MARK: Subscription
func request(_ demand: Subscribers.Demand) {
self.demand += demand
fulfillDemand()
}
// MARK: Cancellable
func cancel() {
controller = nil
subscriber = nil
}
// MARK: NSFetchedResultsControllerDelegate
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
hasChangesToSend = true
fulfillDemand()
}
// MARK: Private
private func fulfillDemand() {
guard demand > 0, hasChangesToSend,
let subscriber,
let fetchedObjects = controller?.fetchedObjects
else { return }
hasChangesToSend = false
demand -= 1
demand += subscriber.receive(fetchedObjects)
}
}

View File

@ -0,0 +1,90 @@
import CoreData
import Foundation
/// A data model for persisting authenticator items
///
class AuthenticatorItemData: NSManagedObject, ManagedUserObject, CodableModelData {
typealias Model = AuthenticatorItemDataModel
// MARK: Properties
/// The item's ID
@NSManaged var id: String
/// The data model encoded as encrypted JSON data
@NSManaged var modelData: Data?
/// The ID of the user who owns the item
@NSManaged var userId: String
// MARK: Initialization
/// Initialize an `AuthenticatorItemData` object for insertion into the managed object context
///
/// - Parameters:
/// - context: The managed object context to insert the initialized item
/// - userId: The ID of the user who owns the item
/// - authenticatorItem: the `AuthenticatorItem` used to create the item
convenience init(
context: NSManagedObjectContext,
userId: String,
authenticatorItem: AuthenticatorItem
) throws {
self.init(context: context)
id = authenticatorItem.id
model = try AuthenticatorItemDataModel(item: authenticatorItem)
self.userId = userId
}
// MARK: Methods
/// Updates the `AuthenticatorItemData` object from an `AuthenticatorItem` and user ID
///
/// - Parameters:
/// - authenticatorItem: The `AuthenticatorItem` used to update the `AuthenticatorItemData` instance
/// - userId: The user ID associated with the item
///
func update(with authenticatorItem: AuthenticatorItem, userId: String) throws {
id = authenticatorItem.id
model = try AuthenticatorItemDataModel(item: authenticatorItem)
self.userId = userId
}
}
extension AuthenticatorItemData {
static func userIdPredicate(userId: String) -> NSPredicate {
NSPredicate(format: "%K == %@", #keyPath(AuthenticatorItemData.userId), userId)
}
static func userIdAndIdPredicate(userId: String, id: String) -> NSPredicate {
NSPredicate(
format: "%K == %@ AND %K == %@",
#keyPath(AuthenticatorItemData.userId),
userId,
#keyPath(AuthenticatorItemData.id),
id
)
}
}
struct AuthenticatorItemDataModel: Codable {
let id: String
let name: String
let totpKey: String?
init(item: AuthenticatorItem) throws {
id = item.id
name = item.name
totpKey = item.totpKey
}
}
/// Errors thrown from converting between SDK and app types.
///
enum DataMappingError: Error {
/// Thrown if an object was unable to be constructed because the data was invalid.
case invalidData
/// Thrown if a required object identifier is nil.
case missingId
}

View File

@ -0,0 +1,60 @@
import Foundation
/// Data model for an encrypted item
///
struct AuthenticatorItem: Equatable, Sendable {
let id: String
let name: String
let totpKey: String?
init(id: String, name: String, totpKey: String?) {
self.id = id
self.name = name
self.totpKey = totpKey
}
init(itemData: AuthenticatorItemData) throws {
guard let model = itemData.model else {
throw DataMappingError.invalidData
}
id = model.id
name = model.name
totpKey = model.totpKey
}
}
extension AuthenticatorItem {
static func fixture(
id: String = "ID",
name: String = "Example",
totpKey: String? = "example"
) -> AuthenticatorItem {
AuthenticatorItem(
id: id,
name: name,
totpKey: totpKey
)
}
}
/// Data model for an unencrypted item
///
struct AuthenticatorItemView: Equatable, Sendable {
let id: String
let name: String
let totpKey: String?
}
extension AuthenticatorItemView {
static func fixture(
id: String = "ID",
name: String = "Example",
totpKey: String? = "example"
) -> AuthenticatorItemView {
AuthenticatorItemView(
id: id,
name: name,
totpKey: totpKey
)
}
}

View File

@ -7,16 +7,25 @@ extension ItemListItem {
static func fixture(
id: String = "123",
name: String = "Name",
token: Token = Token(
name: "Name",
authenticatorKey: "example"
)!,
totpCode: TOTPCodeModel = TOTPCodeModel(
code: "123456",
codeGenerationDate: .now,
period: 30
)
totp: ItemListTotpItem
) -> ItemListItem {
ItemListItem(id: id, name: name, token: token, totpCode: totpCode)
ItemListItem(
id: id,
name: name,
itemType: .totp(model: totp)
)
}
}
extension ItemListTotpItem {
static func fixture(
itemView: AuthenticatorItemView = .fixture(),
totpCode: TOTPCodeModel = TOTPCodeModel(
code: "123456",
codeGenerationDate: Date(),
period: 30
)
) -> ItemListTotpItem {
ItemListTotpItem(itemView: itemView, totpCode: totpCode)
}
}

View File

@ -0,0 +1,21 @@
@testable import AuthenticatorShared
extension AuthenticatorItem {
init(authenticatorItemView: AuthenticatorItemView) {
self.init(
id: authenticatorItemView.id,
name: authenticatorItemView.name,
totpKey: authenticatorItemView.totpKey
)
}
}
extension AuthenticatorItemView {
init(authenticatorItem: AuthenticatorItem) {
self.init(
id: authenticatorItem.id,
name: authenticatorItem.name,
totpKey: authenticatorItem.totpKey
)
}
}

View File

@ -1,28 +0,0 @@
import Foundation
/// Data model for an OTP token
///
public struct Token: Equatable, Sendable {
// MARK: Properties
let id: String
let key: TOTPKeyModel
let name: String
// MARK: Initialization
init?(
id: String = UUID().uuidString,
name: String,
authenticatorKey: String
) {
guard let keyModel = TOTPKeyModel(authenticatorKey: authenticatorKey)
else { return nil }
self.id = id
self.name = name
key = keyModel
}
}

View File

@ -0,0 +1,170 @@
import Combine
import Foundation
// MARK: - AuthenticatorItemRepository
/// A protocol for an `AuthenticatorItemRepository` which manages access to the data layer for items
///
protocol AuthenticatorItemRepository: AnyObject {
// MARK: Data Methods
/// Adds an item to the user's storage
///
/// - Parameters:
/// - authenticatorItem: The item to add
///
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws
/// Deletes an item from the user's storage
///
/// - Parameters:
/// - id: The item ID to delete
///
func deleteAuthenticatorItem(_ id: String) async throws
/// Attempt to fetch an item with the given ID
///
/// - Parameters:
/// - id: The ID of the item to find
/// - Returns: The item if found and `nil` if not
///
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItemView?
/// Fetch all items
///
/// Returns: An array of all items in storage
///
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorItemView]
/// Updates an item in the user's storage
///
/// - Parameters:
/// - authenticatorItem: The updated item
///
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws
// MARK: Publishers
/// A publisher for the details of an item
///
/// - Parameters:
/// - id: The ID of the item that should be published
/// - Returns: A publisher for the details of the item,
/// which will be notified as details of the item change
///
func authenticatorItemDetailsPublisher(
id: String
) async throws -> AsyncThrowingPublisher<AnyPublisher<AuthenticatorItemView?, Error>>
/// A publisher for the list of a user's items, which returns a list of sections
/// of items that are displayed
///
/// - Returns: A publisher for the list of a user's items
///
func itemListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[ItemListSection], Error>>
}
// MARK: - DefaultAuthenticatorItemRepository
/// A default implementation of an `AuthenticatorItemRepository`
///
class DefaultAuthenticatorItemRepository {
// MARK: Properties
private let authenticatorItemService: AuthenticatorItemService
private let cryptographyService: CryptographyService
// MARK: Initialization
/// Initialize a `DefaultAuthenticatorItemRepository`
///
/// - Parameters:
/// - authenticatorItemService
/// - cryptographyService
init(
authenticatorItemService: AuthenticatorItemService,
cryptographyService: CryptographyService
) {
self.authenticatorItemService = authenticatorItemService
self.cryptographyService = cryptographyService
}
// MARK: Private Methods
/// Returns a list of the sections in the item list
///
/// - Parameters:
/// - authenticatorItems: The items in the user's storage
/// - Returns: A list of the sections to display in the item list
///
private func itemListSections(
from authenticatorItems: [AuthenticatorItem]
) async throws -> [ItemListSection] {
let items = try await authenticatorItems.asyncMap { item in
try await self.cryptographyService.decrypt(item)
}
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
let allItems = items.compactMap(ItemListItem.init)
return [
ItemListSection(
id: "Everything",
items: allItems,
name: Localizations.all
),
]
}
}
extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository {
// MARK: Data Methods
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws {
let item = try await cryptographyService.encrypt(authenticatorItem)
try await authenticatorItemService.addAuthenticatorItem(item)
}
func deleteAuthenticatorItem(_ id: String) async throws {
try await authenticatorItemService.deleteAuthenticatorItem(id: id)
}
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorItemView] {
let items = try await authenticatorItemService.fetchAllAuthenticatorItems()
return try await items.asyncMap { item in
try await cryptographyService.decrypt(item)
}
.compactMap { $0 }
}
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItemView? {
guard let item = try await authenticatorItemService.fetchAuthenticatorItem(withId: id) else { return nil }
return try? await cryptographyService.decrypt(item)
}
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws {
let item = try await cryptographyService.encrypt(authenticatorItem)
try await authenticatorItemService.updateAuthenticatorItem(item)
}
func authenticatorItemDetailsPublisher(
id: String
) async throws -> AsyncThrowingPublisher<AnyPublisher<AuthenticatorItemView?, Error>> {
try await authenticatorItemService.authenticatorItemsPublisher()
.asyncTryMap { items -> AuthenticatorItemView? in
guard let item = items.first(where: { $0.id == id }) else { return nil }
return try await self.cryptographyService.decrypt(item)
}
.eraseToAnyPublisher()
.values
}
func itemListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[ItemListSection], Error>> {
try await authenticatorItemService.authenticatorItemsPublisher()
.asyncTryMap { items in
try await self.itemListSections(from: items)
}
.eraseToAnyPublisher()
.values
}
}

View File

@ -0,0 +1,59 @@
import InlineSnapshotTesting
import XCTest
@testable import AuthenticatorShared
class AuthenticatorItemRepositoryTests: AuthenticatorTestCase {
// MARK: Properties
var authenticatorItemService: MockAuthenticatorItemService!
var cryptographyService: MockCryptographyService!
var subject: DefaultAuthenticatorItemRepository!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
authenticatorItemService = MockAuthenticatorItemService()
cryptographyService = MockCryptographyService()
subject = DefaultAuthenticatorItemRepository(
authenticatorItemService: authenticatorItemService,
cryptographyService: cryptographyService
)
}
override func tearDown() {
super.tearDown()
authenticatorItemService = nil
cryptographyService = nil
subject = nil
}
// MARK: Tests
/// `addAuthenticatorItem()` updates the items in storage
func test_addAuthenticatorItem() async throws {
let item = AuthenticatorItemView.fixture()
try await subject.addAuthenticatorItem(item)
XCTAssertEqual(cryptographyService.encryptedAuthenticatorItems, [item])
XCTAssertEqual(
authenticatorItemService.addAuthenticatorItemAuthenticatorItems.last,
AuthenticatorItem(authenticatorItemView: item)
)
}
/// `addAuthenticatorItem()` throws an error if encrypting the item fails
func test_addAuthenticatorItem_encryptError() async {
cryptographyService.encryptError = AuthenticatorTestError.example
await assertAsyncThrows(error: AuthenticatorTestError.example) {
try await subject.addAuthenticatorItem(.fixture())
}
}
// TODO: Backfill tests
}

View File

@ -0,0 +1,72 @@
import Combine
import Foundation
@testable import AuthenticatorShared
class MockAuthenticatorItemRepository: AuthenticatorItemRepository {
// MARK: Properties
var addAuthenticatorItemAuthenticatorItems = [AuthenticatorItemView]()
var addAuthenticatorItemResult: Result<Void, Error> = .success(())
var deletedAuthenticatorItem = [String]()
var deleteAuthenticatorItemResult: Result<Void, Error> = .success(())
var fetchAllAuthenticatorItemsResult: Result<[AuthenticatorItemView], Error> = .success([])
var fetchAuthenticatorItemId: String?
var fetchAuthenticatorItemResult: Result<AuthenticatorItemView?, Error> = .success(nil)
var refreshTOTPCodeResult: Result<TOTPCodeModel, Error> = .success(
TOTPCodeModel(code: .base32Key, codeGenerationDate: .now, period: 30)
)
var refreshedTOTPKeyConfig: TOTPKeyModel?
var authenticatorItemDetailsSubject = CurrentValueSubject<AuthenticatorItemView?, Error>(nil)
var itemListSubject = CurrentValueSubject<[ItemListSection], Error>([])
var updateAuthenticatorItemItems = [AuthenticatorItemView]()
var updateAuthenticatorItemResult: Result<Void, Error> = .success(())
// MARK: Methods
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws {
addAuthenticatorItemAuthenticatorItems.append(authenticatorItem)
try addAuthenticatorItemResult.get()
}
func deleteAuthenticatorItem(_ id: String) async throws {
deletedAuthenticatorItem.append(id)
try deleteAuthenticatorItemResult.get()
}
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorShared.AuthenticatorItemView] {
return try fetchAllAuthenticatorItemsResult.get()
}
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItemView? {
fetchAuthenticatorItemId = id
return try fetchAuthenticatorItemResult.get()
}
func refreshTotpCode(for key: TOTPKeyModel) async throws -> AuthenticatorShared.TOTPCodeModel {
refreshedTOTPKeyConfig = key
return try refreshTOTPCodeResult.get()
}
func authenticatorItemDetailsPublisher(
id: String
) async throws -> AsyncThrowingPublisher<AnyPublisher<AuthenticatorItemView?, Error>> {
authenticatorItemDetailsSubject.eraseToAnyPublisher().values
}
func itemListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[ItemListSection], Error>> {
itemListSubject.eraseToAnyPublisher().values
}
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorItemView) async throws {
updateAuthenticatorItemItems.append(authenticatorItem)
try updateAuthenticatorItemResult.get()
}
}

View File

@ -1,51 +0,0 @@
import BitwardenSdk
import Combine
import Foundation
@testable import AuthenticatorShared
class MockTokenRepository: TokenRepository {
// MARK: Properties
var addTokenTokens = [Token]()
var addTokenResult: Result<Void, Error> = .success(())
var deletedToken = [String]()
var deleteTokenResult: Result<Void, Error> = .success(())
var fetchTokenId: String?
var fetchTokenResult: Result<Token?, Error> = .success(nil)
var refreshTOTPCodeResult: Result<TOTPCodeModel, Error> = .success(
TOTPCodeModel(code: .base32Key, codeGenerationDate: .now, period: 30)
)
var refreshedTOTPKeyConfig: TOTPKeyModel?
var tokenListSubject = CurrentValueSubject<[Token], Never>([])
// MARK: Methods
func addToken(_ token: Token) async throws {
addTokenTokens.append(token)
try addTokenResult.get()
}
func deleteToken(_ id: String) async throws {
deletedToken.append(id)
try deleteTokenResult.get()
}
func fetchToken(withId id: String) async throws -> Token? {
fetchTokenId = id
return try fetchTokenResult.get()
}
func refreshTotpCode(for key: TOTPKeyModel) async throws -> AuthenticatorShared.TOTPCodeModel {
refreshedTOTPKeyConfig = key
return try refreshTOTPCodeResult.get()
}
func tokenPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[AuthenticatorShared.Token], Never>> {
tokenListSubject.eraseToAnyPublisher().values
}
}

View File

@ -1,96 +0,0 @@
import BitwardenSdk
import Combine
import Foundation
/// A protocol for a `TokenReposity` which manages access to the data layer for tokens
///
public protocol TokenRepository: AnyObject {
// MARK: Data Methods
func addToken(_ token: Token) async throws
func deleteToken(_ id: String) async throws
func fetchToken(withId id: String) async throws -> Token?
func refreshTotpCode(for key: TOTPKeyModel) async throws -> TOTPCodeModel
func updateToken(_ token: Token) async throws
// MARK: Publishers
func tokenPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[Token], Never>>
}
class DefaultTokenRepository {
// MARK: Properties
/// The service to communicate with the SDK for encryption/decryption tasks.
private let clientVault: ClientVaultService
/// The service used by the application to report non-fatal errors.
private let errorReporter: ErrorReporter
/// The service used to get the present time.
private let timeProvider: TimeProvider
@Published var tokens: [Token] = [
Token(name: "Amazon", authenticatorKey: "amazon")!,
]
// MARK: Initialization
/// Initialize a `DefaultTokenRepository`.
///
/// - Parameters:
/// - clientVault: The service to communicate with the SDK for encryption/decryption tasks.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - timeProvider: The service used to get the present time.
///
init(
clientVault: ClientVaultService,
errorReporter: ErrorReporter,
timeProvider: TimeProvider
) {
self.clientVault = clientVault
self.errorReporter = errorReporter
self.timeProvider = timeProvider
}
}
extension DefaultTokenRepository: TokenRepository {
// MARK: Data Methods
func addToken(_ token: Token) async throws {
tokens.append(token)
}
func deleteToken(_ id: String) async throws {
tokens.removeAll { $0.id == id }
}
func fetchToken(withId id: String) async throws -> Token? {
tokens.first { $0.id == id }
}
func refreshTotpCode(for key: TOTPKeyModel) async throws -> TOTPCodeModel {
try await clientVault.generateTOTPCode(
for: key.rawAuthenticatorKey,
date: timeProvider.presentTime
)
}
func tokenPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[Token], Never>> {
tokens.publisher
.collect()
.eraseToAnyPublisher()
.values
}
func updateToken(_ token: Token) async throws {
try await Task.sleep(nanoseconds: 1_000_000_000)
guard let tokenIndex = tokens.firstIndex(where: { $0.id == token.id })
else { return }
tokens[tokenIndex] = token
}
}

View File

@ -0,0 +1,103 @@
import Combine
import Foundation
// MARK: - AuthenticatorItemService
/// A protocol for an `AuthenticatorItemService` which is the service layer
/// for managing a user's items
///
protocol AuthenticatorItemService {
/// Add an item for the current user to local storage
///
/// - Parameters:
/// - authenticatorItem: The item to add
///
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorItem) async throws
/// Deletes an item for the current user from local storage
///
/// - Parameters:
/// - id: The ID of the item to delete
///
func deleteAuthenticatorItem(id: String) async throws
/// Attempt to fetch an item for the current user
///
/// - Parameters:
/// - id: The ID of the item to find
/// - Returns: The item if it was found and `nil` if not
///
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItem?
/// Fetches all items for the current user
///
/// - Returns: The items belonging to the current user
///
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorItem]
/// Updates an item for the current user
///
/// - Parameters:
/// - authenticatorItem: The item to update
///
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorItem) async throws
// MARK: Publishers
/// A publisher for the list of items for the current user
///
/// - Returns: The list of items
///
func authenticatorItemsPublisher() async throws -> AnyPublisher<[AuthenticatorItem], Error>
}
// MARK: - DefaultAuthenticatorItemService
class DefaultAuthenticatorItemService {
// MARK: Properties
// TODO: Generate this user ID and store it in the keychain?
private let defaultUserId = "local"
/// The data store for persisted items
private let authenticatorItemDataStore: AuthenticatorItemDataStore
// MARK: Initialization
/// Initializes a `DefaultAuthenticatorItemService`
///
/// - Parameters:
/// - authenticatorItemDataStore: The data store for persisted items
///
init(
authenticatorItemDataStore: AuthenticatorItemDataStore
) {
self.authenticatorItemDataStore = authenticatorItemDataStore
}
}
extension DefaultAuthenticatorItemService: AuthenticatorItemService {
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorItem) async throws {
try await authenticatorItemDataStore.upsertAuthenticatorItem(authenticatorItem, userId: defaultUserId)
}
func deleteAuthenticatorItem(id: String) async throws {
try await authenticatorItemDataStore.deleteAuthenticatorItem(id: id, userId: defaultUserId)
}
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItem? {
try await authenticatorItemDataStore.fetchAuthenticatorItem(withId: id, userId: defaultUserId)
}
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorItem] {
try await authenticatorItemDataStore.fetchAllAuthenticatorItems(userId: defaultUserId)
}
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorItem) async throws {
try await authenticatorItemDataStore.upsertAuthenticatorItem(authenticatorItem, userId: defaultUserId)
}
func authenticatorItemsPublisher() async throws -> AnyPublisher<[AuthenticatorItem], Error> {
authenticatorItemDataStore.authenticatorItemPublisher(userId: defaultUserId)
}
}

View File

@ -0,0 +1,93 @@
import XCTest
@testable import AuthenticatorShared
class AuthenticatorItemServiceTests: AuthenticatorTestCase {
// MARK: Properties
var authenticatorItemDataStore: MockAuthenticatorItemDataStore!
var subject: AuthenticatorItemService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
authenticatorItemDataStore = MockAuthenticatorItemDataStore()
subject = DefaultAuthenticatorItemService(
authenticatorItemDataStore: authenticatorItemDataStore
)
}
override func tearDown() {
super.tearDown()
authenticatorItemDataStore = nil
subject = nil
}
// MARK: Tests
/// `addAuthenticatorItem(_:)` adds the item to local storage
func test_addAuthenticatorItem() async throws {
try await subject.addAuthenticatorItem(.fixture())
XCTAssertEqual(authenticatorItemDataStore.upsertAuthenticatorItemValue?.id, "ID")
}
/// `authenticatorItemsPublisher()` returns a publisher that emits data as the data store changes
func test_authenticatorItemsPublisher() async throws {
var iterator = try await subject.authenticatorItemsPublisher()
.values
.makeAsyncIterator()
_ = try await iterator.next()
let item = AuthenticatorItem.fixture()
authenticatorItemDataStore.authenticatorItemSubject.value = [item]
let publisherValue = try await iterator.next()
try XCTAssertEqual(XCTUnwrap(publisherValue), [item])
}
/// `deleteAuthenticatorItem(id:)` deletes the item from local storage
func test_deleteAuthenticatorItem() async throws {
try await subject.deleteAuthenticatorItem(id: "Test")
XCTAssertEqual(authenticatorItemDataStore.deleteAuthenticatorItemId, "Test")
XCTAssertEqual(authenticatorItemDataStore.deleteAuthenticatorItemUserId, "local")
}
/// `fetchAuthenticatorItem(withId:)` returns the item if it exists and nil otherwise
func test_fetchAuthenticatorItem() async throws {
var item = try await subject.fetchAuthenticatorItem(withId: "1")
XCTAssertNil(item)
XCTAssertEqual(authenticatorItemDataStore.fetchAuthenticatorItemId, "1")
let testItem = AuthenticatorItem.fixture(id: "2")
authenticatorItemDataStore.fetchAuthenticatorItemResult = testItem
item = try await subject.fetchAuthenticatorItem(withId: "2")
XCTAssertEqual(item, testItem)
XCTAssertEqual(authenticatorItemDataStore.fetchAuthenticatorItemId, "2")
}
/// `fetchAllAuthenticatorItems()` returns all items
func test_fetchAllAuthenticatorItems() async throws {
authenticatorItemDataStore.fetchAllAuthenticatorItemsResult = .success([
.fixture(id: "1"),
.fixture(id: "2"),
])
let items = try await subject.fetchAllAuthenticatorItems()
XCTAssertEqual(items.count, 2)
XCTAssertEqual(items[0].id, "1")
XCTAssertEqual(items[1].id, "2")
}
/// `updateAuthenticatorItemWithLocalStorage(_:)` updates the item in the local storage.
func test_updateAuthenticatorItemWithLocalStorage() async throws {
try await subject.updateAuthenticatorItem(.fixture(id: "id"))
XCTAssertEqual(authenticatorItemDataStore.upsertAuthenticatorItemValue?.id, "id")
}
}

View File

@ -0,0 +1,30 @@
import Foundation
// MARK: - CryptographyService
/// A protocol for a `CryptographyService` which manages encrypting and decrypting `AuthenticationItem` objects
///
protocol CryptographyService {
func encrypt(_ authenticatorItemView: AuthenticatorItemView) async throws -> AuthenticatorItem
func decrypt(_ authenticatorItem: AuthenticatorItem) async throws -> AuthenticatorItemView
}
// TODO: Actually encrypt/decrypt the item
class DefaultCryptographyService: CryptographyService {
func encrypt(_ authenticatorItemView: AuthenticatorItemView) async throws -> AuthenticatorItem {
AuthenticatorItem(
id: authenticatorItemView.id,
name: authenticatorItemView.name,
totpKey: authenticatorItemView.totpKey
)
}
func decrypt(_ authenticatorItem: AuthenticatorItem) async throws -> AuthenticatorItemView {
AuthenticatorItemView(
id: authenticatorItem.id,
name: authenticatorItem.name,
totpKey: authenticatorItem.totpKey
)
}
}

View File

@ -0,0 +1,124 @@
import Combine
import CoreData
// MARK: - AuthenticatorItemDataStore
/// A protocol for a data store that handles performing data requests for authenticator items.
///
protocol AuthenticatorItemDataStore: AnyObject {
/// Deletes all `AuthenticatorItem` objects for a specific user.
///
/// - Parameter userId: The user ID of the user associated with the objects to delete.
///
func deleteAllAuthenticatorItems(userId: String) async throws
/// Deletes a `AuthenticatorItem` by ID for a user.
///
/// - Parameters:
/// - id: The ID of the `AuthenticatorItem` to delete.
/// - userId: The user ID of the user associated with the object to delete.
///
func deleteAuthenticatorItem(id: String, userId: String) async throws
/// Attempt to fetch an authenticator item with the given id.
///
/// - Parameters:
/// - id: The id of the authenticator item to find.
/// - userId: The user ID of the user associated with the authenticator items.
/// - Returns: The authenticator item if it was found and `nil` if not.
///
func fetchAuthenticatorItem(withId id: String, userId: String) async throws -> AuthenticatorItem?
/// Fetches all the authenticator items belonging to the specified user id.
///
/// - Parameter userId: The id of the user associated with the authenticator items.
/// - Returns: The authenticator items associated with the user id.
///
func fetchAllAuthenticatorItems(userId: String) async throws -> [AuthenticatorItem]
/// A publisher for a user's authenticator item objects.
///
/// - Parameter userId: The user ID of the user to associated with the objects to fetch.
/// - Returns: A publisher for the user's authenticator items.
///
func authenticatorItemPublisher(userId: String) -> AnyPublisher<[AuthenticatorItem], Error>
/// Replaces a list of `AuthenticatorItem` objects for a user.
///
/// - Parameters:
/// - authenticatorItems: The list of authenticator items to replace any existing authenticator items.
/// - userId: The user ID of the user associated with the authenticator items.
///
func replaceAuthenticatorItems(_ authenticatorItems: [AuthenticatorItem], userId: String) async throws
/// Inserts or updates an authenticator item for a user.
///
/// - Parameters:
/// - authenticatorItem: The authenticator item to insert or update.
/// - userId: The user ID of the user associated with the authenticator item.
///
func upsertAuthenticatorItem(_ authenticatorItem: AuthenticatorItem, userId: String) async throws
}
extension DataStore: AuthenticatorItemDataStore {
func deleteAllAuthenticatorItems(userId: String) async throws {
try await executeBatchDelete(AuthenticatorItemData.deleteByUserIdRequest(userId: userId))
}
func deleteAuthenticatorItem(id: String, userId: String) async throws {
try await backgroundContext.performAndSave {
let results = try self.backgroundContext.fetch(
AuthenticatorItemData.fetchByIdRequest(id: id, userId: userId)
)
for result in results {
self.backgroundContext.delete(result)
}
}
}
func fetchAuthenticatorItem(withId id: String, userId: String) async throws -> AuthenticatorItem? {
try await backgroundContext.perform {
try self.backgroundContext.fetch(AuthenticatorItemData.fetchByIdRequest(id: id, userId: userId))
.compactMap(AuthenticatorItem.init)
.first
}
}
func fetchAllAuthenticatorItems(userId: String) async throws -> [AuthenticatorItem] {
try await backgroundContext.perform {
let fetchRequest = AuthenticatorItemData.fetchByUserIdRequest(userId: userId)
return try self.backgroundContext.fetch(fetchRequest).map(AuthenticatorItem.init)
}
}
func authenticatorItemPublisher(userId: String) -> AnyPublisher<[AuthenticatorItem], Error> {
let fetchRequest = AuthenticatorItemData.fetchByUserIdRequest(userId: userId)
// A sort descriptor is needed by `NSFetchedResultsController`.
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AuthenticatorItemData.id, ascending: true)]
return FetchedResultsPublisher(
context: persistentContainer.viewContext,
request: fetchRequest
)
.tryMap { try $0.map(AuthenticatorItem.init) }
.eraseToAnyPublisher()
}
func replaceAuthenticatorItems(_ authenticatorItems: [AuthenticatorItem], userId: String) async throws {
let deleteRequest = AuthenticatorItemData.deleteByUserIdRequest(userId: userId)
let insertRequest = try AuthenticatorItemData.batchInsertRequest(objects: authenticatorItems, userId: userId)
try await executeBatchReplace(
deleteRequest: deleteRequest,
insertRequest: insertRequest
)
}
func upsertAuthenticatorItem(_ authenticatorItem: AuthenticatorItem, userId: String) async throws {
try await backgroundContext.performAndSave {
_ = try AuthenticatorItemData(
context: self.backgroundContext,
userId: userId,
authenticatorItem: authenticatorItem
)
}
}
}

View File

@ -0,0 +1,147 @@
import CoreData
import XCTest
@testable import AuthenticatorShared
class AuthenticatorItemDataStoreTests: AuthenticatorTestCase {
// MARK: Properties
var subject: DataStore!
let authenticatorItems = [
AuthenticatorItem.fixture(id: "1", name: "item1"),
AuthenticatorItem.fixture(id: "2", name: "item2"),
AuthenticatorItem.fixture(id: "3", name: "item3"),
]
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
subject = DataStore(errorReporter: MockErrorReporter(), storeType: .memory)
}
override func tearDown() {
super.tearDown()
subject = nil
}
// MARK: Tests
/// `authenticatorItemPublisher(userId:)` returns a publisher for a user's authenticatorItem objects.
func test_authenticatorItemPublisher() async throws {
var publishedValues = [[AuthenticatorItem]]()
let publisher = subject.authenticatorItemPublisher(userId: "1")
.sink(
receiveCompletion: { _ in },
receiveValue: { values in
publishedValues.append(values)
}
)
defer { publisher.cancel() }
try await subject.replaceAuthenticatorItems(authenticatorItems, userId: "1")
waitFor { publishedValues.count == 2 }
XCTAssertTrue(publishedValues[0].isEmpty)
XCTAssertEqual(publishedValues[1], authenticatorItems)
}
/// `deleteAllAuthenticatorItems(user:)` removes all objects for the user.
func test_deleteAllAuthenticatorItems() async throws {
try await insertAuthenticatorItems(authenticatorItems, userId: "1")
try await insertAuthenticatorItems(authenticatorItems, userId: "2")
try await subject.deleteAllAuthenticatorItems(userId: "1")
try XCTAssertTrue(fetchAuthenticatorItems(userId: "1").isEmpty)
try XCTAssertEqual(fetchAuthenticatorItems(userId: "2").count, 3)
}
/// `deleteAuthenticatorItem(id:userId:)` removes the authenticatorItem with the given ID for the user.
func test_deleteAuthenticatorItem() async throws {
try await insertAuthenticatorItems(authenticatorItems, userId: "1")
try await subject.deleteAuthenticatorItem(id: "2", userId: "1")
try XCTAssertEqual(
fetchAuthenticatorItems(userId: "1"),
authenticatorItems.filter { $0.id != "2" }
)
}
/// `fetchAuthenticatorItem(withId:)` returns the specified authenticatorItem if it exists and `nil` otherwise.
func test_fetchAuthenticatorItem() async throws {
try await insertAuthenticatorItems(authenticatorItems, userId: "1")
let authenticatorItem1 = try await subject.fetchAuthenticatorItem(withId: "1", userId: "1")
XCTAssertEqual(authenticatorItem1, authenticatorItems.first)
let authenticatorItem42 = try await subject.fetchAuthenticatorItem(withId: "42", userId: "1")
XCTAssertNil(authenticatorItem42)
}
/// `replaceAuthenticatorItems(_:userId)` replaces the list of authenticatorItems for the user.
func test_replaceAuthenticatorItems() async throws {
try await insertAuthenticatorItems(authenticatorItems, userId: "1")
let newAuthenticatorItems = [
AuthenticatorItem.fixture(id: "3", name: "item3"),
AuthenticatorItem.fixture(id: "4", name: "item4"),
AuthenticatorItem.fixture(id: "5", name: "item5"),
]
try await subject.replaceAuthenticatorItems(newAuthenticatorItems, userId: "1")
XCTAssertEqual(try fetchAuthenticatorItems(userId: "1"), newAuthenticatorItems)
}
/// `upsertAuthenticatorItem(_:userId:)` inserts a authenticatorItem for a user.
func test_upsertAuthenticatorItem_insert() async throws {
let authenticatorItem = AuthenticatorItem.fixture(id: "1")
try await subject.upsertAuthenticatorItem(authenticatorItem, userId: "1")
try XCTAssertEqual(fetchAuthenticatorItems(userId: "1"), [authenticatorItem])
let authenticatorItem2 = AuthenticatorItem.fixture(id: "2")
try await subject.upsertAuthenticatorItem(authenticatorItem2, userId: "1")
try XCTAssertEqual(fetchAuthenticatorItems(userId: "1"), [authenticatorItem, authenticatorItem2])
}
/// `upsertAuthenticatorItem(_:userId:)` updates an existing authenticatorItem for a user.
func test_upsertAuthenticatorItem_update() async throws {
try await insertAuthenticatorItems(authenticatorItems, userId: "1")
let updatedAuthenticatorItem = AuthenticatorItem.fixture(id: "2", name: "UPDATED CIPHER2")
try await subject.upsertAuthenticatorItem(updatedAuthenticatorItem, userId: "1")
var expectedAuthenticatorItems = authenticatorItems
expectedAuthenticatorItems[1] = updatedAuthenticatorItem
try XCTAssertEqual(fetchAuthenticatorItems(userId: "1"), expectedAuthenticatorItems)
}
// MARK: Test Helpers
/// A test helper to fetch all authenticatorItem's for a user.
private func fetchAuthenticatorItems(userId: String) throws -> [AuthenticatorItem] {
let fetchRequest = AuthenticatorItemData.fetchByUserIdRequest(userId: userId)
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AuthenticatorItemData.id, ascending: true)]
return try subject.backgroundContext.fetch(fetchRequest).map(AuthenticatorItem.init)
}
/// A test helper for inserting a list of authenticatorItems for a user.
private func insertAuthenticatorItems(_ authenticatorItems: [AuthenticatorItem], userId: String) async throws {
try await subject.backgroundContext.performAndSave {
for authenticatorItem in authenticatorItems {
_ = try AuthenticatorItemData(
context: self.subject.backgroundContext,
userId: userId,
authenticatorItem: authenticatorItem
)
}
}
}
}

View File

@ -0,0 +1,57 @@
import Combine
@testable import AuthenticatorShared
class MockAuthenticatorItemDataStore: AuthenticatorItemDataStore {
var deleteAllAuthenticatorItemsUserId: String?
var deleteAuthenticatorItemId: String?
var deleteAuthenticatorItemUserId: String?
var fetchAllAuthenticatorItemsUserId: String?
var fetchAllAuthenticatorItemsResult: Result<[AuthenticatorItem], Error> = .success([])
var fetchAuthenticatorItemId: String?
var fetchAuthenticatorItemResult: AuthenticatorItem?
var authenticatorItemSubject = CurrentValueSubject<[AuthenticatorItem], Error>([])
var replaceAuthenticatorItemsValue: [AuthenticatorItem]?
var replaceAuthenticatorItemsUserId: String?
var upsertAuthenticatorItemValue: AuthenticatorItem?
var upsertAuthenticatorItemUserId: String?
func deleteAllAuthenticatorItems(userId: String) async throws {
deleteAllAuthenticatorItemsUserId = userId
}
func deleteAuthenticatorItem(id: String, userId: String) async throws {
deleteAuthenticatorItemId = id
deleteAuthenticatorItemUserId = userId
}
func fetchAllAuthenticatorItems(userId: String) async throws -> [AuthenticatorItem] {
fetchAllAuthenticatorItemsUserId = userId
return try fetchAllAuthenticatorItemsResult.get()
}
func fetchAuthenticatorItem(withId id: String, userId _: String) async -> AuthenticatorItem? {
fetchAuthenticatorItemId = id
return fetchAuthenticatorItemResult
}
func authenticatorItemPublisher(userId _: String) -> AnyPublisher<[AuthenticatorItem], Error> {
authenticatorItemSubject.eraseToAnyPublisher()
}
func replaceAuthenticatorItems(_ authenticatorItems: [AuthenticatorItem], userId: String) async throws {
replaceAuthenticatorItemsValue = authenticatorItems
replaceAuthenticatorItemsUserId = userId
}
func upsertAuthenticatorItem(_ authenticatorItem: AuthenticatorItem, userId: String) async throws {
upsertAuthenticatorItemValue = authenticatorItem
upsertAuthenticatorItemUserId = userId
}
}

View File

@ -47,7 +47,8 @@ enum TOTPExpirationCalculator {
timeProvider: any TimeProvider
) -> [Bool: [ItemListItem]] {
let sortedItems: [Bool: [ItemListItem]] = Dictionary(grouping: items, by: { item in
hasCodeExpired(item.totpCode, timeProvider: timeProvider)
guard case let .totp(model) = item.itemType else { return false }
return hasCodeExpired(model.totpCode, timeProvider: timeProvider)
})
return sortedItems
}

View File

@ -66,17 +66,21 @@ final class TOTPExpirationCalculatorTests: AuthenticatorTestCase {
func test_listItemsByExpiration() {
let expired = ItemListItem.fixture(
totpCode: .init(
code: "",
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 29),
period: 30
totp: ItemListTotpItem.fixture(
totpCode: TOTPCodeModel(
code: "",
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 29),
period: 30
)
)
)
let current = ItemListItem.fixture(
totpCode: .init(
code: "",
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 31),
period: 30
totp: ItemListTotpItem.fixture(
totpCode: TOTPCodeModel(
code: "",
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 31),
period: 30
)
)
)
let expectation = [

View File

@ -1,7 +1,15 @@
import BitwardenSdk
import Foundation
/// Protocol defining the functionality of a TOTP (Time-based One-Time Password) service.
protocol TOTPService {
/// Calculates the TOTP code for a given key
///
/// - Parameters:
/// - key: The `TOTPKeyModel` to generate a code for
///
func getTotpCode(for key: TOTPKeyModel) async throws -> TOTPCodeModel
/// Retrieves the TOTP configuration for a given key.
///
/// - Parameter key: A string representing the TOTP key.
@ -11,12 +19,46 @@ protocol TOTPService {
}
/// Default implementation of the `TOTPService`.
struct DefaultTOTPService: TOTPService {
/// Retrieves the TOTP configuration for a given key.
class DefaultTOTPService {
// MARK: Properties
/// The service to communicate with the SDK for encryption/decryption tasks.
private let clientVault: ClientVaultService
/// The service used by the application to report non-fatal errors.
private let errorReporter: ErrorReporter
/// The service used to get the present time.
private let timeProvider: TimeProvider
// MARK: Initialization
/// Initialize a `DefaultTOTPService`.
///
/// - Parameter key: A string representing the TOTP key.
/// - Throws: `TOTPServiceError.invalidKeyFormat` if the key format is invalid.
/// - Returns: A `TOTPKeyModel` containing the configuration details.
/// - Parameters:
/// - clientVault: The service to communicate with the SDK for encryption/decryption tasks.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - timeProvider: The service used to get the present time.
///
init(
clientVault: ClientVaultService,
errorReporter: ErrorReporter,
timeProvider: TimeProvider
) {
self.clientVault = clientVault
self.errorReporter = errorReporter
self.timeProvider = timeProvider
}
}
extension DefaultTOTPService: TOTPService {
func getTotpCode(for key: TOTPKeyModel) async throws -> TOTPCodeModel {
try await clientVault.generateTOTPCode(
for: key.rawAuthenticatorKey,
date: timeProvider.presentTime
)
}
func getTOTPConfiguration(key: String?) throws -> TOTPKeyModel {
guard let key,
let config = TOTPKeyModel(authenticatorKey: key) else {

View File

@ -5,29 +5,61 @@ import XCTest
// MARK: - TOTPServiceTests
final class TOTPServiceTests: AuthenticatorTestCase {
// MARK: Properties
var clientVaultService: MockClientVaultService!
var errorReporter: MockErrorReporter!
var timeProvider: MockTimeProvider!
var subject: DefaultTOTPService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
clientVaultService = MockClientVaultService()
errorReporter = MockErrorReporter()
timeProvider = MockTimeProvider(.currentTime)
subject = DefaultTOTPService(
clientVault: clientVaultService,
errorReporter: errorReporter,
timeProvider: timeProvider
)
}
override func tearDown() {
super.tearDown()
clientVaultService = nil
errorReporter = nil
timeProvider = nil
subject = nil
}
// MARK: Tests
func test_default_getTOTPConfiguration_base32() throws {
let config = try DefaultTOTPService()
let config = try subject
.getTOTPConfiguration(key: .base32Key)
XCTAssertNotNil(config)
}
func test_default_getTOTPConfiguration_otp() throws {
let config = try DefaultTOTPService()
let config = try subject
.getTOTPConfiguration(key: .otpAuthUriKeyComplete)
XCTAssertNotNil(config)
}
func test_default_getTOTPConfiguration_steam() throws {
let config = try DefaultTOTPService()
let config = try subject
.getTOTPConfiguration(key: .steamUriKey)
XCTAssertNotNil(config)
}
func test_default_getTOTPConfiguration_failure() {
XCTAssertThrowsError(
try DefaultTOTPService().getTOTPConfiguration(key: "1234")
try subject.getTOTPConfiguration(key: "1234")
) { error in
XCTAssertEqual(
error as? TOTPServiceError,

View File

@ -1,10 +1,20 @@
@testable import AuthenticatorShared
class MockTOTPService: TOTPService {
var refreshTOTPCodeResult: Result<TOTPCodeModel, Error> = .success(
TOTPCodeModel(code: .base32Key, codeGenerationDate: .now, period: 30)
)
var refreshedTOTPKeyConfig: TOTPKeyModel?
var capturedKey: String?
var getTOTPConfigResult: Result<TOTPKeyModel, Error> = .failure(TOTPServiceError.invalidKeyFormat)
func getTOTPConfiguration(key: String?) throws -> AuthenticatorShared.TOTPKeyModel {
func getTotpCode(for key: TOTPKeyModel) async throws -> TOTPCodeModel {
refreshedTOTPKeyConfig = key
return try refreshTOTPCodeResult.get()
}
func getTOTPConfiguration(key: String?) throws -> TOTPKeyModel {
capturedKey = key
return try getTOTPConfigResult.get()
}

View File

@ -0,0 +1,50 @@
import Combine
import Foundation
@testable import AuthenticatorShared
class MockAuthenticatorItemService: AuthenticatorItemService {
var addAuthenticatorItemAuthenticatorItems = [AuthenticatorItem]()
var addAuthenticatorItemResult: Result<Void, Error> = .success(())
var deleteAuthenticatorItemId: String?
var deleteAuthenticatorItemResult: Result<Void, Error> = .success(())
var fetchAuthenticatorItemId: String?
var fetchAuthenticatorItemResult: Result<AuthenticatorItem?, Error> = .success(nil)
var fetchAllAuthenticatorItemsResult: Result<[AuthenticatorItem], Error> = .success([])
var updateAuthenticatorItemAuthenticatorItem: AuthenticatorItem?
var updateAuthenticatorItemResult: Result<Void, Error> = .success(())
var authenticatorItemsSubject = CurrentValueSubject<[AuthenticatorItem], Error>([])
func addAuthenticatorItem(_ authenticatorItem: AuthenticatorShared.AuthenticatorItem) async throws {
addAuthenticatorItemAuthenticatorItems.append(authenticatorItem)
try addAuthenticatorItemResult.get()
}
func deleteAuthenticatorItem(id: String) async throws {
deleteAuthenticatorItemId = id
try deleteAuthenticatorItemResult.get()
}
func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorShared.AuthenticatorItem? {
fetchAuthenticatorItemId = id
return try fetchAuthenticatorItemResult.get()
}
func fetchAllAuthenticatorItems() async throws -> [AuthenticatorShared.AuthenticatorItem] {
try fetchAllAuthenticatorItemsResult.get()
}
func updateAuthenticatorItem(_ authenticatorItem: AuthenticatorShared.AuthenticatorItem) async throws {
updateAuthenticatorItemAuthenticatorItem = authenticatorItem
try updateAuthenticatorItemResult.get()
}
func authenticatorItemsPublisher() async throws -> AnyPublisher<[AuthenticatorShared.AuthenticatorItem], Error> {
authenticatorItemsSubject.eraseToAnyPublisher()
}
}

View File

@ -0,0 +1,20 @@
import Foundation
@testable import AuthenticatorShared
class MockCryptographyService: CryptographyService {
var encryptError: Error?
var encryptedAuthenticatorItems = [AuthenticatorItemView]()
func decrypt(_ authenticatorItem: AuthenticatorItem) async throws -> AuthenticatorItemView {
AuthenticatorItemView(authenticatorItem: authenticatorItem)
}
func encrypt(_ authenticatorItemView: AuthenticatorItemView) async throws -> AuthenticatorItem {
encryptedAuthenticatorItems.append(authenticatorItemView)
if let encryptError {
throw encryptError
}
return AuthenticatorItem(authenticatorItemView: authenticatorItemView)
}
}

View File

@ -77,9 +77,9 @@ class AppCoordinator: Coordinator, HasRootNavigator {
// MARK: Private Methods
/// Shows the Token List screen.
/// Shows the Item List screen.
///
/// - Parameter route: The token list route to show.
/// - Parameter route: The item list route to show.
///
private func showItemList(route: ItemListRoute) {
if let coordinator = childCoordinator as? AnyCoordinator<ItemListRoute, ItemListEvent> {

View File

@ -1,14 +1,14 @@
import BitwardenSdk
import OSLog
import SwiftUI
// MARK: - TokenCoordinator
// MARK: - AuthenticatorItemCoordinator
/// A coordinator that manages navigation for displaying, editing, and adding individual tokens.
///
class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
class AuthenticatorItemCoordinator: NSObject, Coordinator, HasStackNavigator {
// MARK: Types
typealias Module = TokenModule
typealias Module = AuthenticatorItemModule
typealias Services = HasErrorReporter
& HasTimeProvider
@ -29,7 +29,7 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
// MARK: Initialization
/// Creates a new `TokenCoordinator`.
/// Creates a new `AuthenticatorItemCoordinator`.
///
/// - Parameters:
/// - module: The module used by this coordinator to create child coordinators.
@ -48,9 +48,9 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
// MARK: Methods
func handleEvent(_ event: TokenEvent, context: AnyObject?) async {}
func handleEvent(_ event: AuthenticatorItemEvent, context: AnyObject?) async {}
func navigate(to route: TokenRoute, context: AnyObject?) {
func navigate(to route: AuthenticatorItemRoute, context: AnyObject?) {
switch route {
case let .alert(alert):
stackNavigator?.present(alert)
@ -58,9 +58,11 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
stackNavigator?.dismiss(animated: true, completion: {
onDismiss?.action()
})
case let .editToken(token):
showEditToken(for: token)
case let .editAuthenticatorItem(authenticatorItemView):
Logger.application.log("Edit item \(authenticatorItemView.id)")
showEditAuthenticatorItem(for: authenticatorItemView)
case let .viewToken(id):
Logger.application.log("View token \(id)")
showViewToken(id: id)
}
}
@ -69,7 +71,7 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
// MARK: Private Methods
/// Present a child `TokenCoordinator` on top of the existing coordinator.
/// Present a child `AuthenticatorItemCoordinator` on top of the existing coordinator.
///
/// Presenting a view on top of an already presented view within the same coordinator causes
/// problems when dismissing only the top view. So instead, present a new coordinator and
@ -77,9 +79,9 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
///
/// - Parameter route: The route to navigate to in the presented coordinator.
///
private func presentChildTokenCoordinator(route: TokenRoute, context: AnyObject?) {
private func presentChildAuthenticatorItemCoordinator(route: AuthenticatorItemRoute, context: AnyObject?) {
let navigationController = UINavigationController()
let coordinator = module.makeTokenCoordinator(stackNavigator: navigationController)
let coordinator = module.makeAuthenticatorItemCoordinator(stackNavigator: navigationController)
coordinator.navigate(to: route, context: context)
coordinator.start()
stackNavigator?.present(navigationController)
@ -90,22 +92,25 @@ class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
/// - Parameters:
/// - token: The `Token` to edit.
///
private func showEditToken(for token: Token) {
private func showEditAuthenticatorItem(for authenticatorItemView: AuthenticatorItemView) {
guard let stackNavigator else { return }
if stackNavigator.isEmpty {
guard let state = TokenItemState(existing: token)
guard let state = AuthenticatorItemState(existing: authenticatorItemView)
else { return }
let processor = EditTokenProcessor(
let processor = EditAuthenticatorItemProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: state
)
let store = Store(processor: processor)
let view = EditTokenView(store: store)
let view = EditAuthenticatorItemView(store: store)
stackNavigator.replace(view)
} else {
presentChildTokenCoordinator(route: .editToken(token), context: self)
presentChildAuthenticatorItemCoordinator(
route: .editAuthenticatorItem(authenticatorItemView),
context: self
)
}
}

View File

@ -0,0 +1,28 @@
import Foundation
// MARK: - AuthenticatorItemModule
/// An object that builds coordinators for the token views.
@MainActor
protocol AuthenticatorItemModule {
/// Initializes a coordinator for navigating between `AuthenticatorItemRoute` objects.
///
/// - Parameter stackNavigator: The stack navigator that will be used to navigate between routes.
/// - Returns: A coordinator that can navigate to a `AuthenticatorItemRoute`.
///
func makeAuthenticatorItemCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>
}
extension DefaultAppModule: AuthenticatorItemModule {
func makeAuthenticatorItemCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent> {
AuthenticatorItemCoordinator(
module: self,
services: services,
stackNavigator: stackNavigator
).asAnyCoordinator()
}
}

View File

@ -1,10 +1,10 @@
import BitwardenSdk
import SwiftUI
// MARK: - TokenRoute
// MARK: - AuthenticatorItemRoute
/// A route to a screen for a specific token.
enum TokenRoute: Equatable {
/// A route to a screen for a specific item.
enum AuthenticatorItemRoute: Equatable {
/// A route to display the specified alert.
///
/// - Parameter alert: The alert to display.
@ -19,8 +19,8 @@ enum TokenRoute: Equatable {
/// A route to edit a token.
///
/// - Parameter token: the `Token` to edit
case editToken(_ token: Token)
/// - Parameter authenticatorItemView: the `AuthenticatorItemView` to edit
case editAuthenticatorItem(_ authenticatorItemView: AuthenticatorItemView)
/// A route to the view token screen.
///
@ -29,4 +29,4 @@ enum TokenRoute: Equatable {
case viewToken(id: String)
}
enum TokenEvent {}
enum AuthenticatorItemEvent {}

View File

@ -0,0 +1,108 @@
import BitwardenSdk
import Foundation
// MARK: - AuthenticatorItemState
/// An object that defines the current state of any view interacting with an authenticator item.
///
struct AuthenticatorItemState: Equatable {
// MARK: Types
/// An enum defining if the state is a new or existing item.
enum Configuration: Equatable {
/// We are creating a new item.
case add
/// We are viewing or editing an existing item.
case existing(authenticatorItemView: AuthenticatorItemView)
/// The existing `AuthenticatorItemView` if the configuration is `existing`.
var existingToken: AuthenticatorItemView? {
guard case let .existing(authenticatorItemView) = self else { return nil }
return authenticatorItemView
}
}
// MARK: Properties
/// The account of the item
var account: String
/// The Add or Existing Configuration.
let configuration: Configuration
/// A flag indicating if the key field is visible
var isKeyVisible: Bool = false
/// The issuer of the item
var issuer: String
/// The name of this item.
var name: String
/// A toast for views
var toast: Toast?
/// The TOTP key/code state.
var totpState: LoginTOTPState
// MARK: Initialization
init(
configuration: Configuration,
name: String,
totpState: LoginTOTPState
) {
self.configuration = configuration
self.name = name
self.totpState = totpState
account = "Fixme"
issuer = "Fixme"
}
init?(existing authenticatorItemView: AuthenticatorItemView) {
self.init(
configuration: .existing(authenticatorItemView: authenticatorItemView),
name: authenticatorItemView.name,
totpState: LoginTOTPState(authenticatorItemView.totpKey)
)
}
}
extension AuthenticatorItemState: EditAuthenticatorItemState {
var editState: EditAuthenticatorItemState {
self
}
}
extension AuthenticatorItemState: ViewAuthenticatorItemState {
var authenticatorKey: String? {
totpState.rawAuthenticatorKeyString
}
var authenticatorItemView: AuthenticatorItemView {
switch configuration {
case let .existing(authenticatorItemView):
return authenticatorItemView
case .add:
return newAuthenticatorItemView()
}
}
var totpCode: TOTPCodeModel? {
totpState.codeModel
}
}
extension AuthenticatorItemState {
/// Returns an `AuthenticatorItemView` based on the
/// properties of the `AuthenticatorItemState`.
///
func newAuthenticatorItemView() -> AuthenticatorItemView {
AuthenticatorItemView(
id: UUID().uuidString,
name: name,
totpKey: totpState.rawAuthenticatorKeyString
)
}
}

View File

@ -1,7 +1,7 @@
import BitwardenSdk
/// Synchronous actions that can be processed by an `EditItemProcessor`.
enum EditTokenAction: Equatable {
enum EditAuthenticatorItemAction: Equatable {
/// The account field was changed.
case accountChanged(String)
@ -11,7 +11,7 @@ enum EditTokenAction: Equatable {
/// The key field was changed.
case keyChanged(String)
/// The token's name was changed
/// The item's name was changed
case nameChanged(String)
/// The toast was shown or hidden.

View File

@ -1,7 +1,7 @@
import Foundation
/// Asynchronous effects that can be processed by an `EditTokenProcessor`
enum EditTokenEffect {
/// Asynchronous effects that can be processed by an `EditAuthenticatorItemProcessor`
enum EditAuthenticatorItemEffect {
/// The view appeared.
case appeared

View File

@ -1,28 +1,28 @@
import BitwardenSdk
import Foundation
/// The processor used to manage state and handle actions/effects for the edit token screen
final class EditTokenProcessor: StateProcessor<
EditTokenState,
EditTokenAction,
EditTokenEffect
/// The processor used to manage state and handle actions/effects for the edit item screen
final class EditAuthenticatorItemProcessor: StateProcessor<
EditAuthenticatorItemState,
EditAuthenticatorItemAction,
EditAuthenticatorItemEffect
> {
// MARK: Types
typealias Services = HasErrorReporter
& HasTokenRepository
typealias Services = HasAuthenticatorItemRepository
& HasErrorReporter
// MARK: Properties
/// The `Coordinator` that handles navigation.
private var coordinator: AnyCoordinator<TokenRoute, TokenEvent>
private var coordinator: AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>
/// The services required by this processor.
private let services: Services
// MARK: Initialization
/// Creates a new `EditTokenProcessor`.
/// Creates a new `EditAuthenticatorItemProcessor`.
///
/// - Parameters:
/// - coordinator: The `Coordinator` that handles navigation.
@ -30,9 +30,9 @@ final class EditTokenProcessor: StateProcessor<
/// - state: The initial state for the processor.
///
init(
coordinator: AnyCoordinator<TokenRoute, TokenEvent>,
coordinator: AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>,
services: Services,
state: EditTokenState
state: EditAuthenticatorItemState
) {
self.coordinator = coordinator
self.services = services
@ -42,7 +42,7 @@ final class EditTokenProcessor: StateProcessor<
// MARK: Methods
override func perform(_ effect: EditTokenEffect) async {
override func perform(_ effect: EditAuthenticatorItemEffect) async {
switch effect {
case .appeared:
break
@ -51,7 +51,7 @@ final class EditTokenProcessor: StateProcessor<
}
}
override func receive(_ action: EditTokenAction) {
override func receive(_ action: EditAuthenticatorItemAction) {
switch action {
case let .accountChanged(account):
state.account = account
@ -90,13 +90,13 @@ final class EditTokenProcessor: StateProcessor<
switch state.configuration {
case .add:
return
case let .existing(token: token):
let newToken = Token(
id: token.id,
name: token.name,
authenticatorKey: state.totpState.rawAuthenticatorKeyString!
)!
try await updateToken(token: newToken)
case let .existing(authenticatorItemView: authenticatorItemView):
let newAuthenticatorItemView = AuthenticatorItemView(
id: authenticatorItemView.id,
name: authenticatorItemView.name,
totpKey: state.totpState.rawAuthenticatorKeyString
)
try await updateAuthenticatorItem(authenticatorItem: newAuthenticatorItemView)
}
} catch let error as InputValidationError {
coordinator.showAlert(Alert.inputValidationAlert(error: error))
@ -109,8 +109,8 @@ final class EditTokenProcessor: StateProcessor<
/// Updates the item currently in `state`.
///
private func updateToken(token: Token) async throws {
try await services.tokenRepository.updateToken(token)
private func updateAuthenticatorItem(authenticatorItem: AuthenticatorItemView) async throws {
try await services.authenticatorItemRepository.updateAuthenticatorItem(authenticatorItem)
coordinator.hideLoadingOverlay()
coordinator.navigate(to: .dismiss())
}

View File

@ -1,20 +1,20 @@
import BitwardenSdk
import Foundation
/// The state of an `EditTokenView`
protocol EditTokenState: Sendable {
/// The state of an `EditAuthenticatorItemView`
protocol EditAuthenticatorItemState: Sendable {
// MARK: Properties
/// The account of the token
/// The account of the item
var account: String { get set }
/// The Add or Existing Configuration.
var configuration: TokenItemState.Configuration { get }
var configuration: AuthenticatorItemState.Configuration { get }
/// A flag indicating if the key is visible.
var isKeyVisible: Bool { get set }
/// The issuer of the token
/// The issuer of the item
var issuer: String { get set }
/// The name of this item.

View File

@ -1,11 +1,15 @@
import BitwardenSdk
import SwiftUI
/// A view for editing a token
struct EditTokenView: View {
/// A view for editing an item
struct EditAuthenticatorItemView: View {
// MARK: Properties
@ObservedObject var store: Store<EditTokenState, EditTokenAction, EditTokenEffect>
@ObservedObject var store: Store<
EditAuthenticatorItemState,
EditAuthenticatorItemAction,
EditAuthenticatorItemEffect
>
// MARK: View
@ -20,7 +24,7 @@ struct EditTokenView: View {
.task { await store.perform(.appeared) }
.toast(store.binding(
get: \.toast,
send: EditTokenAction.toastShown
send: EditAuthenticatorItemAction.toastShown
))
}
@ -46,7 +50,7 @@ struct EditTokenView: View {
title: Localizations.name,
text: store.binding(
get: \.name,
send: EditTokenAction.nameChanged
send: EditAuthenticatorItemAction.nameChanged
)
)
@ -54,11 +58,11 @@ struct EditTokenView: View {
title: Localizations.authenticatorKey,
text: store.binding(
get: \.totpState.rawAuthenticatorKeyString!,
send: EditTokenAction.keyChanged
send: EditAuthenticatorItemAction.keyChanged
),
isPasswordVisible: store.binding(
get: \.isKeyVisible,
send: EditTokenAction.toggleKeyVisibilityChanged
send: EditAuthenticatorItemAction.toggleKeyVisibilityChanged
)
)
.textFieldConfiguration(.password)
@ -76,15 +80,16 @@ struct EditTokenView: View {
#if DEBUG
#Preview("Edit") {
EditTokenView(
EditAuthenticatorItemView(
store: Store(
processor: StateProcessor(
state: TokenItemState(
state: AuthenticatorItemState(
configuration: .existing(
token: Token(
authenticatorItemView: AuthenticatorItemView(
id: "Example",
name: "Example",
authenticatorKey: "example"
)!
totpKey: "example"
)
),
name: "Example",
totpState: LoginTOTPState(

View File

@ -0,0 +1,15 @@
import BitwardenSdk
import Foundation
// MARK: - ViewAuthenticatorItemState
/// The state for viewing/adding/editing a totp item
protocol ViewAuthenticatorItemState: Sendable {
// MARK: Properties
/// The TOTP key.
var authenticatorKey: String? { get }
/// The TOTP code model
var totpCode: TOTPCodeModel? { get }
}

View File

@ -10,7 +10,7 @@ struct ViewTokenItemView: View {
// MARK: Properties
/// The `Store` for this view.
@ObservedObject var store: Store<TokenItemState, ViewTokenAction, ViewTokenEffect>
@ObservedObject var store: Store<AuthenticatorItemState, ViewTokenAction, ViewTokenEffect>
/// The `TimeProvider` used to calculate TOTP expiration.
var timeProvider: any TimeProvider
@ -59,7 +59,7 @@ struct ViewTokenItemView: View {
store: Store(
processor: StateProcessor(
state:
TokenItemState(
AuthenticatorItemState(
configuration: .add,
name: "Example",
totpState: LoginTOTPState(

View File

@ -11,16 +11,16 @@ final class ViewTokenProcessor: StateProcessor<
> {
// MARK: Types
typealias Services = HasErrorReporter
typealias Services = HasAuthenticatorItemRepository
& HasErrorReporter
& HasPasteboardService
& HasTOTPService
& HasTimeProvider
& HasTokenRepository
// MARK: Properties
/// The `Coordinator` that handles navigation, typically a `TokenCoordinator`.
private let coordinator: AnyCoordinator<TokenRoute, TokenEvent>
/// The `Coordinator` that handles navigation, typically a `AuthenticatorItemCoordinator`.
private let coordinator: AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>
/// The ID of the item being viewed.
private let itemId: String
@ -39,7 +39,7 @@ final class ViewTokenProcessor: StateProcessor<
/// - state: The initial state of this processor.
///
init(
coordinator: AnyCoordinator<TokenRoute, TokenEvent>,
coordinator: AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>,
itemId: String,
services: Services,
state: ViewTokenState
@ -55,7 +55,7 @@ final class ViewTokenProcessor: StateProcessor<
override func perform(_ effect: ViewTokenEffect) async {
switch effect {
case .appeared:
await streamTokenDetails()
await streamItemDetails()
case .totpCodeExpired:
await updateTOTPCode()
}
@ -80,32 +80,36 @@ private extension ViewTokenProcessor {
/// Triggers the edit state for the item currently stored in `state`.
///
private func editItem() {
guard case let .data(tokenState) = state.loadingState,
case let .existing(token) = tokenState.configuration else {
guard case let .data(authenticatorItemState) = state.loadingState,
case let .existing(authenticatorItemView) = authenticatorItemState.configuration else {
return
}
Task {
coordinator.navigate(to: .editToken(token), context: self)
coordinator.navigate(to: .editAuthenticatorItem(authenticatorItemView), context: self)
}
}
/// Stream the token details.
private func streamTokenDetails() async {
/// Stream the item details.
private func streamItemDetails() async {
do {
guard let token = try await services.tokenRepository.fetchToken(withId: itemId)
guard let authenticatorItemView = try await services.authenticatorItemRepository.fetchAuthenticatorItem(withId: itemId),
let key = authenticatorItemView.totpKey,
let model = TOTPKeyModel(authenticatorKey: key)
else { return }
let code = try await services.tokenRepository.refreshTotpCode(for: token.key)
guard var newTokenState = ViewTokenState(token: token) else { return }
if case var .data(tokenState) = newTokenState.loadingState {
let code = try await services.totpService.getTotpCode(for: model)
guard var newAuthenticatorItemState = ViewTokenState(authenticatorItemView: authenticatorItemView)
else { return }
if case var .data(authenticatorItemState) = newAuthenticatorItemState.loadingState {
let totpState = LoginTOTPState(
authKeyModel: token.key,
authKeyModel: model,
codeModel: code
)
tokenState.totpState = totpState
newTokenState.loadingState = .data(tokenState)
authenticatorItemState.totpState = totpState
newAuthenticatorItemState.loadingState = .data(authenticatorItemState)
}
state = newTokenState
state = newAuthenticatorItemState
} catch {
services.errorReporter.log(error: error)
}
@ -113,20 +117,20 @@ private extension ViewTokenProcessor {
/// Updates the TOTP code for the view.
func updateTOTPCode() async {
guard case let .data(tokenItemState) = state.loadingState,
let calculationKey = tokenItemState.totpState.authKeyModel
guard case let .data(authenticatorItemState) = state.loadingState,
let calculationKey = authenticatorItemState.totpState.authKeyModel
else { return }
do {
let code = try await services.tokenRepository.refreshTotpCode(for: calculationKey)
let code = try await services.totpService.getTotpCode(for: calculationKey)
guard case let .data(tokenItemState) = state.loadingState else { return }
guard case let .data(authenticatorItemState) = state.loadingState else { return }
let newTotpState = LoginTOTPState(
authKeyModel: calculationKey,
codeModel: code
)
var newState = tokenItemState
var newState = authenticatorItemState
newState.totpState = newTotpState
state.loadingState = .data(newState)
} catch {

View File

@ -9,7 +9,7 @@ struct ViewTokenState: Sendable {
/// The current state. If this state is not `.loading`, this value will contain an associated value with the
/// appropriate internal state.
var loadingState: LoadingState<TokenItemState> = .loading(nil)
var loadingState: LoadingState<AuthenticatorItemState> = .loading(nil)
/// A toast message to show in the view.
var toast: Toast?
@ -18,15 +18,15 @@ struct ViewTokenState: Sendable {
extension ViewTokenState {
// MARK: Initialization
/// Creates a new `ViewTokenState` from a provided `CipherView`.
/// Creates a new `ViewTokenState` from a provided `AuthenticatorItemView`.
///
/// - Parameters:
/// - cipherView: The `CipherView` to create this state with.
///
init?(token: Token) {
guard let tokenItemState = TokenItemState(
existing: token
init?(authenticatorItemView: AuthenticatorItemView) {
guard let authenticatorItemState = AuthenticatorItemState(
existing: authenticatorItemView
) else { return nil }
self.init(loadingState: .data(tokenItemState))
self.init(loadingState: .data(authenticatorItemState))
}
}

View File

@ -40,9 +40,9 @@ struct ViewTokenView: View {
// MARK: Private Views
/// The details of the token.
/// The details of the item.
@ViewBuilder
private func details(for state: TokenItemState) -> some View {
private func details(for state: AuthenticatorItemState) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
BitwardenTextValueField(title: Localizations.name, value: state.name)
@ -95,7 +95,7 @@ struct ViewTokenView: View {
processor: StateProcessor(
state: ViewTokenState(
loadingState: .data(
TokenItemState(
AuthenticatorItemState(
configuration: .add,
name: "Example",
totpState: LoginTOTPState(

View File

@ -4,15 +4,46 @@ import Foundation
/// Data model for an item displayed in the item list.
///
public struct ItemListItem: Equatable, Identifiable {
// MARK: Types
/// The type of item being displayed by this item
public enum ItemType: Equatable {
/// A TOTP code item
///
/// - Parameters:
/// - model: The TOTP model
case totp(model: ItemListTotpItem)
}
/// The identifier for the item.
public let id: String
/// The name to display for the item.
public let name: String
/// The token used to generate the code.
public let token: Token
/// The current TOTP code for the ciper.
public var totpCode: TOTPCodeModel
/// The type of item being displayed by this item
public let itemType: ItemType
}
extension ItemListItem {
/// Initialize an `ItemListItem` from an `AuthenticatorItemView`
///
/// - Parameters:
/// - authenticatorItemView: The `AuthenticatorItemView` used to initialize the `ItemListItem`
///
init?(authenticatorItemView: AuthenticatorItemView) {
let totpCode = TOTPCodeModel(code: "123456", codeGenerationDate: .now, period: 30)
let totpModel = ItemListTotpItem(itemView: authenticatorItemView, totpCode: totpCode)
self.init(id: authenticatorItemView.id,
name: authenticatorItemView.name,
itemType: .totp(model: totpModel))
}
}
public struct ItemListTotpItem: Equatable {
/// The `AuthenticatorItemView` used to populate the view
let itemView: AuthenticatorItemView
/// The current TOTP code for the item
var totpCode: TOTPCodeModel
}

View File

@ -1,4 +1,3 @@
import BitwardenSdk
import Foundation
// MARK: - ItemListProcessor
@ -7,12 +6,12 @@ import Foundation
final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, ItemListEffect> {
// MARK: Types
typealias Services = HasCameraService
typealias Services = HasAuthenticatorItemRepository
& HasCameraService
& HasErrorReporter
& HasPasteboardService
& HasTOTPService
& HasTimeProvider
& HasTokenRepository
// MARK: Private Properties
@ -77,7 +76,7 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
services.pasteboardService.copy(code)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.verificationCode))
case let .itemPressed(item):
coordinator.navigate(to: .viewToken(id: item.id))
coordinator.navigate(to: .viewItem(id: item.id))
case .morePressed:
break
case let .toastShown(newValue):
@ -92,19 +91,22 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
private func refreshTOTPCodes(for items: [ItemListItem]) async {
guard case .data = state.loadingState else { return }
let refreshedItems = await items.asyncMap { item in
do {
let refreshedCode = try await services.tokenRepository.refreshTotpCode(for: item.token.key)
return ItemListItem(
id: item.id,
name: item.name,
token: item.token,
totpCode: refreshedCode
)
} catch {
guard case let .totp(model) = item.itemType,
let key = model.itemView.totpKey,
let keyModel = TOTPKeyModel(authenticatorKey: key),
let code = try? await services.totpService.getTotpCode(for: keyModel)
else {
services.errorReporter.log(error: TOTPServiceError
.unableToGenerateCode("Unable to refresh TOTP code for list view item: \(item.id)"))
return item
}
var updatedModel = model
updatedModel.totpCode = code
return ItemListItem(
id: item.id,
name: item.name,
itemType: .totp(model: updatedModel)
)
}
groupTotpExpirationManager?.configureTOTPRefreshScheduling(for: refreshedItems)
state.loadingState = .data(refreshedItems)
@ -128,10 +130,17 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
/// Stream the items list.
private func streamItemList() async {
do {
for try await tokenList in try await services.tokenRepository.tokenPublisher() {
let itemList = try await tokenList.asyncMap { token in
let code = try await services.tokenRepository.refreshTotpCode(for: token.key)
return ItemListItem(id: token.id, name: token.name, token: token, totpCode: code)
for try await value in try await services.authenticatorItemRepository.itemListPublisher() {
guard let items = value.first?.items else { return }
let itemList = try await items.asyncMap { item in
guard case let .totp(model) = item.itemType,
let key = model.itemView.totpKey,
let keyModel = TOTPKeyModel(authenticatorKey: key)
else { return item }
let code = try await services.totpService.getTotpCode(for: keyModel)
var updatedModel = model
updatedModel.totpCode = code
return ItemListItem(id: item.id, name: item.name, itemType: .totp(model: updatedModel))
}
groupTotpExpirationManager?.configureTOTPRefreshScheduling(for: itemList)
state.loadingState = .data(itemList)
@ -201,7 +210,8 @@ private class TOTPExpirationManager {
func configureTOTPRefreshScheduling(for items: [ItemListItem]) {
var newItemsByInterval = [UInt32: [ItemListItem]]()
items.forEach { item in
newItemsByInterval[item.totpCode.period, default: []].append(item)
guard case let .totp(model) = item.itemType else { return }
newItemsByInterval[model.totpCode.period, default: []].append(item)
}
itemsByInterval = newItemsByInterval
}
@ -245,11 +255,11 @@ extension ItemListProcessor: AuthenticatorKeyCaptureDelegate {
do {
let authKeyModel = try services.totpService.getTOTPConfiguration(key: key)
let loginTotpState = LoginTOTPState(authKeyModel: authKeyModel)
guard let key = loginTotpState.rawAuthenticatorKeyString,
let newToken = Token(name: "Example", authenticatorKey: key)
guard let key = loginTotpState.rawAuthenticatorKeyString
else { return }
Task {
try await services.tokenRepository.addToken(newToken)
let newItem = AuthenticatorItemView(id: UUID().uuidString, name: "Example", totpKey: key)
try await services.authenticatorItemRepository.addAuthenticatorItem(newItem)
await perform(.refresh)
}
state.toast = Toast(text: Localizations.authenticatorKeyAdded)

View File

@ -0,0 +1,14 @@
/// Data model for a section of items in the item list
///
public struct ItemListSection: Equatable, Identifiable {
// MARK: Properties
/// The identifier for the section
public let id: String
/// The list of items in the section
public let items: [ItemListItem]
/// The name of the section, displayed as a section header
public let name: String
}

View File

@ -162,27 +162,29 @@ struct ItemListView: View {
ItemListItem(
id: "One",
name: "One",
token: Token(
name: "One",
authenticatorKey: "One"
)!,
totpCode: TOTPCodeModel(
code: "123456",
codeGenerationDate: Date(),
period: 30
itemType: .totp(
model: ItemListTotpItem(
itemView: AuthenticatorItemView.fixture(),
totpCode: TOTPCodeModel(
code: "123456",
codeGenerationDate: Date(),
period: 30
)
)
)
),
ItemListItem(
id: "Two",
name: "Two",
token: Token(
name: "Two",
authenticatorKey: "Two"
)!,
totpCode: TOTPCodeModel(
code: "123456",
codeGenerationDate: Date(),
period: 30
itemType: .totp(
model: ItemListTotpItem(
itemView: AuthenticatorItemView.fixture(),
totpCode: TOTPCodeModel(
code: "123456",
codeGenerationDate: Date(),
period: 30
)
)
)
),
]

View File

@ -1,4 +1,4 @@
import BitwardenSdk
import OSLog
import SwiftUI
// MARK: - ItemListCoordinator
@ -8,8 +8,8 @@ import SwiftUI
final class ItemListCoordinator: Coordinator, HasStackNavigator {
// MARK: - Types
typealias Module = ItemListModule
& TokenModule
typealias Module = AuthenticatorItemModule
& ItemListModule
typealias Services = HasTimeProvider
& ItemListProcessor.Services
@ -65,7 +65,8 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
case .setupTotpManual:
guard let delegate = context as? AuthenticatorKeyCaptureDelegate else { return }
showManualTotp(delegate: delegate)
case let .viewToken(id):
case let .viewItem(id):
Logger.application.log("View token \(id)")
showToken(route: .viewToken(id: id))
}
}
@ -121,9 +122,9 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
///
/// - Parameter route: The route to navigate to in the coordinator.
///
private func showToken(route: TokenRoute) {
private func showToken(route: AuthenticatorItemRoute) {
let navigationController = UINavigationController()
let coordinator = module.makeTokenCoordinator(stackNavigator: navigationController)
let coordinator = module.makeAuthenticatorItemCoordinator(stackNavigator: navigationController)
coordinator.start()
coordinator.navigate(to: route, context: self)

View File

@ -3,22 +3,22 @@ import Foundation
// MARK: - ItemListRoute
/// A route to a specific screen or subscreen of the Token List
/// A route to a specific screen or subscreen of the Item List
public enum ItemListRoute: Equatable, Hashable {
/// A route to the add item screen.
case addItem
/// A route to the base token list screen.
/// A route to the base item list screen.
case list
/// A route to the manual totp screen for setting up TOTP.
case setupTotpManual
/// A route to the view token screen.
/// A route to the view item screen.
///
/// - Parameter id: The id of the token to display.
///
case viewToken(id: String)
case viewItem(id: String)
}
enum ItemListEvent {

View File

@ -1,103 +0,0 @@
import BitwardenSdk
import Foundation
// MARK: - TokenState
/// An object that defines the current state of any view interacting with a token.
///
struct TokenItemState: Equatable {
// MARK: Types
/// An enum defining if the state is a new or existing token.
enum Configuration: Equatable {
/// We are creating a new token.
case add
/// We are viewing or editing an existing token.
case existing(token: Token)
/// The existing `CipherView` if the configuration is `existing`.
var existingToken: Token? {
guard case let .existing(token) = self else { return nil }
return token
}
}
// MARK: Properties
/// The account of the token
var account: String
/// The Add or Existing Configuration.
let configuration: Configuration
/// A flag indicating if the key field is visible
var isKeyVisible: Bool = false
/// The issuer of the token
var issuer: String
/// The name of this item.
var name: String
/// A toast for views
var toast: Toast?
/// The TOTP key/code state.
var totpState: LoginTOTPState
// MARK: Initialization
init(
configuration: Configuration,
name: String,
totpState: LoginTOTPState
) {
self.configuration = configuration
self.name = name
self.totpState = totpState
account = "Fixme"
issuer = "Fixme"
}
init?(existing token: Token) {
self.init(
configuration: .existing(token: token),
name: token.name,
totpState: LoginTOTPState(token.key.base32Key)
)
}
}
extension TokenItemState: EditTokenState {
var editState: EditTokenState {
self
}
}
extension TokenItemState: ViewTokenItemState {
var authenticatorKey: String? {
totpState.rawAuthenticatorKeyString
}
var token: Token {
switch configuration {
case let .existing(token):
return token
case .add:
return newToken()
}
}
var totpCode: TOTPCodeModel? {
totpState.codeModel
}
}
extension TokenItemState {
/// Returns a `Token` based on the properties of the `TokenItemState`.
///
func newToken() -> Token {
Token(name: name, authenticatorKey: totpState.rawAuthenticatorKeyString!)!
}
}

View File

@ -1,28 +0,0 @@
import Foundation
// MARK: - TokenModule
/// An object that builds coordinators for the token views.
@MainActor
protocol TokenModule {
/// Initializes a coordinator for navigating between `TokenRoute` objects.
///
/// - Parameter stackNavigator: The stack navigator that will be used to navigate between routes.
/// - Returns: A coordinator that can navigate to a `TokenRoute`.
///
func makeTokenCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<TokenRoute, TokenEvent>
}
extension DefaultAppModule: TokenModule {
func makeTokenCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<TokenRoute, TokenEvent> {
TokenCoordinator(
module: self,
services: services,
stackNavigator: stackNavigator
).asAnyCoordinator()
}
}

View File

@ -1,24 +0,0 @@
import BitwardenSdk
import Foundation
// MARK: - ViewTokenItemState
// The state for viewing/adding/editing a token item
protocol ViewTokenItemState: Sendable {
// MARK: Properties
/// The TOTP key.
var authenticatorKey: String? { get }
/// The TOTP key/code state.
// var totpState: LoginTOTPState
/// The TOTP code model
var totpCode: TOTPCodeModel? { get }
}
//extension ViewTokenItemState {
// var totpCode: TOTPCodeModel? {
// totpState.codeModel
// }
//}

View File

@ -27,7 +27,10 @@ struct ItemListItemRowView: View {
.accessibilityHidden(true)
HStack {
totpCodeRow(store.state.item.name, store.state.item.totpCode)
switch store.state.item.itemType {
case let .totp(model):
totpCodeRow(store.state.item.name, model.totpCode)
}
}
.padding(.vertical, 9)
}
@ -91,6 +94,7 @@ struct ItemListItemRowView: View {
}
}
#if DEBUG
#Preview {
ItemListItemRowView(
store: Store(
@ -99,14 +103,15 @@ struct ItemListItemRowView: View {
item: ItemListItem(
id: UUID().uuidString,
name: "Example",
token: Token(
name: "Example",
authenticatorKey: "Example"
)!,
totpCode: TOTPCodeModel(
code: "123456",
codeGenerationDate: Date(),
period: 30
itemType: .totp(
model: ItemListTotpItem(
itemView: AuthenticatorItemView.fixture(),
totpCode: TOTPCodeModel(
code: "123456",
codeGenerationDate: Date(),
period: 30
)
)
)
),
hasDivider: true,
@ -117,3 +122,4 @@ struct ItemListItemRowView: View {
timeProvider: PreviewTimeProvider()
)
}
#endif