基于shiro与JWT规范实现的单点登录认证服务

在一些环境中,可能需要把Web应用做成无状态的,即服务器端无状态,就是说服务器端不会存储像会话这种东西,而是每次请求时access_token进行资源访问。如一些REST风格的API,如果不使用OAuth2协议,就可以使用如REST+JWT认证进行访问。JWT(JSON WEB Token):基于散列的消息认证码,使用一个密钥和一个消息作为输入,生成它们的消息摘要。该密钥只有服务端知道。访问时使用该消息摘要进行传播,服务端然后对该消息摘要进行验证。

认证步骤

  • 1、客户端第一次使用用户名密码访问认证服务器,服务器验证用户名和密码,认证成功,使用用户密钥生成JWT并返回
  • 2、之后每次请求客户端带上JWT
  • 3、服务器对JWT进行验证

自定义shiro拦截器 继承FormAuthenticationFilter,并改写核心认证逻辑即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/**
* Created by LiangT on 2017/5/24.
*/
public class StatelessAuthcFilter extends FormAuthenticationFilter {
private final Logger logger = LoggerFactory.getLogger(StatelessAuthcFilter.class);
@Autowired
private SysUserMapper sysUserMapper;
/**
* shiro权限拦截核心方法 返回true允许访问resource,这里改写了shiro源码实现,使用JWT进行认证
*
* author liangGTY
* date 2017/5/25
*
* @param
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
//获取token
String accessToken = getaccessToken((HttpServletRequest) request);
if (StringUtils.isBlank(accessToken)) {
return false;
}
//获取userId
Integer userId = getUserIdForToken(accessToken);
SysUser user = sysUserMapper.selectByPrimaryKey(userId);
//获取用户的密钥
String key = JWTUtil.getKey(user);
try {
//ExpiredJwtException JWT已过期
//SignatureException JWT可能被篡改
Jwts.parser().setSigningKey(key).parseClaimsJws(accessToken).getBody();
} catch (Exception e) {
logger.info("authentication fali -- accessToken : {}", accessToken);
try {
onLoginFail(response);
} catch (IOException e1) {
logger.error("io exception");
}
return false;
}
// 将userId放进ThreadLocal中 方便后续业务代码获取
RequestUtil.put(userId);
return true;
}
/**
* 当访问拒绝时是否已经处理了;如果返回true表示需要继续处理;如果返回false表示该拦截器实例已经处理 * 了,将直接返回即可。
*
* author liangGTY
* date 2017/5/25
*
* @param
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
//return super.onAccessDenied(request, response);
//return true;
if (isLoginRequest(request, response)) {
if (isLoginSubmission(request, response)) {
return executeLogin(request, response);
} else {
return true;
}
} else {
//saveRequestAndRedirectToLogin(request, response);
onLoginFail(response);
return false;
}
}
//鉴权失败 返回错误信息
private void onLoginFail(ServletResponse response) throws IOException {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
Map result = new HashMap();
result.put(Constants.RES_ACCESS_KEY,false);
result.put(Constants.RES_MSG_KEY,Constants.RES_MSG_NO_ACCESS_VALUE);
String json = JSON.toJSONString(result);
httpServletResponse.setHeader("Content-type", "application/json;charset=UTF-8");
httpServletResponse.getWriter().write(json);
}
/**
* 解码JWT 从payload中获取userId
* @param accessToken
* @return
*/
private Integer getUserIdForToken(String accessToken) {
Integer userId;
String payload = JWTUtil.parseBase64UrlEncodedPayload(accessToken);
Map claims = JSON.parseObject(payload, Map.class);
Object value = claims.get(Constants.PARAM_USER_ID);
if (value == null) {
return null;
}
if (value instanceof Integer) {
userId = (Integer) value;
} else if (value instanceof String) {
userId = Integer.valueOf((String) value);
} else {
return null;
}
return userId;
}
/**
* 从request从获取token, 先从uri参数中获取 如没有 再从cookie中获取
* @param request
* @return
*/
private String getaccessToken(HttpServletRequest request) {
String accessToken = request.getParameter(Constants.PARAM_DIGEST);
if (!StringUtils.isBlank(accessToken)) {
return accessToken;
}
Cookie[] cookies = request.getCookies();
if(null==cookies||cookies.length==0){
return null;
}
for (Cookie cookie : cookies) {
if (cookie.getName().equals(Constants.PARAM_DIGEST)) {
accessToken = cookie.getValue();
continue;
}
}
return accessToken;
}
}

申请access_token实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* 功能描述:登陆
* <p>
* author liangGTY
* date 2017/4/21
*
* @param
*/
@RequestMapping("/login.do")
@ResponseBody
public Object login(HttpServletRequest request,HttpServletResponse response, String username, String password) {
//返回参数
Map result = new HashMap();
//验证用户名密码,使用shiro进行匹配 如不喜欢 也可以自己验证
if (!authentication(username, password, response, result)) {
result.put(Constants.RES_ACCESS_KEY, Constants.RES_ACCESS_FAIL);
result.put(Constants.PARAM_DIGEST,null);
return result;
}
//获取令牌
String token = getToken(username);
//设置JWT到cookie
setCookie(response,Constants.PARAM_DIGEST,token);
//返回参数
result.put(Constants.RES_MSG_KEY,"success");
result.put(Constants.RES_ACCESS_KEY, Constants.RES_ACCESS_SUCCESS);
result.put(Constants.PARAM_DIGEST, token);
return result;
}
private boolean authentication(String username, String password, HttpServletResponse response, Map result) {
Subject subject = SecurityUtils.getSubject();
AuthenticationToken token = new UsernamePasswordToken(username, password);
try {
subject.login(token);
} catch (LockedAccountException e) {
result.put(Constants.RES_MSG_KEY, "账号已禁用");
//response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
} catch (UnknownAccountException e) {
//response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
result.put(Constants.RES_MSG_KEY, "账号不存在");
return false;
} catch (IncorrectCredentialsException e) {
//response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
result.put(Constants.RES_MSG_KEY, "密码不正确");
return false;
}
return true;
}

