Skip to content
Open
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
71 changes: 69 additions & 2 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.web.UserAgentDeviceSummarizer;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
Expand All @@ -30,8 +31,10 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand Down Expand Up @@ -215,8 +218,13 @@ public ResponseEntity<Void> completePasswordReset(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
LoginResult result = authService.login(request.toCommand());
public ResponseEntity<LoginResponse> login(
@Valid @RequestBody LoginRequest request,
@RequestHeader(value = HttpHeaders.USER_AGENT, required = false) String userAgent
) {
LoginResult result = authService.login(
request.toCommand(UserAgentDeviceSummarizer.summarize(userAgent))
);
ResponseCookie refreshTokenCookie = refreshTokenCookieFactory.create(result.refreshToken());

return ResponseEntity.ok()
Expand Down Expand Up @@ -361,4 +369,63 @@ public ResponseEntity<Void> logout(
public CurrentActorResponse me() {
return CurrentActorResponse.from(actorContextProvider.requireCurrentActor());
}

@Operation(
operationId = "getMyProfile",
summary = "내 프로필 조회",
description = "현재 로그인한 사용자의 표시 이름·연락처를 반환합니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "현재 사용자 프로필",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ProfileResponse.class)
)
),
@ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized")
})
@GetMapping(path = "/me/profile", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
public ProfileResponse getMyProfile() {
var actor = actorContextProvider.requireCurrentActor();
return ProfileResponse.from(authService.currentProfile(actor.actorId(), actor.companyId()));
}

@Operation(
operationId = "updateMyProfile",
summary = "내 프로필 수정",
description = "현재 로그인한 사용자의 표시 이름·연락처를 수정합니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "수정된 프로필",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ProfileResponse.class)
)
),
@ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"),
@ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"),
@ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType")
})
@PatchMapping(
path = "/me/profile",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
public ProfileResponse updateMyProfile(@Valid @RequestBody UpdateProfileRequest request) {
var actor = actorContextProvider.requireCurrentActor();
return ProfileResponse.from(authService.updateProfile(
actor.actorId(),
actor.companyId(),
request.getDisplayName(),
request.getPhone()
));
}
}
4 changes: 2 additions & 2 deletions src/main/java/com/fowoco/server/auth/api/LoginRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public String getPassword() {
return password;
}

public LoginCommand toCommand() {
return new LoginCommand(email, password);
public LoginCommand toCommand(String deviceSummary) {
return new LoginCommand(email, password, deviceSummary);
}
}
51 changes: 51 additions & 0 deletions src/main/java/com/fowoco/server/auth/api/ProfileResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.fowoco.server.auth.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.auth.application.ProfileSnapshot;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;

