From d3198876b3eb7b53646ae53f3ccce50a35752575 Mon Sep 17 00:00:00 2001 From: hywznn Date: Fri, 24 Jul 2026 12:51:50 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(auth):=20=EC=82=AC=EC=97=85=EC=9E=A5?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=EA=B3=BC=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20ADMIN=20=EC=83=9D=EC=84=B1=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/auth/api/AuthController.java | 51 ++++++++- .../fowoco/server/auth/api/LoginResponse.java | 15 +++ .../fowoco/server/auth/api/SignupRequest.java | 104 ++++++++++++++++++ .../server/auth/api/SignupResponse.java | 102 +++++++++++++++++ .../auth/application/AuthAuditEvent.java | 2 + .../server/auth/application/AuthService.java | 1 + .../server/auth/application/LoginResult.java | 7 ++ .../auth/application/SignupCommand.java | 18 +++ .../server/auth/application/SignupResult.java | 27 +++++ .../auth/application/SignupService.java | 99 +++++++++++++++++ .../auth/application/error/AuthErrorCode.java | 1 + .../auth/application/port/PasswordHasher.java | 7 ++ .../port/UserAccountRepository.java | 2 + .../server/auth/domain/UserAccount.java | 26 +++++ .../persistence/JpaUserAccountRepository.java | 16 +++ .../persistence/UserAccountJpaEntity.java | 7 ++ .../security/BCryptPasswordHasher.java | 20 ++++ .../seed/DemoAuthSeedProperties.java | 2 + .../seed/DemoAuthSeedRunner.java | 2 + .../server/common/config/OpenApiConfig.java | 12 ++ .../server/common/config/SecurityConfig.java | 1 + .../fowoco/server/company/domain/Company.java | 3 + src/main/resources/application.yaml | 1 + .../migration/V6__add_user_display_name.sql | 6 + 24 files changed, 531 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fowoco/server/auth/api/SignupRequest.java create mode 100644 src/main/java/com/fowoco/server/auth/api/SignupResponse.java create mode 100644 src/main/java/com/fowoco/server/auth/application/SignupCommand.java create mode 100644 src/main/java/com/fowoco/server/auth/application/SignupResult.java create mode 100644 src/main/java/com/fowoco/server/auth/application/SignupService.java create mode 100644 src/main/java/com/fowoco/server/auth/application/port/PasswordHasher.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/security/BCryptPasswordHasher.java create mode 100644 src/main/resources/db/migration/V6__add_user_display_name.sql 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 2e6ae49..7a3a52f 100644 --- a/src/main/java/com/fowoco/server/auth/api/AuthController.java +++ b/src/main/java/com/fowoco/server/auth/api/AuthController.java @@ -3,6 +3,8 @@ import com.fowoco.server.auth.application.AuthService; import com.fowoco.server.auth.application.LoginResult; import com.fowoco.server.auth.application.RefreshResult; +import com.fowoco.server.auth.application.SignupResult; +import com.fowoco.server.auth.application.SignupService; import com.fowoco.server.auth.application.error.InvalidRefreshTokenException; import com.fowoco.server.auth.application.port.ActorContextProvider; import io.swagger.v3.oas.annotations.Operation; @@ -30,25 +32,72 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "Authentication", description = "사업장 사용자 로그인과 현재 인증 정보") +@Tag(name = "Authentication", description = "사업장 회원가입·로그인과 현재 인증 정보") @RestController @RequestMapping("/api/v1/auth") public class AuthController { private final AuthService authService; + private final SignupService signupService; private final RefreshTokenCookieFactory refreshTokenCookieFactory; private final ActorContextProvider actorContextProvider; public AuthController( AuthService authService, + SignupService signupService, RefreshTokenCookieFactory refreshTokenCookieFactory, ActorContextProvider actorContextProvider ) { this.authService = authService; + this.signupService = signupService; this.refreshTokenCookieFactory = refreshTokenCookieFactory; this.actorContextProvider = actorContextProvider; } + @Operation( + operationId = "signup", + summary = "사업장과 최초 관리자 회원가입", + description = "사업장과 최초 ADMIN 계정을 하나의 transaction으로 생성합니다. " + + "자동 로그인하거나 Token을 발급하지 않으며, 성공 후 기존 로그인 API를 사용합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "사업장과 최초 ADMIN 계정 생성 성공", + headers = { + @Header( + name = HttpHeaders.CACHE_CONTROL, + description = "회원가입 응답 저장 방지", + schema = @Schema(type = "string", example = "no-store") + ), + @Header( + name = HttpHeaders.PRAGMA, + description = "구형 캐시의 회원가입 응답 저장 방지", + schema = @Schema(type = "string", example = "no-cache") + ) + }, + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SignupResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/EmailAlreadyRegistered"), + @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType") + }) + @PostMapping( + path = "/signup", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { + SignupResult result = signupService.signup(request.toCommand()); + return ResponseEntity.status(org.springframework.http.HttpStatus.CREATED) + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.PRAGMA, "no-cache") + .body(SignupResponse.from(result)); + } + @Operation( operationId = "login", summary = "사업장 사용자 로그인", diff --git a/src/main/java/com/fowoco/server/auth/api/LoginResponse.java b/src/main/java/com/fowoco/server/auth/api/LoginResponse.java index f180ebe..053d43a 100644 --- a/src/main/java/com/fowoco/server/auth/api/LoginResponse.java +++ b/src/main/java/com/fowoco/server/auth/api/LoginResponse.java @@ -36,6 +36,14 @@ public final class LoginResponse { requiredMode = Schema.RequiredMode.REQUIRED ) private final String companyName; + @JsonProperty("display_name") + @Schema( + name = "display_name", + description = "로그인한 담당자의 화면 표시 이름", + example = "김경민", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final String displayName; @Schema( description = "사용자 역할", allowableValues = {"ADMIN", "HR", "VIEWER"}, @@ -83,6 +91,7 @@ private LoginResponse( UUID userId, UUID companyId, String companyName, + String displayName, String role, String accessToken, String tokenType, @@ -92,6 +101,7 @@ private LoginResponse( this.userId = userId; this.companyId = companyId; this.companyName = companyName; + this.displayName = displayName; this.role = role; this.accessToken = accessToken; this.tokenType = tokenType; @@ -104,6 +114,7 @@ public static LoginResponse from(LoginResult result) { result.userId(), result.companyId(), result.companyName(), + result.displayName(), result.role().name(), result.accessToken(), "Bearer", @@ -124,6 +135,10 @@ public String getCompanyName() { return companyName; } + public String getDisplayName() { + return displayName; + } + public String getRole() { return role; } diff --git a/src/main/java/com/fowoco/server/auth/api/SignupRequest.java b/src/main/java/com/fowoco/server/auth/api/SignupRequest.java new file mode 100644 index 0000000..3570bf9 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/api/SignupRequest.java @@ -0,0 +1,104 @@ +package com.fowoco.server.auth.api; + +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.application.SignupCommand; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(name = "SignupRequest", description = "사업장과 최초 ADMIN 계정 생성 요청") +public final class SignupRequest { + + @JsonProperty("company_name") + @Schema( + name = "company_name", + description = "가입할 사업장 표시 이름", + example = "한빛정밀", + maxLength = 120, + requiredMode = Schema.RequiredMode.REQUIRED + ) + @NotBlank(message = "사업장명을 입력해 주세요.") + @Size(max = 120, message = "사업장명은 120자 이하여야 합니다.") + @Pattern(regexp = "^[^\\p{Cc}]+$", message = "사업장명에 제어 문자를 사용할 수 없습니다.") + private final String companyName; + + @JsonProperty("display_name") + @Schema( + name = "display_name", + description = "최초 담당자의 화면 표시 이름", + example = "김경민", + maxLength = 80, + requiredMode = Schema.RequiredMode.REQUIRED + ) + @NotBlank(message = "담당자 이름을 입력해 주세요.") + @Size(max = 80, message = "담당자 이름은 80자 이하여야 합니다.") + @Pattern(regexp = "^[^\\p{Cc}]+$", message = "담당자 이름에 제어 문자를 사용할 수 없습니다.") + private final String displayName; + + @Schema( + description = "로그인에 사용할 이메일. 앞뒤 공백 제거 후 소문자로 정규화합니다.", + example = "name@company.com", + format = "email", + maxLength = 254, + requiredMode = Schema.RequiredMode.REQUIRED + ) + @NotBlank(message = "이메일을 입력해 주세요.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + @Size(max = 254, message = "이메일은 254자 이하여야 합니다.") + private final String email; + + @Schema( + description = "로그인 비밀번호. UTF-8 기준 72바이트 이하이며 " + + "원문은 저장하지 않고 BCrypt hash만 저장합니다.", + format = "password", + minLength = 8, + maxLength = 128, + 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바이트 이하여야 합니다.") + private final String password; + + @JsonCreator + public SignupRequest( + @JsonProperty("company_name") String companyName, + @JsonProperty("display_name") String displayName, + @JsonProperty("email") String email, + @JsonProperty("password") String password + ) { + this.companyName = stripNullable(companyName); + this.displayName = stripNullable(displayName); + this.email = stripNullable(email); + this.password = password; + } + + public String getCompanyName() { + return companyName; + } + + public String getDisplayName() { + return displayName; + } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + + public SignupCommand toCommand() { + return new SignupCommand(companyName, displayName, email, password); + } + + private static String stripNullable(String value) { + return value == null ? null : value.strip(); + } +} diff --git a/src/main/java/com/fowoco/server/auth/api/SignupResponse.java b/src/main/java/com/fowoco/server/auth/api/SignupResponse.java new file mode 100644 index 0000000..d0844c8 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/api/SignupResponse.java @@ -0,0 +1,102 @@ +package com.fowoco.server.auth.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.auth.application.SignupResult; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; + +@Schema( + name = "SignupResponse", + description = "사업장과 최초 ADMIN 계정 생성 결과. Token은 발급하지 않습니다." +) +public final class SignupResponse { + + @JsonProperty("user_id") + @Schema(name = "user_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID userId; + @JsonProperty("company_id") + @Schema(name = "company_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID companyId; + @JsonProperty("company_name") + @Schema(name = "company_name", example = "한빛정밀", requiredMode = Schema.RequiredMode.REQUIRED) + private final String companyName; + @JsonProperty("display_name") + @Schema(name = "display_name", example = "김경민", requiredMode = Schema.RequiredMode.REQUIRED) + private final String displayName; + @Schema( + description = "등록된 로그인 이메일", + example = "name@company.com", + format = "email", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final String email; + @Schema( + description = "최초 계정 역할. Client 입력이 아니라 Server가 결정합니다.", + allowableValues = "ADMIN", + example = "ADMIN", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final String role; + @JsonProperty("created_at") + @Schema(name = "created_at", format = "date-time", requiredMode = Schema.RequiredMode.REQUIRED) + private final Instant createdAt; + + private SignupResponse( + UUID userId, + UUID companyId, + String companyName, + String displayName, + String email, + String role, + Instant createdAt + ) { + this.userId = userId; + this.companyId = companyId; + this.companyName = companyName; + this.displayName = displayName; + this.email = email; + this.role = role; + this.createdAt = createdAt; + } + + public static SignupResponse from(SignupResult result) { + return new SignupResponse( + result.userId(), + result.companyId(), + result.companyName(), + result.displayName(), + result.email(), + result.role().name(), + result.createdAt() + ); + } + + public UUID getUserId() { + return userId; + } + + public UUID getCompanyId() { + return companyId; + } + + public String getCompanyName() { + return companyName; + } + + public String getDisplayName() { + return displayName; + } + + public String getEmail() { + return email; + } + + public String getRole() { + return role; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/fowoco/server/auth/application/AuthAuditEvent.java b/src/main/java/com/fowoco/server/auth/application/AuthAuditEvent.java index 6a4944b..d1811f9 100644 --- a/src/main/java/com/fowoco/server/auth/application/AuthAuditEvent.java +++ b/src/main/java/com/fowoco/server/auth/application/AuthAuditEvent.java @@ -7,6 +7,8 @@ public final class AuthAuditEvent { public enum Action { + SIGNUP_SUCCEEDED, + SIGNUP_REJECTED, LOGIN_SUCCEEDED, LOGIN_REJECTED, REFRESH_SUCCEEDED, diff --git a/src/main/java/com/fowoco/server/auth/application/AuthService.java b/src/main/java/com/fowoco/server/auth/application/AuthService.java index a63d20e..afa373f 100644 --- a/src/main/java/com/fowoco/server/auth/application/AuthService.java +++ b/src/main/java/com/fowoco/server/auth/application/AuthService.java @@ -111,6 +111,7 @@ public LoginResult login(LoginCommand command) { userAccount.userId(), userAccount.companyId(), company.companyName(), + userAccount.displayName(), userAccount.role(), accessToken.value(), accessToken.expiresAt(), diff --git a/src/main/java/com/fowoco/server/auth/application/LoginResult.java b/src/main/java/com/fowoco/server/auth/application/LoginResult.java index 762e4a3..08531bb 100644 --- a/src/main/java/com/fowoco/server/auth/application/LoginResult.java +++ b/src/main/java/com/fowoco/server/auth/application/LoginResult.java @@ -10,6 +10,7 @@ public final class LoginResult { private final UUID userId; private final UUID companyId; private final String companyName; + private final String displayName; private final UserRole role; private final String accessToken; private final Instant accessTokenExpiresAt; @@ -21,6 +22,7 @@ public LoginResult( UUID userId, UUID companyId, String companyName, + String displayName, UserRole role, String accessToken, Instant accessTokenExpiresAt, @@ -31,6 +33,7 @@ public LoginResult( this.userId = Objects.requireNonNull(userId, "userId must not be null"); this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); this.companyName = requireText(companyName, "companyName"); + this.displayName = requireText(displayName, "displayName"); this.role = Objects.requireNonNull(role, "role must not be null"); this.accessToken = requireText(accessToken, "accessToken"); this.accessTokenExpiresAt = Objects.requireNonNull( @@ -60,6 +63,10 @@ public String companyName() { return companyName; } + public String displayName() { + return displayName; + } + public UserRole role() { return role; } diff --git a/src/main/java/com/fowoco/server/auth/application/SignupCommand.java b/src/main/java/com/fowoco/server/auth/application/SignupCommand.java new file mode 100644 index 0000000..a3e6bf2 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/SignupCommand.java @@ -0,0 +1,18 @@ +package com.fowoco.server.auth.application; + +import java.util.Objects; + +public record SignupCommand( + String companyName, + String displayName, + String email, + String password +) { + + public SignupCommand { + Objects.requireNonNull(companyName, "companyName must not be null"); + Objects.requireNonNull(displayName, "displayName must not be null"); + Objects.requireNonNull(email, "email must not be null"); + Objects.requireNonNull(password, "password must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/auth/application/SignupResult.java b/src/main/java/com/fowoco/server/auth/application/SignupResult.java new file mode 100644 index 0000000..f2adc50 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/SignupResult.java @@ -0,0 +1,27 @@ +package com.fowoco.server.auth.application; + +import com.fowoco.server.auth.domain.UserRole; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public record SignupResult( + UUID userId, + UUID companyId, + String companyName, + String displayName, + String email, + UserRole role, + Instant createdAt +) { + + public SignupResult { + Objects.requireNonNull(userId, "userId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(companyName, "companyName must not be null"); + Objects.requireNonNull(displayName, "displayName must not be null"); + Objects.requireNonNull(email, "email must not be null"); + Objects.requireNonNull(role, "role must not be null"); + Objects.requireNonNull(createdAt, "createdAt must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/auth/application/SignupService.java b/src/main/java/com/fowoco/server/auth/application/SignupService.java new file mode 100644 index 0000000..5e459fc --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/SignupService.java @@ -0,0 +1,99 @@ +package com.fowoco.server.auth.application; + +import com.fowoco.server.auth.application.error.AuthErrorCode; +import com.fowoco.server.auth.application.port.AuthAuditPort; +import com.fowoco.server.auth.application.port.PasswordHasher; +import com.fowoco.server.auth.application.port.UserAccountRepository; +import com.fowoco.server.auth.domain.UserAccount; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.company.domain.Company; +import java.time.Clock; +import java.time.Instant; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SignupService { + + private final CompanyRepository companyRepository; + private final UserAccountRepository userAccountRepository; + private final PasswordHasher passwordHasher; + private final AuthAuditPort authAuditPort; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public SignupService( + CompanyRepository companyRepository, + UserAccountRepository userAccountRepository, + PasswordHasher passwordHasher, + AuthAuditPort authAuditPort, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.companyRepository = companyRepository; + this.userAccountRepository = userAccountRepository; + this.passwordHasher = passwordHasher; + this.authAuditPort = authAuditPort; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public SignupResult signup(SignupCommand command) { + String normalizedEmail = UserAccount.normalizeEmail(command.email()); + if (userAccountRepository.existsByNormalizedEmail(normalizedEmail)) { + throw duplicateEmail(); + } + + Instant now = clock.instant(); + Company company = Company.create( + uuidGenerator.generate(), + command.companyName(), + now + ); + UserAccount initialAdmin = UserAccount.create( + uuidGenerator.generate(), + company.companyId(), + command.displayName(), + command.email(), + passwordHasher.hash(command.password()), + UserRole.ADMIN, + now + ); + + companyRepository.insert(company); + try { + userAccountRepository.insert(initialAdmin); + } catch (DataIntegrityViolationException exception) { + throw duplicateEmail(); + } + + authAuditPort.record(AuthAuditEvent.account( + AuthAuditEvent.Action.SIGNUP_SUCCEEDED, + initialAdmin.userId(), + company.companyId(), + now + )); + return new SignupResult( + initialAdmin.userId(), + company.companyId(), + company.name(), + initialAdmin.displayName(), + initialAdmin.email(), + initialAdmin.role(), + now + ); + } + + private ApiException duplicateEmail() { + authAuditPort.record(AuthAuditEvent.anonymous( + AuthAuditEvent.Action.SIGNUP_REJECTED, + clock.instant() + )); + return new ApiException(AuthErrorCode.EMAIL_ALREADY_REGISTERED); + } +} diff --git a/src/main/java/com/fowoco/server/auth/application/error/AuthErrorCode.java b/src/main/java/com/fowoco/server/auth/application/error/AuthErrorCode.java index 389c431..c72e841 100644 --- a/src/main/java/com/fowoco/server/auth/application/error/AuthErrorCode.java +++ b/src/main/java/com/fowoco/server/auth/application/error/AuthErrorCode.java @@ -4,6 +4,7 @@ import org.springframework.http.HttpStatus; public enum AuthErrorCode implements ApiErrorCode { + EMAIL_ALREADY_REGISTERED(HttpStatus.CONFLICT, "이미 가입된 이메일입니다."), INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호를 확인해 주세요."), INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "로그인 정보를 갱신할 수 없습니다. 다시 로그인해 주세요."); diff --git a/src/main/java/com/fowoco/server/auth/application/port/PasswordHasher.java b/src/main/java/com/fowoco/server/auth/application/port/PasswordHasher.java new file mode 100644 index 0000000..9b796fe --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/port/PasswordHasher.java @@ -0,0 +1,7 @@ +package com.fowoco.server.auth.application.port; + +@FunctionalInterface +public interface PasswordHasher { + + String hash(String rawPassword); +} diff --git a/src/main/java/com/fowoco/server/auth/application/port/UserAccountRepository.java b/src/main/java/com/fowoco/server/auth/application/port/UserAccountRepository.java index 45d2e94..6451ff0 100644 --- a/src/main/java/com/fowoco/server/auth/application/port/UserAccountRepository.java +++ b/src/main/java/com/fowoco/server/auth/application/port/UserAccountRepository.java @@ -8,6 +8,8 @@ public interface UserAccountRepository { void insert(UserAccount userAccount); + boolean existsByNormalizedEmail(String normalizedEmail); + Optional findByNormalizedEmail(String normalizedEmail); Optional findByUserIdAndCompanyId(UUID userId, UUID companyId); diff --git a/src/main/java/com/fowoco/server/auth/domain/UserAccount.java b/src/main/java/com/fowoco/server/auth/domain/UserAccount.java index c326815..f577442 100644 --- a/src/main/java/com/fowoco/server/auth/domain/UserAccount.java +++ b/src/main/java/com/fowoco/server/auth/domain/UserAccount.java @@ -8,10 +8,12 @@ public final class UserAccount { private static final int MAX_EMAIL_LENGTH = 254; + private static final int MAX_DISPLAY_NAME_LENGTH = 80; private static final int MAX_PASSWORD_HASH_LENGTH = 255; private final UUID userId; private final UUID companyId; + private final String displayName; private final String email; private final String normalizedEmail; private final String passwordHash; @@ -24,6 +26,7 @@ public final class UserAccount { public UserAccount( UUID userId, UUID companyId, + String displayName, String email, String normalizedEmail, String passwordHash, @@ -35,6 +38,7 @@ public UserAccount( ) { this.userId = Objects.requireNonNull(userId, "userId must not be null"); this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.displayName = requireDisplayName(displayName); this.email = requireEmail(email); String expectedNormalizedEmail = normalizeEmail(this.email); if (!expectedNormalizedEmail.equals(normalizedEmail)) { @@ -58,6 +62,7 @@ public UserAccount( public static UserAccount create( UUID userId, UUID companyId, + String displayName, String email, String passwordHash, UserRole role, @@ -67,6 +72,7 @@ public static UserAccount create( return new UserAccount( userId, companyId, + displayName, email, normalizeEmail(email), passwordHash, @@ -98,6 +104,10 @@ public UUID companyId() { return companyId; } + public String displayName() { + return displayName; + } + public String email() { return email; } @@ -141,6 +151,22 @@ private static String requireEmail(String email) { return stripped; } + private static String requireDisplayName(String displayName) { + if (displayName == null || displayName.isBlank()) { + throw new IllegalArgumentException("displayName must not be blank"); + } + String normalized = displayName.strip(); + if (normalized.length() > MAX_DISPLAY_NAME_LENGTH) { + throw new IllegalArgumentException( + "displayName must not exceed " + MAX_DISPLAY_NAME_LENGTH + " characters" + ); + } + if (normalized.codePoints().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("displayName must not contain control characters"); + } + return normalized; + } + private static String requirePasswordHash(String passwordHash) { if (passwordHash == null || passwordHash.isBlank()) { throw new IllegalArgumentException("passwordHash must not be blank"); diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java index ee88aa3..5025591 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java @@ -24,6 +24,22 @@ public void insert(UserAccount userAccount) { entityManager.flush(); } + @Override + public boolean existsByNormalizedEmail(String normalizedEmail) { + Objects.requireNonNull(normalizedEmail, "normalizedEmail must not be null"); + Long count = entityManager.createQuery( + """ + select count(userAccount) + from UserAccountJpaEntity userAccount + where userAccount.normalizedEmail = :normalizedEmail + """, + Long.class + ) + .setParameter("normalizedEmail", normalizedEmail) + .getSingleResult(); + return count > 0; + } + @Override public Optional findByNormalizedEmail(String normalizedEmail) { return entityManager.createQuery( diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java index 0fd05c6..0250512 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java @@ -38,6 +38,9 @@ public class UserAccountJpaEntity { @Column(name = "company_id", nullable = false, updatable = false) private UUID companyId; + @Column(name = "display_name", nullable = false, length = 80) + private String displayName; + @Column(name = "email", nullable = false, length = 254) private String email; @@ -71,6 +74,7 @@ protected UserAccountJpaEntity() { private UserAccountJpaEntity( UUID userId, UUID companyId, + String displayName, String email, String normalizedEmail, String passwordHash, @@ -82,6 +86,7 @@ private UserAccountJpaEntity( ) { this.userId = userId; this.companyId = companyId; + this.displayName = displayName; this.email = email; this.normalizedEmail = normalizedEmail; this.passwordHash = passwordHash; @@ -97,6 +102,7 @@ public static UserAccountJpaEntity fromDomain(UserAccount userAccount) { return new UserAccountJpaEntity( userAccount.userId(), userAccount.companyId(), + userAccount.displayName(), userAccount.email(), userAccount.normalizedEmail(), userAccount.passwordHash(), @@ -112,6 +118,7 @@ public UserAccount toDomain() { return new UserAccount( userId, companyId, + displayName, email, normalizedEmail, passwordHash, diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/security/BCryptPasswordHasher.java b/src/main/java/com/fowoco/server/auth/infrastructure/security/BCryptPasswordHasher.java new file mode 100644 index 0000000..2e7e3e7 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/security/BCryptPasswordHasher.java @@ -0,0 +1,20 @@ +package com.fowoco.server.auth.infrastructure.security; + +import com.fowoco.server.auth.application.port.PasswordHasher; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public final class BCryptPasswordHasher implements PasswordHasher { + + private final PasswordEncoder passwordEncoder; + + public BCryptPasswordHasher(PasswordEncoder passwordEncoder) { + this.passwordEncoder = passwordEncoder; + } + + @Override + public String hash(String rawPassword) { + return passwordEncoder.encode(rawPassword); + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedProperties.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedProperties.java index 6cf5674..e42a08d 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedProperties.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedProperties.java @@ -9,6 +9,7 @@ public record DemoAuthSeedProperties( UUID companyId, String companyName, UUID adminUserId, + String adminDisplayName, String adminEmail, String adminPassword ) { @@ -19,6 +20,7 @@ public String toString() { + ", companyId=" + companyId + ", companyName=" + companyName + ", adminUserId=" + adminUserId + + ", adminDisplayName=" + adminDisplayName + ", adminEmail=" + adminEmail + ", adminPassword=]"; } diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java index 2aec60c..02179ec 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java @@ -80,6 +80,7 @@ public void run(ApplicationArguments arguments) { UserAccount demoAdmin = UserAccount.create( properties.adminUserId(), properties.companyId(), + properties.adminDisplayName(), properties.adminEmail(), passwordEncoder.encode(properties.adminPassword()), UserRole.ADMIN, @@ -97,6 +98,7 @@ private void validateConfiguration() { Objects.requireNonNull(properties.companyId(), "demo seed companyId must not be null"); Objects.requireNonNull(properties.adminUserId(), "demo seed adminUserId must not be null"); requireText(properties.companyName(), "demo seed companyName"); + requireText(properties.adminDisplayName(), "demo seed adminDisplayName"); requireText(properties.adminEmail(), "demo seed adminEmail"); String password = requireText(properties.adminPassword(), "DEMO_SEED_ADMIN_PASSWORD"); if (password.length() < MINIMUM_DEMO_PASSWORD_LENGTH) { diff --git a/src/main/java/com/fowoco/server/common/config/OpenApiConfig.java b/src/main/java/com/fowoco/server/common/config/OpenApiConfig.java index 8cfa886..486fc0f 100644 --- a/src/main/java/com/fowoco/server/common/config/OpenApiConfig.java +++ b/src/main/java/com/fowoco/server/common/config/OpenApiConfig.java @@ -74,6 +74,18 @@ public OpenAPI fowocoOpenApi() { + "Max-Age=0; HttpOnly; SameSite=Strict" ))) .addResponses("BadRequest", errorResponse("요청 형식 또는 입력값 오류")) + .addResponses("EmailAlreadyRegistered", errorResponse( + "이미 등록된 이메일로 가입할 수 없음", + Map.of( + "timestamp", "2026-07-24T04:00:00Z", + "status", 409, + "code", "EMAIL_ALREADY_REGISTERED", + "message", "이미 가입된 이메일입니다.", + "path", "/api/v1/auth/signup", + "request_id", "01-example-request-id", + "field_errors", java.util.List.of() + ) + )) .addResponses("Unauthorized", errorResponse("인증 필요")) .addResponses("InvalidCredentials", errorResponse( "이메일·비밀번호·계정 또는 사업장 상태로 인한 로그인 실패", 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 c7ada0a..ed23ab4 100644 --- a/src/main/java/com/fowoco/server/common/config/SecurityConfig.java +++ b/src/main/java/com/fowoco/server/common/config/SecurityConfig.java @@ -85,6 +85,7 @@ public SecurityFilterChain applicationSecurityFilterChain( "/swagger-ui/**" ).permitAll() .requestMatchers(HttpMethod.POST, + "/api/v1/auth/signup", "/api/v1/auth/login", "/api/v1/auth/refresh", "/api/v1/auth/logout" diff --git a/src/main/java/com/fowoco/server/company/domain/Company.java b/src/main/java/com/fowoco/server/company/domain/Company.java index b47287e..c7e582e 100644 --- a/src/main/java/com/fowoco/server/company/domain/Company.java +++ b/src/main/java/com/fowoco/server/company/domain/Company.java @@ -78,6 +78,9 @@ private static String requireName(String name) { if (normalized.length() > MAX_NAME_LENGTH) { throw new IllegalArgumentException("name must not exceed " + MAX_NAME_LENGTH + " characters"); } + if (normalized.codePoints().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("name must not contain control characters"); + } return normalized; } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ca98288..d51a5a9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -48,6 +48,7 @@ app: company-id: ${DEMO_SEED_COMPANY_ID:90000000-0000-0000-0000-000000000001} company-name: ${DEMO_SEED_COMPANY_NAME:FOWOCO Demo Company} admin-user-id: ${DEMO_SEED_ADMIN_USER_ID:90000000-0000-0000-0000-000000000002} + admin-display-name: ${DEMO_SEED_ADMIN_DISPLAY_NAME:데모 관리자} admin-email: ${DEMO_SEED_ADMIN_EMAIL:demo.admin@example.com} admin-password: ${DEMO_SEED_ADMIN_PASSWORD:} auth: diff --git a/src/main/resources/db/migration/V6__add_user_display_name.sql b/src/main/resources/db/migration/V6__add_user_display_name.sql new file mode 100644 index 0000000..83d218c --- /dev/null +++ b/src/main/resources/db/migration/V6__add_user_display_name.sql @@ -0,0 +1,6 @@ +ALTER TABLE user_account + ADD COLUMN display_name VARCHAR(80) NOT NULL DEFAULT '사용자'; + +ALTER TABLE user_account + ADD CONSTRAINT ck_user_account_display_name_not_blank + CHECK (CHAR_LENGTH(TRIM(display_name)) > 0); From 97a9320e847cb0fd8e07fbe1c46300f13eb20578 Mon Sep 17 00:00:00 2001 From: hywznn Date: Fri, 24 Jul 2026 12:51:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(auth):=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=B3=B4=EC=95=88=C2=B7=EC=A0=95=ED=95=A9=EC=84=B1?= =?UTF-8?q?=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/PostgreSqlMigrationTests.java | 1 + .../auth/AuthSecurityIntegrationTest.java | 1 + .../server/auth/SignupIntegrationTest.java | 262 ++++++++++++++++++ .../auth/api/AuthOpenApiContractTest.java | 59 ++++ .../seed/DemoAuthSeedIntegrationTest.java | 3 +- .../seed/DemoAuthSeedRunnerTest.java | 9 + 6 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 995c3fe..8608da9 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -80,6 +80,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { assertThat(columnSpecs(connection, "user_account")) .containsEntry("user_id", new ColumnSpec("uuid", false)) .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("display_name", new ColumnSpec("varchar", false)) .containsEntry("normalized_email", new ColumnSpec("varchar", false)) .containsEntry("password_hash", new ColumnSpec("varchar", false)) .containsEntry("role", new ColumnSpec("varchar", false)) diff --git a/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java index 9eabcbd..bd98e63 100644 --- a/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java @@ -100,6 +100,7 @@ void loginTokenCreatesActorContextAndStoresOnlyRefreshTokenHash() throws Excepti .isEqualTo(VIEWER_A.toString()); assertThat(JsonPath.read(loginResponse.body(), "$.company_id")) .isEqualTo(COMPANY_A.toString()); + assertThat(JsonPath.read(loginResponse.body(), "$.display_name")).isEqualTo("사용자"); assertThat(JsonPath.read(loginResponse.body(), "$.role")).isEqualTo("VIEWER"); assertThat(loginResponse.headers().firstValue(HttpHeaders.CACHE_CONTROL)).contains("no-store"); assertThat(loginResponse.headers().firstValue(HttpHeaders.PRAGMA)).contains("no-cache"); diff --git a/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java b/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java new file mode 100644 index 0000000..30f86fe --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java @@ -0,0 +1,262 @@ +package com.fowoco.server.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.datasource.url=jdbc:h2:mem:fowoco-signup-test;" + + "MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;" + + "DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1" + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class SignupIntegrationTest { + + private static final String PASSWORD = "Signup-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeEach + void cleanSignupData() { + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + } + + @Test + void signupCreatesOneCompanyAndInitialAdminThenExistingLoginWorks() throws Exception { + HttpResponse signupResponse = signup( + "한빛정밀", + "김경민", + " Admin@Example.com ", + PASSWORD + ); + + assertThat(signupResponse.statusCode()).isEqualTo(201); + assertThat(signupResponse.headers().firstValue(HttpHeaders.CACHE_CONTROL)).contains("no-store"); + assertThat(signupResponse.headers().firstValue(HttpHeaders.PRAGMA)).contains("no-cache"); + assertThat(signupResponse.headers().firstValue(HttpHeaders.SET_COOKIE)).isEmpty(); + assertThat(JsonPath.read(signupResponse.body(), "$.company_name")).isEqualTo("한빛정밀"); + assertThat(JsonPath.read(signupResponse.body(), "$.display_name")).isEqualTo("김경민"); + assertThat(JsonPath.read(signupResponse.body(), "$.email")).isEqualTo("Admin@Example.com"); + assertThat(JsonPath.read(signupResponse.body(), "$.role")).isEqualTo("ADMIN"); + assertThat(signupResponse.body()) + .doesNotContain(PASSWORD) + .doesNotContain("password_hash", "access_token", "refresh_token"); + + String userId = JsonPath.read(signupResponse.body(), "$.user_id"); + String companyId = JsonPath.read(signupResponse.body(), "$.company_id"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM company WHERE company_id = ? AND name = ? AND status = 'ACTIVE'", + Integer.class, + companyId, + "한빛정밀" + )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) FROM user_account + WHERE user_id = ? AND company_id = ? AND display_name = ? + AND normalized_email = ? AND role = 'ADMIN' AND status = 'ACTIVE' + """, + Integer.class, + userId, + companyId, + "김경민", + "admin@example.com" + )).isEqualTo(1); + + String passwordHash = jdbcTemplate.queryForObject( + "SELECT password_hash FROM user_account WHERE user_id = ?", + String.class, + userId + ); + assertThat(passwordHash).isNotEqualTo(PASSWORD); + assertThat(passwordEncoder.matches(PASSWORD, passwordHash)).isTrue(); + + HttpResponse loginResponse = login("admin@example.com", PASSWORD); + assertThat(loginResponse.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(loginResponse.body(), "$.user_id")).isEqualTo(userId); + assertThat(JsonPath.read(loginResponse.body(), "$.company_id")).isEqualTo(companyId); + assertThat(JsonPath.read(loginResponse.body(), "$.company_name")).isEqualTo("한빛정밀"); + assertThat(JsonPath.read(loginResponse.body(), "$.display_name")).isEqualTo("김경민"); + assertThat(JsonPath.read(loginResponse.body(), "$.role")).isEqualTo("ADMIN"); + } + + @Test + void duplicateNormalizedEmailReturnsConflictAndRollsBackNewCompany() throws Exception { + assertThat(signup("첫 번째 사업장", "첫 관리자", "owner@example.com", PASSWORD).statusCode()) + .isEqualTo(201); + + HttpResponse duplicate = signup( + "남으면 안 되는 사업장", + "두 번째 관리자", + " OWNER@EXAMPLE.COM ", + PASSWORD + ); + + assertThat(duplicate.statusCode()).isEqualTo(409); + assertThat(JsonPath.read(duplicate.body(), "$.code")) + .isEqualTo("EMAIL_ALREADY_REGISTERED"); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM company", Integer.class)).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Integer.class)).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM company WHERE name = '남으면 안 되는 사업장'", + Integer.class + )).isZero(); + } + + @Test + void concurrentSignupWithTheSameEmailLeavesExactlyOneTenant() { + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + CompletableFuture> first = concurrentSignup( + executor, + start, + "동시 사업장 A" + ); + CompletableFuture> second = concurrentSignup( + executor, + start, + "동시 사업장 B" + ); + + start.countDown(); + List statuses = List.of(first.join().statusCode(), second.join().statusCode()); + + assertThat(statuses).containsExactlyInAnyOrder(201, 409); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM company", Integer.class)).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Integer.class)).isEqualTo(1); + } finally { + executor.shutdownNow(); + } + } + + @Test + void invalidOrClientControlledFieldsAreRejectedWithoutPartialData() throws Exception { + assertBadRequest(""" + { + "company_name": "", + "display_name": "담당자", + "email": "owner@example.com", + "password": "Signup-password-1!" + } + """); + assertBadRequest(""" + { + "company_name": "사업장", + "display_name": "담당자", + "email": "not-an-email", + "password": "Signup-password-1!" + } + """); + assertBadRequest(""" + { + "company_name": "사업장", + "display_name": "담당자", + "email": "owner@example.com", + "password": "short" + } + """); + assertBadRequest(""" + { + "company_name": "사업장", + "display_name": "담당자", + "email": "owner@example.com", + "password": "Signup-password-1!", + "role": "ADMIN", + "company_id": "10000000-0000-0000-0000-000000000001" + } + """); + + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM company", Integer.class)).isZero(); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user_account", Integer.class)).isZero(); + } + + private void assertBadRequest(String body) throws Exception { + HttpResponse response = postJson("/api/v1/auth/signup", body); + assertThat(response.statusCode()).isEqualTo(400); + assertThat(JsonPath.read(response.body(), "$.code")) + .isIn("VALIDATION_FAILED", "INVALID_REQUEST"); + } + + private HttpResponse signup( + String companyName, + String displayName, + String email, + String password + ) throws Exception { + return postJson("/api/v1/auth/signup", """ + { + "company_name": "%s", + "display_name": "%s", + "email": "%s", + "password": "%s" + } + """.formatted(companyName, displayName, email, password)); + } + + private CompletableFuture> concurrentSignup( + ExecutorService executor, + CountDownLatch start, + String companyName + ) { + return CompletableFuture.supplyAsync(() -> { + try { + start.await(); + return signup(companyName, "동시 관리자", "concurrent@example.com", PASSWORD); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + }, executor); + } + + private HttpResponse login(String email, String password) throws Exception { + return postJson("/api/v1/auth/login", """ + { + "email": "%s", + "password": "%s" + } + """.formatted(email, password)); + } + + private HttpResponse postJson(String path, String body) throws Exception { + HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } +} 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 f65af88..a45e66a 100644 --- a/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java +++ b/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java @@ -60,6 +60,64 @@ void refreshTokenCookieSecuritySchemeIsDocumented() { .contains("Refresh", "Logout", "JSON", "Authorization"); } + @Test + void signupDocumentsPublicAtomicCompanyAndAdminCreation() { + JsonNode signup = openApi.at("/paths/~1api~1v1~1auth~1signup/post"); + + assertThat(signup.path("operationId").asText()).isEqualTo("signup"); + assertThat(signup.has("security") && !signup.path("security").isEmpty()).isFalse(); + assertThat(signup.path("description").asText()) + .contains("ADMIN", "transaction", "자동 로그인하거나 Token을 발급하지 않으며"); + assertThat(signup.at("/requestBody/content/application~1json/schema/$ref").asText()) + .isEqualTo("#/components/schemas/SignupRequest"); + assertThat(signup.at("/responses/201/content/application~1json/schema/$ref").asText()) + .isEqualTo("#/components/schemas/SignupResponse"); + assertThat(signup.at("/responses/201/headers/Cache-Control/schema/example").asText()) + .isEqualTo("no-store"); + assertThat(signup.at("/responses/201/headers/Pragma/schema/example").asText()) + .isEqualTo("no-cache"); + assertThat(signup.at("/responses/201/headers/Set-Cookie").isMissingNode()).isTrue(); + assertThat(signup.at("/responses/400/$ref").asText()) + .isEqualTo("#/components/responses/BadRequest"); + assertThat(signup.at("/responses/409/$ref").asText()) + .isEqualTo("#/components/responses/EmailAlreadyRegistered"); + assertThat(signup.has("401")).isFalse(); + assertThat(signup.has("403")).isFalse(); + } + + @Test + void signupSchemasUseSnakeCaseAndDoNotAcceptAuthorityOrExposeSecrets() { + JsonNode request = openApi.at("/components/schemas/SignupRequest"); + JsonNode requestProperties = request.path("properties"); + JsonNode responseProperties = openApi.at("/components/schemas/SignupResponse/properties"); + + assertThat(request.path("required").toString()) + .contains("company_name", "display_name", "email", "password"); + assertThat(requestProperties.properties()) + .extracting(java.util.Map.Entry::getKey) + .containsExactlyInAnyOrder("company_name", "display_name", "email", "password"); + assertThat(request.at("/properties/password/minLength").asInt()).isEqualTo(8); + assertThat(request.at("/properties/password/maxLength").asInt()).isEqualTo(128); + assertThat(request.at("/properties/password/writeOnly").asBoolean()).isTrue(); + assertThat(requestProperties.has("role")).isFalse(); + assertThat(requestProperties.has("company_id")).isFalse(); + assertThat(responseProperties.properties()) + .extracting(java.util.Map.Entry::getKey) + .containsExactlyInAnyOrder( + "user_id", + "company_id", + "company_name", + "display_name", + "email", + "role", + "created_at" + ); + assertThat(responseProperties.has("password")).isFalse(); + assertThat(responseProperties.has("password_hash")).isFalse(); + assertThat(responseProperties.has("access_token")).isFalse(); + assertThat(responseProperties.has("refresh_token")).isFalse(); + } + @Test void loginDocumentsPublicRequestResponseCookieAndErrors() { JsonNode login = openApi.at("/paths/~1api~1v1~1auth~1login/post"); @@ -96,6 +154,7 @@ void loginSchemasUseSnakeCaseAndDoNotExposeRefreshToken() { "user_id", "company_id", "company_name", + "display_name", "role", "access_token", "token_type", diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index def7c85..227bb16 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -43,7 +43,8 @@ void enabledSeedCreatesAnActiveCompanyAndHashedAdminAfterFlyway() { COMPANY_ID )).isEqualTo(1); assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE user_id = ? AND role = 'ADMIN' AND status = 'ACTIVE'", + "SELECT COUNT(*) FROM user_account WHERE user_id = ? AND display_name = '데모 관리자' " + + "AND role = 'ADMIN' AND status = 'ACTIVE'", Integer.class, ADMIN_USER_ID )).isEqualTo(1); diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java index 9d953ac..3327a17 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java @@ -24,6 +24,7 @@ class DemoAuthSeedRunnerTest { private static final UUID COMPANY_ID = UUID.fromString("90000000-0000-0000-0000-000000000001"); private static final UUID ADMIN_USER_ID = UUID.fromString("90000000-0000-0000-0000-000000000002"); + private static final String ADMIN_DISPLAY_NAME = "데모 관리자"; private static final String ADMIN_EMAIL = "demo.admin@example.com"; private static final String ADMIN_PASSWORD = "Demo-password-1!"; private static final Instant NOW = Instant.parse("2026-07-22T00:00:00Z"); @@ -47,6 +48,7 @@ void createsAnIdempotentAdminSeedAndStoresOnlyThePasswordHash() throws Exception assertThat(userAccountRepository.users).hasSize(1); UserAccount admin = userAccountRepository.users.get(ADMIN_USER_ID); assertThat(admin.companyId()).isEqualTo(COMPANY_ID); + assertThat(admin.displayName()).isEqualTo(ADMIN_DISPLAY_NAME); assertThat(admin.normalizedEmail()).isEqualTo(ADMIN_EMAIL); assertThat(admin.passwordHash()).isNotEqualTo(ADMIN_PASSWORD); assertThat(passwordEncoder.matches(ADMIN_PASSWORD, admin.passwordHash())).isTrue(); @@ -122,6 +124,7 @@ private DemoAuthSeedProperties properties(String password) { COMPANY_ID, "FOWOCO Demo Company", ADMIN_USER_ID, + ADMIN_DISPLAY_NAME, ADMIN_EMAIL, password ); @@ -155,6 +158,12 @@ public void insert(UserAccount userAccount) { } } + @Override + public boolean existsByNormalizedEmail(String normalizedEmail) { + return users.values().stream() + .anyMatch(user -> user.normalizedEmail().equals(normalizedEmail)); + } + @Override public Optional findByNormalizedEmail(String normalizedEmail) { return users.values().stream() From 91341fea772eeddcba2d4a4d9964b57ab2147723 Mon Sep 17 00:00:00 2001 From: hywznn Date: Fri, 24 Jul 2026 12:52:03 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(auth):=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=EC=99=80=20=EB=8D=B0=EB=AA=A8=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=84=A4=EC=A0=95=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + README.md | 29 ++++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index be6fe7d..5c9357d 100644 --- a/.env.example +++ b/.env.example @@ -37,5 +37,6 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict # 실제 비밀번호를 이 파일이나 Git에 커밋하지 않습니다. DEMO_SEED_ENABLED=false # DEMO_SEED_ADMIN_PASSWORD= +# DEMO_SEED_ADMIN_DISPLAY_NAME=데모 관리자 # DEMO_SEED_ADMIN_EMAIL=demo.admin@example.com # DEMO_SEED_COMPANY_NAME=FOWOCO Demo Company diff --git a/README.md b/README.md index 0dc9cc2..4a0f8c3 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,29 @@ API 문서는 아래 주소에서 확인합니다. local은 기본 Profile이라 별도 데이터베이스가 필요하지 않습니다. 서버를 다시 실행하면 메모리 DB가 초기화되고 Flyway migration이 처음부터 적용됩니다. H2 Console 보호를 위해 local 서버는 기본적으로 내 PC(`127.0.0.1`)에서만 접근할 수 있습니다. -### 로그인·재발급·로그아웃 흐름 +### 회원가입·로그인·재발급·로그아웃 흐름 + +회원가입 화면은 `POST /api/v1/auth/signup`으로 사업장과 최초 `ADMIN` 계정을 함께 +생성합니다. + +```json +{ + "company_name": "한빛정밀", + "display_name": "김경민", + "email": "name@company.com", + "password": "8자 이상의 비밀번호" +} +``` + +- Client 화면의 `workplace`는 `company_name`, `name`은 `display_name`으로 변환합니다. +- `confirmPassword`는 Client에서 일치 여부만 확인하고 Server에 보내지 않습니다. +- Client가 `role`이나 `company_id`를 선택할 수 없으며 최초 계정은 항상 `ADMIN`입니다. +- Company와 UserAccount는 같은 transaction에서 생성되어 하나만 남을 수 없습니다. +- 가입 성공은 `201 Created`이며 Token을 발급하지 않습니다. 사용자는 기존 로그인 API로 + 로그인합니다. +- 이메일 인증·담당자 초대·MFA·비밀번호 재설정은 후속 기능입니다. +- 외부 공개 환경에서는 회원가입 endpoint에 Gateway 또는 배포 경계 Rate Limit을 + 추가해야 합니다. 1. Client가 `POST /api/v1/auth/login`에 `email`, `password`를 보냅니다. 2. 서버는 JSON 본문에 짧게 사용하는 `access_token`을 반환합니다. @@ -138,7 +160,7 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 또는 배포 Secret의 12자 이상 값 서버는 Flyway 적용 뒤 사업장과 계정을 한 번만 만들고, 비밀번호 원문이 아니라 BCrypt hash만 저장합니다. 같은 설정으로 다시 실행해도 중복 생성하지 않습니다. 같은 이메일이 다른 사업장·사용자·역할로 이미 존재하면 덮어쓰지 않고 시작을 중단합니다. -이 값은 개인 `.env`나 배포 환경의 Secret에만 보관하고 `.env.example`, GitHub, 로그에 실제 비밀번호를 넣지 않습니다. ID·이메일·사업장 이름을 바꿔야 하면 `DEMO_SEED_COMPANY_ID`, `DEMO_SEED_ADMIN_USER_ID`, `DEMO_SEED_ADMIN_EMAIL`, `DEMO_SEED_COMPANY_NAME`을 함께 설정할 수 있습니다. 최초 계정을 확인한 뒤에는 `DEMO_SEED_ENABLED=false`로 되돌려 의도하지 않은 Seed 실행을 막습니다. +이 값은 개인 `.env`나 배포 환경의 Secret에만 보관하고 `.env.example`, GitHub, 로그에 실제 비밀번호를 넣지 않습니다. ID·이메일·표시 이름·사업장 이름을 바꿔야 하면 `DEMO_SEED_COMPANY_ID`, `DEMO_SEED_ADMIN_USER_ID`, `DEMO_SEED_ADMIN_EMAIL`, `DEMO_SEED_ADMIN_DISPLAY_NAME`, `DEMO_SEED_COMPANY_NAME`을 함께 설정할 수 있습니다. 최초 계정을 확인한 뒤에는 `DEMO_SEED_ENABLED=false`로 되돌려 의도하지 않은 Seed 실행을 막습니다. ## 개발 기반은 어떻게 동작하나요? @@ -272,7 +294,8 @@ server/ │ ├── V2__create_auth_company.sql # Auth·Company·Refresh Token │ ├── V3__create_worker_document.sql # Worker·Document metadata │ ├── V4__create_task_workflow_core.sql # Task·Checklist·전이 이력 - │ └── V5__create_approval_audit.sql # 승인·제출·증빙·감사 + │ ├── V5__create_approval_audit.sql # 승인·제출·증빙·감사 + │ └── V6__add_user_display_name.sql # 회원가입 담당자 표시 이름 └── test/ └── java/com/fowoco/server/ ├── architecture/