使用Shiro和token进行无状态登录使用Shiro和token进行无状态登录
我们之前可以使用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
buf.append(HEX[(bytes[j] >> 4) & 0x0f]);
buf.append(HEX[bytes[j] & 0x0f]);
}
return buf.toString();
}
}
Token生成与校验、存储
token的生成方法与校验,首先引入依赖:
com.auth0
java-jwt
3.4.1
然后编写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 redisTemplate;
private static final Logger logger = LoggerFactory.getLogger(RedisCacheManager4Shiro.class);
//@Resource RedisUtil redisUtil;
@Override
public Cache getCache(String arg0) throws CacheException {
return new RedisCache();
}
class RedisCache implements Cache{
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 keys() {
logger.info("keys");
return (Set)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 values() {
logger.info("values");
return (Collection)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;
}
2020-03-22鱼鱼