商城增加了实时性较强的竞价模块,需要通过 websocket 或类似技术来实现双向通信。后端是 spring boot 开发的,因为 react native 对 stomp 的支持存在些问题,就选择用 golang ( 顺便温习一下久违的golang ) 来写一个基于 websocket 的竞价服务。
由于登录端点在 spring boot 侧,jwt ( 基于 jsonwebtoken) 也是该侧生成。所以 golang 使用 dgrijalva/jwt-go 实现 jwt 的解析。 spring boot 侧生成和解析 jwt 代码:
public class JWTAuthentication { private static final String SECRET = "secret key"; public static final String PREFIX = "Bearer "; public static final String HEADER = "Authorization"; public static String generateToken(String name, Collection<? extends GrantedAuthority> authorities) { return PREFIX + Jwts.builder() .claim("authorities", authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(","))) .setSubject(name) .signWith(SignatureAlgorithm.HS512, SECRET) .compact(); } public static Authentication parseToken(String token) { if (token == null) { return null; } Claims claims = Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token.replace(PREFIX, "")) .getBody(); String name = claims.getSubject(); return name != null ? new UsernamePasswordAuthenticationToken(name, null, AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"))) : null; } } 复制代码
因为 go 侧未使用 authorities,所以请无视。 go 侧解析 jwt 的代码:
type Claims struct { Authorities string `json:"authorities"` jwt.StandardClaims } func parseJwt(tokenString string) (Claims, error) { if tokenString == "" { return nil, fmt.Errorf("missing token string") } token, err := jwt.ParseWithClaims(strings.TrimPrefix(tokenStr, "Bearer "), &Claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte("secret key"), nil }) if err != nil { return nil, err } claims, ok := token.Claims.(*Claims) if !ok || !token.Valid { return nil, fmt.Errorf("invalid token") } return claims, nil } 复制代码
习惯性的用了跟 spring boot 侧一致的SECRET。但是一跑,就报 "signature is invalid"。在 jwt-go 的 issue 272 中找到一些线索:用 base64.URLEncoding.DecodeString(key) 代替 []byte(key)。上面的代码改写后:
func parseJwt(tokenString string) (Claims, error) { if tokenString == "" { return nil, fmt.Errorf("missing token string") } token, err := jwt.ParseWithClaims(strings.TrimPrefix(tokenStr, "Bearer "), &Claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } // return []byte("secret key"), nil return base64.URLEncoding.DecodeString("secret key") }) if err != nil { return nil, err } claims, ok := token.Claims.(*Claims) if !ok || !token.Valid { return nil, fmt.Errorf("invalid token") } return claims, nil } 复制代码
跑起来后就报 "illegal base64 data at input byte 2"。显然不接受“空格”字符。 但是受该 issue 启发,去查看 java 源码,signWith 的实现:
public JwtBuilder signWith(SignatureAlgorithm alg, String base64EncodedSecretKey) { Assert.hasText(base64EncodedSecretKey, "base64-encoded secret key cannot be null or empty."); Assert.isTrue(alg.isHmac(), "Base64-encoded key bytes may only be specified for HMAC signatures. If using RSA or Elliptic Curve, use the signWith(SignatureAlgorithm, Key) method instead."); byte[] bytes = TextCodec.BASE64.decode(base64EncodedSecretKey); return signWith(alg, bytes); } 复制代码
TextCodec.BASE64.decode 的实现:
public static byte[] parseBase64Binary( String lexicalXSDBase64Binary ) { if (theConverter == null) initConverter(); return theConverter.parseBase64Binary( lexicalXSDBase64Binary ); } 复制代码
theConverter.parseBase64Binary 的实现:
public byte[] parseBase64Binary(String lexicalXSDBase64Binary) { return _parseBase64Binary(lexicalXSDBase64Binary); } 复制代码
_parseBase64Binary 实现:
public static byte[] _parseBase64Binary(String text) { final int buflen = guessLength(text); final byte[] out = new byte[buflen]; int o = 0; final int len = text.length(); int i; final byte[] quadruplet = new byte[4]; int q = 0; // convert each quadruplet to three bytes. for (i = 0; i < len; i++) char ch = text.charAt(i); byte v = decodeMap[ch]; if (v != -1) { quadruplet[q++] = v } if (q == 4) { // quadruplet is now filled. out[o++] = (byte) ((quadruplet[0] << 2) | (quadruplet[1] >> 4)); if (quadruplet[2] != PADDING) { out[o++] = (byte) ((quadruplet[1] << 4) | (quadruplet[2] >> 2)); } if (quadruplet[3] != PADDING) { out[o++] = (byte) ((quadruplet[2] << 6) | (quadruplet[3])); } q = 0; } } if (buflen == o) // speculation worked out to be OK { return out; } // we overestimated, so need to create a new buffer byte[] nb = new byte[o]; System.arraycopy(out, 0, nb, 0, o); return nb; } 复制代码
由decodeMap 的定义:
private static final byte[] decodeMap = initDecodeMap(); private static final byte PADDING = 127; private static byte[] initDecodeMap() { byte[] map = new byte[128]; int i; for (i = 0; i < 128; i++) { map[i] = -1; } for (i = 'A'; i <= 'Z'; i++) { map[i] = (byte) (i - 'A'); } for (i = 'a'; i <= 'z'; i++) { map[i] = (byte) (i - 'a' + 26); } for (i = '0'; i <= '9'; i++) { map[i] = (byte) (i - '0' + 52); } map['+'] = 62; map['/'] = 63; map['='] = PADDING; return map; } 复制代码
结合起来看,是把 "A-Z", "a-z", "0-9", "+/=" 之外的其它字符都过滤掉了。问题也就迎刃而解了,把 SECRET 中的空格去掉:
func parseJwt(tokenString string) (Claims, error) { if tokenString == "" { return nil, fmt.Errorf("missing token string") } token, err := jwt.ParseWithClaims(strings.TrimPrefix(tokenStr, "Bearer "), &Claims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } // return []byte("secret key"), nil return base64.StdEncoding.DecodeString("secretkey") }) if err != nil { return nil, err } claims, ok := token.Claims.(*Claims) if !ok || !token.Valid { return nil, fmt.Errorf("invalid token") } return claims, nil } 复制代码
如果 jwt 不跨语言和库的话,是不存在这个坑的。但是从安全角度看,spring boot 侧的方案摒弃了一些特殊字符如"!", "@"等,降低了安全性和暴力破解的成本。
spring boot 侧登录成功后,会在 redis 中保存 用户信息:
@Component public class TokenCache { @Resource private RedisTemplate<String, Object> redisTemplate; public void setUserInfo(String token, String name) { redisTemplate.opsForValue().set(token, name); } public String getUserInfo(String token) { if (token == null) { return null; } return (String) redisTemplate.opsForValue().get(token); } } 复制代码
go 基于 gomodule/redigo 访问 redis,主要是 token 换取用户信息:
func getUserInfo(token string) (string, error) { if token == nil { return nil, fmt.Errorf("missing token") } conn := pool.Get() // pool 是 redis 池。为简化代码,此处不表 defer func() { conn.Close() }() return redis.String(conn.Do("GET", token)) } 复制代码
redigo 侧一直无法读取到 token 对应的值。通过 redis 图形客户端,显示 key 前面有几个乱码符号。通过 redis-cli 的 KEYS 命令则显示 key 之前存在 "/xac/xed/x00/x05t" 等值。这是因为 spring boot 侧的 RedisTemplate 默认使用 JdkSerializationRedisSerializer 序列化 Key 和 Value。为了修正该问题,需要自定义 RedisTemplate 的序列化类:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) throws UnknownHostException { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); template.setKeySerializer(stringRedisSerializer); template.setValueSerializer(stringRedisSerializer); template.setHashKeySerializer(stringRedisSerializer); template.setHashValueSerializer(stringRedisSerializer); template.afterPropertiesSet(); return template; } } 复制代码
把 key,value,HashKey 和 HashValue 的序列化类都指定为 StringRedisSerializer。在使用该 RedisTemplate 处用 @Autowired 注解代替 @Resource 即可。 Java是一门开放性的语言,但 RedisTemplate 的默认序列化行为有失妥当,忽略了其它语言工具的适用性和便利性,差评。