JWT工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* JWTUtil
*
* @author liangGTY
* @date 2017/5/25
*/
public class JWTUtil {
public static final String SALT = "liangGTY";
public static final char SEPARATOR_CHAR = '.';
public static String creatCompact(String secretkey, Map claims) {
checkNotNull(secretkey,"secrekey not null");
String compact = Jwts.builder()
.setClaims(claims)
.setExpiration(DateUtils.addDays(new Date(), 40)) //令牌有效期,40天
.signWith(SignatureAlgorithm.HS512, secretkey) //密钥
.compact();
return compact;
}
public static String parseBase64UrlEncodedPayload(String accessToken) {
String base64UrlEncodedHeader = null;
String base64UrlEncodedPayload = null;
int delimiterCount = 0;
StringBuilder sb = new StringBuilder(128);
for (char c : accessToken.toCharArray()) {
if (c == SEPARATOR_CHAR) {
CharSequence tokenSeq = Strings.clean(sb);
String token = tokenSeq != null ? tokenSeq.toString() : null;
if (delimiterCount == 0) {
base64UrlEncodedHeader = token;
} else if (delimiterCount == 1) {
base64UrlEncodedPayload = token;
}
delimiterCount++;
sb.setLength(0);
} else {
sb.append(c);
}
}
//base64解码,获取payload
org.apache.commons.codec.binary.Base64 base64 = new Base64();
byte[] decode = base64.decode(base64UrlEncodedPayload);
return new String(decode);
}
public static String getKey(SysUser user) {
// TODO: 密钥算法优化
return user.getPassword() + SALT + user.getUserName();
}
private String getToken(String username) {
//设置用户ID
claims.put(Constants.PARAM_USER_ID, getUserId());
//获取key
String key = JWTUtil.getKey(user);
String compact = JWTUtil.creatCompact(key, claims);
return compact;
}
}

用于认证的Realm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* SysRealm
*
* @author liangGTY
* @date 2017/4/17
*/
public class SysRealm extends AuthorizingRealm {
@Autowired
private SysUserService sysUserService;
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SysUser user = RequestUtil.getLogin();
Integer id = user.getId();
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
// 获取roles并设置
Set<String> roles = sysUserService.findUserRolesById(id);
authorizationInfo.setRoles(roles);
return authorizationInfo;
}
/**
* 认证回调函数,登录时调用
* 首先根据传入的用户名获取User信息;然后如果user为空,那么抛出没找到帐号异常UnknownAccountException;
* 如果user找到但锁定了抛出锁定异常LockedAccountException;最后生成AuthenticationInfo信息,
* 交给间接父类AuthenticatingRealm使用CredentialsMatcher进行判断密码是否匹配,
* 如果不匹配将抛出密码错误异常IncorrectCredentialsException;
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String username = (String) token.getPrincipal();
SysUserDO user = sysUserService.findUserByUsername(username);
if (user == null) {
throw new UnknownAccountException();
}
if(user.getStatus()==1){
throw new LockedAccountException();
}
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user.getUserName(),
user.getPassword(),
getName()
);
return authenticationInfo;
}
}

配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<!--JWT过滤器-->
<bean id="statelessAuthcFilter" class="com.onway.web.shiro.filter.StatelessAuthcFilter"/>
<!--subject工厂 ,禁用了session-->
<bean id="subjectFactory"
class="com.onway.web.shiro.factory.StatelessDefaultSubjectFactory"/>
<!--会话工厂-->
<bean id="sessionManager" class="org.apache.shiro.session.mgt.DefaultSessionManager">
<property name="sessionValidationSchedulerEnabled" value="false"/>
</bean>
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
<property name="realm" ref="realm"></property>
<property name="subjectDAO.sessionStorageEvaluator.sessionStorageEnabled"
value="false"/>
<property name="subjectFactory" ref="subjectFactory"/>
<property name="sessionManager" ref="sessionManager"/>
</bean>
<bean class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
<property name="staticMethod"
value="org.apache.shiro.SecurityUtils.setSecurityManager"/>
<property name="arguments" ref="securityManager"/>
</bean>
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
<property name="securityManager" ref="securityManager"></property>
<property name="loginUrl" value="/login.htm"></property>
<property name="successUrl" value="/index.htm"></property>
<property name="unauthorizedUrl" value="/error.jsp"></property>
<property name="filters">
<map>
<entry key="statelessAuthc" value-ref="statelessAuthcFilter"/>
</map>
</property>
<property name="filterChainDefinitions">
<value>
/user/login.do = anon
/jquery-easyui-1.4.5/** = anon
/font-awesome-4.7.0/** = anon
/bootstrap-3.3.7-dist/** = anon
/img/** = anon
/** = statelessAuthc
</value>
</property>
</bean>

写在最后

由于JWT特性,不需要在服务端保存JWT,也不需要在服务端生成session,虽然服务端可能需要做解码与编码的一些计算,但相对于在服务端对session的管理来说,这点性能损耗的是很小的,可以说JWT就适合用来做SSO
这里也可以使用spring-security-oauth2与JWT的配合