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
11 changes: 9 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 @@ -33,6 +34,7 @@
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 @@ -216,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
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);
}
}
40 changes: 36 additions & 4 deletions src/main/java/com/fowoco/server/auth/api/ProfileResponse.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,51 @@
package com.fowoco.server.auth.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.auth.domain.UserAccount;
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
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(UserAccount userAccount) {
return new ProfileResponse(userAccount.displayName(), userAccount.phone());
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()
);
}
}
38 changes: 34 additions & 4 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 @@ -156,18 +170,34 @@ public RefreshResult refresh(String rawRefreshToken) {
}

@Transactional(readOnly = true)
public UserAccount currentProfile(UUID userId, UUID companyId) {
return userAccountRepository.findByUserIdAndCompanyId(userId, companyId)
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 UserAccount updateProfile(UUID userId, UUID companyId, String displayName, String phone) {
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 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) {
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;
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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<LoginEventRecord> findRecent(UUID userId, UUID companyId, int limit);

record LoginEventRecord(String deviceSummary, Instant loggedInAt) {
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/fowoco/server/auth/domain/UserAccount.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ 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(
Expand All @@ -37,6 +38,7 @@ public UserAccount(
AccountStatus status,
Instant createdAt,
Instant updatedAt,
Instant passwordChangedAt,
long version
) {
this.userId = Objects.requireNonNull(userId, "userId must not be null");
Expand All @@ -57,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");
}
Expand Down Expand Up @@ -86,6 +92,7 @@ public static UserAccount create(
AccountStatus.ACTIVE,
now,
now,
now,
0L
);
}
Expand Down Expand Up @@ -119,6 +126,7 @@ public UserAccount changePassword(String newPasswordHash, Instant now) {
status,
createdAt,
now,
now,
version + 1
);
}
Expand All @@ -140,6 +148,7 @@ public UserAccount updateProfile(String newDisplayName, String newPhone, Instant
status,
createdAt,
now,
passwordChangedAt,
version + 1
);
}
Expand Down Expand Up @@ -188,6 +197,10 @@ public Instant updatedAt() {
return updatedAt;
}

public Instant passwordChangedAt() {
return passwordChangedAt;
}

public long version() {
return version;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<LoginEventRecord> 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()
);
}
}
Loading
Loading