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 ab901abf..04ffd340 100644 --- a/src/main/java/com/fowoco/server/auth/api/AuthController.java +++ b/src/main/java/com/fowoco/server/auth/api/AuthController.java @@ -8,6 +8,7 @@ import com.fowoco.server.auth.application.SignupService; import com.fowoco.server.auth.application.error.InvalidRefreshTokenException; import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.auth.infrastructure.web.UserAgentDeviceSummarizer; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -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; @@ -215,8 +218,13 @@ public ResponseEntity completePasswordReset( consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) - public ResponseEntity login(@Valid @RequestBody LoginRequest request) { - LoginResult result = authService.login(request.toCommand()); + public ResponseEntity 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() @@ -361,4 +369,63 @@ public ResponseEntity 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() + )); + } } diff --git a/src/main/java/com/fowoco/server/auth/api/LoginRequest.java b/src/main/java/com/fowoco/server/auth/api/LoginRequest.java index bce3a344..9d74b2b1 100644 --- a/src/main/java/com/fowoco/server/auth/api/LoginRequest.java +++ b/src/main/java/com/fowoco/server/auth/api/LoginRequest.java @@ -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); } } diff --git a/src/main/java/com/fowoco/server/auth/api/ProfileResponse.java b/src/main/java/com/fowoco/server/auth/api/ProfileResponse.java new file mode 100644 index 00000000..9cf28f96 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/api/ProfileResponse.java @@ -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() + ); + } +} diff --git a/src/main/java/com/fowoco/server/auth/api/SignupRequest.java b/src/main/java/com/fowoco/server/auth/api/SignupRequest.java index 6404204e..a42426bb 100644 --- a/src/main/java/com/fowoco/server/auth/api/SignupRequest.java +++ b/src/main/java/com/fowoco/server/auth/api/SignupRequest.java @@ -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", @@ -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; @@ -95,6 +108,10 @@ public String getDisplayName() { return displayName; } + public String getPhone() { + return phone; + } + public String getEmail() { return email; } @@ -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) { diff --git a/src/main/java/com/fowoco/server/auth/api/UpdateProfileRequest.java b/src/main/java/com/fowoco/server/auth/api/UpdateProfileRequest.java new file mode 100644 index 00000000..0cce6c15 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/api/UpdateProfileRequest.java @@ -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; + } +} 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 427090b5..584454fb 100644 --- a/src/main/java/com/fowoco/server/auth/application/AuthService.java +++ b/src/main/java/com/fowoco/server/auth/application/AuthService.java @@ -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; @@ -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; @@ -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, @@ -55,6 +60,7 @@ public AuthService( RefreshTokenRotationTransaction refreshTokenRotationTransaction, RefreshTokenLogoutTransaction refreshTokenLogoutTransaction, AuthAuditPort authAuditPort, + UserLoginEventRepository userLoginEventRepository, UuidGenerator uuidGenerator, Clock clock ) { @@ -70,6 +76,7 @@ public AuthService( this.refreshTokenRotationTransaction = refreshTokenRotationTransaction; this.refreshTokenLogoutTransaction = refreshTokenLogoutTransaction; this.authAuditPort = authAuditPort; + this.userLoginEventRepository = userLoginEventRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; } @@ -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(), @@ -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 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( diff --git a/src/main/java/com/fowoco/server/auth/application/LoginCommand.java b/src/main/java/com/fowoco/server/auth/application/LoginCommand.java index b737c24a..9617815f 100644 --- a/src/main/java/com/fowoco/server/auth/application/LoginCommand.java +++ b/src/main/java/com/fowoco/server/auth/application/LoginCommand.java @@ -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"); } @@ -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() { @@ -37,4 +42,8 @@ public String email() { public String password() { return password; } + + public String deviceSummary() { + return deviceSummary; + } } diff --git a/src/main/java/com/fowoco/server/auth/application/ProfileSnapshot.java b/src/main/java/com/fowoco/server/auth/application/ProfileSnapshot.java new file mode 100644 index 00000000..f9a5a6f4 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/ProfileSnapshot.java @@ -0,0 +1,18 @@ +package com.fowoco.server.auth.application; + +import com.fowoco.server.auth.domain.UserAccount; +import java.time.Instant; + +/** + * {@link UserAccount} plus login-history facts the auth module tracks separately + * (see {@link com.fowoco.server.auth.application.port.UserLoginEventRepository}). + * Both fields are null only if the account has never completed a login, which cannot happen for + * an authenticated caller (a Bearer token can only be issued by a successful login). + */ +public record ProfileSnapshot( + UserAccount account, + Instant lastLoginAt, + String lastLoginDevice, + int recentDeviceCount +) { +} diff --git a/src/main/java/com/fowoco/server/auth/application/SignupCommand.java b/src/main/java/com/fowoco/server/auth/application/SignupCommand.java index cbfa6474..0d22664a 100644 --- a/src/main/java/com/fowoco/server/auth/application/SignupCommand.java +++ b/src/main/java/com/fowoco/server/auth/application/SignupCommand.java @@ -5,6 +5,7 @@ public record SignupCommand( String companyName, String displayName, + String phone, String email, String password, SignupAgreements agreements, diff --git a/src/main/java/com/fowoco/server/auth/application/SignupService.java b/src/main/java/com/fowoco/server/auth/application/SignupService.java index 1c914d05..8dc0ff6d 100644 --- a/src/main/java/com/fowoco/server/auth/application/SignupService.java +++ b/src/main/java/com/fowoco/server/auth/application/SignupService.java @@ -92,6 +92,7 @@ public SignupResult signup(SignupCommand command) { uuidGenerator.generate(), company.companyId(), command.displayName(), + command.phone(), command.email(), passwordHasher.hash(command.password()), UserRole.ADMIN, diff --git a/src/main/java/com/fowoco/server/auth/application/port/UserLoginEventRepository.java b/src/main/java/com/fowoco/server/auth/application/port/UserLoginEventRepository.java new file mode 100644 index 00000000..531346aa --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/port/UserLoginEventRepository.java @@ -0,0 +1,16 @@ +package com.fowoco.server.auth.application.port; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public interface UserLoginEventRepository { + + void insert(UUID loginEventId, UUID userId, UUID companyId, String deviceSummary, Instant loggedInAt); + + /** Most recent events first, at most {@code limit} rows. */ + List findRecent(UUID userId, UUID companyId, int limit); + + record LoginEventRecord(String deviceSummary, Instant loggedInAt) { + } +} 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 747c74de..48674e4d 100644 --- a/src/main/java/com/fowoco/server/auth/domain/UserAccount.java +++ b/src/main/java/com/fowoco/server/auth/domain/UserAccount.java @@ -10,10 +10,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 static final int MAX_PHONE_LENGTH = 30; private final UUID userId; private final UUID companyId; private final String displayName; + private final String phone; private final String email; private final String normalizedEmail; private final String passwordHash; @@ -21,12 +23,14 @@ public final class UserAccount { private final AccountStatus status; private final Instant createdAt; private final Instant updatedAt; + private final Instant passwordChangedAt; private final long version; public UserAccount( UUID userId, UUID companyId, String displayName, + String phone, String email, String normalizedEmail, String passwordHash, @@ -34,11 +38,13 @@ public UserAccount( AccountStatus status, Instant createdAt, Instant updatedAt, + Instant passwordChangedAt, long version ) { 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.phone = requirePhone(phone); this.email = requireEmail(email); String expectedNormalizedEmail = normalizeEmail(this.email); if (!expectedNormalizedEmail.equals(normalizedEmail)) { @@ -53,6 +59,10 @@ public UserAccount( if (updatedAt.isBefore(createdAt)) { throw new IllegalArgumentException("updatedAt must not be before createdAt"); } + this.passwordChangedAt = Objects.requireNonNull(passwordChangedAt, "passwordChangedAt must not be null"); + if (passwordChangedAt.isBefore(createdAt)) { + throw new IllegalArgumentException("passwordChangedAt must not be before createdAt"); + } if (version < 0) { throw new IllegalArgumentException("version must not be negative"); } @@ -63,6 +73,7 @@ public static UserAccount create( UUID userId, UUID companyId, String displayName, + String phone, String email, String passwordHash, UserRole role, @@ -73,6 +84,7 @@ public static UserAccount create( userId, companyId, displayName, + phone, email, normalizeEmail(email), passwordHash, @@ -80,6 +92,7 @@ public static UserAccount create( AccountStatus.ACTIVE, now, now, + now, 0L ); } @@ -105,6 +118,7 @@ public UserAccount changePassword(String newPasswordHash, Instant now) { userId, companyId, displayName, + phone, email, normalizedEmail, newPasswordHash, @@ -112,6 +126,29 @@ public UserAccount changePassword(String newPasswordHash, Instant now) { status, createdAt, now, + now, + version + 1 + ); + } + + public UserAccount updateProfile(String newDisplayName, String newPhone, Instant now) { + Objects.requireNonNull(now, "now must not be null"); + if (now.isBefore(updatedAt)) { + throw new IllegalArgumentException("now must not be before updatedAt"); + } + return new UserAccount( + userId, + companyId, + newDisplayName, + newPhone, + email, + normalizedEmail, + passwordHash, + role, + status, + createdAt, + now, + passwordChangedAt, version + 1 ); } @@ -128,6 +165,10 @@ public String displayName() { return displayName; } + public String phone() { + return phone; + } + public String email() { return email; } @@ -156,6 +197,10 @@ public Instant updatedAt() { return updatedAt; } + public Instant passwordChangedAt() { + return passwordChangedAt; + } + public long version() { return version; } @@ -187,6 +232,23 @@ private static String requireDisplayName(String displayName) { return normalized; } + private static String requirePhone(String phone) { + if (phone == null) { + return null; + } + String stripped = phone.strip(); + if (stripped.isEmpty()) { + return null; + } + if (stripped.length() > MAX_PHONE_LENGTH) { + throw new IllegalArgumentException("phone must not exceed " + MAX_PHONE_LENGTH + " characters"); + } + if (!stripped.matches("^[0-9+()\\-\\s]+$")) { + throw new IllegalArgumentException("phone format is invalid"); + } + return stripped; + } + 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/JdbcUserLoginEventRepository.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JdbcUserLoginEventRepository.java new file mode 100644 index 00000000..39dbbb15 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JdbcUserLoginEventRepository.java @@ -0,0 +1,60 @@ +package com.fowoco.server.auth.infrastructure.persistence; + +import com.fowoco.server.auth.application.port.UserLoginEventRepository; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class JdbcUserLoginEventRepository implements UserLoginEventRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcUserLoginEventRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public void insert(UUID loginEventId, UUID userId, UUID companyId, String deviceSummary, Instant loggedInAt) { + jdbcTemplate.update( + """ + INSERT INTO user_login_event (login_event_id, user_id, company_id, device_summary, logged_in_at) + VALUES (?, ?, ?, ?, ?) + """, + loginEventId, + userId, + companyId, + deviceSummary, + Timestamp.from(loggedInAt) + ); + } + + @Override + public List findRecent(UUID userId, UUID companyId, int limit) { + return jdbcTemplate.query( + """ + SELECT device_summary, logged_in_at + FROM user_login_event + WHERE user_id = ? AND company_id = ? + ORDER BY logged_in_at DESC + LIMIT ? + """, + (resultSet, rowNum) -> mapRow(resultSet), + userId, + companyId, + limit + ); + } + + private LoginEventRecord mapRow(ResultSet resultSet) throws SQLException { + return new LoginEventRecord( + resultSet.getString("device_summary"), + resultSet.getTimestamp("logged_in_at").toInstant() + ); + } +} 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 bb8039bd..2dcb00dd 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 @@ -41,6 +41,9 @@ public class UserAccountJpaEntity { @Column(name = "display_name", nullable = false, length = 80) private String displayName; + @Column(name = "phone", length = 30) + private String phone; + @Column(name = "email", nullable = false, length = 254) private String email; @@ -64,6 +67,9 @@ public class UserAccountJpaEntity { @Column(name = "updated_at", nullable = false) private Instant updatedAt; + @Column(name = "password_changed_at", nullable = false) + private Instant passwordChangedAt; + @Version @Column(name = "version", nullable = false) private long version; @@ -75,6 +81,7 @@ private UserAccountJpaEntity( UUID userId, UUID companyId, String displayName, + String phone, String email, String normalizedEmail, String passwordHash, @@ -82,11 +89,13 @@ private UserAccountJpaEntity( AccountStatus status, Instant createdAt, Instant updatedAt, + Instant passwordChangedAt, long version ) { this.userId = userId; this.companyId = companyId; this.displayName = displayName; + this.phone = phone; this.email = email; this.normalizedEmail = normalizedEmail; this.passwordHash = passwordHash; @@ -94,6 +103,7 @@ private UserAccountJpaEntity( this.status = status; this.createdAt = createdAt; this.updatedAt = updatedAt; + this.passwordChangedAt = passwordChangedAt; this.version = version; } @@ -103,6 +113,7 @@ public static UserAccountJpaEntity fromDomain(UserAccount userAccount) { userAccount.userId(), userAccount.companyId(), userAccount.displayName(), + userAccount.phone(), userAccount.email(), userAccount.normalizedEmail(), userAccount.passwordHash(), @@ -110,6 +121,7 @@ public static UserAccountJpaEntity fromDomain(UserAccount userAccount) { userAccount.status(), userAccount.createdAt(), userAccount.updatedAt(), + userAccount.passwordChangedAt(), userAccount.version() ); } @@ -119,6 +131,7 @@ public UserAccount toDomain() { userId, companyId, displayName, + phone, email, normalizedEmail, passwordHash, @@ -126,6 +139,7 @@ public UserAccount toDomain() { status, createdAt, updatedAt, + passwordChangedAt, version ); } @@ -135,8 +149,11 @@ void applyState(UserAccount userAccount) { if (!userId.equals(userAccount.userId()) || version + 1 != userAccount.version()) { throw new IllegalArgumentException("user account version transition is invalid"); } + displayName = userAccount.displayName(); + phone = userAccount.phone(); passwordHash = userAccount.passwordHash(); updatedAt = userAccount.updatedAt(); + passwordChangedAt = userAccount.passwordChangedAt(); } } 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 566a783b..dceb07f3 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 @@ -169,6 +169,7 @@ private void seedUser(UUID companyId, DemoUser demoUser, Instant now) { demoUser.userId(), companyId, demoUser.displayName(), + null, demoUser.email(), passwordEncoder.encode(properties.adminPassword()), demoUser.role(), diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizer.java b/src/main/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizer.java new file mode 100644 index 00000000..7538b333 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizer.java @@ -0,0 +1,74 @@ +package com.fowoco.server.auth.infrastructure.web; + +/** + * Best-effort "Browser · OS" label from a raw User-Agent header, for the login-history display + * on the profile page. Not a security control — only used for a human-readable device hint. + */ +public final class UserAgentDeviceSummarizer { + + private static final String UNKNOWN = "알 수 없는 기기"; + + private UserAgentDeviceSummarizer() { + } + + public static String summarize(String userAgent) { + if (userAgent == null || userAgent.isBlank()) { + return UNKNOWN; + } + String browser = browserOf(userAgent); + String os = osOf(userAgent); + if (browser == null && os == null) { + return UNKNOWN; + } + if (browser == null) { + return os; + } + if (os == null) { + return browser; + } + return browser + " · " + os; + } + + private static String browserOf(String userAgent) { + // Order matters: Edge/Chrome/Samsung UAs also contain "Safari", and Chrome-based + // Edge/Opera UAs also contain "Chrome". + if (userAgent.contains("Edg/") || userAgent.contains("EdgA/") || userAgent.contains("EdgiOS/")) { + return "Edge"; + } + if (userAgent.contains("OPR/") || userAgent.contains("Opera")) { + return "Opera"; + } + if (userAgent.contains("SamsungBrowser/")) { + return "Samsung Internet"; + } + if (userAgent.contains("Firefox/") && !userAgent.contains("Seamonkey/")) { + return "Firefox"; + } + if (userAgent.contains("Chrome/") || userAgent.contains("CriOS/")) { + return "Chrome"; + } + if (userAgent.contains("Safari/") && (userAgent.contains("Version/") || userAgent.contains("Mobile/"))) { + return "Safari"; + } + return null; + } + + private static String osOf(String userAgent) { + if (userAgent.contains("Windows")) { + return "Windows"; + } + if (userAgent.contains("iPhone") || userAgent.contains("iPad") || userAgent.contains("iPod")) { + return "iOS"; + } + if (userAgent.contains("Mac OS X") || userAgent.contains("Macintosh")) { + return "macOS"; + } + if (userAgent.contains("Android")) { + return "Android"; + } + if (userAgent.contains("Linux")) { + return "Linux"; + } + return null; + } +} diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationController.java b/src/main/java/com/fowoco/server/notification/api/NotificationController.java index 0985a9b3..8338ca24 100644 --- a/src/main/java/com/fowoco/server/notification/api/NotificationController.java +++ b/src/main/java/com/fowoco/server/notification/api/NotificationController.java @@ -3,6 +3,7 @@ import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.application.port.ActorContextProvider; import com.fowoco.server.notification.application.NotificationPageResult; +import com.fowoco.server.notification.application.NotificationPreferenceService; import com.fowoco.server.notification.application.NotificationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -12,9 +13,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.time.Instant; +import java.util.List; import java.util.UUID; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -22,7 +25,9 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +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.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -35,13 +40,16 @@ public class NotificationController { private final NotificationService notificationService; + private final NotificationPreferenceService notificationPreferenceService; private final ActorContextProvider actorContextProvider; public NotificationController( NotificationService notificationService, + NotificationPreferenceService notificationPreferenceService, ActorContextProvider actorContextProvider ) { this.notificationService = notificationService; + this.notificationPreferenceService = notificationPreferenceService; this.actorContextProvider = actorContextProvider; } @@ -102,4 +110,65 @@ public ResponseEntity read( notificationService.markAsRead(notificationId, actor); return ResponseEntity.noContent().build(); } + + @Operation( + operationId = "listNotificationPreferences", + summary = "알림 수신 설정 조회", + description = "알림 유형별 수신 여부를 반환합니다. 저장된 값이 없으면 기본값을 반환합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = NotificationPreferenceResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") + }) + @GetMapping(path = "/preferences", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public List listPreferences() { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return notificationPreferenceService.list(actor).stream() + .map(NotificationPreferenceResponse::from) + .toList(); + } + + @Operation( + operationId = "updateNotificationPreference", + summary = "알림 수신 설정 변경", + description = "특정 알림 유형의 수신 여부를 변경합니다. 필수 알림은 끌 수 없습니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "변경된 전체 알림 수신 설정", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = NotificationPreferenceResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") + }) + @PatchMapping( + path = "/preferences/{key}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public List updatePreference( + @Parameter(description = "알림 유형 key") @PathVariable String key, + @Valid @RequestBody UpdateNotificationPreferenceRequest request + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return notificationPreferenceService.update(actor, key, request.getEnabled()).stream() + .map(NotificationPreferenceResponse::from) + .toList(); + } } diff --git a/src/main/java/com/fowoco/server/notification/api/NotificationPreferenceResponse.java b/src/main/java/com/fowoco/server/notification/api/NotificationPreferenceResponse.java new file mode 100644 index 00000000..525373fc --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/api/NotificationPreferenceResponse.java @@ -0,0 +1,23 @@ +package com.fowoco.server.notification.api; + +import com.fowoco.server.notification.application.NotificationPreferenceService.PreferenceState; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "NotificationPreferenceResponse", description = "알림 유형별 수신 설정") +public record NotificationPreferenceResponse( + @Schema(description = "알림 유형 key", example = "due-soon") + String key, + @Schema(description = "수신 여부") + boolean enabled, + @Schema(description = "필수 알림 여부. true면 enabled를 false로 바꿀 수 없습니다.") + boolean required +) { + + public static NotificationPreferenceResponse from(PreferenceState state) { + return new NotificationPreferenceResponse( + state.key().key(), + state.enabled(), + state.key().required() + ); + } +} diff --git a/src/main/java/com/fowoco/server/notification/api/UpdateNotificationPreferenceRequest.java b/src/main/java/com/fowoco/server/notification/api/UpdateNotificationPreferenceRequest.java new file mode 100644 index 00000000..ce5c2686 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/api/UpdateNotificationPreferenceRequest.java @@ -0,0 +1,23 @@ +package com.fowoco.server.notification.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.NotNull; + +@Schema(name = "UpdateNotificationPreferenceRequest", description = "알림 유형 수신 설정 변경 요청") +public final class UpdateNotificationPreferenceRequest { + + @Schema(description = "수신 여부", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "enabled 값을 입력해 주세요.") + private final Boolean enabled; + + @JsonCreator + public UpdateNotificationPreferenceRequest(@JsonProperty("enabled") Boolean enabled) { + this.enabled = enabled; + } + + public Boolean getEnabled() { + return enabled; + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/NotificationPreferenceService.java b/src/main/java/com/fowoco/server/notification/application/NotificationPreferenceService.java new file mode 100644 index 00000000..e779337f --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/NotificationPreferenceService.java @@ -0,0 +1,72 @@ +package com.fowoco.server.notification.application; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.notification.application.error.NotificationErrorCode; +import com.fowoco.server.notification.application.port.NotificationPreferenceRepository; +import com.fowoco.server.notification.domain.NotificationPreferenceKey; +import java.time.Clock; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class NotificationPreferenceService { + + private final NotificationPreferenceRepository notificationPreferenceRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public NotificationPreferenceService( + NotificationPreferenceRepository notificationPreferenceRepository, + TenantDatabaseContext tenantDatabaseContext, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.notificationPreferenceRepository = notificationPreferenceRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + public record PreferenceState(NotificationPreferenceKey key, boolean enabled) { + } + + @Transactional(readOnly = true) + public List list(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + Map overrides = + notificationPreferenceRepository.findOverrides(actor.actorId(), actor.companyId()); + return NotificationPreferenceKey.defaults().entrySet().stream() + .map(entry -> new PreferenceState( + entry.getKey(), + overrides.getOrDefault(entry.getKey(), entry.getValue()) + )) + .toList(); + } + + @Transactional + public List update(ActorContext actor, String rawKey, boolean enabled) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + NotificationPreferenceKey key = NotificationPreferenceKey.fromKey(rawKey); + if (key == null) { + throw new ApiException(NotificationErrorCode.NOTIFICATION_PREFERENCE_NOT_FOUND); + } + if (key.required() && !enabled) { + throw new ApiException(NotificationErrorCode.NOTIFICATION_PREFERENCE_REQUIRED); + } + notificationPreferenceRepository.upsert( + uuidGenerator.generate(), + actor.actorId(), + actor.companyId(), + key, + enabled, + clock.instant() + ); + return list(actor); + } +} diff --git a/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java b/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java index 23ba7b16..33495669 100644 --- a/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java +++ b/src/main/java/com/fowoco/server/notification/application/error/NotificationErrorCode.java @@ -7,6 +7,14 @@ public enum NotificationErrorCode implements ApiErrorCode { NOTIFICATION_NOT_FOUND( HttpStatus.NOT_FOUND, "알림을 찾을 수 없습니다." + ), + NOTIFICATION_PREFERENCE_NOT_FOUND( + HttpStatus.NOT_FOUND, + "알 수 없는 알림 유형입니다." + ), + NOTIFICATION_PREFERENCE_REQUIRED( + HttpStatus.UNPROCESSABLE_ENTITY, + "필수 알림은 끌 수 없습니다." ); private final HttpStatus status; diff --git a/src/main/java/com/fowoco/server/notification/application/port/NotificationPreferenceRepository.java b/src/main/java/com/fowoco/server/notification/application/port/NotificationPreferenceRepository.java new file mode 100644 index 00000000..d17fc002 --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/application/port/NotificationPreferenceRepository.java @@ -0,0 +1,21 @@ +package com.fowoco.server.notification.application.port; + +import com.fowoco.server.notification.domain.NotificationPreferenceKey; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +public interface NotificationPreferenceRepository { + + /** Only rows the user has explicitly overridden from the default. */ + Map findOverrides(UUID userId, UUID companyId); + + void upsert( + UUID preferenceId, + UUID userId, + UUID companyId, + NotificationPreferenceKey key, + boolean enabled, + Instant updatedAt + ); +} diff --git a/src/main/java/com/fowoco/server/notification/domain/NotificationPreferenceKey.java b/src/main/java/com/fowoco/server/notification/domain/NotificationPreferenceKey.java new file mode 100644 index 00000000..8f10ce9b --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/domain/NotificationPreferenceKey.java @@ -0,0 +1,59 @@ +package com.fowoco.server.notification.domain; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Notification categories a user can opt in/out of. Keys and default states mirror + * fowoco/client's ProfilePage notification list, kept in one place so both sides agree on + * what "default on/off" and "required" mean. + */ +public enum NotificationPreferenceKey { + SECURITY_PERMISSION("security-permission", true, true), + APPROVAL_REQUEST("approval-request", true, false), + DOCUMENT_SUBMITTED("document-submitted", true, false), + DOCUMENT_NEEDS_FIX("document-needs-fix", true, false), + DUE_SOON("due-soon", true, false), + ASSIGNED("assigned", false, false), + AGENT_READY("agent-ready", true, false); + + private final String key; + private final boolean defaultEnabled; + private final boolean required; + + NotificationPreferenceKey(String key, boolean defaultEnabled, boolean required) { + this.key = key; + this.defaultEnabled = defaultEnabled; + this.required = required; + } + + public String key() { + return key; + } + + public boolean defaultEnabled() { + return defaultEnabled; + } + + /** Required preferences (security/permission alerts) can never be disabled. */ + public boolean required() { + return required; + } + + public static NotificationPreferenceKey fromKey(String key) { + for (NotificationPreferenceKey value : values()) { + if (value.key.equals(key)) { + return value; + } + } + return null; + } + + public static Map defaults() { + Map defaults = new LinkedHashMap<>(); + for (NotificationPreferenceKey value : values()) { + defaults.put(value, value.defaultEnabled); + } + return defaults; + } +} diff --git a/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JdbcNotificationPreferenceRepository.java b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JdbcNotificationPreferenceRepository.java new file mode 100644 index 00000000..0e1698ee --- /dev/null +++ b/src/main/java/com/fowoco/server/notification/infrastructure/persistence/JdbcNotificationPreferenceRepository.java @@ -0,0 +1,81 @@ +package com.fowoco.server.notification.infrastructure.persistence; + +import com.fowoco.server.notification.application.port.NotificationPreferenceRepository; +import com.fowoco.server.notification.domain.NotificationPreferenceKey; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.EnumMap; +import java.util.Map; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class JdbcNotificationPreferenceRepository implements NotificationPreferenceRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcNotificationPreferenceRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public Map findOverrides(UUID userId, UUID companyId) { + Map overrides = new EnumMap<>(NotificationPreferenceKey.class); + jdbcTemplate.query( + """ + SELECT pref_key, enabled + FROM notification_preference + WHERE user_id = ? AND company_id = ? + """, + resultSet -> { + NotificationPreferenceKey key = + NotificationPreferenceKey.fromKey(resultSet.getString("pref_key")); + if (key != null) { + overrides.put(key, resultSet.getBoolean("enabled")); + } + }, + userId, + companyId + ); + return overrides; + } + + @Override + public void upsert( + UUID preferenceId, + UUID userId, + UUID companyId, + NotificationPreferenceKey key, + boolean enabled, + Instant updatedAt + ) { + int updated = jdbcTemplate.update( + """ + UPDATE notification_preference + SET enabled = ?, updated_at = ? + WHERE user_id = ? AND company_id = ? AND pref_key = ? + """, + enabled, + Timestamp.from(updatedAt), + userId, + companyId, + key.key() + ); + if (updated == 0) { + jdbcTemplate.update( + """ + INSERT INTO notification_preference + (notification_preference_id, user_id, company_id, pref_key, enabled, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + preferenceId, + userId, + companyId, + key.key(), + enabled, + Timestamp.from(updatedAt) + ); + } + } +} diff --git a/src/main/resources/db/migration/V47__add_user_account_phone.sql b/src/main/resources/db/migration/V47__add_user_account_phone.sql new file mode 100644 index 00000000..b68287d6 --- /dev/null +++ b/src/main/resources/db/migration/V47__add_user_account_phone.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_account + ADD COLUMN phone VARCHAR(30); diff --git a/src/main/resources/db/migration/V48__add_user_account_password_changed_at.sql b/src/main/resources/db/migration/V48__add_user_account_password_changed_at.sql new file mode 100644 index 00000000..60ebdaea --- /dev/null +++ b/src/main/resources/db/migration/V48__add_user_account_password_changed_at.sql @@ -0,0 +1,4 @@ +ALTER TABLE user_account + ADD COLUMN password_changed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP; + +UPDATE user_account SET password_changed_at = created_at; diff --git a/src/main/resources/db/migration/V49__create_user_login_event.sql b/src/main/resources/db/migration/V49__create_user_login_event.sql new file mode 100644 index 00000000..2c4b903b --- /dev/null +++ b/src/main/resources/db/migration/V49__create_user_login_event.sql @@ -0,0 +1,16 @@ +CREATE TABLE user_login_event ( + login_event_id UUID NOT NULL, + user_id UUID NOT NULL, + company_id UUID NOT NULL, + device_summary VARCHAR(120) NOT NULL, + logged_in_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + CONSTRAINT pk_user_login_event PRIMARY KEY (login_event_id), + CONSTRAINT fk_user_login_event_user_company + FOREIGN KEY (user_id, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE CASCADE, + CONSTRAINT ck_user_login_event_device_summary_not_blank + CHECK (CHAR_LENGTH(TRIM(device_summary)) > 0) +); + +CREATE INDEX idx_user_login_event_user_time + ON user_login_event (user_id, company_id, logged_in_at DESC); diff --git a/src/main/resources/db/migration/V50__create_notification_preference.sql b/src/main/resources/db/migration/V50__create_notification_preference.sql new file mode 100644 index 00000000..c67e4b72 --- /dev/null +++ b/src/main/resources/db/migration/V50__create_notification_preference.sql @@ -0,0 +1,17 @@ +CREATE TABLE notification_preference ( + notification_preference_id UUID NOT NULL, + user_id UUID NOT NULL, + company_id UUID NOT NULL, + pref_key VARCHAR(60) NOT NULL, + enabled BOOLEAN NOT NULL, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + CONSTRAINT pk_notification_preference PRIMARY KEY (notification_preference_id), + CONSTRAINT uq_notification_preference_user_key UNIQUE (user_id, company_id, pref_key), + CONSTRAINT fk_notification_preference_user_company + FOREIGN KEY (user_id, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE CASCADE, + CONSTRAINT ck_notification_preference_key_not_blank CHECK (CHAR_LENGTH(TRIM(pref_key)) > 0) +); + +CREATE INDEX idx_notification_preference_user + ON notification_preference (user_id, company_id); diff --git a/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java index bea525e9..46eeea57 100644 --- a/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java @@ -85,6 +85,7 @@ void seedCompaniesAndUsers() { @BeforeEach void resetAuthenticationState() { jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_login_event"); jdbcTemplate.update( "UPDATE user_account SET status = 'ACTIVE', updated_at = CURRENT_TIMESTAMP, version = version + 1" ); @@ -132,6 +133,27 @@ void loginTokenCreatesActorContextAndStoresOnlyRefreshTokenHash() throws Excepti assertThat(JsonPath.read(meResponse.body(), "$.roles[0]")).isEqualTo("VIEWER"); } + @Test + void getMyProfileReflectsAccountStateAndRecordsLoginDeviceHistory() throws Exception { + String chromeMacUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; + HttpResponse loginResponse = loginWithUserAgent(HR_A_EMAIL, PASSWORD, chromeMacUserAgent); + String accessToken = accessToken(loginResponse); + + HttpResponse profileResponse = authorizedGet("/api/v1/auth/me/profile", accessToken); + + assertThat(profileResponse.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(profileResponse.body(), "$.role")).isEqualTo("HR"); + assertThat(JsonPath.read(profileResponse.body(), "$.account_status")).isEqualTo("ACTIVE"); + assertThat(JsonPath.read(profileResponse.body(), "$.password_changed_at")).isNotBlank(); + assertThat(JsonPath.read(profileResponse.body(), "$.last_login_at")).isNotBlank(); + assertThat(JsonPath.read(profileResponse.body(), "$.last_login_device")) + .isEqualTo("Chrome · macOS"); + assertThat(JsonPath.read(profileResponse.body(), "$.recent_device_count").intValue()) + .isEqualTo(1); + } + @Test void refreshRotatesTokenAndPersistsTheReplacementChain() throws Exception { HttpResponse loginResponse = login(VIEWER_A_EMAIL, PASSWORD); @@ -423,6 +445,19 @@ private HttpResponse login(String email, String password) throws Excepti return postJson("/api/v1/auth/login", body, null); } + private HttpResponse loginWithUserAgent(String email, String password, String userAgent) + throws Exception { + String body = """ + {"email":"%s","password":"%s"} + """.formatted(email, password); + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header(HttpHeaders.USER_AGENT, userAgent) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + private HttpResponse refresh(String rawRefreshToken) throws Exception { return postWithRefreshTokenCookie("/api/v1/auth/refresh", rawRefreshToken); } 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 0dbc74e3..505f362c 100644 --- a/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java +++ b/src/test/java/com/fowoco/server/auth/api/AuthOpenApiContractTest.java @@ -95,7 +95,9 @@ void signupSchemasUseSnakeCaseAndDoNotAcceptAuthorityOrExposeSecrets() { .contains("company_name", "display_name", "email", "password", "agreements"); assertThat(requestProperties.properties()) .extracting(java.util.Map.Entry::getKey) - .containsExactlyInAnyOrder("company_name", "display_name", "email", "password", "agreements"); + .containsExactlyInAnyOrder( + "company_name", "display_name", "phone", "email", "password", "agreements" + ); 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(); 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 eaa904d5..4300e8a6 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 @@ -139,6 +139,7 @@ void refusesToStartWhenAReservedUserIdBelongsToAnotherEmail() { ADMIN_USER_ID, COMPANY_ID, "ID collision", + null, "different@example.com", passwordEncoder.encode(ADMIN_PASSWORD), UserRole.ADMIN, @@ -165,6 +166,7 @@ void refusesToStartWhenAReservedEmailBelongsToAnotherAccount() { UUID.fromString("aaaaaaaa-0000-0000-0000-000000000001"), TEST_COMPANY_ID, "Email collision", + null, ADMIN_EMAIL, passwordEncoder.encode(ADMIN_PASSWORD), UserRole.HR, diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizerTest.java new file mode 100644 index 00000000..c4a4eed0 --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/infrastructure/web/UserAgentDeviceSummarizerTest.java @@ -0,0 +1,46 @@ +package com.fowoco.server.auth.infrastructure.web; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class UserAgentDeviceSummarizerTest { + + @Test + void recognizesChromeOnMac() { + String ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"; + assertThat(UserAgentDeviceSummarizer.summarize(ua)).isEqualTo("Chrome · macOS"); + } + + @Test + void recognizesSafariOnIphone() { + String ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"; + assertThat(UserAgentDeviceSummarizer.summarize(ua)).isEqualTo("Safari · iOS"); + } + + @Test + void recognizesEdgeOnWindowsAndDoesNotMisreadItAsChrome() { + String ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0"; + assertThat(UserAgentDeviceSummarizer.summarize(ua)).isEqualTo("Edge · Windows"); + } + + @Test + void recognizesFirefoxOnLinux() { + String ua = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"; + assertThat(UserAgentDeviceSummarizer.summarize(ua)).isEqualTo("Firefox · Linux"); + } + + @Test + void fallsBackToUnknownWhenBlank() { + assertThat(UserAgentDeviceSummarizer.summarize(null)).isEqualTo("알 수 없는 기기"); + assertThat(UserAgentDeviceSummarizer.summarize(" ")).isEqualTo("알 수 없는 기기"); + } + + @Test + void fallsBackToUnknownWhenUnrecognized() { + assertThat(UserAgentDeviceSummarizer.summarize("curl/8.4.0")).isEqualTo("알 수 없는 기기"); + } +} diff --git a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java index f45e569d..1001409f 100644 --- a/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/notification/NotificationSecurityIntegrationTest.java @@ -209,6 +209,76 @@ void cursorAndUnreadOnlyFiltersCanBeCombined() throws Exception { assertThat(JsonPath.>read(unreadPage.body(), "$.items")).hasSize(1); } + @Test + void listPreferencesReturnsDefaultsWhenNothingStored() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/notifications/preferences", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$")).hasSize(7); + assertThat(preferenceEnabled(response.body(), "security-permission")).isTrue(); + assertThat(preferenceRequired(response.body(), "security-permission")).isTrue(); + assertThat(preferenceEnabled(response.body(), "assigned")).isFalse(); + } + + @Test + void updatePreferencePersistsAcrossRequests() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse updateResponse = authorizedPatch( + "/api/v1/notifications/preferences/due-soon", "{\"enabled\":false}", accessToken + ); + assertThat(updateResponse.statusCode()).isEqualTo(200); + assertThat(preferenceEnabled(updateResponse.body(), "due-soon")).isFalse(); + + HttpResponse listResponse = authorizedGet("/api/v1/notifications/preferences", accessToken); + assertThat(preferenceEnabled(listResponse.body(), "due-soon")).isFalse(); + } + + @Test + void updatingRequiredPreferenceToDisabledIsRejected() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedPatch( + "/api/v1/notifications/preferences/security-permission", "{\"enabled\":false}", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(422); + } + + @Test + void updatingUnknownPreferenceKeyReturnsNotFound() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedPatch( + "/api/v1/notifications/preferences/not-a-real-key", "{\"enabled\":false}", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(404); + } + + @Test + void preferencesAreIsolatedPerUser() throws Exception { + String hrAToken = accessToken(login(HR_A_EMAIL)); + String hrA2Token = accessToken(login(HR_A2_EMAIL)); + authorizedPatch("/api/v1/notifications/preferences/agent-ready", "{\"enabled\":false}", hrAToken); + + HttpResponse otherUserList = authorizedGet("/api/v1/notifications/preferences", hrA2Token); + + assertThat(preferenceEnabled(otherUserList.body(), "agent-ready")).isTrue(); + } + + private boolean preferenceEnabled(String body, String key) { + java.util.List matches = JsonPath.read(body, "$[?(@.key=='" + key + "')].enabled"); + return matches.get(0); + } + + private boolean preferenceRequired(String body, String key) { + java.util.List matches = JsonPath.read(body, "$[?(@.key=='" + key + "')].required"); + return matches.get(0); + } + private UUID insertNotification(UUID companyId, String targetType, boolean read, Instant occurredAt) { return insertNotification(companyId, HR_A, targetType, read, occurredAt); } @@ -290,6 +360,15 @@ private HttpResponse postJson(String path, String body, String accessTok return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); } + private HttpResponse authorizedPatch(String path, String body, String accessToken) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .method("PATCH", HttpRequest.BodyPublishers.ofString(body)) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + private URI uri(String path) { return URI.create("http://localhost:" + port + path); }