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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,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`을 반환합니다.
Expand Down Expand Up @@ -144,7 +166,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 실행을 막습니다.

## 개발 기반은 어떻게 동작하나요?

Expand Down Expand Up @@ -278,7 +300,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/
Expand Down
51 changes: 50 additions & 1 deletion src/main/java/com/fowoco/server/auth/api/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SignupResponse> 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 = "사업장 사용자 로그인",
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/fowoco/server/auth/api/LoginResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -83,6 +91,7 @@ private LoginResponse(
UUID userId,
UUID companyId,
String companyName,
String displayName,
String role,
String accessToken,
String tokenType,
Expand All @@ -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;
Expand All @@ -104,6 +114,7 @@ public static LoginResponse from(LoginResult result) {
result.userId(),
result.companyId(),
result.companyName(),
result.displayName(),
result.role().name(),
result.accessToken(),
"Bearer",
Expand All @@ -124,6 +135,10 @@ public String getCompanyName() {
return companyName;
}

public String getDisplayName() {
return displayName;
}

public String getRole() {
return role;
}
Expand Down
104 changes: 104 additions & 0 deletions src/main/java/com/fowoco/server/auth/api/SignupRequest.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
102 changes: 102 additions & 0 deletions src/main/java/com/fowoco/server/auth/api/SignupResponse.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading