From a7aea5968801aa78839c177625d47f209ed8e24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 6 Sep 2026 15:35:37 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B3=B5=EC=9C=A0=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=EA=B3=BC=20=EA=B3=B5=EA=B0=9C=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=EC=9D=98=20=EC=9D=BD=EA=B8=B0=20=EB=B2=94=EC=9C=84=EB=A5=BC=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EA=B4=80=EA=B3=84=EB=A1=9C=20=EC=A2=81?= =?UTF-8?q?=ED=9E=8C=EB=8B=A4=20(174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「로그인했는가」 는 범위가 아니었다. Google 계정은 몇 초 만에 새로 만들 수 있어, 로그인만 조건으로 둔 컬렉션은 사실상 공개였다. - publicProfiles 는 get 만 열고 list 를 막는다. 목록이 열려 있으면 문서 이름을 몰라도 컬렉션을 통째로 훑을 수 있어 「아는 사람만 본다」 가 성립하지 않았다. - 공유 코드를 공개 프로필에서 빼고, 코드를 문서 이름으로 삼는 등록부(shareCodes)로 옮긴다. 중복 확인이 만들기 한 번으로 끝나 확인과 저장 사이의 틈도 함께 사라진다. - 공유 일정의 읽기와 완료 변경을 그 코드의 감시자로 등록한 사람으로 좁힌다. 읽기보다 이쪽이 더 아프다 — 남이 완료로 바꿔 두면 어르신이 약을 이미 먹은 줄로 안다. - getUserNames 를 목록 질의에서 문서별 읽기로 바꾼다. - 감시자 등록이 이제 읽기 권한의 근거라, FCM 토큰을 못 받아도 등록은 되게 한다. 등록에 실패한 코드는 저장하지 않고 화면에 이유를 남긴다. 앞 코드도 그대로 둔다. - 코드가 저장됐는데 등록만 실패한 채 남은 기기를 위해, 공유 일정을 구독하기 전에 등록을 다시 세운다. 규칙 테스트에 제3자 계정을 넣어 읽기·완료 변경이 모두 막히는 것과, 공유 일정이 많아도 목록 조회가 규칙의 문서 접근 한도에 걸리지 않는 것을 실제 건수로 잠갔다. 비공개 보안 권고 GHSA-5473-2vph-69w6. --- .../slowclock/data/FirestoreCollections.kt | 8 +- .../data/remote/repository/UserRepository.kt | 120 ++++++++----- .../slowclock/data/model/PublicProfile.kt | 7 +- docs/firestore.md | 19 ++- .../slowclock/ui/main/MainViewModel.kt | 6 + .../ui/settings/SettingsScreenShareCode.kt | 11 ++ .../ui/settings/ShareCodeContract.kt | 10 +- .../ui/settings/ShareCodeViewModel.kt | 39 ++++- .../slowclock/ui/main/MainViewModelTest.kt | 1 + .../ui/settings/ShareCodeViewModelTest.kt | 33 ++++ firestore-tests/rules.test.mjs | 160 ++++++++++++++---- firestore.rules | 56 ++++-- 12 files changed, 375 insertions(+), 95 deletions(-) diff --git a/core/data/src/main/java/com/example/slowclock/data/FirestoreCollections.kt b/core/data/src/main/java/com/example/slowclock/data/FirestoreCollections.kt index 9dcb1f58..e5b78845 100644 --- a/core/data/src/main/java/com/example/slowclock/data/FirestoreCollections.kt +++ b/core/data/src/main/java/com/example/slowclock/data/FirestoreCollections.kt @@ -4,12 +4,18 @@ package com.example.slowclock.data object FirestoreCollections { const val USERS = "users" - /** 이름·공유 코드만 담는 공개 프로필. `users` 는 본인만 읽는다. */ + /** 이름만 담는 공개 프로필. `users` 는 본인만 읽는다. */ const val PUBLIC_PROFILES = "publicProfiles" const val SCHEDULES = "schedules" const val NOTIFICATIONS = "notifications" const val SCHEDULE_RECOMMENDATIONS = "scheduleRecommendations" const val FAMILY_GROUPS = "familyGroups" + + /** + * 공유 코드 등록부. 코드 자체가 문서 이름이라 만들기 한 번으로 중복이 걸러진다. + * 아무도 읽지 못한다 — 코드 목록은 그 자체가 사람을 찾는 열쇠 꾸러미다(#174). + */ + const val SHARE_CODES = "shareCodes" const val SHARE_CODE_WATCHERS = "shareCodeWatchers" const val SHARE_CODE_WATCHER_TOKENS = "tokens" diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt index 61ce4ed3..cf9ea7ba 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt @@ -7,8 +7,13 @@ import com.example.slowclock.data.model.User import com.google.firebase.Timestamp import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.FirebaseFirestoreException +import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.toObject import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.tasks.await import javax.inject.Inject @@ -26,25 +31,30 @@ class UserRepository ) { private val usersCollection = firestore.collection(FirestoreCollections.USERS) private val publicProfilesCollection = firestore.collection(FirestoreCollections.PUBLIC_PROFILES) + private val shareCodesCollection = firestore.collection(FirestoreCollections.SHARE_CODES) - // 6-character code generator - private suspend fun generateUniqueShareCode(): String { + /** + * 아직 임자가 없는 여섯 자리 코드를 하나 만들어 [uid] 앞으로 등록한다. + * + * 등록부는 코드 자체가 문서 이름이라, 이미 있는 코드에 대한 만들기는 규칙이 거절한다. + * 확인과 저장이 한 번으로 합쳐지므로 그 사이에 같은 코드를 두 사람이 가져가는 틈이 없다. + * 질의로 확인하던 앞의 방식은 컬렉션을 훑을 권한을 함께 열어야 했다(#174). + */ + private suspend fun claimShareCode(uid: String): String { val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - while (true) { - val code = - (1..6) - .map { chars.random() } - .joinToString("") - // Check uniqueness in Firestore - val exists = - publicProfilesCollection - .whereEqualTo("shareCode", code) - .get() - .await() - .documents - .isNotEmpty() - if (!exists) return code + repeat(SHARE_CODE_CLAIM_ATTEMPTS) { + val code = (1..SHARE_CODE_LENGTH).map { chars.random() }.joinToString("") + try { + shareCodesCollection.document(code).set(mapOf("userId" to uid)).await() + return code + } catch (e: FirebaseFirestoreException) { + // 임자가 있는 코드였다. 그 밖의 실패는 다음 코드로 넘어가도 똑같이 실패하므로 + // 여기서 던져 부르는 쪽이 안내로 바꾸게 한다. + if (e.code != FirebaseFirestoreException.Code.PERMISSION_DENIED) throw e + Log.d(TAG, "이미 쓰이는 공유 코드다. 다시 만든다") + } } + error("공유 코드를 ${SHARE_CODE_CLAIM_ATTEMPTS}번 만들어 봤지만 모두 임자가 있었다") } // 현재 로그인한 사용자 정보 가져오기 @@ -61,6 +71,18 @@ class UserRepository // 계정 삭제용: 사용자 문서 삭제 suspend fun deleteUserDocument(userId: String): Boolean = try { + // 공유 코드 등록부는 본인만 지울 수 있다. 사용자 문서를 먼저 지우면 코드를 알 + // 길이 없어져 등록부에 영영 남고, 그 코드는 아무도 다시 쓰지 못한다(#174). + val shareCode = + usersCollection + .document(userId) + .get() + .await() + .getString("shareCode") + .orEmpty() + if (shareCode.isNotBlank()) { + shareCodesCollection.document(shareCode).delete().await() + } usersCollection.document(userId).delete().await() publicProfilesCollection.document(userId).delete().await() true @@ -87,12 +109,12 @@ class UserRepository id = uid, name = name, email = email, - shareCode = generateUniqueShareCode(), + shareCode = claimShareCode(uid), createdAt = existing?.createdAt ?: Timestamp.now(), updatedAt = Timestamp.now(), ) usersCollection.document(uid).set(newUser).await() - savePublicProfile(newUser.id, newUser.name, newUser.shareCode) + savePublicProfile(newUser.id, newUser.name) } else { val updates = mutableMapOf() if (existing.name != name && name.isNotBlank()) updates["name"] = name @@ -102,7 +124,7 @@ class UserRepository usersCollection.document(uid).update(updates).await() } // 공개 프로필이 아직 없거나 이름이 바뀐 경우를 함께 맞춘다. - savePublicProfile(uid, if (name.isNotBlank()) name else existing.name, existing.shareCode) + savePublicProfile(uid, if (name.isNotBlank()) name else existing.name) } true } catch (e: Exception) { @@ -114,41 +136,52 @@ class UserRepository } /** - * 공개 프로필을 만들거나 갱신한다. 이름·공유 코드만 담으므로 다른 사용자가 읽어도 된다. + * 공개 프로필을 만들거나 갱신한다. 이름만 담으므로 다른 사용자가 읽어도 된다. * 실패해도 사용자 문서 저장 결과를 뒤집지 않는다. 다음 로그인에서 다시 맞춘다. */ private suspend fun savePublicProfile( uid: String, name: String, - shareCode: String, ) { - if (shareCode.isBlank()) return try { publicProfilesCollection .document(uid) - .set(PublicProfile(id = uid, name = name, shareCode = shareCode)) + .set(PublicProfile(id = uid, name = name)) .await() } catch (e: Exception) { Log.w(TAG, "공개 프로필 저장 실패", e) } } - /** 사용자 ID → 이름. Firestore `whereIn` 은 10개까지라 나눠 조회한다. */ + /** + * 사용자 ID → 이름. + * + * 문서를 하나씩 읽는다. 공개 프로필은 목록 조회를 막아 두었다 — 목록이 열려 있으면 문서 + * 이름을 몰라도 컬렉션을 통째로 훑을 수 있기 때문이다. 여기 들어오는 uid 는 이미 읽을 수 + * 있는 공유 일정에서 나온 값이라 하나씩 읽는 것으로 충분하다(#174). + */ suspend fun getUserNames(userIds: List): Map { if (userIds.isEmpty()) return emptyMap() return try { - val result = mutableMapOf() - userIds.distinct().chunked(FIRESTORE_WHERE_IN_LIMIT).forEach { chunk -> - publicProfilesCollection - .whereIn("id", chunk) - .get() - .await() - .documents - .mapNotNull { it.toObject() } - .forEach { result[it.id] = it.name } + coroutineScope { + userIds + .distinct() + .map { id -> + async { + id to + publicProfilesCollection + .document(id) + .get() + .await() + .getString("name") + .orEmpty() + } + }.awaitAll() + .filter { (_, name) -> name.isNotBlank() } + .toMap() } - result } catch (e: Exception) { + Log.w(TAG, "공유 일정 소유자 이름 조회 실패", e) emptyMap() } } @@ -166,12 +199,22 @@ class UserRepository } } - /** 공유 코드 감시자로 현재 기기 토큰을 등록한다. 공유 일정 변경 알림을 받기 위한 것이다. */ + /** + * 공유 코드 감시자로 이 기기를 등록한다. + * + * 이 문서가 있다는 것이 곧 「그 코드를 받은 사람」 이라는 표시라, 공유 일정을 읽을 권한의 + * 근거가 된다. 그래서 FCM 토큰을 못 받아도 등록 자체는 한다. 토큰이 없으면 알림만 못 받고, + * 등록을 거르면 가족 일정이 통째로 안 보인다(#174). + */ suspend fun registerShareCodeWatcher(shareCode: String): Boolean { val uid = auth.currentUser?.uid ?: return false return try { - val token = messaging.token.await() - shareCodeWatcherTokens(shareCode).document(uid).set(mapOf("fcmToken" to token)).await() + val token = runCatching { messaging.token.await() }.getOrNull() + if (token == null) Log.w(TAG, "FCM 토큰 없이 감시자를 등록한다. 알림은 다음 등록에서 붙는다") + val fields = mutableMapOf("userId" to uid) + token?.let { fields["fcmToken"] = it } + // merge 로 쓴다. 토큰을 못 받은 등록이 앞서 저장해 둔 토큰을 지우면 안 된다. + shareCodeWatcherTokens(shareCode).document(uid).set(fields, SetOptions.merge()).await() true } catch (e: Exception) { Log.e(TAG, "공유 코드 감시자 등록 실패", e) @@ -197,6 +240,7 @@ class UserRepository .collection(FirestoreCollections.SHARE_CODE_WATCHER_TOKENS) private companion object { - const val FIRESTORE_WHERE_IN_LIMIT = 10 + const val SHARE_CODE_LENGTH = 6 + const val SHARE_CODE_CLAIM_ATTEMPTS = 8 } } diff --git a/core/model/src/main/java/com/example/slowclock/data/model/PublicProfile.kt b/core/model/src/main/java/com/example/slowclock/data/model/PublicProfile.kt index fdc28731..14e03393 100644 --- a/core/model/src/main/java/com/example/slowclock/data/model/PublicProfile.kt +++ b/core/model/src/main/java/com/example/slowclock/data/model/PublicProfile.kt @@ -1,14 +1,15 @@ package com.example.slowclock.data.model /** - * 다른 사용자에게 보여도 되는 값만 담는다. 공유 일정의 소유자 이름을 보여 주고 공유 코드로 사람을 - * 찾는 데 쓴다. + * 다른 사용자에게 보여도 되는 값만 담는다. 공유 일정의 소유자 이름을 보여 주는 데 쓴다. * * `users` 문서에는 이메일과 FCM 토큰이 함께 있어 통째로 열 수 없다. Firestore 는 필드 단위 읽기 * 제한이 없으므로 공개할 값만 따로 둔다. + * + * 공유 코드는 여기에 두지 않는다. 사람을 찾는 열쇠라 이름 옆에 둘 값이 아니고, 코드를 문서 이름으로 + * 삼는 등록부(`shareCodes`)가 그 자리를 대신한다(#174). */ data class PublicProfile( val id: String = "", val name: String = "", - val shareCode: String = "", ) diff --git a/docs/firestore.md b/docs/firestore.md index 6d79e852..b83d0359 100644 --- a/docs/firestore.md +++ b/docs/firestore.md @@ -7,8 +7,9 @@ | 경로 | 담는 값 | 읽기 | 쓰기 | |---|---|---|---| | `users/{uid}` | 이름, 이메일, 공유 코드, FCM 토큰, 생성·수정 시각 | 본인만 | 본인만 | -| `publicProfiles/{uid}` | id, 이름, 공유 코드 | 로그인한 사용자 전체 | 본인만, 이 세 필드만 | -| `schedules/{id}` | 일정 제목·설명·시각·완료 여부·소유자 uid·공유 코드 | 소유자, 그리고 `sharedCode` 가 있으면 로그인 사용자 | 소유자. 공유 일정은 `completed`·`updatedAt` 만 | +| `publicProfiles/{uid}` | id, 이름 | 로그인한 사용자가 문서 하나씩(`get`). 목록 조회(`list`)는 아무도 못 함 | 본인만, 이 두 필드만 | +| `shareCodes/{code}` | 그 코드의 임자 uid | 아무도 못 함 | 만들기는 본인 이름으로 한 번만(이미 있으면 거절), 지우기는 임자만 | +| `schedules/{id}` | 일정 제목·설명·시각·완료 여부·소유자 uid·공유 코드 | 소유자, 그리고 그 `sharedCode` 의 감시자로 등록한 사람 | 소유자. 공유 일정은 감시자가 `completed`·`completedDates`·`updatedAt` 만 | | `notifications/{id}` | 알림 기록(소유자 uid) | 소유자 | 소유자 | | `shareCodeWatchers/{code}/tokens/{uid}` | 그 공유 코드를 보는 기기의 FCM 토큰 | 본인 문서만 | 본인 문서만 | | `scheduleRecommendations/{id}` | 유형별 추천 일정 | 로그인한 사용자 전체 | 아무도 못 함(콘솔로만) | @@ -19,7 +20,17 @@ Firestore 에는 필드 단위 읽기 제한이 없다. 문서 하나를 읽게 허용하면 그 문서의 모든 필드가 함께 나간다. 그래서 이름 하나를 보여 주려고 `users` 를 열면 이메일과 FCM 토큰도 같이 열린다. 실제로 그런 규칙이 배포돼 있었고 #95 에서 이 구조로 바꿨다. -`publicProfiles` 는 쓰기 규칙이 필드 목록을 `id`·`name`·`shareCode` 로 묶는다. 필드를 늘리려면 규칙과 테스트를 함께 고쳐야 한다. 그 마찰이 의도한 것이다. +`publicProfiles` 는 쓰기 규칙이 필드 목록을 `id`·`name` 으로 묶는다. 필드를 늘리려면 규칙과 테스트를 함께 고쳐야 한다. 그 마찰이 의도한 것이다. + +## 공유 범위를 무엇으로 정하는가 + +「로그인했는가」 는 범위가 아니다. Google 계정은 누구나 몇 초 만에 새로 만들 수 있어, 로그인만 조건으로 두면 그 컬렉션은 사실상 공개다. 그리고 읽기 권한을 문서 하나 단위로만 생각하면 부족하다. `get` 을 열면 「문서 이름을 아는 사람」 이 읽지만, `list` 까지 열면 「아무것도 모르는 사람」 이 컬렉션을 통째로 가져간다. + +그래서 이 저장소는 세 가지를 지킨다(#174). + +1. 사람을 찾는 열쇠(공유 코드)는 훑을 수 있는 자리에 두지 않는다. `shareCodes` 는 코드가 문서 이름이고 읽기를 아무에게도 열지 않는다. 중복 확인은 만들기 한 번으로 끝나므로, 확인과 저장 사이에 같은 코드를 두 사람이 가져가는 틈도 없다. +2. 공유 일정은 「공유로 표시됐는가」 가 아니라 「그 코드의 감시자로 등록한 사람인가」 로 연다. 등록 문서(`shareCodeWatchers/{code}/tokens/{uid}`)가 그 관계의 증거다. 이 문서는 읽기 권한의 근거이기도 해서, FCM 토큰을 못 받아도 등록 자체는 한다. +3. 읽기보다 쓰기를 더 좁게 본다. 남이 완료 표시를 뒤집을 수 있으면 어르신이 약을 이미 먹은 줄로 안다. 이 앱에서는 그쪽이 더 아픈 피해다. ## 서버가 관리자 권한으로 읽는 곳 @@ -50,6 +61,8 @@ firebase deploy --only firestore:indexes 배포 전에 `npm test` 로 규칙 테스트를 돌린다. 규칙을 좁히는 배포는 앱의 읽기 경로를 끊을 수 있어, 배포 뒤 기기에서 로그인·일정 조회·공유 코드 입력까지 확인한다. +공유 읽기 규칙은 문서마다 감시자 등록을 확인한다. 규칙의 문서 접근 횟수에는 한도가 있으므로, 공유 일정이 늘어도 목록 조회가 막히지 않는지 규칙 테스트가 실제 건수로 재 둔다. + ## Play 데이터 보안 선언과의 관계 [`play-release.md`](play-release.md) 의 데이터 보안 표가 수집·공유 항목의 정본이다. 컬렉션을 늘리거나 담는 값을 바꾸면 그 표와 [`privacy.html`](privacy.html) 을 함께 고친다. 규칙이 선언보다 넓으면 선언이 거짓이 된다. diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt index 1df9988a..64ecb7d0 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt @@ -296,6 +296,12 @@ class MainViewModel if (uid == null || shareCode.isNullOrBlank()) { flowOf(emptyList()) } else { + // 감시자 등록이 곧 공유 일정을 읽을 권한이다. 코드는 저장됐는데 등록만 + // 실패한 채로 남아 있으면 가족 일정이 영영 비어 보이므로 구독 전에 다시 + // 세운다. 같은 문서에 다시 쓰는 것이라 값은 그대로고, 그 사이 바뀐 FCM + // 토큰이 함께 갱신된다(#174). + val registered = userRepository.registerShareCodeWatcher(shareCode) + if (!registered) Log.w(TAG, "감시자 등록에 실패해 공유 일정을 못 읽을 수 있다") scheduleRepository.observeSchedulesBySharedCode(shareCode).catch { e -> Log.e(TAG, "공유 일정 구독 실패", e) emit(emptyList()) diff --git a/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt index 0e0420ec..479b417d 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreenShareCode.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -67,5 +68,15 @@ internal fun ShareCodeContent( ) { Text(if (state.isSaving) "저장 중..." else "저장하고 돌아가기") } + // 등록이 곧 읽기 권한이라, 실패를 알리지 않으면 가족 일정이 왜 비어 있는지 알 수 없다(#174). + if (state.saveError != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = state.saveError, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } } } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeContract.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeContract.kt index 17bf12e9..f0a96705 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeContract.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeContract.kt @@ -14,11 +14,17 @@ sealed interface ShareCodeIntent : MviIntent { data object ConsumeSaved : ShareCodeIntent } -/** [isSaved] 는 일회성 신호다. 화면이 소비하고 [ShareCodeIntent.ConsumeSaved] 로 되돌린다. */ +/** + * [isSaved] 는 일회성 신호다. 화면이 소비하고 [ShareCodeIntent.ConsumeSaved] 로 되돌린다. + * + * [saveError] 는 신호가 아니라 화면에 남는 값이다. 잠깐 떴다 사라지는 안내로는 이 화면에서 할 + * 일을 알 수 없다 — 저장이 안 됐다는 사실과 다시 눌러야 한다는 것이 계속 보여야 한다. + */ data class ShareCodeUiState( val input: String = "", val isSaving: Boolean = false, val isSaved: Boolean = false, + val saveError: String? = null, ) : UiState { val canSave: Boolean get() = input.isNotBlank() && !isSaving } @@ -33,4 +39,6 @@ sealed interface ShareCodeReducerEvent : ReducerEvent { data object Saved : ShareCodeReducerEvent data object SavedConsumed : ShareCodeReducerEvent + + data object SaveFailed : ShareCodeReducerEvent } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt index 46a9c6ca..5f3d219b 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt @@ -9,7 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject -/** 공유 코드 입력 화면. 코드를 기기에 저장하고 감시자 토큰을 등록한다. */ +/** 공유 코드 입력 화면. 감시자로 등록한 뒤 코드를 기기에 저장한다. */ @HiltViewModel class ShareCodeViewModel @Inject @@ -34,10 +34,28 @@ class ShareCodeViewModel event: ShareCodeReducerEvent, ): ShareCodeUiState = when (event) { - is ShareCodeReducerEvent.InputChanged -> state.copy(input = event.value) - ShareCodeReducerEvent.Saving -> state.copy(isSaving = true) - ShareCodeReducerEvent.Saved -> state.copy(isSaving = false, isSaved = true) - ShareCodeReducerEvent.SavedConsumed -> state.copy(isSaved = false) + is ShareCodeReducerEvent.InputChanged -> { + state.copy(input = event.value, saveError = null) + } + + ShareCodeReducerEvent.Saving -> { + state.copy(isSaving = true, saveError = null) + } + + ShareCodeReducerEvent.Saved -> { + state.copy(isSaving = false, isSaved = true) + } + + ShareCodeReducerEvent.SavedConsumed -> { + state.copy(isSaved = false) + } + + ShareCodeReducerEvent.SaveFailed -> { + state.copy( + isSaving = false, + saveError = "코드를 등록하지 못했습니다. 인터넷에 연결한 뒤 다시 눌러 주세요.", + ) + } } private fun save() { @@ -47,13 +65,20 @@ class ShareCodeViewModel val previousShareCode = settingsRepository.getShareCode() dispatch(ShareCodeReducerEvent.Saving) viewModelScope.launch { + // 감시자 등록이 곧 공유 일정을 읽을 권한이다. 등록에 실패한 코드를 기기에 저장하면 + // 가족 일정이 빈 채로 남고 사용자는 이유를 알 길이 없다. 그래서 등록이 된 뒤에 + // 저장한다. 실패하면 앞 코드도 그대로 둔다 — 새 코드가 안 됐는데 앞의 것까지 + // 잃으면 되돌아갈 자리가 없다(#174). + if (!userRepository.registerShareCodeWatcher(shareCode)) { + Log.w("ShareCodeWatcher", "watcher register failed shareCode=$shareCode") + dispatch(ShareCodeReducerEvent.SaveFailed) + return@launch + } if (!previousShareCode.isNullOrBlank() && previousShareCode != shareCode) { val unregistered = userRepository.unregisterShareCodeWatcher(previousShareCode) Log.d("ShareCodeWatcher", "watcher unregistered=$unregistered shareCode=$previousShareCode") } settingsRepository.setShareCode(shareCode) - val registered = userRepository.registerShareCodeWatcher(shareCode) - Log.d("ShareCodeWatcher", "watcher registered=$registered shareCode=$shareCode") dispatch(ShareCodeReducerEvent.Saved) } } diff --git a/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt index 5acc12d9..65040f5c 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt @@ -57,6 +57,7 @@ class MainViewModelTest { every { settingsRepository.observeShareCode() } returns shareCode every { scheduleRepository.observeSchedulesBySharedCode(any()) } returns flowOf(emptyList()) coEvery { userRepository.getUserNames(any()) } returns emptyMap() + coEvery { userRepository.registerShareCodeWatcher(any()) } returns true // 기본값은 정시 알람이 허용된 기기다. 안내 다이얼로그를 다루는 테스트만 이 값을 뒤집는다. every { alarmScheduler.canScheduleExactAlarms() } returns true every { settingsRepository.hasSeenExactAlarmNotice() } returns false diff --git a/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt index 4508f186..47ada14d 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -65,6 +67,37 @@ class ShareCodeViewModelTest { assertFalse(viewModel.uiState.value.isSaved) } + @Test + fun `감시자 등록에 실패하면 코드를 저장하지 않고 앞 코드도 그대로 둔다`() = + runTest { + // 등록이 곧 공유 일정을 읽을 권한이다. 등록 못 한 코드를 저장하면 가족 일정이 빈 채로 + // 남고, 앞 코드까지 지우면 되돌아갈 자리도 없다(#174). + coEvery { userRepository.registerShareCodeWatcher("NEW002") } returns false + val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + + viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW002")) + viewModel.onIntent(ShareCodeIntent.Save) + + verify(exactly = 0) { settingsRepository.setShareCode(any()) } + coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any()) } + assertFalse(viewModel.uiState.value.isSaved) + assertFalse(viewModel.uiState.value.isSaving) + assertNotNull(viewModel.uiState.value.saveError) + } + + @Test + fun `입력을 고치면 실패 안내가 사라진다`() = + runTest { + coEvery { userRepository.registerShareCodeWatcher(any()) } returns false + val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + + viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW002")) + viewModel.onIntent(ShareCodeIntent.Save) + viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW003")) + + assertNull(viewModel.uiState.value.saveError) + } + @Test fun `빈 입력은 저장하지 않는다`() = runTest { diff --git a/firestore-tests/rules.test.mjs b/firestore-tests/rules.test.mjs index 235a37be..564df7be 100644 --- a/firestore-tests/rules.test.mjs +++ b/firestore-tests/rules.test.mjs @@ -28,7 +28,13 @@ import { } from "firebase/firestore"; const OWNER = "uid-owner"; + +/** 공유 코드를 받아 감시자로 등록한 가족. 앱에서 「보호자」 자리다. */ const OTHER = "uid-other"; + +/** 코드를 받은 적 없는 제3자. 로그인만 한 계정이 어디까지 닿는지 이 계정으로 잰다(#174). */ +const STRANGER = "uid-stranger"; + const SHARE_CODE = "ABC123"; let testEnv; @@ -88,15 +94,23 @@ beforeEach(async () => { completed: false, sharedCode: "", }); - await setDoc(doc(db, "publicProfiles", OWNER), { - id: OWNER, - name: "느린 사용자", - shareCode: SHARE_CODE, + // 코드를 받은 적 없는 사람에게도 공유 일정이 열리는지 보려면, 감시자가 붙지 않은 + // 다른 코드의 공유 일정이 하나 있어야 한다. + await setDoc(doc(db, "schedules", "other-shared-1"), { + id: "other-shared-1", + userId: OTHER, + title: "남의 공유 일정", + completed: false, + sharedCode: "ZZZ999", }); - await setDoc(doc(db, "publicProfiles", OTHER), { - id: OTHER, - name: "다른 사용자", - shareCode: "ZZZ999", + await setDoc(doc(db, "publicProfiles", OWNER), { id: OWNER, name: "느린 사용자" }); + await setDoc(doc(db, "publicProfiles", OTHER), { id: OTHER, name: "다른 사용자" }); + await setDoc(doc(db, "shareCodes", SHARE_CODE), { userId: OWNER }); + await setDoc(doc(db, "shareCodes", "ZZZ999"), { userId: OTHER }); + // 가족이 코드를 입력해 감시자로 등록한 상태. 이 문서가 공유 읽기 권한의 근거다(#174). + await setDoc(doc(db, "shareCodeWatchers", SHARE_CODE, "tokens", OTHER), { + userId: OTHER, + fcmToken: "token-other", }); await setDoc(doc(db, "notifications", "n-own"), { userId: OWNER, message: "알림" }); await setDoc(doc(db, "notifications", "n-other"), { userId: OTHER, message: "남의 알림" }); @@ -112,6 +126,10 @@ function other() { return testEnv.authenticatedContext(OTHER).firestore(); } +function stranger() { + return testEnv.authenticatedContext(STRANGER).firestore(); +} + function anonymous() { return testEnv.unauthenticatedContext().firestore(); } @@ -143,48 +161,80 @@ describe("users", () => { }); describe("publicProfiles", () => { - it("공유 일정 소유자 이름 조회 질의가 통과한다", async () => { - // UserRepository.getUserNames 가 쓰는 질의다. - const db = other(); - await assertSucceeds(getDocs(query(collection(db, "publicProfiles"), where("id", "in", [OWNER])))); + it("공유 일정 소유자 이름을 문서 하나씩 읽는다", async () => { + // UserRepository.getUserNames 가 쓰는 경로다. 소유자 uid 는 읽을 수 있는 공유 일정에서 나온다. + await assertSucceeds(getDoc(doc(other(), "publicProfiles", OWNER))); }); - it("공유 코드 중복 확인 질의가 통과한다", async () => { - // UserRepository.generateUniqueShareCode 가 쓰는 질의다. - const db = owner(); - await assertSucceeds( - getDocs(query(collection(db, "publicProfiles"), where("shareCode", "==", "NEW123"))), - ); + it("목록으로는 훑지 못한다", async () => { + // 목록을 열어 두면 문서 이름을 몰라도 컬렉션을 통째로 가져갈 수 있다(#174). + const db = other(); + await assertFails(getDocs(collection(db, "publicProfiles"))); + await assertFails(getDocs(query(collection(db, "publicProfiles"), where("id", "in", [OWNER])))); }); it("본인 프로필만 쓴다", async () => { const db = owner(); - await assertSucceeds( - setDoc(doc(db, "publicProfiles", OWNER), { id: OWNER, name: "새 이름", shareCode: SHARE_CODE }), - ); - await assertFails( - setDoc(doc(db, "publicProfiles", OTHER), { id: OTHER, name: "가로채기", shareCode: "ZZZ999" }), - ); + await assertSucceeds(setDoc(doc(db, "publicProfiles", OWNER), { id: OWNER, name: "새 이름" })); + await assertFails(setDoc(doc(db, "publicProfiles", OTHER), { id: OTHER, name: "가로채기" })); }); - it("이름·공유 코드 밖의 값은 넣지 못한다", async () => { + it("이름 밖의 값은 넣지 못한다", async () => { // 이메일이나 토큰이 공개 프로필로 새어 나가지 않게 필드를 묶는다. const db = owner(); await assertFails( setDoc(doc(db, "publicProfiles", OWNER), { id: OWNER, name: "느린 사용자", - shareCode: SHARE_CODE, email: "owner@example.com", }), ); }); + it("공유 코드는 공개 프로필에 담지 못한다", async () => { + // 코드는 사람을 찾는 열쇠라 이름 옆에 둘 값이 아니다(#174). + await assertFails( + setDoc(doc(owner(), "publicProfiles", OWNER), { + id: OWNER, + name: "느린 사용자", + shareCode: SHARE_CODE, + }), + ); + }); + it("로그인하지 않으면 읽지 못한다", async () => { await assertFails(getDoc(doc(anonymous(), "publicProfiles", OWNER))); }); }); +describe("shareCodes", () => { + it("비어 있는 코드는 본인 것으로 만들 수 있다", async () => { + // UserRepository.generateUniqueShareCode 가 쓰는 경로다. + await assertSucceeds(setDoc(doc(owner(), "shareCodes", "NEW123"), { userId: OWNER })); + }); + + it("이미 임자가 있는 코드는 가져가지 못한다", async () => { + // 중복 확인과 저장이 한 번의 만들기로 합쳐져 그 사이의 틈이 없다(#174). + await assertFails(setDoc(doc(other(), "shareCodes", SHARE_CODE), { userId: OTHER })); + }); + + it("남의 이름으로 코드를 만들지 못한다", async () => { + await assertFails(setDoc(doc(other(), "shareCodes", "NEW456"), { userId: OWNER })); + }); + + it("코드 등록부는 읽지 못한다", async () => { + // 코드 목록은 그 자체가 열쇠 꾸러미다. 누구에게도 열지 않는다. + const db = other(); + await assertFails(getDoc(doc(db, "shareCodes", SHARE_CODE))); + await assertFails(getDocs(collection(db, "shareCodes"))); + }); + + it("본인 코드만 반납한다", async () => { + await assertSucceeds(deleteDoc(doc(owner(), "shareCodes", SHARE_CODE))); + await assertFails(deleteDoc(doc(other(), "shareCodes", SHARE_CODE))); + }); +}); + describe("schedules", () => { it("본인 일정을 날짜로 조회하고 만들고 지운다", async () => { const db = owner(); @@ -205,7 +255,7 @@ describe("schedules", () => { ); }); - it("공유 코드로 조회한 일정은 읽고 완료만 바꾼다", async () => { + it("코드를 등록한 가족은 공유 일정을 조회하고 완료만 바꾼다", async () => { const db = other(); await assertSucceeds(getDocs(query(collection(db, "schedules"), where("sharedCode", "==", SHARE_CODE)))); await assertSucceeds(updateDoc(doc(db, "schedules", "shared-1"), { completed: true })); @@ -222,6 +272,49 @@ describe("schedules", () => { it("공유 일정이라도 제목은 바꾸지 못한다", async () => { await assertFails(updateDoc(doc(other(), "schedules", "shared-1"), { title: "바뀐 제목" })); }); + + it("코드를 받지 않은 제3자는 공유 일정을 읽지 못한다", async () => { + // 「공유로 표시됐는가」 만 보면 로그인만 한 계정에게도 열린다. 병원·복약 같은 제목이 + // 그대로 나가므로 관계를 확인한다(#174). + const db = stranger(); + await assertFails(getDoc(doc(db, "schedules", "shared-1"))); + await assertFails(getDocs(query(collection(db, "schedules"), where("sharedCode", "==", SHARE_CODE)))); + }); + + it("코드를 받지 않은 제3자는 완료 상태를 바꾸지 못한다", async () => { + // 읽기보다 이쪽이 더 아프다. 남이 완료로 바꿔 두면 어르신이 약을 이미 먹은 줄로 안다. + const db = stranger(); + await assertFails(updateDoc(doc(db, "schedules", "shared-1"), { completed: true })); + await assertFails(updateDoc(doc(db, "schedules", "shared-1"), { completedDates: ["2026-09-06"] })); + }); + + it("공유로 표시된 일정을 통째로 훑지 못한다", async () => { + // 코드를 하나 받았다고 다른 집 일정까지 딸려 오면 안 된다. + const db = other(); + await assertFails(getDocs(query(collection(db, "schedules"), where("sharedCode", ">", "")))); + await assertFails(getDocs(collection(db, "schedules"))); + }); + + it("한 코드에 공유 일정이 많아도 목록 조회가 통과한다", async () => { + // 공유 읽기 규칙이 문서마다 감시자 등록을 확인한다. 규칙의 문서 접근 횟수에는 한도가 + // 있으므로, 일정이 늘어도 조회가 막히지 않는지 실제로 재 둔다(#174). + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + for (let i = 0; i < 40; i += 1) { + await setDoc(doc(db, "schedules", `bulk-${i}`), { + id: `bulk-${i}`, + userId: OWNER, + title: `일정 ${i}`, + completed: false, + sharedCode: SHARE_CODE, + }); + } + }); + const snapshot = await assertSucceeds( + getDocs(query(collection(other(), "schedules"), where("sharedCode", "==", SHARE_CODE))), + ); + assert.equal(snapshot.size, 41); + }); }); describe("notifications", () => { @@ -242,9 +335,9 @@ describe("notifications", () => { describe("shareCodeWatchers", () => { it("자기 토큰만 등록하고 지운다", async () => { // UserRepository.registerShareCodeWatcher / unregisterShareCodeWatcher 가 쓰는 경로다. - const db = other(); - const ref = doc(db, "shareCodeWatchers", SHARE_CODE, "tokens", OTHER); - await assertSucceeds(setDoc(ref, { fcmToken: "token-other" })); + const db = stranger(); + const ref = doc(db, "shareCodeWatchers", SHARE_CODE, "tokens", STRANGER); + await assertSucceeds(setDoc(ref, { userId: STRANGER, fcmToken: "token-stranger" })); await assertSucceeds(deleteDoc(ref)); }); @@ -252,6 +345,13 @@ describe("shareCodeWatchers", () => { const ref = doc(other(), "shareCodeWatchers", SHARE_CODE, "tokens", OWNER); await assertFails(setDoc(ref, { fcmToken: "가짜" })); }); + + it("FCM 토큰이 없어도 등록된다", async () => { + // 이 문서가 읽기 권한의 근거라, 토큰을 못 받았다고 만들지 못하면 가족 일정이 통째로 + // 안 보인다. 토큰은 알림을 보내기 위한 값일 뿐이다(#174). + const ref = doc(stranger(), "shareCodeWatchers", SHARE_CODE, "tokens", STRANGER); + await assertSucceeds(setDoc(ref, { userId: STRANGER })); + }); }); describe("scheduleRecommendations", () => { diff --git a/firestore.rules b/firestore.rules index e9aebb3a..168ab5be 100644 --- a/firestore.rules +++ b/firestore.rules @@ -9,21 +9,51 @@ service cloud.firestore { return isSignedIn() && request.auth.uid == userId; } + // 이 사람이 그 공유 코드의 감시자로 등록돼 있는가. + // + // 「로그인했는가」 만 보면 공유로 표시된 일정은 관계 없는 계정에게도 열린다. 공유 코드를 + // 실제로 받아 등록한 사람인지까지 봐야 범위가 가족으로 좁아진다(#174). + function watchesShareCode(shareCode) { + return isSignedIn() && exists( + /databases/$(database)/documents/shareCodeWatchers/$(shareCode)/tokens/$(request.auth.uid) + ); + } + // 사용자 문서. 이메일과 FCM 토큰이 함께 있어 본인만 읽고 쓴다. - // 다른 사람에게 보여야 하는 이름·공유 코드는 publicProfiles 에 따로 둔다(#93). + // 다른 사람에게 보여야 하는 이름은 publicProfiles 에 따로 둔다(#93). match /users/{userId} { allow read, write: if isSelf(userId); } - // 공개 프로필. 공유 일정 소유자의 이름을 보여 주고 공유 코드로 사람을 찾는 데 쓴다. - // 읽기는 로그인한 사용자 전체, 쓰기는 본인 문서만이고 담을 수 있는 필드도 셋으로 묶는다. + // 공개 프로필. 공유 일정 소유자의 이름을 보여 주는 데만 쓴다. + // + // get 만 열고 list 는 막는다. 목록을 열어 두면 문서 이름을 몰라도 컬렉션을 통째로 훑을 수 + // 있어 「아는 사람만 본다」 라는 전제가 성립하지 않는다. 소유자 uid 는 이미 읽을 수 있는 + // 공유 일정에서만 나오므로 get 하나로 충분하다(#174). + // + // 공유 코드는 여기에 두지 않는다. 사람을 찾는 열쇠라 이름 옆에 둘 값이 아니다. match /publicProfiles/{userId} { - allow read: if isSignedIn(); + allow get: if isSignedIn(); + allow list: if false; allow write: if isSelf(userId) - && request.resource.data.keys().hasOnly(['id', 'name', 'shareCode']) + && request.resource.data.keys().hasOnly(['id', 'name']) && request.resource.data.id == userId; } + // 공유 코드 등록부. 코드 자체가 문서 이름이다. + // + // 중복 확인을 질의로 하면 컬렉션을 훑을 권한이 필요하고, 확인과 저장 사이에 같은 코드를 + // 두 사람이 가져가는 틈도 남는다. 문서 만들기 한 번으로 두 문제가 함께 사라진다. + // 읽기는 아무에게도 열지 않는다. 코드 목록은 그 자체가 사람을 찾는 열쇠 꾸러미다(#174). + match /shareCodes/{shareCode} { + allow read: if false; + allow create: if isSignedIn() + && request.resource.data.keys().hasOnly(['userId']) + && request.resource.data.userId == request.auth.uid; + allow update: if false; + allow delete: if isSignedIn() && resource.data.userId == request.auth.uid; + } + // 일정 match /schedules/{scheduleId} { // 본인이 만든 일정 @@ -31,16 +61,16 @@ service cloud.firestore { (request.auth.uid == resource.data.userId || request.auth.uid == request.resource.data.userId); - // 공유 일정은 sharedCode 가 비어 있지 않으면 읽을 수 있다. - allow read: if isSignedIn() && - resource.data.sharedCode != null && - resource.data.sharedCode != ""; + // 공유 일정은 그 코드의 감시자로 등록한 사람만 읽는다. + allow read: if resource.data.sharedCode is string && + resource.data.sharedCode != "" && + watchesShareCode(resource.data.sharedCode); // 공유 일정의 완료 상태만 바꿀 수 있다. 반복 일정은 회차마다 따로 남으므로 // completedDates 도 같은 범위에 든다(#130). - allow update: if isSignedIn() && - resource.data.sharedCode != null && + allow update: if resource.data.sharedCode is string && resource.data.sharedCode != "" && + watchesShareCode(resource.data.sharedCode) && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['completed', 'completedDates', 'updatedAt']); } @@ -63,7 +93,9 @@ service cloud.firestore { request.auth.uid in resource.data.memberIds); } - // 공유코드 감시자 (FCM 토큰 저장용). 서버(Cloud Functions)가 관리자 권한으로 읽어 알림을 보낸다. + // 공유코드 감시자. 이 문서가 있다는 것이 곧 「그 코드를 받은 사람」 이라는 표시라, + // 공유 일정 읽기 권한의 근거가 된다. 서버(Cloud Functions)가 관리자 권한으로 읽어 + // 알림을 보낸다. match /shareCodeWatchers/{shareCode}/tokens/{userId} { allow read, write: if isSelf(userId); }