mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
fix(auth): read device-code state via ObjectMapper conversion, not cast
The shared RedisTemplate uses GenericJackson2JsonRedisSerializer with the application ObjectMapper, which embeds no type information, so stored DeviceCodeData deserializes as a LinkedHashMap. The typed casts in pollToken and authorizeDeviceCode then throw ClassCastException on every call, making the whole device authorization flow unusable (every poll returns 500). Convert the raw value with ObjectMapper.convertValue instead of casting; this reads both the current untyped map format and any typed format, so no stored-data migration is needed. Adds bean setters to DeviceCodeData for map conversion and regression tests that feed the service exactly what Redis returns in production (untyped maps). Fixes #604 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Gal Eyal <gal.e@popai.health>
This commit is contained in:
parent
ac46ad5391
commit
8435ee1ab1
3 changed files with 125 additions and 2 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue