feat: 会话记录接入客户端 X-Device-Id(auth_sessions.device_id)
- Flutter 端每次请求已携带 X-Device-Id;auth 读取该头(截断 128)经 CreateSessionRequest 透传,user 落 auth_sessions.device_id,为多设备会话列表备数据 - 门禁:./mvnw clean test → BUILD SUCCESS,74 测试 0 失败(新增 registerForwardsDeviceIdHeaderToTheSessionRecord + 会话落库断言) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,14 +53,19 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private AuthService.ClientInfo clientInfo(HttpServletRequest request) {
|
private AuthService.ClientInfo clientInfo(HttpServletRequest request) {
|
||||||
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
|
String deviceId = truncate(request.getHeader("X-Device-Id"), 128);
|
||||||
if (userAgent != null && userAgent.length() > 512) {
|
String userAgent = truncate(request.getHeader(HttpHeaders.USER_AGENT), 512);
|
||||||
userAgent = userAgent.substring(0, 512);
|
|
||||||
}
|
|
||||||
String forwarded = request.getHeader("X-Forwarded-For");
|
String forwarded = request.getHeader("X-Forwarded-For");
|
||||||
String ip = forwarded != null && !forwarded.isBlank()
|
String ip = forwarded != null && !forwarded.isBlank()
|
||||||
? forwarded.split(",")[0].trim()
|
? forwarded.split(",")[0].trim()
|
||||||
: request.getRemoteAddr();
|
: request.getRemoteAddr();
|
||||||
return new AuthService.ClientInfo(userAgent, ip);
|
return new AuthService.ClientInfo(deviceId, userAgent, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String value, int maxLength) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.length() > maxLength ? value.substring(0, maxLength) : value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import java.util.UUID;
|
|||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
/** Device metadata forwarded to the session record (observability only). */
|
/** Device metadata forwarded to the session record (observability only). */
|
||||||
public record ClientInfo(String userAgent, String ipAddress) {
|
public record ClientInfo(String deviceId, String userAgent, String ipAddress) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private final UserClient userClient;
|
private final UserClient userClient;
|
||||||
@@ -88,7 +88,8 @@ public class AuthService {
|
|||||||
|
|
||||||
private AuthTokenResponse openSession(UUID userId, ClientInfo clientInfo) {
|
private AuthTokenResponse openSession(UUID userId, ClientInfo clientInfo) {
|
||||||
SessionTokens tokens = requireData(sessionClient.create(new CreateSessionRequest(
|
SessionTokens tokens = requireData(sessionClient.create(new CreateSessionRequest(
|
||||||
userId, clientInfo.userAgent(), clientInfo.ipAddress())), "创建会话失败");
|
userId, clientInfo.deviceId(), clientInfo.userAgent(), clientInfo.ipAddress())),
|
||||||
|
"创建会话失败");
|
||||||
return assemble(tokens);
|
return assemble(tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
@@ -8,6 +8,7 @@ import com.patbond.patbond.auth.support.TestJwtKeys;
|
|||||||
import com.patbond.patbond.common.error.BusinessException;
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
import com.patbond.patbond.common.error.ErrorCode;
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
import com.patbond.patbond.common.response.ApiResponse;
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.common.session.CreateSessionRequest;
|
||||||
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
||||||
import com.patbond.patbond.common.session.SessionTokens;
|
import com.patbond.patbond.common.session.SessionTokens;
|
||||||
import com.patbond.patbond.common.user.UserProfile;
|
import com.patbond.patbond.common.user.UserProfile;
|
||||||
@@ -112,6 +113,25 @@ class AuthControllerTest {
|
|||||||
.andExpect(jsonPath("$.data.expiresAt").doesNotExist());
|
.andExpect(jsonPath("$.data.expiresAt").doesNotExist());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerForwardsDeviceIdHeaderToTheSessionRecord() throws Exception {
|
||||||
|
when(userClient.createUser(any())).thenReturn(ApiResponse.success(
|
||||||
|
new UserProfile(USER_ID, "alice", null, "+8613800138000", OffsetDateTime.now())));
|
||||||
|
when(sessionClient.create(any())).thenReturn(ApiResponse.success(sessionTokens()));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/register")
|
||||||
|
.header("X-Device-Id", "pixel-8-of-alice")
|
||||||
|
.contentType(APPLICATION_JSON)
|
||||||
|
.content(REGISTER_BODY))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
ArgumentCaptor<CreateSessionRequest> captor =
|
||||||
|
ArgumentCaptor.forClass(CreateSessionRequest.class);
|
||||||
|
verify(sessionClient).create(captor.capture());
|
||||||
|
assertThat(captor.getValue().getDeviceId()).isEqualTo("pixel-8-of-alice");
|
||||||
|
assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void registerPropagatesDuplicateUsernameAsConflict() throws Exception {
|
void registerPropagatesDuplicateUsernameAsConflict() throws Exception {
|
||||||
when(userClient.createUser(any()))
|
when(userClient.createUser(any()))
|
||||||
|
|||||||
+13
-1
@@ -15,6 +15,9 @@ public class CreateSessionRequest {
|
|||||||
@NotNull(message = "userId 不能为空")
|
@NotNull(message = "userId 不能为空")
|
||||||
private UUID userId;
|
private UUID userId;
|
||||||
|
|
||||||
|
@Size(max = 128, message = "deviceId 长度不能超过128位")
|
||||||
|
private String deviceId;
|
||||||
|
|
||||||
@Size(max = 512, message = "userAgent 长度不能超过512位")
|
@Size(max = 512, message = "userAgent 长度不能超过512位")
|
||||||
private String userAgent;
|
private String userAgent;
|
||||||
|
|
||||||
@@ -24,8 +27,9 @@ public class CreateSessionRequest {
|
|||||||
public CreateSessionRequest() {
|
public CreateSessionRequest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateSessionRequest(UUID userId, String userAgent, String ipAddress) {
|
public CreateSessionRequest(UUID userId, String deviceId, String userAgent, String ipAddress) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
|
this.deviceId = deviceId;
|
||||||
this.userAgent = userAgent;
|
this.userAgent = userAgent;
|
||||||
this.ipAddress = ipAddress;
|
this.ipAddress = ipAddress;
|
||||||
}
|
}
|
||||||
@@ -38,6 +42,14 @@ public class CreateSessionRequest {
|
|||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDeviceId() {
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceId(String deviceId) {
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getUserAgent() {
|
public String getUserAgent() {
|
||||||
return userAgent;
|
return userAgent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ public class SessionRepository {
|
|||||||
|
|
||||||
public void insert(UUID id, UUID userId, UUID tokenFamilyId, byte[] refreshTokenHash,
|
public void insert(UUID id, UUID userId, UUID tokenFamilyId, byte[] refreshTokenHash,
|
||||||
String accessTokenJti, OffsetDateTime expiresAt,
|
String accessTokenJti, OffsetDateTime expiresAt,
|
||||||
String userAgent, String ipAddress) {
|
String deviceId, String userAgent, String ipAddress) {
|
||||||
jdbcClient.sql("""
|
jdbcClient.sql("""
|
||||||
INSERT INTO identity.auth_sessions
|
INSERT INTO identity.auth_sessions
|
||||||
(id, user_id, token_family_id, refresh_token_hash, access_token_jti,
|
(id, user_id, token_family_id, refresh_token_hash, access_token_jti,
|
||||||
expires_at, user_agent, ip_address)
|
expires_at, device_id, user_agent, ip_address)
|
||||||
VALUES (:id, :userId, :familyId, :hash, :jti, :expiresAt,
|
VALUES (:id, :userId, :familyId, :hash, :jti, :expiresAt,
|
||||||
:userAgent, CAST(:ipAddress AS inet))
|
:deviceId, :userAgent, CAST(:ipAddress AS inet))
|
||||||
""")
|
""")
|
||||||
.param("id", id)
|
.param("id", id)
|
||||||
.param("userId", userId)
|
.param("userId", userId)
|
||||||
@@ -41,6 +41,7 @@ public class SessionRepository {
|
|||||||
.param("hash", refreshTokenHash)
|
.param("hash", refreshTokenHash)
|
||||||
.param("jti", accessTokenJti)
|
.param("jti", accessTokenJti)
|
||||||
.param("expiresAt", expiresAt)
|
.param("expiresAt", expiresAt)
|
||||||
|
.param("deviceId", deviceId)
|
||||||
.param("userAgent", userAgent)
|
.param("userAgent", userAgent)
|
||||||
.param("ipAddress", ipAddress)
|
.param("ipAddress", ipAddress)
|
||||||
.update();
|
.update();
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public class SessionService {
|
|||||||
/** Opens a new session (= new token family) for a freshly authenticated user. */
|
/** Opens a new session (= new token family) for a freshly authenticated user. */
|
||||||
public SessionTokens create(CreateSessionRequest request) {
|
public SessionTokens create(CreateSessionRequest request) {
|
||||||
return insertSession(request.getUserId(), UuidV7.generate(),
|
return insertSession(request.getUserId(), UuidV7.generate(),
|
||||||
request.getUserAgent(), request.getIpAddress());
|
request.getDeviceId(), request.getUserAgent(), request.getIpAddress());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,7 +75,8 @@ public class SessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SessionTokens rotated = transactionTemplate.execute(status -> {
|
SessionTokens rotated = transactionTemplate.execute(status -> {
|
||||||
SessionTokens tokens = insertSession(session.userId(), session.tokenFamilyId(), null, null);
|
SessionTokens tokens = insertSession(session.userId(), session.tokenFamilyId(),
|
||||||
|
null, null, null);
|
||||||
if (sessionRepository.markRotated(session.id(), tokens.getSessionId()) != 1) {
|
if (sessionRepository.markRotated(session.id(), tokens.getSessionId()) != 1) {
|
||||||
status.setRollbackOnly();
|
status.setRollbackOnly();
|
||||||
return null;
|
return null;
|
||||||
@@ -98,7 +99,8 @@ public class SessionService {
|
|||||||
sessionRepository.revokeByTokenHashAndUser(sha256(refreshToken), userId, "logout");
|
sessionRepository.revokeByTokenHashAndUser(sha256(refreshToken), userId, "logout");
|
||||||
}
|
}
|
||||||
|
|
||||||
private SessionTokens insertSession(UUID userId, UUID familyId, String userAgent, String ipAddress) {
|
private SessionTokens insertSession(UUID userId, UUID familyId,
|
||||||
|
String deviceId, String userAgent, String ipAddress) {
|
||||||
UUID sessionId = UuidV7.generate();
|
UUID sessionId = UuidV7.generate();
|
||||||
String jti = UuidV7.generate().toString();
|
String jti = UuidV7.generate().toString();
|
||||||
byte[] tokenBytes = new byte[32];
|
byte[] tokenBytes = new byte[32];
|
||||||
@@ -108,7 +110,7 @@ public class SessionService {
|
|||||||
.plus(properties.getSession().getRefreshTtl());
|
.plus(properties.getSession().getRefreshTtl());
|
||||||
|
|
||||||
sessionRepository.insert(sessionId, userId, familyId, sha256(refreshToken), jti,
|
sessionRepository.insert(sessionId, userId, familyId, sha256(refreshToken), jti,
|
||||||
expiresAt, userAgent, ipAddress);
|
expiresAt, deviceId, userAgent, ipAddress);
|
||||||
return new SessionTokens(sessionId, userId, jti, refreshToken, expiresAt);
|
return new SessionTokens(sessionId, userId, jti, refreshToken, expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -57,7 +57,8 @@ class SessionLifecycleIntegrationTest {
|
|||||||
|
|
||||||
private Map<String, Object> createSession(String userId) throws Exception {
|
private Map<String, Object> createSession(String userId) throws Exception {
|
||||||
String body = mockMvc.perform(internalPost("/internal/sessions")
|
String body = mockMvc.perform(internalPost("/internal/sessions")
|
||||||
.content("{\"userId\":\"%s\",\"userAgent\":\"junit\",\"ipAddress\":\"127.0.0.1\"}"
|
.content(("{\"userId\":\"%s\",\"deviceId\":\"junit-device\","
|
||||||
|
+ "\"userAgent\":\"junit\",\"ipAddress\":\"127.0.0.1\"}")
|
||||||
.formatted(userId)))
|
.formatted(userId)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(0))
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
@@ -91,6 +92,13 @@ class SessionLifecycleIntegrationTest {
|
|||||||
assertThat(storedHash).isEqualTo(expected).hasSize(32);
|
assertThat(storedHash).isEqualTo(expected).hasSize(32);
|
||||||
// The plaintext token appears nowhere in the row.
|
// The plaintext token appears nowhere in the row.
|
||||||
assertThat(new String(storedHash, StandardCharsets.ISO_8859_1)).isNotEqualTo(refreshToken);
|
assertThat(new String(storedHash, StandardCharsets.ISO_8859_1)).isNotEqualTo(refreshToken);
|
||||||
|
|
||||||
|
String deviceId = jdbcClient.sql(
|
||||||
|
"SELECT device_id FROM identity.auth_sessions WHERE id = :id")
|
||||||
|
.param("id", UUID.fromString((String) session.get("sessionId")))
|
||||||
|
.query(String.class)
|
||||||
|
.single();
|
||||||
|
assertThat(deviceId).isEqualTo("junit-device");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user