我们之前可以使用shiro实现登录,但这些都是基于session或是cookie实现的,这些只能用于单机部署的服务,或是分布式服务共享会话,显然后者开销极大,所以JWT(JSON Web Token)应运而生,JWT是一套约定好的认证协议,通过请求携带令牌来访问那些需鉴权的接口。
我们在这里使用token,原理类似,但是规则更为简单,没有形式上的约束,只是在请求Head或是body中添加token用于校验用户身份,token是可以和会话共存的,此处我们使用Shiro的会话登录结合JWT来实现无状态登录,从而实现扫码登录和一般的接口访问授权。
项目中,需要实现无状态登录(单点登录,SSO),但是同时也要保持Shiro本身自带的会话登录。
Shiro本身并不适合做基于鉴权的无状态登录,更适合它的应用场景是单体应用,这点需要注意。
有关token
此文中有关token和SSO的叙述比较粗略,只是大意阐释了流程和部分相关概念。
token一般的校验流程
长token与短token
此处采用OAuth2.0的标准,多数服务都是客户端(浏览器)与服务端后台分离的令牌,前端用于确认用户身份的是短token(通常称access token),而实际保存用户信息(包含用户基本信息、角色、权限等)的是长token(通常称refresh token),只存在后台,以短token映射长token实现用户身份和权限校验。
这里的长与短,不仅指token所存信息量的多少或是token文本的长度,还指token的有效时间,一般短token对应着会话,存活时间较短,临近过期若用户仍未登出会进行刷新。使用长短token的模式其实近似于 sessionid/uuid+内存或是nosql存储用户实体对象 的处理方案。
JWT、token
token相比之下是一个很广泛的定义,一切表示令牌的内容都可以称作是token,jwt算是token中相对很经典、适用范围很广的一套规范,它将用户访问令牌封装在Json格式的数据中,包含在每次请求中。java中相关的实现为auth0的包。
无状态登录与单点登录
前文中所述的无状态登陆与单点登录还是有所不同的,常见安全框架(例如Shiro)的默认行为是有状态登录,他在cookie中存储了sessionId,同时后台中保存了sessionId对应的用户映射,这样对于每一个sessionId后台都记录了对应的登录信息,有状态的登录缺憾是在集群环境下需要进行session共享,而且功能的实际实现一般都是集成了安全框架,对外不可见,也不便于扩展(扩展可以尝试使用Spring Security安全框架,但是相较Shiro上手门槛较高)。
而无状态登录就是本文中所说的token验证方式,相比于有状态登录信息默认存储在服务本地内存中,无状态的用户信息(token与信息映射)存储在服务公有资源中,服务的横向扩展是毫无压力的,同时也解决了服务多节点跳动的sessionId变化问题,但是需要考虑token的安全存储。
总而言之,有状态服务的特点和优缺点为:
用户信息映射实时生成,安全可靠,不必担心登录信息被复用
登录用户信息存储在服务内存中,多节点的服务必须做会话共享
安全框架集成方便,但是不便于功能的扩展
相较之下,无状态服务的特点为:
token与用户信息映射是固定的编码,要通过短token、非对称加密等方式避免用户直接获取到token的内容
token编码方式固定,可不存储登录用户token或是在公有空间(etc:redis)存储短token,不必考虑服务切换会话登录状态失效的问题
需要自己集成,但是定制程度高,业务可见性高,便于扩展
为什么需要SSO
前文中所说的此网站扫码登录需要用到SSO,凭此将用户的登录状态由手机客户端转移至浏览器中,但是这种情况下的登录状态转移是SSO的落地实现,并不能很好的解释为什么要通过SSO实现它。例如,我们也可以将用户名和密码加密后直接传入扫码网站实现登录,但这么做显然是不合时宜的,主要因为:
安全性没有保障,不使用单点登录就要求用户端留存相关密码密钥,这样一来很容易直接泄露不更改便永久有效的密码,而不是一段会过期的token;
有些使用登录中心的应用跟客户端(浏览器)并不互相信任,例如对接第三方平台(QQ、微信等)实现登陆,第三方肯定不能将用户的全部信息传回,他们反而把整个登陆过程承包了下来,返回给我们的只是用户能对外公开的基本信息,或是一段id,每次都要请求第三方再校验;
业务处理
假定的一般实现:不使用框架
假如实现登录功能不使用框架(Shiro),而只在后台使用生成jwt的相关依赖,我们需要一片能被所有服务访问的公共存储维护用户的在线信息,最佳手段是采取诸如nosql的k-v存储,此处假定采用redis实现,那么需要在redis中存储如下数据:
用户标识(id)和token(此处指长token)的映射
token和用户的信息映射
但是因为需要将用户的token存在前端,参考上文中的长短token,我们还需要存储用户到短token的映射,可将上面的用户标识直接更正为短token(或者可以使用自定义的sessionId,注意务必确保全局唯一)。
无状态登录系统的标配
无状态登录系统标配的组件和逻辑有以下几种:
一套token和用户的映射关系;
一套针对用户密码的双向加密或是单向编码算法;
一个用户鉴权中心,可以是独立的服务;
一个用户信息存储介质(一般是noSQL);
可以基于Shiro开发鉴权中心,但是实际上不是很合适。
流程设计
基于以上的思想,设计使用Shiro的单点登录,整体方案的实施需要两个主要的模块,身份认证和安全控制,本篇身份认证采用jwt,安全控制采用Shiro框架。
流程设计如下:
实战
关于基本使用请参考安全框架的使用:shiro——鱼鱼的Java小站,此处主要附Token的生成和基于Redis的存储。
编写加密算法
@Service public class PswEncodeService { //使用了SHA-1 public String pswEncode(String text,String salt){ return encode(text+salt,"SHA1",encodeCount(salt)); } public boolean checkPsw(String text,String cipher,String salt){ return checkcode(text+salt,cipher,"SHA1",encodeCount(salt)); } /** * @param text 待加密明文 * @param key 加密方式 * @param x 递归加密次数 * 采用地柜加密更加安全 */ public String encode(String text,String key,int x){ if (text == null) { return null; } if(x>1){ try { MessageDigest messageDigest = MessageDigest.getInstance(key); messageDigest.update(text.getBytes()); return encode(getFormattedText(messageDigest.digest()),key,x-1); } catch (Exception e) { throw new RuntimeException(e); } }else{ return text; } } public String encode(String text,String key){ return encode(text,key,1); } public boolean checkcode(String text,String cipher,String key,int x){ if(encode(text,key,x).equals(cipher)) return true; else return false; } /** * 按照既定的明文编码规则获取加密迭代次数 为1000——1999次不等 * @param text key明文 * */ public int encodeCount(String text){ int a=1024 //可以设计不同的迭代次数 return a; }; private static final char[] HEX = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; private static String getFormattedText(byte[] bytes) { int len = bytes.length; StringBuilder buf = new StringBuilder(len * 2); // 把密文转换成十六进制的字符串形式 for (int j = 0; j < len; j++) { buf.append(HEX[(bytes[j] >> 4) & 0x0f]); buf.append(HEX[bytes[j] & 0x0f]); } return buf.toString(); } }
Token生成与校验、存储
token的生成方法与校验,首先引入依赖:
<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.4.1</version> </dependency>
然后编写token类:
@Service public class TokenService { // 过期时间60分钟 private static final long EXPIRE_TIME = 60*60*1000; @Autowired private PswService pswService; @Autowired LoginLogMapper loginLogMapper; private Logger logger = LoggerFactory.getLogger(TokenService.class); /** * 校验token是否正确 * @param token 密钥 * @param secret 用户的密码 * @return 是否正确 */ public boolean verify(String token, String username, String secret) { try { Algorithm algorithm = Algorithm.HMAC256(pswEncodeFacade.pswEncode(secret,username)); JWTVerifier verifier = JWT.require(algorithm) .withClaim("username", username) .build(); DecodedJWT jwt = verifier.verify(token); return true; } catch (Exception exception) { return false; } } /** * 获得token中的信息无需secret解密也能获得 * @return token中包含的用户名 */ public String getUsername(String token) { try { DecodedJWT jwt = JWT.decode(token); return jwt.getClaim("username").asString(); } catch (JWTDecodeException e) { return null; } } public String getUserId(String token) { try { DecodedJWT jwt = JWT.decode(token); return jwt.getClaim("id").asString(); } catch (JWTDecodeException e) { return null; } } /** * 生成签名,60min后过期 * @param username 用户名 * @param secret 用户的密码 * @return 超级加密的token */ public String sign(String username, String secret,String id) { Date date = new Date(System.currentTimeMillis()+EXPIRE_TIME); Algorithm algorithm = Algorithm.HMAC256(pswEncodeFacade.pswEncode(secret,username)); // 附带username信息,也可在token中直接标示更多信息 return JWT.create() .withClaim("username", username) .withClaim("id",id) .withExpiresAt(date) .sign(algorithm); } }
基于Redis存储Shiro会话
编写cacheManager:
@Component public class RedisCacheManager4Shiro implements CacheManager { @Resource RedisTemplate<String, Object> redisTemplate; private static final Logger logger = LoggerFactory.getLogger(RedisCacheManager4Shiro.class); //@Resource RedisUtil redisUtil; @Override public <K, V> Cache<K, V> getCache(String arg0) throws CacheException { return new RedisCache<K, V>(); } class RedisCache<K, V> implements Cache<K, V>{ public RedisCache() { redisTemplate.boundHashOps(CACHE_KEY).expire(180, TimeUnit.MINUTES); } //Cache 前缀 private static final String CACHE_KEY = "shiro_redis_subject"; @Override public void clear() throws CacheException { redisTemplate.delete(CACHE_KEY); } private String toString(Object obj){ if(obj instanceof String){ return obj.toString(); }else{ return JSONObject.toJSONString(obj); } } @SuppressWarnings("unchecked") @Override public V get(K k) throws CacheException { logger.info("get field:{}", toString(k)); return (V)redisTemplate.boundHashOps(CACHE_KEY).get(k); } @SuppressWarnings("unchecked") @Override public Set<K> keys() { logger.info("keys"); return (Set<K>)redisTemplate.boundHashOps(CACHE_KEY).keys(); } @Override public V put(K k, V v) throws CacheException { logger.info("put field:{}, value:{}", toString(k), toString(v)); redisTemplate.boundHashOps(CACHE_KEY).put(k, v); return v; } @Override public V remove(K k) throws CacheException { logger.info("remove field:{}", toString(k)); V v = get(k); redisTemplate.boundHashOps(CACHE_KEY).delete(k); return v; } @Override public int size() { int size = redisTemplate.boundHashOps(CACHE_KEY).size().intValue(); logger.info("size:{}", size); return size; } @SuppressWarnings("unchecked") @Override public Collection<V> values() { logger.info("values"); return (Collection<V>)redisTemplate.boundHashOps(CACHE_KEY).values(); } public String getCacheKey() { return "RedisCache"; } } }
然后在我们配置时指定:
//配置核心安全事务管理器 @Bean(name = "securityManager") public SecurityManager securityManager(//@Qualifier("sessionManager")WebSessionManager webSessionManager, @Qualifier("authRealm") AuthRealm authRealm, @Qualifier("redisCacheManager4Shiro") RedisCacheManager4Shiro redisCacheManager4Shiro) { DefaultWebSecurityManager manager = new DefaultWebSecurityManager(); manager.setCacheManager(redisCacheManager4Shiro); manager.setSessionManager(redisSessionManager); manager.setRealm(authRealm); return manager; }