diff --git a/src/main/java/com/fowoco/server/auth/api/AuthController.java b/src/main/java/com/fowoco/server/auth/api/AuthController.java index 04ffd340..63191dd1 100644 --- a/src/main/java/com/fowoco/server/auth/api/AuthController.java +++ b/src/main/java/com/fowoco/server/auth/api/AuthController.java @@ -8,6 +8,7 @@ import com.fowoco.server.auth.application.SignupService; import com.fowoco.server.auth.application.error.InvalidRefreshTokenException; import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.auth.infrastructure.security.AgreementPolicyProperties; import com.fowoco.server.auth.infrastructure.web.UserAgentDeviceSummarizer; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -46,6 +47,7 @@ public class AuthController { private final AuthService authService; private final SignupService signupService; private final PasswordResetService passwordResetService; + private final AgreementPolicyProperties agreementPolicy; private final RefreshTokenCookieFactory refreshTokenCookieFactory; private final ActorContextProvider actorContextProvider; @@ -53,16 +55,39 @@ public AuthController( AuthService authService, SignupService signupService, PasswordResetService passwordResetService, + AgreementPolicyProperties agreementPolicy, RefreshTokenCookieFactory refreshTokenCookieFactory, ActorContextProvider actorContextProvider ) { this.authService = authService; this.signupService = signupService; this.passwordResetService = passwordResetService; + this.agreementPolicy = agreementPolicy; this.refreshTokenCookieFactory = refreshTokenCookieFactory; this.actorContextProvider = actorContextProvider; } + @Operation( + operationId = "getSignupPolicy", + summary = "현재 회원가입 정책 조회", + description = "회원가입 화면이 적용할 비밀번호 규칙과 약관별 현재 버전·필수 여부를 반환합니다." + ) + @ApiResponse( + responseCode = "200", + description = "현재 회원가입 정책", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SignupPolicyResponse.class) + ) + ) + @GetMapping(path = "/signup-policy", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getSignupPolicy() { + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.PRAGMA, "no-cache") + .body(SignupPolicyResponse.from(agreementPolicy)); + } + @Operation( operationId = "signup", summary = "사업장과 최초 관리자 회원가입", diff --git a/src/main/java/com/fowoco/server/auth/api/PasswordResetCompleteRequest.java b/src/main/java/com/fowoco/server/auth/api/PasswordResetCompleteRequest.java index 9efb915e..f6dcebf8 100644 --- a/src/main/java/com/fowoco/server/auth/api/PasswordResetCompleteRequest.java +++ b/src/main/java/com/fowoco/server/auth/api/PasswordResetCompleteRequest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.fowoco.server.auth.api.validation.Utf8ByteLength; +import com.fowoco.server.auth.api.validation.PasswordPolicy; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -28,14 +29,25 @@ public final class PasswordResetCompleteRequest { name = "new_password", description = "새 비밀번호. 원문은 저장하지 않고 BCrypt hash만 저장합니다.", format = "password", - minLength = 8, - maxLength = 128, + minLength = PasswordPolicy.MIN_LENGTH, + maxLength = PasswordPolicy.MAX_LENGTH, accessMode = Schema.AccessMode.WRITE_ONLY, requiredMode = Schema.RequiredMode.REQUIRED ) @NotBlank(message = "새 비밀번호를 입력해 주세요.") - @Size(min = 8, max = 128, message = "비밀번호는 8자 이상 128자 이하여야 합니다.") - @Utf8ByteLength(max = 72, message = "비밀번호는 UTF-8 기준 72바이트 이하여야 합니다.") + @Size( + min = PasswordPolicy.MIN_LENGTH, + max = PasswordPolicy.MAX_LENGTH, + message = "비밀번호는 8자 이상 128자 이하여야 합니다." + ) + @Pattern( + regexp = PasswordPolicy.LETTER_AND_DIGIT_PATTERN, + message = "비밀번호에는 영문과 숫자가 각각 하나 이상 포함되어야 합니다." + ) + @Utf8ByteLength( + max = PasswordPolicy.MAX_UTF8_BYTES, + message = "비밀번호는 UTF-8 기준 72바이트 이하여야 합니다." + ) private final String newPassword; @JsonCreator diff --git a/src/main/java/com/fowoco/server/auth/api/SignupPolicyResponse.java b/src/main/java/com/fowoco/server/auth/api/SignupPolicyResponse.java new file mode 100644 index 00000000..841601e1 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/api/SignupPolicyResponse.java @@ -0,0 +1,69 @@ +package com.fowoco.server.auth.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.auth.api.validation.PasswordPolicy; +import com.fowoco.server.auth.infrastructure.security.AgreementPolicyProperties; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "SignupPolicyResponse", description = "회원가입 화면이 적용할 현재 정책") +public record SignupPolicyResponse( + @JsonProperty("password_policy") PasswordPolicyResponse passwordPolicy, + AgreementsPolicyResponse agreements +) { + + private static final String SERVICE_TERMS_PATH = "/legal/terms"; + private static final String PRIVACY_POLICY_PATH = "/legal/privacy"; + + public static SignupPolicyResponse from(AgreementPolicyProperties policy) { + return new SignupPolicyResponse( + new PasswordPolicyResponse( + PasswordPolicy.MIN_LENGTH, + PasswordPolicy.MAX_LENGTH, + true, + true + ), + new AgreementsPolicyResponse( + new AgreementPolicyResponse( + policy.serviceTermsVersion(), + true, + SERVICE_TERMS_PATH + ), + new AgreementPolicyResponse( + policy.privacyPolicyVersion(), + true, + PRIVACY_POLICY_PATH + ), + new AgreementPolicyResponse( + policy.marketingVersion(), + false, + null + ) + ) + ); + } + + @Schema(name = "PasswordPolicyResponse", description = "비밀번호 생성 규칙") + public record PasswordPolicyResponse( + @JsonProperty("min_length") int minLength, + @JsonProperty("max_length") int maxLength, + @JsonProperty("require_letter") boolean requireLetter, + @JsonProperty("require_digit") boolean requireDigit + ) { + } + + @Schema(name = "AgreementsPolicyResponse", description = "현재 약관별 가입 정책") + public record AgreementsPolicyResponse( + @JsonProperty("service_terms") AgreementPolicyResponse serviceTerms, + @JsonProperty("privacy_policy") AgreementPolicyResponse privacyPolicy, + AgreementPolicyResponse marketing + ) { + } + + @Schema(name = "AgreementPolicyResponse", description = "개별 약관의 현재 버전과 필수 여부") + public record AgreementPolicyResponse( + String version, + boolean required, + @JsonProperty("content_path") String contentPath + ) { + } +} diff --git a/src/main/java/com/fowoco/server/auth/api/SignupRequest.java b/src/main/java/com/fowoco/server/auth/api/SignupRequest.java index a42426bb..03922efd 100644 --- a/src/main/java/com/fowoco/server/auth/api/SignupRequest.java +++ b/src/main/java/com/fowoco/server/auth/api/SignupRequest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.fowoco.server.auth.api.validation.Utf8ByteLength; +import com.fowoco.server.auth.api.validation.PasswordPolicy; import com.fowoco.server.auth.application.SignupCommand; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; @@ -68,14 +69,25 @@ public final class SignupRequest { description = "로그인 비밀번호. UTF-8 기준 72바이트 이하이며 " + "원문은 저장하지 않고 BCrypt hash만 저장합니다.", format = "password", - minLength = 8, - maxLength = 128, + minLength = PasswordPolicy.MIN_LENGTH, + maxLength = PasswordPolicy.MAX_LENGTH, accessMode = Schema.AccessMode.WRITE_ONLY, requiredMode = Schema.RequiredMode.REQUIRED ) @NotBlank(message = "비밀번호를 입력해 주세요.") - @Size(min = 8, max = 128, message = "비밀번호는 8자 이상 128자 이하여야 합니다.") - @Utf8ByteLength(max = 72, message = "비밀번호는 UTF-8 기준 72바이트 이하여야 합니다.") + @Size( + min = PasswordPolicy.MIN_LENGTH, + max = PasswordPolicy.MAX_LENGTH, + message = "비밀번호는 8자 이상 128자 이하여야 합니다." + ) + @Pattern( + regexp = PasswordPolicy.LETTER_AND_DIGIT_PATTERN, + message = "비밀번호에는 영문과 숫자가 각각 하나 이상 포함되어야 합니다." + ) + @Utf8ByteLength( + max = PasswordPolicy.MAX_UTF8_BYTES, + message = "비밀번호는 UTF-8 기준 72바이트 이하여야 합니다." + ) private final String password; @Valid diff --git a/src/main/java/com/fowoco/server/auth/api/validation/PasswordPolicy.java b/src/main/java/com/fowoco/server/auth/api/validation/PasswordPolicy.java new file mode 100644 index 00000000..570bf91e --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/api/validation/PasswordPolicy.java @@ -0,0 +1,12 @@ +package com.fowoco.server.auth.api.validation; + +public final class PasswordPolicy { + + public static final int MIN_LENGTH = 8; + public static final int MAX_LENGTH = 128; + public static final int MAX_UTF8_BYTES = 72; + public static final String LETTER_AND_DIGIT_PATTERN = "^(?=.*[A-Za-z])(?=.*\\d).+$"; + + private PasswordPolicy() { + } +} diff --git a/src/main/java/com/fowoco/server/common/config/SecurityConfig.java b/src/main/java/com/fowoco/server/common/config/SecurityConfig.java index dbd4e708..0217a407 100644 --- a/src/main/java/com/fowoco/server/common/config/SecurityConfig.java +++ b/src/main/java/com/fowoco/server/common/config/SecurityConfig.java @@ -100,6 +100,7 @@ public SecurityFilterChain applicationSecurityFilterChain( "/swagger-ui.html", "/swagger-ui/**" ).permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/auth/signup-policy").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/auth/signup", "/api/v1/auth/login", diff --git a/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java b/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java index a583edd6..ec526ad2 100644 --- a/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java @@ -154,6 +154,40 @@ SELECT COUNT(*) FROM audit_event assertThat(JsonPath.read(loginResponse.body(), "$.role")).isEqualTo("ADMIN"); } + @Test + void signupPolicyIsPublicAndUsesConfiguredAgreementVersions() throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/api/v1/auth/signup-policy")) + .GET() + .build(); + + HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers().firstValue(HttpHeaders.CACHE_CONTROL)).contains("no-store"); + assertThat(JsonPath.read(response.body(), "$.password_policy.min_length")) + .isEqualTo(8); + assertThat(JsonPath.read(response.body(), "$.password_policy.max_length")) + .isEqualTo(128); + assertThat(JsonPath.read(response.body(), "$.password_policy.require_letter")) + .isTrue(); + assertThat(JsonPath.read(response.body(), "$.password_policy.require_digit")) + .isTrue(); + assertThat(JsonPath.read(response.body(), "$.agreements.service_terms.version")) + .isEqualTo("1.0"); + assertThat(JsonPath.read(response.body(), "$.agreements.service_terms.required")) + .isTrue(); + assertThat(JsonPath.read(response.body(), "$.agreements.service_terms.content_path")) + .isEqualTo("/legal/terms"); + assertThat(JsonPath.read(response.body(), "$.agreements.privacy_policy.content_path")) + .isEqualTo("/legal/privacy"); + assertThat(JsonPath.read(response.body(), "$.agreements.marketing.required")) + .isFalse(); + } + @Test void duplicateNormalizedEmailReturnsConflictAndRollsBackNewCompany() throws Exception { assertThat(signup("첫 번째 사업장", "첫 관리자", "owner@example.com", PASSWORD).statusCode()) @@ -247,6 +281,15 @@ void invalidOrClientControlledFieldsAreRejectedWithoutPartialData() throws Excep assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Integer.class)).isZero(); } + @Test + void passwordRequiresAtLeastOneLetterAndOneDigit() throws Exception { + assertInvalidPassword("onlyletters"); + assertInvalidPassword("12345678"); + + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM company", Integer.class)).isZero(); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Integer.class)).isZero(); + } + @Test void requiredAgreementAndSupportedVersionsAreValidated() throws Exception { HttpResponse missingRequiredConsent = postJson("/api/v1/auth/signup", """ @@ -291,6 +334,26 @@ private void assertBadRequest(String body) throws Exception { .isIn("VALIDATION_FAILED", "INVALID_REQUEST"); } + private void assertInvalidPassword(String password) throws Exception { + HttpResponse response = postJson("/api/v1/auth/signup", """ + { + "company_name":"사업장", + "display_name":"담당자", + "email":"owner@example.com", + "password":"%s", + "agreements":{ + "service_terms":{"agreed":true,"version":"1.0"}, + "privacy_policy":{"agreed":true,"version":"1.0"}, + "marketing":{"agreed":false,"version":"1.0"} + } + } + """.formatted(password)); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(JsonPath.read(response.body(), "$.code")).isEqualTo("VALIDATION_FAILED"); + assertThat(response.body()).contains("영문과 숫자"); + } + private HttpResponse signup( String companyName, String displayName, diff --git a/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java b/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java index 505f362c..477c9993 100644 --- a/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java +++ b/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java @@ -85,6 +85,23 @@ void signupDocumentsPublicAtomicCompanyAndAdminCreation() { assertThat(signup.has("403")).isFalse(); } + @Test + void signupPolicyDocumentsPublicServerAuthoritativeRules() { + JsonNode policy = openApi.at("/paths/~1api~1v1~1auth~1signup-policy/get"); + + assertThat(policy.path("operationId").asText()).isEqualTo("getSignupPolicy"); + assertThat(policy.has("security") && !policy.path("security").isEmpty()).isFalse(); + assertThat(policy.at("/responses/200/content/application~1json/schema/$ref").asText()) + .isEqualTo("#/components/schemas/SignupPolicyResponse"); + + JsonNode passwordPolicy = openApi.at("/components/schemas/PasswordPolicyResponse/properties"); + assertThat(passwordPolicy.properties()) + .extracting(java.util.Map.Entry::getKey) + .containsExactlyInAnyOrder( + "min_length", "max_length", "require_letter", "require_digit" + ); + } + @Test void signupSchemasUseSnakeCaseAndDoNotAcceptAuthorityOrExposeSecrets() { JsonNode request = openApi.at("/components/schemas/SignupRequest");