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
25 changes: 25 additions & 0 deletions src/main/java/com/fowoco/server/auth/api/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,23 +47,47 @@ 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;

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<SignupPolicyResponse> getSignupPolicy() {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.PRAGMA, "no-cache")
.body(SignupPolicyResponse.from(agreementPolicy));
}

@Operation(
operationId = "signup",
summary = "사업장과 최초 관리자 회원가입",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
69 changes: 69 additions & 0 deletions src/main/java/com/fowoco/server/auth/api/SignupPolicyResponse.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
}
20 changes: 16 additions & 4 deletions src/main/java/com/fowoco/server/auth/api/SignupRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 63 additions & 0 deletions src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,40 @@ SELECT COUNT(*) FROM audit_event
assertThat(JsonPath.<String>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<String> response = httpClient.send(
request,
HttpResponse.BodyHandlers.ofString()
);

assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.headers().firstValue(HttpHeaders.CACHE_CONTROL)).contains("no-store");
assertThat(JsonPath.<Integer>read(response.body(), "$.password_policy.min_length"))
.isEqualTo(8);
assertThat(JsonPath.<Integer>read(response.body(), "$.password_policy.max_length"))
.isEqualTo(128);
assertThat(JsonPath.<Boolean>read(response.body(), "$.password_policy.require_letter"))
.isTrue();
assertThat(JsonPath.<Boolean>read(response.body(), "$.password_policy.require_digit"))
.isTrue();
assertThat(JsonPath.<String>read(response.body(), "$.agreements.service_terms.version"))
.isEqualTo("1.0");
assertThat(JsonPath.<Boolean>read(response.body(), "$.agreements.service_terms.required"))
.isTrue();
assertThat(JsonPath.<String>read(response.body(), "$.agreements.service_terms.content_path"))
.isEqualTo("/legal/terms");
assertThat(JsonPath.<String>read(response.body(), "$.agreements.privacy_policy.content_path"))
.isEqualTo("/legal/privacy");
assertThat(JsonPath.<Boolean>read(response.body(), "$.agreements.marketing.required"))
.isFalse();
}

@Test
void duplicateNormalizedEmailReturnsConflictAndRollsBackNewCompany() throws Exception {
assertThat(signup("첫 번째 사업장", "첫 관리자", "owner@example.com", PASSWORD).statusCode())
Expand Down Expand Up @@ -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<String> missingRequiredConsent = postJson("/api/v1/auth/signup", """
Expand Down Expand Up @@ -291,6 +334,26 @@ private void assertBadRequest(String body) throws Exception {
.isIn("VALIDATION_FAILED", "INVALID_REQUEST");
}

private void assertInvalidPassword(String password) throws Exception {
HttpResponse<String> 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.<String>read(response.body(), "$.code")).isEqualTo("VALIDATION_FAILED");
assertThat(response.body()).contains("영문과 숫자");
}

private HttpResponse<String> signup(
String companyName,
String displayName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading