Merge pull request #607 from gale-popai/fix/device-auth-redis-typing

fix(auth): read device-code state via ObjectMapper conversion, not cast
This commit is contained in:
XiaoSeS 2026-07-29 03:37:25 +08:00 committed by GitHub
commit 19c3070291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 125 additions and 2 deletions

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.auth.device;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.springframework.beans.factory.annotation.Value;
@ -33,14 +34,17 @@ public class DeviceAuthService {
private final RedisTemplate<String, Object> redisTemplate;
private final ApiTokenService apiTokenService;
private final ObjectMapper objectMapper;
private final String verificationUri;
private final SecureRandom random = new SecureRandom();
public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
ApiTokenService apiTokenService,
ObjectMapper objectMapper,
@Value("${skillhub.device-auth.verification-uri:/cli/auth}") String verificationUri) {
this.redisTemplate = redisTemplate;
this.apiTokenService = apiTokenService;
this.objectMapper = objectMapper;
this.verificationUri = verificationUri;
}
@ -71,7 +75,7 @@ public class DeviceAuthService {
throw new DomainBadRequestException("error.deviceAuth.userCode.invalid");
}
DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
DeviceCodeData data = readDeviceCodeData(deviceCode);
if (data == null) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired");
}
@ -97,7 +101,7 @@ public class DeviceAuthService {
* into an API token exactly once.
*/
public DeviceTokenResponse pollToken(String deviceCode) {
DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
DeviceCodeData data = readDeviceCodeData(deviceCode);
if (data == null) {
throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid");
@ -147,6 +151,17 @@ public class DeviceAuthService {
}
}
/**
* Reads device-code state from Redis. The shared template's JSON value
* serializer carries no type information, so values deserialize as plain
* maps; convert explicitly instead of casting (a direct cast throws
* {@code ClassCastException} on every read).
*/
private DeviceCodeData readDeviceCodeData(String deviceCode) {
Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode);
return raw == null ? null : objectMapper.convertValue(raw, DeviceCodeData.class);
}
private String generateRandomDeviceCode() {
byte[] bytes = new byte[32];
random.nextBytes(bytes);

View file

@ -19,7 +19,9 @@ public class DeviceCodeData implements Serializable {
}
public String getDeviceCode() { return deviceCode; }
public void setDeviceCode(String deviceCode) { this.deviceCode = deviceCode; }
public String getUserCode() { return userCode; }
public void setUserCode(String userCode) { this.userCode = userCode; }
public DeviceCodeStatus getStatus() { return status; }
public void setStatus(DeviceCodeStatus status) { this.status = status; }
public String getUserId() { return userId; }

View file

@ -0,0 +1,106 @@
package com.iflytek.skillhub.auth.device;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DeviceAuthServiceTest {
private static final String DEVICE_CODE = "device-code-1";
private static final String USER_CODE = "ABCD-2345";
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ValueOperations<String, Object> valueOperations;
@Mock
private ApiTokenService apiTokenService;
private DeviceAuthService service;
@BeforeEach
void setUp() {
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
service = new DeviceAuthService(redisTemplate, apiTokenService, new ObjectMapper(), "/cli/auth");
}
/**
* The shared RedisTemplate's JSON serializer keeps no type information, so
* stored DeviceCodeData comes back as a plain map. A typed cast used to
* throw ClassCastException on every poll; the service must convert instead.
*/
private static Map<String, Object> storedDeviceCode(DeviceCodeStatus status, String userId) {
Map<String, Object> raw = new LinkedHashMap<>();
raw.put("deviceCode", DEVICE_CODE);
raw.put("userCode", USER_CODE);
raw.put("status", status.name());
raw.put("userId", userId);
return raw;
}
@Test
void pollTokenReturnsPendingWhenRedisValueIsUntypedMap() {
when(valueOperations.get("device:code:" + DEVICE_CODE))
.thenReturn(storedDeviceCode(DeviceCodeStatus.PENDING, null));
DeviceTokenResponse response = service.pollToken(DEVICE_CODE);
assertThat(response.error()).isEqualTo("authorization_pending");
}
@Test
void pollTokenRedeemsAuthorizedCodeFromUntypedMap() {
when(valueOperations.get("device:code:" + DEVICE_CODE))
.thenReturn(storedDeviceCode(DeviceCodeStatus.AUTHORIZED, "usr_1"));
when(valueOperations.setIfAbsent(eq("device:claim:" + DEVICE_CODE), any(), anyLong(), any()))
.thenReturn(Boolean.TRUE);
when(apiTokenService.rotateToken(eq("usr_1"), any(), any()))
.thenReturn(new ApiTokenService.TokenCreateResult("sk_test_token", null));
DeviceTokenResponse response = service.pollToken(DEVICE_CODE);
assertThat(response.accessToken()).isEqualTo("sk_test_token");
}
@Test
void pollTokenRejectsUnknownDeviceCode() {
when(valueOperations.get("device:code:" + DEVICE_CODE)).thenReturn(null);
assertThatThrownBy(() -> service.pollToken(DEVICE_CODE))
.isInstanceOf(DomainBadRequestException.class);
}
@Test
void authorizeDeviceCodeMarksPendingCodeFromUntypedMap() {
when(valueOperations.get("device:usercode:" + USER_CODE)).thenReturn(DEVICE_CODE);
when(valueOperations.get("device:code:" + DEVICE_CODE))
.thenReturn(storedDeviceCode(DeviceCodeStatus.PENDING, null));
service.authorizeDeviceCode(USER_CODE, "usr_1");
verify(valueOperations).set(startsWith("device:code:"), any(DeviceCodeData.class), anyLong(), any());
}
}