#40: Add UserSessionStoreProtocol.

Only log out of the specific account.
Add tests for the keychain controller.
Expand test coverage.
PR comments
This commit is contained in:
Doug
2022-06-15 10:10:22 +01:00
committed by Doug
parent abd6a9c7c0
commit 8653f1dd6b
17 changed files with 261 additions and 78 deletions
@@ -1,53 +0,0 @@
//
// KeychainController.swift
// ElementX
//
// Created by Stefan Ceriu on 14.02.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import KeychainAccess
class KeychainController: KeychainControllerProtocol {
private let keychain: Keychain
init(identifier: String) {
keychain = Keychain(service: identifier)
}
func setAccessToken(_ accessToken: String, forUsername username: String) {
do {
try keychain.set(accessToken, key: username)
} catch {
MXLog.error("Failed storing user restore token with error: \(error)")
}
}
func accessTokenForUsername(_ username: String) -> String? {
do {
return try keychain.get(username)
} catch {
MXLog.error("Failed retrieving user restore token")
return nil
}
}
func accessTokens() -> [(username: String, accessToken: String)] {
keychain.allKeys().compactMap { username in
guard let accessToken = accessTokenForUsername(username) else {
return nil
}
return (username, accessToken)
}
}
func removeAllAccessTokens() {
do {
try keychain.removeAll()
} catch {
MXLog.error("Failed removing all tokens")
}
}
}
@@ -1,16 +0,0 @@
//
// KeychainControllerProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 14.02.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
protocol KeychainControllerProtocol {
func setAccessToken(_ accessToken: String, forUsername username: String)
func accessTokenForUsername(_ username: String) -> String?
func accessTokens() -> [(username: String, accessToken: String)]
func removeAllAccessTokens()
}
@@ -1,134 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import MatrixRustSDK
import Kingfisher
enum UserSessionStoreError: Error {
case missingCredentials
case failedRestoringLogin
case failedSettingUpSession
}
@MainActor
class UserSessionStore {
private let keychainController: KeychainControllerProtocol
/// Whether or not there are sessions in the store.
var hasSessions: Bool { !keychainController.accessTokens().isEmpty }
init(bundleIdentifier: String) {
keychainController = KeychainController(identifier: bundleIdentifier)
}
func restoreUserSession() async -> Result<UserSession, UserSessionStoreError> {
let availableAccessTokens = keychainController.accessTokens()
guard let usernameTokenTuple = availableAccessTokens.first else {
return .failure(.missingCredentials)
}
switch await restorePreviousLogin(usernameTokenTuple) {
case .success(let clientProxy):
return .success(UserSession(clientProxy: clientProxy,
mediaProvider: MediaProvider(clientProxy: clientProxy, imageCache: ImageCache.default)))
case .failure(let error):
MXLog.error("Failed restoring login with error: \(error)")
// On any restoration failure reset the token and restart
keychainController.removeAllAccessTokens()
deleteBaseDirectory(for: usernameTokenTuple.username)
return .failure(error)
}
}
func userSession(for client: Client) async -> Result<UserSession, UserSessionStoreError> {
switch await setupProxyForClient(client) {
case .success(let clientProxy):
return .success(UserSession(clientProxy: clientProxy,
mediaProvider: MediaProvider(clientProxy: clientProxy, imageCache: ImageCache.default)))
case .failure(let error):
MXLog.error("Failed creating user session with error: \(error)")
return .failure(error)
}
}
func logout(userSession: UserSessionProtocol) {
keychainController.removeAllAccessTokens()
deleteBaseDirectory(for: userSession.clientProxy.userIdentifier)
}
private func restorePreviousLogin(_ usernameTokenTuple: (username: String, accessToken: String)) async -> Result<ClientProxyProtocol, UserSessionStoreError> {
Benchmark.startTrackingForIdentifier("Login", message: "Started restoring previous login")
let basePath = baseDirectoryPath(for: usernameTokenTuple.username)
let loginTask = Task.detached {
try loginWithToken(basePath: basePath,
restoreToken: usernameTokenTuple.accessToken)
}
switch await loginTask.result {
case .success(let client):
return await setupProxyForClient(client)
case .failure(let error):
MXLog.error("Failed restoring login with error: \(error)")
return .failure(.failedRestoringLogin)
}
}
private func setupProxyForClient(_ client: Client) async -> Result<ClientProxyProtocol, UserSessionStoreError> {
Benchmark.endTrackingForIdentifier("Login", message: "Finished login")
do {
let accessToken = try client.restoreToken()
let userId = try client.userId()
keychainController.setAccessToken(accessToken, forUsername: userId)
} catch {
MXLog.error("Failed setting up user session with error: \(error)")
return .failure(.failedSettingUpSession)
}
let clientProxy = ClientProxy(client: client)
return .success(clientProxy)
}
func baseDirectoryPath(for username: String) -> String {
guard var url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
fatalError("Should always be able to retrieve the caches directory")
}
url = url.appendingPathComponent(username)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: false, attributes: nil)
return url.path
}
private func deleteBaseDirectory(for username: String) {
guard var url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
fatalError("Should always be able to retrieve the caches directory")
}
url = url.appendingPathComponent(username)
try? FileManager.default.removeItem(at: url)
}
}