모바일 앱에서 민감한 사용자 인증 정보인 액세스 토큰과 리프레시 토큰을 안전하게 저장하는 것은 매우 중요하다. 개발 초기에는 간단한 구현을 위해 UserDefaults를 고려했지만, UserDefaults는 plist 파일 형태로 앱 샌드박스 내에 일반 텍스트로 저장되어 보안에 취약하다. 이러한 보안 위험을 해결하기 위해 iOS의 KeyChain을 도입했다.
TokenStorage
편리한 토큰 관리를 위해 토큰 저장소를 구현했다. 토큰 저장소는 크게 토큰 저장, 토큰 불러오기, 토큰 삭제, 토큰 유효성 검사, 토큰 업데이트 기능을 한다.
✅ 토큰 저장
func saveToken(_ token: String, type: TokenType) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, // kSecClassGenericPassword는 일반적인 비밀번호/토큰 저장에 사용한다.
kSecAttrService as String: serviceIdentifier,
kSecAttrAccount as String: type.key, // 실제 키체인 항목을 식별하는 고유 키, 같은 서비스 내에서 여러 항목을 구분한다.
kSecValueData as String : token.data(using: .utf8, allowLossyConversion: false) as Any // 실제 저장할 데이터 값으로, 반드시 Data 타입으로 변환해야 한다.
]
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
try updateToken(token, type: type)
} else if status != errSecSuccess {
throw TokenStorageError.failedToSaveToken
}
}
파라미터로 토큰과 토큰 타입을 받아온다. SecItemAdd 메소드를 사용하여 키체인에 저장한다. 만약 중복 된 키가 있다면 updateToken() 메소드를 호출하여 기존 값을 업데이트한다. 저장에 실패하면 에러를 throw한다.
✅ 토큰 불러오기
func loadToken(type: TokenType) throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceIdentifier,
kSecAttrAccount as String: type.key,
kSecMatchLimit as String: kSecMatchLimitOne, // 첫 번째 일치하는 항목만 반환
kSecReturnData as String: true // true로 설정하면 저장된 데이터를 반환
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result) // 일치하는 아이템이 있으면 result에 할당
if status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8) {
return token
} else if status == errSecItemNotFound {
return nil
}
throw TokenStorageError.failedToLoadToken
}
파라미터로 토큰 타입을 받아온다. 반환 값이 옵셔널인 이유는 토큰이 없을 수도 있기 때문이다. result는 검색 결과를 저장할 변수이다. SecItemCopyMatching을 이용해 키체인에서 항목을 검색한다. 검색에 성공하면 result에 데이터가 할당 되고, 토큰이 없는 경우 nil을 반환, 그 외의 경우 로드 실패 에러를 throw한다.
✅ 토큰 삭제
func deleteToken(type: TokenType) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceIdentifier,
kSecAttrAccount as String: type.key
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw TokenStorageError.failedToDeleteToken
}
}
파라미터로 삭제할 토큰의 타입을 받아온다. SetItemDelete를 이용해 키체인에서 query와 일치하는 항목을 삭제한다. 삭제에 성공하거나 이미 삭제 된 경우를 제외하면 삭제 실패로 간주하고 에러를 throw한다.
✅ 토큰 유효성 검사
func validateAuthentication() -> Bool {
do {
let hasAccessToken = try loadToken(type: .access) != nil
let hasRefreshToken = try loadToken(type: .refresh) != nil
return hasAccessToken && hasRefreshToken
} catch {
return false
}
}
파라미터의 호출 없이 인증 상태의 유효성을 Bool으로 반환한다. 두 토큰이 모두 있어야 true를 반환하고, 토큰이 없거나 토큰 로드 과정에서 에러가 발생하면 false를 리턴한다.
✅ 토큰 업데이트
func updateToken(_ token: String, type: TokenType) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceIdentifier,
kSecAttrAccount as String: type.key
]
let attributes: [String: Any] = [
kSecValueData as String: token.data(using: .utf8, allowLossyConversion: false) as Any
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status != errSecSuccess {
throw TokenStorageError.failedToSaveToken
}
}
파라미터로 새로운 토큰과 토큰 타입을 받는다. 업데이트 할 속성을 설정한다. allowLossyConversion을 false로 설정하여 데이터 변환 시 손실을 방지한다. 설정한 속성과 쿼리를 바탕으로 SecItemUpdate를 사용해 키체인의 기존 항목을 업데이트한다. (query로 항목을 찾고, attributes의 값으로 업데이트) 성공이 아닌 경우 에러를 throw한다.
토큰 유효성 검사, 자동 로그인
앱 실행 시 토큰 유효성을 검사하고, 토큰 유효성에 따라 분기처리를 구현했다.
final class AppCoordinator: Coordinator {
...
private func showSplashScreen() {
let splashViewController = SplashViewController()
navigationController.setViewControllers([splashViewController], animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.checkAuthenticationAndStart()
}
}
private func checkAuthenticationAndStart() {
if TokenStorage.shared.validateAuthentication() {
print("✅ 저장된 토큰 있음 - 메인 화면으로 이동")
connectTabBarFlow()
} else {
print("⚠️ 인증 필요 - 로그인 화면으로 이동")
connectAuthFlow()
}
}
...
}
토큰 저장소의 토큰 유효성 검사 메소드를 통해 분기처리를 했다. 앱 코디네이터 실행 시(앱 실행 시) 토큰 유효성 검사를 통해 저장된 토큰이 있다면 메인 화면으로 이동하고, 저장된 토큰이 없으면 로그인 화면으로 이동하도록 구현하였다. (자동 로그인)
로그인 로직 수정
애플 로그인에 성공하면 서버에서 반환한 액세스 토큰과 리프레시 토큰을 키체인에 저장하도록 수정했다.
final class AuthRepository: NSObject, AuthRepositoryProtocol {
...
func signInWithApple(credentials: SignInWithAppleRequestDTO) -> Observable<Bool> {
return service.rx.request(.signInWithApple(credentials))
.filterSuccessfulStatusCodes()
.map { [weak self] response in
let res = try JSONDecoder().decode(SignInWithAppleResponsesDTO.self, from: response.data)
if let accessToken = response.response?.allHeaderFields["Authorization"] as? String {
try self?.tokenStorage.saveToken(accessToken, type: .access)
}
try self?.tokenStorage.saveToken(res.data.refreshToken, type: .refresh)
return false
}
.catch { error in
if let moyaError = error as? MoyaError {
switch moyaError {
case .statusCode(let response):
print("에러 상태 코드: \(response.statusCode)")
default:
print("Moya 에러: \(moyaError.localizedDescription)")
}
} else {
print("기타 에러: \(error.localizedDescription)")
}
return .error(error)
}
.asObservable()
}
...
}
파인뮤즈 서버는 엑세스 토큰은 헤더로, 리프레시 토큰은 바디로 반환하기 때문에 그에 맞게 키체인에 저장되도록 구현했다. 기존 로직에서 토큰 저장소의 토큰 저장 메소드를 통해 저장하도록 하는 코드를 추가했다.
인증 값 전달
extension AuthService: TargetType {
...
public var headers: [String : String]? {
switch self {
case .signInWithApple, .refreshToken:
return ["Content-Type" : "application/json"]
case .fetchIsValidNickname, .registerNickname:
let accessToken = try? AuthService.tokenStorage.loadToken(type: .access)
return [
"Content-Type" : "application/json",
"Authorization" : "Bearer \(accessToken ?? "")"
]
}
}
...
}
파인뮤즈 서버에 API 요청 시 헤더에 인증 정보를 전달해야하는 경우가 있다. Moya를 사용했기 때문에 헤더 부분에 액세스 토큰을 전달하도록 수정하였다.
'우리 같이 협업하자' 카테고리의 다른 글
[우협하] 16주차 회고 (1) | 2024.10.28 |
---|---|
[우협하] 수평 컬렉션 뷰를 커스텀해보자. - 동적 배경과 페이징 효과 (0) | 2024.10.21 |
[우협하] 13~15주차 회고 (1) | 2024.10.21 |
[우협하] 9~12주차 회고 (4) | 2024.09.29 |
[우협하/문제해결] 네이버 다이나믹맵에서 자동으로 카메라 무브가 발생하는 원인을 해결해보자. (5) | 2024.09.02 |