RemoteAuthService.swift 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import Foundation
  2. private struct AuthPayload: Decodable {
  3. let user: UserAccount
  4. let token: String
  5. let refreshToken: String
  6. let expiresAt: Date
  7. }
  8. private struct LoginBody: Encodable {
  9. let identifier: String
  10. let password: String
  11. }
  12. private struct RegisterBody: Encodable {
  13. let username: String
  14. let identifier: String
  15. let password: String
  16. }
  17. private struct UpdateProfileBody: Encodable {
  18. let username: String?
  19. let email: String?
  20. let phoneNumber: String?
  21. let avatarURL: String?
  22. }
  23. private struct ChangePasswordBody: Encodable {
  24. let oldPassword: String
  25. let newPassword: String
  26. }
  27. final class RemoteAuthService: AuthServiceProtocol {
  28. private let client: APIClient
  29. private let profileKey = "com.celestia.trace.cached_server_profile"
  30. private var cachedUser: UserAccount?
  31. init(client: APIClient = .shared) {
  32. self.client = client
  33. // Remove credentials created by the retired local-only auth repository.
  34. // These keys never contained a valid server session.
  35. UserDefaults.standard.removeObject(forKey: "com.celestia.trace.registered_users_db")
  36. UserDefaults.standard.removeObject(forKey: "com.celestia.trace.current_user_session")
  37. if let data = UserDefaults.standard.data(forKey: profileKey) {
  38. cachedUser = try? JSONDecoder().decode(UserAccount.self, from: data)
  39. }
  40. }
  41. func restoreSession(completion: @escaping (AuthResult<UserAccount?>) -> Void) {
  42. Task {
  43. guard await client.hasStoredSession() else {
  44. clearCachedProfile()
  45. completion(.success(nil))
  46. return
  47. }
  48. do {
  49. let user: UserAccount = try await client.request("user/profile", authenticated: true)
  50. cache(user)
  51. completion(.success(user))
  52. } catch APIError.transport {
  53. completion(.success(cachedUser))
  54. } catch {
  55. await client.clearSession()
  56. clearCachedProfile()
  57. completion(.failure(map(error)))
  58. }
  59. }
  60. }
  61. func login(request: LoginRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
  62. performAuthentication(path: "auth/login", body: LoginBody(identifier: request.identifier, password: request.password), completion: completion)
  63. }
  64. func register(request: RegisterRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
  65. performAuthentication(
  66. path: "auth/register",
  67. body: RegisterBody(username: request.username, identifier: request.identifier, password: request.password),
  68. completion: completion
  69. )
  70. }
  71. func updateProfile(request: UpdateProfileRequest, completion: @escaping (AuthResult<UserAccount>) -> Void) {
  72. Task {
  73. do {
  74. let body = UpdateProfileBody(username: request.username, email: request.email, phoneNumber: request.phoneNumber, avatarURL: request.avatarURL)
  75. let user: UserAccount = try await client.request("user/profile", method: .put, body: try JSONEncoder().encode(body), authenticated: true)
  76. cache(user)
  77. completion(.success(user))
  78. } catch {
  79. completion(.failure(map(error)))
  80. }
  81. }
  82. }
  83. func changePassword(request: ChangePasswordRequest, completion: @escaping (AuthResult<Bool>) -> Void) {
  84. Task {
  85. do {
  86. let body = ChangePasswordBody(oldPassword: request.oldPassword, newPassword: request.newPassword)
  87. try await client.requestVoid("user/change-password", method: .post, body: try JSONEncoder().encode(body), authenticated: true)
  88. completion(.success(true))
  89. } catch {
  90. completion(.failure(map(error)))
  91. }
  92. }
  93. }
  94. func logout(completion: @escaping () -> Void) {
  95. Task {
  96. try? await client.requestVoid("auth/logout", method: .post, authenticated: true)
  97. await client.clearSession()
  98. clearCachedProfile()
  99. completion()
  100. }
  101. }
  102. func getCurrentUser() -> UserAccount? {
  103. cachedUser
  104. }
  105. private func performAuthentication<Body: Encodable>(
  106. path: String,
  107. body: Body,
  108. completion: @escaping (AuthResult<UserAccount>) -> Void
  109. ) {
  110. Task {
  111. do {
  112. let payload: AuthPayload = try await client.request(path, method: .post, body: try JSONEncoder().encode(body))
  113. try await client.storeTokens(accessToken: payload.token, refreshToken: payload.refreshToken, expiresAt: payload.expiresAt)
  114. cache(payload.user)
  115. completion(.success(payload.user))
  116. } catch {
  117. completion(.failure(map(error)))
  118. }
  119. }
  120. }
  121. private func cache(_ user: UserAccount) {
  122. cachedUser = user
  123. if let data = try? JSONEncoder().encode(user) {
  124. UserDefaults.standard.set(data, forKey: profileKey)
  125. }
  126. }
  127. private func clearCachedProfile() {
  128. cachedUser = nil
  129. UserDefaults.standard.removeObject(forKey: profileKey)
  130. }
  131. private func map(_ error: Error) -> AuthError {
  132. if case APIError.unauthorized = error { return .invalidCredentials }
  133. if case APIError.server(let code, _) = error {
  134. if code == 409 { return .userAlreadyExists }
  135. if code == 401 { return .invalidCredentials }
  136. }
  137. return .networkError(error.localizedDescription)
  138. }
  139. }