Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
7 changes: 7 additions & 0 deletions core/ui/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,11 @@
<string name="core_ui_media_sheet_voice">음성 추가하기</string>
<string name="core_ui_media_sheet_file">파일 추가하기</string>
<string name="core_ui_media_sheet_link">링크 추가하기</string>

<!--
새 비밀번호 조합 규칙 안내 문구 (#2191). 판정 정본 core:domain `NewPasswordRule` 과 짝이라
규칙이 바뀌면 이 문장도 함께 고친다. 설정과 온보딩(onboarding_signup_password_rule_combination)이
같은 값을 각자 갖고 있던 것을 core 정본으로 올린다. 온보딩 전환은 #2192.
-->
<string name="core_ui_password_rule_combination">8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &amp;)를 각각 1자 이상 사용해 주세요.</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
* 비밀번호 변경 화면의 상태 없는 본문.
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@
<string name="setting_password_change_current_placeholder">현재 비밀번호를 입력해주세요</string>
<string name="setting_password_change_new_label">새 비밀번호</string>
<string name="setting_password_change_new_placeholder">새 비밀번호를 입력해주세요</string>
<string name="setting_password_change_rule">8 ~ 15자의 영문, 숫자, 특수문자(@, $, !, %, *, #, ?, &amp;)를 각각 1자 이상 사용해 주세요.</string>
<string name="setting_password_change_submit">변경하기</string>
<string name="setting_password_change_unchanged">지금 쓰고 있는 비밀번호입니다. 다른 비밀번호를 입력해 주세요.</string>
<string name="setting_password_change_social_blocked">소셜 로그인으로 가입한 계정은 비밀번호를 변경할 수 없습니다.</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading