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 0000000000..bfaac24eed --- /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 0000000000..2522d74d47 --- /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/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 0000000000..eff69da47f --- /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 f22ea01c5b..529f44a62d 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 24b699a0ec..d43e9cf127 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/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 5810bed96c..c0ee79e6d4 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 9603577ca6..0000000000 --- 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/main/res/values/strings.xml b/feature/setting/presentation/src/main/res/values/strings.xml index 1bcff3c581..d594c9e076 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자 이상 사용해 주세요. 변경하기 지금 쓰고 있는 비밀번호입니다. 다른 비밀번호를 입력해 주세요. 소셜 로그인으로 가입한 계정은 비밀번호를 변경할 수 없습니다. 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 569e0b0345..341b552141 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))