177 lines
6.1 KiB
Swift
177 lines
6.1 KiB
Swift
import Foundation
|
|
import FirebaseAuth
|
|
import FirebaseCore
|
|
import GoogleSignIn
|
|
|
|
// MARK: - Auth Service
|
|
|
|
final class AuthService: NSObject, @unchecked Sendable {
|
|
static let shared = AuthService()
|
|
|
|
private let auth = Auth.auth()
|
|
private let limiter = AuthRateLimiter.shared
|
|
|
|
private override init() {
|
|
super.init()
|
|
}
|
|
|
|
// MARK: - Auth State
|
|
|
|
var currentUserId: String? { auth.currentUser?.uid }
|
|
var currentUserEmail: String? { auth.currentUser?.email }
|
|
var isSignedIn: Bool { auth.currentUser != nil }
|
|
var isAnonymous: Bool { auth.currentUser?.isAnonymous ?? false }
|
|
var isGoogleAccount: Bool {
|
|
auth.currentUser?.providerData.contains { $0.providerID == GoogleAuthProviderID } ?? false
|
|
}
|
|
|
|
/// Observe auth state changes as an async sequence
|
|
func authStateStream() -> AsyncStream<AuthState> {
|
|
AsyncStream { continuation in
|
|
let listener = Auth.auth().addStateDidChangeListener { _, user in
|
|
if let user = user {
|
|
continuation.yield(.authenticated(userId: user.uid, isAnonymous: user.isAnonymous))
|
|
} else {
|
|
continuation.yield(.unauthenticated)
|
|
}
|
|
}
|
|
continuation.onTermination = { _ in
|
|
Auth.auth().removeStateDidChangeListener(listener)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Auth Operations
|
|
|
|
func signInAnonymously() async throws -> String {
|
|
_ = limiter.recordFailure(.anonymous)
|
|
guard limiter.timeUntilNextAttempt(.anonymous) == 0 else {
|
|
throw AuthError.throttled(limiter.throttleMessage(.anonymous) ?? "Too many attempts. Please wait.")
|
|
}
|
|
|
|
let result = try await auth.signInAnonymously()
|
|
limiter.recordSuccess(.anonymous)
|
|
return result.user.uid
|
|
}
|
|
|
|
func signInWithEmail(_ email: String, password: String) async throws -> String {
|
|
guard limiter.timeUntilNextAttempt(.login) == 0 else {
|
|
throw AuthError.throttled(limiter.throttleMessage(.login) ?? "Too many attempts. Please wait.")
|
|
}
|
|
|
|
do {
|
|
let result = try await auth.signIn(withEmail: email, password: password)
|
|
limiter.recordSuccess(.login)
|
|
return result.user.uid
|
|
} catch {
|
|
limiter.recordFailure(.login)
|
|
throw AuthError.map(error)
|
|
}
|
|
}
|
|
|
|
func signUpWithEmail(_ email: String, password: String) async throws -> String {
|
|
guard limiter.timeUntilNextAttempt(.signUp) == 0 else {
|
|
throw AuthError.throttled(limiter.throttleMessage(.signUp) ?? "Too many attempts. Please wait.")
|
|
}
|
|
|
|
do {
|
|
let result = try await auth.createUser(withEmail: email, password: password)
|
|
limiter.recordSuccess(.signUp)
|
|
return result.user.uid
|
|
} catch {
|
|
limiter.recordFailure(.signUp)
|
|
throw AuthError.map(error)
|
|
}
|
|
}
|
|
|
|
func sendPasswordResetEmail(_ email: String) async throws {
|
|
guard limiter.timeUntilNextAttempt(.passwordReset) == 0 else {
|
|
throw AuthError.throttled(limiter.throttleMessage(.passwordReset) ?? "Too many attempts. Please wait.")
|
|
}
|
|
|
|
do {
|
|
try await auth.sendPasswordResetEmail(withEmail: email)
|
|
limiter.recordSuccess(.passwordReset)
|
|
} catch {
|
|
limiter.recordFailure(.passwordReset)
|
|
throw AuthError.map(error)
|
|
}
|
|
}
|
|
|
|
func signInWithGoogle(idToken: String, accessToken: String) async throws -> GoogleSignInResult {
|
|
let credential = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken)
|
|
let result = try await auth.signIn(with: credential)
|
|
|
|
let user = result.user
|
|
return GoogleSignInResult(
|
|
uid: user.uid,
|
|
displayName: user.displayName ?? "",
|
|
photoUrl: user.photoURL?.absoluteString ?? "",
|
|
email: user.email ?? "",
|
|
isAnonymous: user.isAnonymous
|
|
)
|
|
}
|
|
|
|
func signOut() throws {
|
|
try auth.signOut()
|
|
}
|
|
|
|
func reauthenticateWithEmail(_ email: String, password: String) async throws {
|
|
guard let currentUser = auth.currentUser else {
|
|
throw AuthError.notSignedIn
|
|
}
|
|
let credential = EmailAuthProvider.credential(withEmail: email, password: password)
|
|
try await currentUser.reauthenticate(with: credential)
|
|
}
|
|
|
|
func deleteAccount() async throws {
|
|
guard let currentUser = auth.currentUser else {
|
|
throw AuthError.notSignedIn
|
|
}
|
|
try await currentUser.delete()
|
|
}
|
|
}
|
|
|
|
// MARK: - Auth Errors
|
|
|
|
enum AuthError: LocalizedError {
|
|
case throttled(String)
|
|
case notSignedIn
|
|
case networkError
|
|
case invalidCredential
|
|
case emailAlreadyInUse
|
|
case weakPassword
|
|
case userNotFound
|
|
case unknown(Error)
|
|
|
|
static func map(_ error: Error) -> AuthError {
|
|
let nsError = error as NSError
|
|
switch nsError.code {
|
|
case AuthErrorCode.networkError.rawValue:
|
|
return .networkError
|
|
case AuthErrorCode.invalidCredential.rawValue:
|
|
return .invalidCredential
|
|
case AuthErrorCode.emailAlreadyInUse.rawValue:
|
|
return .emailAlreadyInUse
|
|
case AuthErrorCode.weakPassword.rawValue:
|
|
return .weakPassword
|
|
case AuthErrorCode.userNotFound.rawValue:
|
|
return .userNotFound
|
|
default:
|
|
return .unknown(error)
|
|
}
|
|
}
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .throttled(let msg): return msg
|
|
case .notSignedIn: return "No user is signed in."
|
|
case .networkError: return "Network error. Please check your connection."
|
|
case .invalidCredential: return "Invalid email or password."
|
|
case .emailAlreadyInUse: return "This email is already registered."
|
|
case .weakPassword: return "Password must be at least 6 characters."
|
|
case .userNotFound: return "No account found with this email."
|
|
case .unknown(let error): return error.localizedDescription
|
|
}
|
|
}
|
|
} |