springSecurity配置jwt
在分布式项目下,同一个项目后端可能部署多次,通过负载均衡分配到每个实例上,传统的Session是每个实例独有的,在一个实例上登陆后,其他实例并不知道登录状态。要想解决此问题,有以下几种办法,下面进行分析。
前端部分
首先前端能存储数据的方式有两种,一种是前端通过localstorage
主动存储数据,在发送请求时主动携带。另一种是后端将数据放入Cookie
中,前端发起请求时浏览器自动携带。
对于后端来说,这两种方式并没有本质上的区别。
后端部分
后端能存储数据的方式有以下几种
-
存储在共享介质中,如
mysql
,redis
,分布式缓存,但本质上是一样的。项目仍然是有状态的,但是状态共享。 - 存储在项目本身的缓存或
session
里。因为存储在自身的项目下,其他项目无法获取到数据。 - 以
Cookie
的形式或者接口返回值的形式传给前端,前端请求时每次都带回来,这样其他项目就能拿到共享数据。
方案1 本质上的有状态的,只是状态是共享的。需要使用用户唯一标识来获取用户存储的数据,此标识可以存储在Cookie
里也可以存储在localStorage
由前台主动发送,这是对原始cookie session
功能的改进,只是将session
由单机变成分布式而已。
方案2 无法解决负载均衡下的多实例问题。
方案3 将信息下发送到前端,每个实例处理请求是拿到前端的信息,就能得到数据,不需要第三方存储,但是需要一种方案验证信息的有效性,且不宜过大。
jwt以及变种
jwt
就属于方案三,在登陆时将用户信息存储到前台,下次访问时在重新解析出信息就能判断用户是谁,权限是什么,是否登录。
标准的jwt
规范是使用json
存储数据。除了使用json
外,也可以使用其他变种格式,比如用逗号分隔,都是可以的。
SpringSecurity
里自带了RememberMeService
,这个原始用法是为了实现记住我功能,用户session
关闭后下次访问能自动登录。如果用户压根没有session
,那么每次登录都是自动登录,以此来验证用户身份也是可以的。
标准的TokenBasedRememberMeServices
会将『用户名,过期时间,密码,盐』加一起进行md5,自动登录时进行同样的运算,如果获取的签名一致则允许自动登录,但是查询密码时仍然需要查询数据库,我们可以简化这一步,使他恒返回一个唯一的密码,因为有盐的存在,签名仍然是安全的,只不过用户改密码后jwt
不会失效,不过jwt
就是如此。
我们将生成的值放入cookie里,这样就不需要前台配合,仅后端就能实现。
配置过程
将SpringSecurity
改为STATELESS
模式,这样他就不会使用session
了,
然后配置RememberMeService
,我们继承TokenBasedRememberMeServices
,重写其中的retrievePassword
方法,并且设置一个userDetailService
,传入构造方法里。
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
public class BaseSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http){
//配置为无状态模式,不使用session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); //配置form表单登录,以及登录后的执行逻辑
http.formLogin().loginPage("/error").loginProcessingUrl("/login").permitAll()
.successHandler((request, response, authentication) -> {
String str = "{\"s\":1,\"r\":\"login success\"}";
sendOut(str, response);
})
.failureHandler((request, response, exception) -> {
String str = "{\"s\":0,\"r\":\"" + exception.getMessage() + "\"}";
sendOut(str, response);
});
//使用RememberMe功能实现jwt登录
TokenBasedRememberMeServices rememberMeServices = new JwtRemember("tokenxxx");
rememberMeServices.setAlwaysRemember(true);
rememberMeServices.setCookieName("jwt.token");
http.rememberMe().rememberMeServices(rememberMeServices).key("tokenxxx");
//所有端口都需要验证
http.authorizeRequests().anyRequest().authenticated();
}
private static class JwtRemember extends TokenBasedRememberMeServices {
private static String salt = "_randow_salt";
//传入userDetailService,返回的新用户的密码是 用户名 + salt
protected JwtRemember(String key) {
super(key, username -> new User(username, username + salt, Collections.emptyList()));
}
//重写解析密码的方法,密码固定返回 用户名 + salt
@Override
protected String retrievePassword(Authentication authentication) {
return retrieveUserName(authentication) + salt;
}
}
//配置全局userDetailsService 和 passwordEncoder
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
}
private UserDetailsService userDetailsService = username -> {
User user = userService.getUserByUserName(username);
if (user == null) {
throw new UsernameNotFoundException(username);
}
return user;
};
private PasswordEncoder passwordEncoder = new PasswordEncoder() {
@Override
public String encode(CharSequence rawPassword) {
return UserService.securityPassword(rawPassword.toString());
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return encode(rawPassword).equals(encodedPassword);
}
};
}
初次登录时,会通过下面的UserDetailsService
和 PasswordEncoder
查询数据库,验证用户身份,登陆完成后RememberMeService
会自动创建Cookie,Cookie的内容就是上面写的 。
第二次访问时,请求经过RememberMeAuthenticationFilter
时,它检测到当前未登录,从Cookie中获取数据,自动登录。
这里是不查询数据库的。
下面是此RememberService
进行生成Token
的过程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public String makeTokenSignature(long tokenExpiryTime, String username, String password) {
//用户名,过期时间,密码(此密码已被我们改为用户名+salt了)+ key
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
MessageDigest digest;
try {
digest = MessageDigest.getInstance("MD5");
}
catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("No MD5 algorithm available!");
}
return new String(Hex.encode(digest.digest(data.getBytes())));
}
以下是自动登录的过程
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
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
HttpServletRequest request, HttpServletResponse response) {
//验证过期时间
long tokenExpiryTime;
try {
tokenExpiryTime = new Long(cookieTokens[1]).longValue();
}
catch (NumberFormatException nfe) {
throw new InvalidCookieException(
"Cookie token[1] did not contain a valid number (contained '"
+ cookieTokens[1] + "')");
}
if (isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '"
+ new Date(tokenExpiryTime) + "'; current time is '" + new Date()
+ "')");
}
//通过用户名查找用户,这里使用的是构造方法里传入的userDetailSevice
UserDetails userDetails = getUserDetailsService().loadUserByUsername(
cookieTokens[0]);
//使用查询到的userDetails 再次生成签名
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime,
userDetails.getUsername(), userDetails.getPassword());
//验证签名和cookie里携带的签名相同
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '"
+ cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
}
return userDetails;
}
下面是正常登录成功后,Cookie生成逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = retrieveUserName(successfulAuthentication);
//此处的获取密码方法已被我们重写,返回 用户名+salt
String password = retrievePassword(successfulAuthentication);
//过期时间
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);
//生成签名
String signatureValue = makeTokenSignature(expiryTime, username, password);
//设置cookie
setCookie(new String[] { username, Long.toString(expiryTime), signatureValue },tokenLifetime, request, response);
}
总结
以上就是使用SpringSecurity
做无状态服务的方法,除了第一次登陆时,后面验证是否登录是不需要查库的,我们也可以重写生成Cookie
的方法,将更多的信息保存在前端。
rememberMeService
是完全嵌入到SpringSecurity
体系内的组件,且其中的设置Cookie
,清理Cookie
都已经有实现,我们用这个来实现jwt
是相当方便的。
除此之外也可以使用自定义filter
达到同样效果,但是利用rememberMeService
实现起来比较简单。