Skip to content

Commit 9fe43cd

Browse files
authored
Сохраняем логин/пароль/токен в Keychain (#292)
* Добавил пакет SWKeychain Храним логин/пароль в `Keychain`, а токен генерируем при необходимости из этой модели * Поправил смену пароля Больше не делаем принудительный логаут
1 parent 1536412 commit 9fe43cd

File tree

17 files changed

+354
-46
lines changed

17 files changed

+354
-46
lines changed

SwiftUI-WorkoutApp.xcodeproj/project.pbxproj

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
67D916862838F0DD0098D3CB /* DialogScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67D916852838F0DD0098D3CB /* DialogScreen.swift */; };
8484
67D9169628396C1E0098D3CB /* SendMessageScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67D9169528396C1E0098D3CB /* SendMessageScreen.swift */; };
8585
67EA685C2A71A99700697C88 /* PhotoDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67EA685B2A71A99700697C88 /* PhotoDetailScreen.swift */; };
86+
67F2ABAB2D7451C3008F9928 /* SWKeychain in Frameworks */ = {isa = PBXBuildFile; productRef = 67F2ABAA2D7451C3008F9928 /* SWKeychain */; };
8687
67F9534F2964A5700077DFDC /* ImagePicker in Frameworks */ = {isa = PBXBuildFile; productRef = 67F9534E2964A5700077DFDC /* ImagePicker */; };
8788
67FBF64F28338A2E008A7968 /* EventDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67FBF64E28338A2E008A7968 /* EventDetailsScreen.swift */; };
8889
/* End PBXBuildFile section */
@@ -176,6 +177,7 @@
176177
67D916852838F0DD0098D3CB /* DialogScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogScreen.swift; sourceTree = "<group>"; };
177178
67D9169528396C1E0098D3CB /* SendMessageScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageScreen.swift; sourceTree = "<group>"; };
178179
67EA685B2A71A99700697C88 /* PhotoDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoDetailScreen.swift; sourceTree = "<group>"; };
180+
67F2ABA92D74508E008F9928 /* SWKeychain */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SWKeychain; sourceTree = "<group>"; };
179181
67FBF64E28338A2E008A7968 /* EventDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailsScreen.swift; sourceTree = "<group>"; };
180182
/* End PBXFileReference section */
181183