@Schema(name = "ProfileResponse", description = "현재 사용자의 개인 프로필")
public record ProfileResponse(
@JsonProperty("display_name")
@Schema(name = "display_name", description = "화면 표시 이름")
String displayName,
@Schema(description = "연락처 (선택 입력, 미등록 시 null)", example = "010-1234-5678")
String phone,
@Schema(description = "사업장 내 역할", allowableValues = {"ADMIN", "HR", "VIEWER"}, example = "HR")
String role,
@JsonProperty("account_status")
@Schema(
name = "account_status",
description = "계정 상태",
allowableValues = {"ACTIVE", "SUSPENDED", "DISABLED"},
example = "ACTIVE"
)
String accountStatus,
@JsonProperty("password_changed_at")
@Schema(name = "password_changed_at", description = "마지막 비밀번호 변경 시각(가입 시 최초 설정 포함)")
Instant passwordChangedAt,
@JsonProperty("last_login_at")
@Schema(name = "last_login_at", description = "가장 최근 로그인 성공 시각")
Instant lastLoginAt,
@JsonProperty("last_login_device")
@Schema(name = "last_login_device", description = "가장 최근 로그인 기기 (User-Agent 기반 추정)", example = "Chrome · macOS")
String lastLoginDevice,
@JsonProperty("recent_device_count")
@Schema(name = "recent_device_count", description = "최근 로그인 이력에서 확인된 서로 다른 기기 수")
int recentDeviceCount
) {

public static ProfileResponse from(ProfileSnapshot snapshot) {
return new ProfileResponse(
snapshot.account().displayName(),
snapshot.account().phone(),
snapshot.account().role().name(),
snapshot.account().status().name(),
snapshot.account().passwordChangedAt(),
snapshot.lastLoginAt(),
snapshot.lastLoginDevice(),
snapshot.recentDeviceCount()
);
}
}
21 changes: 20 additions & 1 deletion src/main/java/com/fowoco/server/auth/api/SignupRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ public final class SignupRequest {
@Pattern(regexp = "^[^\\p{Cc}]+$", message = "담당자 이름에 제어 문자를 사용할 수 없습니다.")
private final String displayName;

@JsonProperty("phone")
@Schema(
description = "최초 담당자의 연락처 (선택)",
example = "010-1234-5678",
maxLength = 30,
requiredMode = Schema.RequiredMode.NOT_REQUIRED
)
@Size(max = 30, message = "연락처는 30자 이하여야 합니다.")
@Pattern(regexp = "^[0-9+()\\-\\s]*$", message = "연락처 형식이 올바르지 않습니다.")
private final String phone;

@Schema(
description = "로그인에 사용할 이메일. 앞뒤 공백 제거 후 소문자로 정규화합니다.",
example = "name@company.com",
Expand Down Expand Up @@ -76,12 +87,14 @@ public final class SignupRequest {
public SignupRequest(
@JsonProperty("company_name") String companyName,
@JsonProperty("display_name") String displayName,
@JsonProperty("phone") String phone,
@JsonProperty("email") String email,
@JsonProperty("password") String password,
@JsonProperty("agreements") SignupAgreementsRequest agreements
) {
this.companyName = stripNullable(companyName);
this.displayName = stripNullable(displayName);
this.phone = stripNullable(phone);
this.email = stripNullable(email);
this.password = password;
this.agreements = agreements;
Expand All @@ -95,6 +108,10 @@ public String getDisplayName() {
return displayName;
}

public String getPhone() {
return phone;
}

public String getEmail() {
return email;
}
Expand All @@ -108,7 +125,9 @@ public SignupAgreementsRequest getAgreements() {
}

public SignupCommand toCommand(String requestId) {
return new SignupCommand(companyName, displayName, email, password, agreements.toAgreements(), requestId);
return new SignupCommand(
companyName, displayName, phone, email, password, agreements.toAgreements(), requestId
);
}

private static String stripNullable(String value) {
Expand Down
52 changes: 52 additions & 0 deletions src/main/java/com/fowoco/server/auth/api/UpdateProfileRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.fowoco.server.auth.api;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

@Schema(name = "UpdateProfileRequest", description = "현재 사용자의 개인 프로필 수정 요청")
public final class UpdateProfileRequest {

@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 = "010-1234-5678",
maxLength = 30,
requiredMode = Schema.RequiredMode.NOT_REQUIRED
)
@Size(max = 30, message = "연락처는 30자 이하여야 합니다.")
@Pattern(regexp = "^[0-9+()\\-\\s]*$", message = "연락처 형식이 올바르지 않습니다.")
private final String phone;

@JsonCreator
public UpdateProfileRequest(
@JsonProperty("display_name") String displayName,
@JsonProperty("phone") String phone
) {
this.displayName = displayName == null ? null : displayName.strip();
this.phone = phone == null ? null : phone.strip();
}

public String getDisplayName() {
return displayName;
}

public String getPhone() {
return phone;
}
}
45 changes: 45 additions & 0 deletions src/main/java/com/fowoco/server/auth/application/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.fowoco.server.auth.application.port.RefreshTokenHashPort;
import com.fowoco.server.auth.application.port.RefreshTokenRepository;
import com.fowoco.server.auth.application.port.UserAccountRepository;
import com.fowoco.server.auth.application.port.UserLoginEventRepository;
import com.fowoco.server.auth.domain.RefreshToken;
import com.fowoco.server.auth.domain.UserAccount;
import com.fowoco.server.common.error.ApiException;
Expand All @@ -19,6 +20,7 @@
import com.fowoco.server.company.application.CompanyAuthenticationSnapshot;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Service;
Expand All @@ -39,9 +41,12 @@ public class AuthService {
private final RefreshTokenRotationTransaction refreshTokenRotationTransaction;
private final RefreshTokenLogoutTransaction refreshTokenLogoutTransaction;
private final AuthAuditPort authAuditPort;
private final UserLoginEventRepository userLoginEventRepository;
private final UuidGenerator uuidGenerator;
private final Clock clock;

private static final int LOGIN_HISTORY_LOOKBACK = 20;

public AuthService(
UserAccountRepository userAccountRepository,
AuthTenantBootstrap authTenantBootstrap,
Expand All @@ -55,6 +60,7 @@ public AuthService(
RefreshTokenRotationTransaction refreshTokenRotationTransaction,
RefreshTokenLogoutTransaction refreshTokenLogoutTransaction,
AuthAuditPort authAuditPort,
UserLoginEventRepository userLoginEventRepository,
UuidGenerator uuidGenerator,
Clock clock
) {
Expand All @@ -70,6 +76,7 @@ public AuthService(
this.refreshTokenRotationTransaction = refreshTokenRotationTransaction;
this.refreshTokenLogoutTransaction = refreshTokenLogoutTransaction;
this.authAuditPort = authAuditPort;
this.userLoginEventRepository = userLoginEventRepository;
this.uuidGenerator = uuidGenerator;
this.clock = clock;
}
Expand Down Expand Up @@ -126,6 +133,13 @@ public LoginResult login(LoginCommand command) {
userAccount.companyId(),
issuedAt
));
userLoginEventRepository.insert(
uuidGenerator.generate(),
userAccount.userId(),
userAccount.companyId(),
command.deviceSummary(),
issuedAt
);

return new LoginResult(
userAccount.userId(),
Expand Down Expand Up @@ -155,6 +169,37 @@ public RefreshResult refresh(String rawRefreshToken) {
return outcome.result().orElseThrow(InvalidRefreshTokenException::new);
}

@Transactional(readOnly = true)
public ProfileSnapshot currentProfile(UUID userId, UUID companyId) {
UserAccount account = userAccountRepository.findByUserIdAndCompanyId(userId, companyId)
.orElseThrow(() -> new IllegalStateException("authenticated user account was not found"));
return withLoginHistory(account);
}

@Transactional
public ProfileSnapshot updateProfile(UUID userId, UUID companyId, String displayName, String phone) {
UserAccount current = userAccountRepository.findByUserIdAndCompanyIdWithLock(userId, companyId)
.orElseThrow(() -> new IllegalStateException("authenticated user account was not found"));
UserAccount updated = current.updateProfile(displayName, phone, clock.instant());
userAccountRepository.update(updated);
return withLoginHistory(updated);
}

private ProfileSnapshot withLoginHistory(UserAccount account) {
List<UserLoginEventRepository.LoginEventRecord> recentLogins = userLoginEventRepository.findRecent(
account.userId(), account.companyId(), LOGIN_HISTORY_LOOKBACK
);
if (recentLogins.isEmpty()) {
return new ProfileSnapshot(account, null, null, 0);
}
UserLoginEventRepository.LoginEventRecord latest = recentLogins.get(0);
long distinctDevices = recentLogins.stream()
.map(UserLoginEventRepository.LoginEventRecord::deviceSummary)
.distinct()
.count();
return new ProfileSnapshot(account, latest.loggedInAt(), latest.deviceSummary(), (int) distinctDevices);
}

public void logout(String rawRefreshToken) {
if (!RefreshTokenFormat.isValidRawValue(rawRefreshToken)) {
authAuditPort.record(AuthAuditEvent.anonymous(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ public final class LoginCommand {

private final String email;
private final String password;
private final String deviceSummary;

public LoginCommand(String email, String password) {
public LoginCommand(String email, String password, String deviceSummary) {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("email must not be blank");
}
Expand All @@ -26,8 +27,12 @@ public LoginCommand(String email, String password) {
if (password.getBytes(StandardCharsets.UTF_8).length > 72) {
throw new IllegalArgumentException("password must not exceed 72 UTF-8 bytes");
}
if (deviceSummary == null || deviceSummary.isBlank()) {
throw new IllegalArgumentException("deviceSummary must not be blank");
}
this.email = email;
this.password = password;
this.deviceSummary = deviceSummary;
}

public String email() {
Expand All @@ -37,4 +42,8 @@ public String email() {
public String password() {
return password;
}

public String deviceSummary() {
return deviceSummary;
}
}
Loading