From f9c487c2b6fd51fbd4f650a668784deb38be6ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 22:14:34 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(core):=20=EC=83=88=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EA=B7=9C=EC=B9=99=EC=9D=84=20cor?= =?UTF-8?q?e:domain=20=EC=9C=BC=EB=A1=9C=20=EC=98=AC=EB=A6=AC=EA=B3=A0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=B4=20=EC=93=B0=EA=B2=8C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#2191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정의 SettingPasswordRule 은 온보딩 OnboardingPasswordRule 과 정규식이 같은 사본이었다. 규칙을 core:domain 의 NewPasswordRule 하나로 두고 설정이 그것을 쓴다. 경계값 테스트는 온보딩·설정 두 벌의 사례를 합쳐 core 로 옮겼다. 온보딩 전환은 #2192. --- .../core/domain/auth/NewPasswordRule.kt | 25 ++++++ .../core/domain/auth/NewPasswordRuleTest.kt | 77 +++++++++++++++++++ .../password/PasswordChangeUiState.kt | 5 +- .../password/SettingPasswordRule.kt | 30 -------- .../password/PasswordChangeViewModelTest.kt | 27 ------- 5 files changed, 105 insertions(+), 59 deletions(-) create mode 100644 core/domain/src/main/java/com/afternote/core/domain/auth/NewPasswordRule.kt create mode 100644 core/domain/src/test/java/com/afternote/core/domain/auth/NewPasswordRuleTest.kt delete mode 100644 feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt diff --git a/core/domain/src/main/java/com/afternote/core/domain/auth/NewPasswordRule.kt b/core/domain/src/main/java/com/afternote/core/domain/auth/NewPasswordRule.kt new file mode 100644 index 000000000..bfaac24ee --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/auth/NewPasswordRule.kt @@ -0,0 +1,25 @@ +package com.afternote.core.domain.auth + +/** + * 새 비밀번호에 요구하는 조합 규칙의 클라 정본 (#2191). + * + * **서버 규칙과 같다.** BE `domain/auth/dto/PasswordValidation.REGEX` 를 그대로 옮겼다 — 영문·숫자·허용 + * 특수문자(`@ $ ! % * # ? &`)를 각각 하나 이상 포함하고, 전체 문자는 그 ASCII 허용 목록 안에서만 + * 8~15자여야 한다. 서버는 이 상수 하나를 `SignupRequest`·`PasswordChangeRequest`·`PasswordFindRequest` + * 세 곳에 `@Pattern` 으로 걸고 컨트롤러 진입 전에 거절하므로, 클라가 더 넓으면 사용자는 조건 충족 + * 표시를 다 받고 제출에서 400 을 맞는다. 서버가 상수 하나를 쓰듯 클라도 여기 하나만 둔다 — 화면마다 + * 사본을 들면 규칙이 바뀔 때 한쪽만 고쳐져 가입과 변경의 판정이 조용히 갈린다. + * + * Android 정규식의 `\d` 는 Unicode 숫자까지 포함해 전각 `1`·아라비아-인도 `١` 을 통과시킨다. + * 서버 JVM 의 기본 판정과 갈리지 않도록 숫자를 `[0-9]` 로 명시한다(#1628). + * + * **현재 비밀번호와 로그인 입력에는 걸지 않는다.** 옛 규칙으로 만든 비밀번호가 그대로 살아 있을 수 + * 있어, 클라가 막으면 그 계정은 로그인도 변경도 못 한다. + */ +object NewPasswordRule { + /** 8~15자, 영문·숫자·허용 특수문자 8종 각 1개 이상, 그 밖의 문자는 금지. */ + private val REGEX = + Regex("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[@$!%*#?&])[A-Za-z0-9@$!%*#?&]{8,15}$") + + fun isSatisfied(password: String): Boolean = REGEX.matches(password) +} diff --git a/core/domain/src/test/java/com/afternote/core/domain/auth/NewPasswordRuleTest.kt b/core/domain/src/test/java/com/afternote/core/domain/auth/NewPasswordRuleTest.kt new file mode 100644 index 000000000..2522d74d4 --- /dev/null +++ b/core/domain/src/test/java/com/afternote/core/domain/auth/NewPasswordRuleTest.kt @@ -0,0 +1,77 @@ +package com.afternote.core.domain.auth + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 새 비밀번호 규칙이 서버 `PasswordValidation.REGEX` 와 같은 판정을 내는지 지킨다. + * + * 사례는 dev 서버에 실제로 보내 본 결과(1차·2차)를 그대로 옮겼다. 유니코드 숫자는 **허용 목록 밖 + * 문자라서** 거절된다 — `[0-9]` 를 `\d` 로 되돌리는 변이는 이 단언으로 잡히지 않는다. 허용 목록 + * `[A-Za-z0-9@$!%*#?&]` 이 이미 그 문자들을 닫고 있어 두 정규식의 판정이 갈리는 입력이 없고, JVM + * 유닛 테스트는 호스트 정규식 엔진이라 Android 의 유니코드 문자 클래스도 재현하지 못한다. `\d` 를 + * 쓰지 않는 근거는 [NewPasswordRule] KDoc 이 갖는다. + */ +class NewPasswordRuleTest { + @Test + fun `dev 서버를 통과한 비밀번호를 모두 허용한다`() { + val acceptedPasswords = + listOf( + PasswordCase("1차 #1", "Password1!"), + PasswordCase("1차 #2 - 15자", "Password12345!a"), + PasswordCase("1차 #7 - 소문자 영문", "password12345!a"), + PasswordCase("2차 기준", "Aa1!aaaa"), + PasswordCase("2차 15자", "Aa1!aaaaaaaaaaa"), + PasswordCase("2차 대문자만", "AA1!AAAA"), + PasswordCase("2차 소문자만", "aa1!aaaa"), + PasswordCase("허용 특수문자 @", "Aa1@aaaa"), + PasswordCase("허용 특수문자 $", "Aa1\$aaaa"), + PasswordCase("허용 특수문자 !", "Aa1!aaaa"), + PasswordCase("허용 특수문자 %", "Aa1%aaaa"), + PasswordCase("허용 특수문자 *", "Aa1*aaaa"), + PasswordCase("허용 특수문자 #", "Aa1#aaaa"), + PasswordCase("허용 특수문자 ?", "Aa1?aaaa"), + PasswordCase("허용 특수문자 &", "Aa1&aaaa"), + ) + + acceptedPasswords.forEach { case -> + assertTrue("${case.description}: ${case.password}", NewPasswordRule.isSatisfied(case.password)) + } + } + + @Test + fun `dev 서버가 거절한 비밀번호와 Unicode 숫자를 모두 차단한다`() { + val rejectedPasswords = + listOf( + PasswordCase("1차 #3 - 16자", "Password12345!ab"), + PasswordCase("1차 #4 - 하이픈", "Password123-abc"), + PasswordCase("1차 #5 - 언더바", "Password123_abc"), + PasswordCase("1차 #6 - 물결", "Password123~abc"), + PasswordCase("1차 #8 - 한글", "Password1가나다"), + PasswordCase("1차 #9 - 공백", "Password1 abc"), + PasswordCase("2차 하이픈", "Aa1!aaa-"), + PasswordCase("2차 언더바", "Aa1!aaa_"), + PasswordCase("2차 물결", "Aa1!aaa~"), + PasswordCase("2차 마침표", "Aa1!aaa."), + PasswordCase("2차 한글", "Aa1!aaa가"), + PasswordCase("2차 중간 공백", "Aa1!a a a"), + PasswordCase("2차 7자", "Aa1!aaa"), + PasswordCase("2차 16자", "Aa1!aaaaaaaaaaaa"), + PasswordCase("영문 없음", "12345678!"), + PasswordCase("숫자 없음", "Abcdefgh!"), + PasswordCase("특수문자 없음", "Abcdefg12"), + PasswordCase("전각 숫자", "Aa1!aaaa"), + PasswordCase("아라비아-인도 숫자", "Aa١!aaaa"), + ) + + rejectedPasswords.forEach { case -> + assertFalse("${case.description}: ${case.password}", NewPasswordRule.isSatisfied(case.password)) + } + } +} + +private data class PasswordCase( + val description: String, + val password: String, +) diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt index 5810bed96..c0ee79e6d 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeUiState.kt @@ -1,5 +1,6 @@ package com.afternote.feature.setting.presentation.password +import com.afternote.core.domain.auth.NewPasswordRule import com.afternote.core.ui.UiText import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent @@ -25,13 +26,13 @@ internal data class PasswordChangeUiState( /** 변경 완료 신호. 화면이 한 번 소비하고 [PasswordChangeIntent.ConsumeChanged] 로 되돌린다(#228). */ val changed: PasswordChanged? = null, ) : UiState { - val isNewPasswordRuleSatisfied: Boolean get() = SettingPasswordRule.isSatisfied(newPassword) + val isNewPasswordRuleSatisfied: Boolean get() = NewPasswordRule.isSatisfied(newPassword) /** * 제출 가능 여부. * * 현재 비밀번호는 비어 있지만 않으면 된다 — 규칙 검사를 걸면 옛 규칙으로 만든 비밀번호를 쓰는 - * 계정이 변경 자체를 못 한다([SettingPasswordRule]). 새 비밀번호가 현재와 같은지는 서버만 + * 계정이 변경 자체를 못 한다([NewPasswordRule]). 새 비밀번호가 현재와 같은지는 서버만 * 판정할 수 있다(BE `NEWPASSWORD_MATCH`) — 클라는 현재 비밀번호의 평문을 비교 근거로 삼지 않는다. */ val isSubmitEnabled: Boolean diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt deleted file mode 100644 index 9603577ca..000000000 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/SettingPasswordRule.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.afternote.feature.setting.presentation.password - -/** - * 설정의 비밀번호 변경이 **새 비밀번호** 에 요구하는 조합 규칙. - * - * **서버 규칙과 같다.** BE `domain/auth/dto/PasswordValidation.REGEX` 를 그대로 옮겼다(2026-09-13 - * Afternote-BE main 실코드 확인) — 영문·숫자·허용 특수문자(`@ $ ! % * # ? &`)를 각각 하나 이상 - * 포함하고, 전체 문자는 그 ASCII 허용 목록 안에서만 8~15자여야 한다. 서버는 이 상수 하나를 - * `SignupRequest`·`PasswordChangeRequest`·`PasswordFindRequest` 세 곳에 `@Pattern` 으로 걸고 - * 컨트롤러 진입 전에 거절하므로, 클라가 더 넓으면 사용자는 조건 충족 표시를 다 받고 제출에서 400 을 맞는다. - * - * Android 정규식의 `\d` 는 Unicode 숫자까지 포함해 전각 `1`·아라비아-인도 `١` 을 통과시킨다. - * 서버 JVM 의 기본 판정과 갈리지 않도록 숫자를 `[0-9]` 로 명시한다(#1628). - * - * ### 온보딩의 `OnboardingPasswordRule` 과 사본이 둘이다 - * #1644 는 「규칙 사본은 한 곳」을 목표로 두었고 그때 설정의 비밀번호 변경은 화면·호출부가 없어 - * 대상이 아니었다. 지금 그 소비처가 생겼지만 온보딩 쪽 객체는 `feature:onboarding:presentation` - * 의 `internal` 이라 모듈 밖에서 쓸 수 없고, 공용 자리로 옮기는 일은 남의 모듈 프로덕션을 고치는 - * 일이라 이 변경의 범위 밖이다. 값을 고칠 땐 **BE 실코드를 정본으로** 두 사본을 함께 고친다. - * - * 현재 비밀번호에는 이 규칙을 걸지 않는다 — 옛 규칙으로 만든 비밀번호가 그대로 살아 있을 수 있어 - * 클라가 막으면 변경 자체가 불가능해진다(로그인에 규칙을 걸지 않는 것과 같은 판단). - */ -internal object SettingPasswordRule { - /** 8~15자, 영문·숫자·허용 특수문자 8종 각 1개 이상, 그 밖의 문자는 금지. */ - private val REGEX = - Regex("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[@$!%*#?&])[A-Za-z0-9@$!%*#?&]{8,15}$") - - fun isSatisfied(password: String): Boolean = REGEX.matches(password) -} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt index 569e0b034..341b55214 100644 --- a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeViewModelTest.kt @@ -170,33 +170,6 @@ class PasswordChangeViewModelTest { assertNull(viewModel.uiState.value.errorMessage) } - /** - * 유니코드 숫자는 **허용 목록 밖 문자라서** 거절된다 — 서버와 같은 판정이다. - * - * `[0-9]` 를 `\d` 로 되돌리는 변이는 이 단언으로 잡히지 않는다(실측). 허용 목록 - * `[A-Za-z0-9@$!%*#?&]` 이 이미 그 문자들을 닫고 있어 두 정규식의 판정이 갈리는 입력 자체가 - * 없고, JVM 유닛 테스트는 호스트 정규식 엔진이라 Android 의 유니코드 문자 클래스도 재현하지 - * 못한다. `\d` 를 쓰지 않는 이유는 허용 목록이 넓어지는 날 갈리기 때문이고, 그 근거는 - * [SettingPasswordRule] KDoc 이 갖는다. - */ - @Test - fun `규칙은 서버와 같다 - 유니코드 숫자를 숫자로 받지 않는다`() { - assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1!")) - assertFalse(SettingPasswordRule.isSatisfied("Abcdefg١!")) - assertTrue(SettingPasswordRule.isSatisfied("Abcdefg1!")) - } - - @Test - fun `규칙은 서버와 같다 - 허용 목록 밖 문자와 길이를 막는다`() { - assertFalse(SettingPasswordRule.isSatisfied("Abcde1!")) - assertFalse(SettingPasswordRule.isSatisfied("Abcdefghijklmn1!")) - assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1_")) - assertFalse(SettingPasswordRule.isSatisfied("Abcdefg1 !")) - assertFalse(SettingPasswordRule.isSatisfied("Abcdefgh!")) - assertFalse(SettingPasswordRule.isSatisfied("Abcdefg12")) - assertTrue(SettingPasswordRule.isSatisfied("Abcdefghijklm1!")) - } - private fun submit(viewModel: PasswordChangeViewModel) { viewModel.onIntent(PasswordChangeIntent.UpdateCurrentPassword(CURRENT_PASSWORD)) viewModel.onIntent(PasswordChangeIntent.UpdateNewPassword(NEW_PASSWORD)) From dd81f1cd6d6601269530f9579942e20ff3457998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 22:57:40 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(core):=20=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EA=B7=9C=EC=B9=99=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EC=A4=84=EA=B3=BC=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20core:ui=20?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AC=EB=A6=AC=EA=B3=A0=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9D=B4=20=EC=93=B0=EA=B2=8C=20=ED=95=9C=EB=8B=A4=20(#2191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정의 규칙 안내는 온보딩 internal PasswordRuleItem 을 다시 그린 Text 와 온보딩 문구의 사본이었다. 안내 줄 부품을 core:ui PasswordRuleItem 으로, 문구를 core_ui_password_rule_combination 으로 올리고 설정이 그것을 쓴다. 설정 화면의 규칙 줄에 온보딩 시안과 같은 불릿이 생긴다. 온보딩 전환은 #2192. --- .../core/ui/auth/PasswordRuleItem.kt | 58 +++++++++++++++++++ core/ui/src/main/res/values/strings.xml | 7 +++ .../password/PasswordChangeContent.kt | 15 ++--- .../src/main/res/values/strings.xml | 1 - 4 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 core/ui/src/main/kotlin/com/afternote/core/ui/auth/PasswordRuleItem.kt diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/auth/PasswordRuleItem.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/auth/PasswordRuleItem.kt new file mode 100644 index 000000000..eff69da47 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/auth/PasswordRuleItem.kt @@ -0,0 +1,58 @@ +package com.afternote.core.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteDesign + +/** + * 비밀번호 규칙 안내 한 줄 — 불릿 + 문구 (#2191). + * + * 새 비밀번호를 받는 화면(회원가입 3단계·비밀번호 찾기의 변경·설정의 비밀번호 변경)이 같은 시안 + * `2383:16789` 의 안내 줄을 쓴다. 조합 규칙 문구는 `core_ui_password_rule_combination` 이고, 판정은 + * core:domain `NewPasswordRule` 이 갖는다. + * + * @param isSatisfied 규칙 충족 여부에 따라 색이 갈리는 줄만 넘긴다. null(기본)이면 상태 없는 + * 안내 줄이라 항상 강조색이다 — "이전에 사용한 적 없는 비밀번호가 안전합니다." 처럼 클라가 + * 판정할 수 없는 문구가 여기 해당한다. + */ +@Composable +public fun PasswordRuleItem( + text: String, + modifier: Modifier = Modifier, + isSatisfied: Boolean? = null, +) { + val color = + if (isSatisfied == false) { + AfternoteDesign.colors.gray5 + } else { + AfternoteDesign.colors.b1 + } + Row( + modifier = + modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "•", + modifier = Modifier.clearAndSetSemantics {}, + style = AfternoteDesign.typography.captionLargeB, + color = color, + ) + Text( + text = text, + style = AfternoteDesign.typography.captionLargeB, + color = color, + ) + } +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index f22ea01c5..529f44a62 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -78,4 +78,11 @@ 음성 추가하기 파일 추가하기 링크 추가하기 + + + 8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &)를 각각 1자 이상 사용해 주세요. diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt index 24b699a0e..d43e9cf12 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/password/PasswordChangeContent.kt @@ -21,10 +21,12 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.afternote.core.ui.AfternoteTextField import com.afternote.core.ui.asString +import com.afternote.core.ui.auth.PasswordRuleItem import com.afternote.core.ui.mvi.ObserveSignal import com.afternote.core.ui.scaffold.FlowStepScaffold import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.feature.setting.presentation.R +import com.afternote.core.ui.R as CoreUiR /** * 비밀번호 변경 화면의 상태 없는 본문. @@ -101,16 +103,9 @@ internal fun PasswordChangeContent( onImeAction = { onIntent(PasswordChangeIntent.Submit) }, ) - Text( - text = stringResource(R.string.setting_password_change_rule), - modifier = Modifier.fillMaxWidth(), - style = AfternoteDesign.typography.captionLargeB, - color = - if (uiState.isNewPasswordRuleSatisfied) { - AfternoteDesign.colors.b1 - } else { - AfternoteDesign.colors.gray5 - }, + PasswordRuleItem( + text = stringResource(CoreUiR.string.core_ui_password_rule_combination), + isSatisfied = uiState.isNewPasswordRuleSatisfied, ) uiState.errorMessage?.let { message -> diff --git a/feature/setting/presentation/src/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index 1bcff3c58..d594c9e07 100644 --- a/feature/setting/presentation/src/main/res/values/strings.xml +++ b/feature/setting/presentation/src/main/res/values/strings.xml @@ -175,7 +175,6 @@ 현재 비밀번호를 입력해주세요 새 비밀번호 새 비밀번호를 입력해주세요 - 8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &)를 각각 1자 이상 사용해 주세요. 변경하기 지금 쓰고 있는 비밀번호입니다. 다른 비밀번호를 입력해 주세요. 소셜 로그인으로 가입한 계정은 비밀번호를 변경할 수 없습니다.