@@ -192,6 +194,7 @@
192194
buildActionMask = 2147483647;
193195
files = (
194196
67795FB22D1C05D90087132F /* SWModels in Frameworks */,
197+
67F2ABAB2D7451C3008F9928 /* SWKeychain in Frameworks */,
195198
67D67DE52AE8526600F7A8B0 /* SWDesignSystem in Frameworks */,
196199
67B7EB812D4FED510004A40D /* SWUtils in Frameworks */,
197200
67D322972AE993F90045B92F /* MapView991 in Frameworks */,
@@ -462,6 +465,7 @@
462465
67AE08472D1C049F0097B59F /* Libraries */ = {
463466
isa = PBXGroup;
464467
children = (
468+
67F2ABA92D74508E008F9928 /* SWKeychain */,
465469
67B7EB7F2D4FECD90004A40D /* SWUtils */,
466470
678CE36A2D1C510900F060C6 /* SWNetwork */,
467471
67AE08482D1C05020097B59F /* SWModels */,
@@ -521,6 +525,7 @@
521525
67795FB12D1C05D90087132F /* SWModels */,
522526
67795FB32D1C05D90087132F /* SWNetworkClient */,
523527
67B7EB802D4FED510004A40D /* SWUtils */,
528+
67F2ABAA2D7451C3008F9928 /* SWKeychain */,
524529
);
525530
productName = "SwiftUI-WorkoutApp";
526531
productReference = 6798AA3A280AEDC900DB76F1 /* WorkoutApp.app */;
@@ -874,7 +879,7 @@
874879
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
875880
CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES;
876881
CODE_SIGN_STYLE = Automatic;
877-
CURRENT_PROJECT_VERSION = 2;
882+
CURRENT_PROJECT_VERSION = 3;
878883
DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content";
879884
DEVELOPMENT_TEAM = CR68PP2Z3F;
880885
ENABLE_PREVIEWS = YES;
@@ -927,7 +932,7 @@
927932
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
928933
CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES;
929934
CODE_SIGN_STYLE = Automatic;
930-
CURRENT_PROJECT_VERSION = 2;
935+
CURRENT_PROJECT_VERSION = 3;
931936
DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content";
932937
DEVELOPMENT_TEAM = CR68PP2Z3F;
933938
ENABLE_PREVIEWS = YES;
@@ -1048,6 +1053,10 @@
10481053
package = 67D67DE32AE8526600F7A8B0 /* XCRemoteSwiftPackageReference "SWDesignSystem" */;
10491054
productName = SWDesignSystem;
10501055
};
1056+
67F2ABAA2D7451C3008F9928 /* SWKeychain */ = {
1057+
isa = XCSwiftPackageProductDependency;
1058+
productName = SWKeychain;
1059+
};
10511060
67F9534E2964A5700077DFDC /* ImagePicker */ = {
10521061
isa = XCSwiftPackageProductDependency;
10531062
package = 67F9534D2964A5700077DFDC /* XCRemoteSwiftPackageReference "ImagePicker" */;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// swift-tools-version: 5.10
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "SWKeychain",
8+
platforms: [.iOS(.v15)],
9+
products: [
10+
.library(
11+
name: "SWKeychain", targets: ["SWKeychain"]
12+
)
13+
],
14+
targets: [
15+
.target(name: "SWKeychain"),
16+
.testTarget(name: "SWKeychainTests", dependencies: ["SWKeychain"])
17+
]
18+
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Foundation
2+
3+
public struct AuthData: Codable {
4+
/// Логин
5+
///
6+
/// Нужен для генерации токена
7+
let login: String
8+
/// Пароль
9+
///
10+
/// - Нужен для генерации токена
11+
/// - Используется при смене логина, когда нужно сгенерировать новый токен, чтобы не выбросило из аккаунта
12+
public let password: String
13+
/// Токен авторизации, который отправляем на сервер
14+
public var token: String? {
15+
guard [login, password].allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else {
16+
return nil
17+
}
18+
return (login + ":" + password).data(using: .utf8)?.base64EncodedString()
19+
}
20+
21+
public init(login: String, password: String) {
22+
self.login = login
23+
self.password = password
24+
}
25+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
@propertyWrapper
5+
public struct KeychainWrapper: DynamicProperty {
6+
private let label: String
7+
private let storage = SecureStorage()
8+
9+
public init(_ label: String) {
10+
self.label = label
11+
}
12+
13+
public var wrappedValue: AuthData? {
14+
get { storage.getCredentials(with: label) }
15+
set {
16+
if let newValue {
17+
storage.updateCredentials(newValue, with: label)
18+
} else {
19+
storage.deleteCredentials(with: label)
20+
}
21+
}
22+
}
23+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import Foundation
2+
import OSLog
3+
import Security
4+
5+
final class SecureStorage {
6+
private let logger = Logger(
7+
subsystem: Bundle.main.bundleIdentifier!,
8+
category: String(describing: SecureStorage.self)
9+
)
10+
11+
enum KeychainError: Error, LocalizedError {
12+
case itemAlreadyExist
13+
case itemNotFound
14+
case errorStatus(String?)
15+
16+
var errorDescription: String? {
17+
switch self {
18+
case .itemAlreadyExist:
19+
"Элемент уже существует"
20+
case .itemNotFound:
21+
"Элемент не найден"
22+
case let .errorStatus(message):
23+
message
24+
}
25+
}
26+
27+
init(status: OSStatus) {
28+
switch status {
29+
case errSecDuplicateItem:
30+
self = .itemAlreadyExist
31+
case errSecItemNotFound:
32+
self = .itemNotFound
33+
default:
34+
let message = SecCopyErrorMessageString(status, nil) as String?
35+
self = .errorStatus(message)
36+
}
37+
}
38+
}
39+
40+
func addItem(query: [CFString: Any]) throws {
41+
let status = SecItemAdd(query as CFDictionary, nil)
42+
43+
if status != errSecSuccess {
44+
throw KeychainError(status: status)
45+
}
46+
}
47+
48+
func findItem(query: [CFString: Any]) throws -> [CFString: Any]? {
49+
var query = query
50+
query[kSecReturnAttributes] = kCFBooleanTrue
51+
query[kSecReturnData] = kCFBooleanTrue
52+
53+
var searchResult: AnyObject?
54+
55+
let status = withUnsafeMutablePointer(to: &searchResult) {
56+
SecItemCopyMatching(query as CFDictionary, $0)
57+
}
58+
59+
if status != errSecSuccess {
60+
throw KeychainError(status: status)
61+
} else {
62+
return searchResult as? [CFString: Any]
63+
}
64+
}
65+
66+
func updateItem(query: [CFString: Any], attributesToUpdate: [CFString: Any]) throws {
67+
let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)
68+
69+
if status != errSecSuccess {
70+
throw KeychainError(status: status)
71+
}
72+
}
73+
74+
func deleteItem(query: [CFString: Any]) throws {
75+
let status = SecItemDelete(query as CFDictionary)
76+
77+
if status != errSecSuccess {
78+
throw KeychainError(status: status)
79+
}
80+
}
81+
}
82+
83+
extension SecureStorage {
84+
func addCredentials(_ credentials: AuthData, with label: String) {
85+
var query: [CFString: Any] = [:]
86+
query[kSecClass] = kSecClassGenericPassword
87+
query[kSecAttrLabel] = label
88+
query[kSecAttrAccount] = credentials.login
89+
query[kSecValueData] = credentials.password.data(using: .utf8)
90+
91+
do {
92+
try addItem(query: query)
93+
} catch {
94+
logger.error("\(error.localizedDescription), label: \(label)")
95+
return
96+
}
97+
}
98+
99+
func updateCredentials(_ credentials: AuthData, with label: String) {
100+
deleteCredentials(with: label)
101+
addCredentials(credentials, with: label)
102+
}
103+
104+
func getCredentials(with label: String) -> AuthData? {
105+
var query: [CFString: Any] = [:]
106+
query[kSecClass] = kSecClassGenericPassword
107+
query[kSecAttrLabel] = label
108+
109+
var result: [CFString: Any]?
110+
111+
do {
112+
result = try findItem(query: query)
113+
} catch {
114+
logger.error("\(error.localizedDescription), label: \(label)")
115+
return nil
116+
}
117+
118+
if let account = result?[kSecAttrAccount] as? String,
119+
let data = result?[kSecValueData] as? Data,
120+
let password = String(data: data, encoding: .utf8) {
121+
return AuthData(login: account, password: password)
122+
} else {
123+
return nil
124+
}
125+
}
126+
127+
func deleteCredentials(with label: String) {
128+
var query: [CFString: Any] = [:]
129+
query[kSecClass] = kSecClassGenericPassword
130+
query[kSecAttrLabel] = label
131+
132+
do {
133+
try deleteItem(query: query)
134+
} catch {
135+
logger.error("\(error.localizedDescription), label: \(label)")
136+
return
137+
}
138+
}
139+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
@testable import SWKeychain
2+
import Testing
3+
4+
struct AuthDataTests {
5+
private static let validLogin = "user@domain.com"
6+
private static let validPassword = "p@ss!123"
7+
8+
@Test
9+
func validCredentialsGenerateCorrectToken() throws {
10+
let login = Self.validLogin
11+
let password = Self.validPassword
12+
let model = AuthData(login: login, password: password)
13+
let token = try #require(model.token)
14+
#expect(model.login == login)
15+
#expect(model.password == password)
16+
#expect(token == "dXNlckBkb21haW4uY29tOnBAc3MhMTIz")
17+
}
18+
19+
@Test(arguments: [
20+
("", Self.validPassword),
21+
(Self.validLogin, ""),
22+
("", ""),
23+
(" ", Self.validPassword),
24+
(Self.validLogin, " "),
25+
(" ", " ")
26+
])
27+
func invalidCredentialsProduceNilToken(login: String, password: String) {
28+
let model = AuthData(login: login, password: password)
29+
#expect(model.token == nil)
30+
}
31+
}

SwiftUI-WorkoutApp/Libraries/SWModels/Sources/SWModels/AuthData.swift

Lines changed: 0 additions & 14 deletions
This file was deleted.

SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/SWNetwork.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ private extension SWNetworkService {
110110
func logSuccess(request: URLRequest, data: Data) {
111111
logger.info(
112112
"""
113-
Обработали ответ сервера
113+
Получили успешный ответ сервера
114114
\nURL запроса: \(request.urlString, privacy: .public)
115115
\nJSON в ответе: \(data.prettyJson, privacy: .public)
116116
"""

SwiftUI-WorkoutApp/Libraries/SWUtils/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ let package = Package(
1010
.library(name: "SWUtils", targets: ["SWUtils"])
1111
],
1212
targets: [
13-
.target(name: "SWUtils", dependencies: []),
13+
.target(name: "SWUtils"),
1414
.testTarget(name: "SWUtilsTests", dependencies: ["SWUtils"])
1515
]
1616
)

0 commit comments

Comments
 (0)