Security+JWT实战

简介

身份认证和用户授权:

用户认证(Authentication):系统通过校验用户提供的用户名和密码来验证该用户是否为系统中的合法主体,即是否可以访问该系统;
用户授权(Authorization):系统为用户分配不同的角色,以获取对应的权限,即验证该用户是否有权限执行该操作;
Web应用的安全性包括用户认证和用户授权两个部分,而Spring Security(以下简称Security)基于Spring框架,正好可以完整解决该问题。

它的真正强大之处在于它可以轻松扩展以满足自定义要求。

原理

Security可以看做是由一组filter过滤器链组成的权限认证。它的整个工作流程如下所示:图片图中绿色认证方式是可以配置的,橘黄色和蓝色的位置不可更改:

FilterSecurityInterceptor:最后的过滤器,它会决定当前的请求可不可以访问Controller
ExceptionTranslationFilter:异常过滤器,接收到异常消息时会引导用户进行认证;

实战

项目准备

使用Spring Boot框架来集成。

1.pom文件引入的依赖

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
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

<!-- 阿里JSON解析器 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.74</version>
</dependency>

<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.10.6</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>

2.application.yml配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
spring:
application:
name: securityjwt
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/cheetah?characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
username: root
password: 123456

server:
port: 8080

mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.itcheetah.securityjwt.entity
configuration:
map-underscore-to-camel-case: true

rsa:
key:
pubKeyFile: C:\Users\Desktop\jwt\id_key_rsa.pub
priKeyFile: C:\Users\Desktop\jwt\id_key_rsa

3.SQL文件

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
/**
* sys_user_info
**/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for sys_user_info
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_info`;
CREATE TABLE `sys_user_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;


/**
* product_info
**/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for product_info
-- ----------------------------
DROP TABLE IF EXISTS `product_info`;
CREATE TABLE `product_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`price` decimal(10, 4) NULL DEFAULT NULL,
`create_date` datetime(0) NULL DEFAULT NULL,
`update_date` datetime(0) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

引入依赖

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!--Token生成与解析-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

引入之后启动项目,会有如图所示:图片其中用户名为user

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
SecurityConfig类
//开启全局方法安全性
@EnableGlobalMethodSecurity(prePostEnabled=true, securedEnabled=true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

//认证失败处理类
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;

//提供公钥私钥的配置类
@Autowired
private RsaKeyProperties prop;

@Autowired
private UserInfoService userInfoService;

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
.antMatchers(
HttpMethod.GET,
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js"
).permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
// 添加JWT filter
httpSecurity.addFilter(new TokenLoginFilter(super.authenticationManager(), prop))
.addFilter(new TokenVerifyFilter(super.authenticationManager(), prop));
}

//指定认证对象的来源
public void configure(AuthenticationManagerBuilder auth) throws Exception {

auth.userDetailsService(userInfoService)
//从前端传递过来的密码就会被加密,所以从数据库
//查询到的密码必须是经过加密的,而这个过程都是
//在用户注册的时候进行加密的。
.passwordEncoder(passwordEncoder());
}

//密码加密
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
「拦截规则」

anyRequest:匹配所有请求路径
access:SpringEl表达式结果为true时可以访问
anonymous:匿名可以访问
`denyAll:用户不能访问
fullyAuthenticated:用户完全认证可以访问(非remember-me下自动登录)
hasAnyAuthority:如果有参数,参数表示权限,则其中任何一个权限可以访问
hasAnyRole:如果有参数,参数表示角色,则其中任何一个角色可以访问
hasAuthority:如果有参数,参数表示权限,则其权限可以访问
hasIpAddress:如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
hasRole:如果有参数,参数表示角色,则其角色可以访问
permitAll:用户可以任意访问
rememberMe:允许通过remember-me登录的用户访问
authenticated:用户登录后可访问
认证失败处理类

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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
/**
* 返回未授权
*/
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable {

private static final long serialVersionUID = -8970718410437077606L;

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
throws IOException {
int code = HttpStatus.UNAUTHORIZED;
String msg = "认证失败,无法访问系统资源,请先登陆";
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
}
}
认证流程
自定义认证过滤器

public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {

private AuthenticationManager authenticationManager;

private RsaKeyProperties prop;

public TokenLoginFilter(AuthenticationManager authenticationManager, RsaKeyProperties prop) {
this.authenticationManager = authenticationManager;
this.prop = prop;
}

/**
* @author cheetah
* @description 登陆验证
* @date 2021/6/28 16:17
* @Param [request, response]
* @return org.springframework.security.core.Authentication
**/
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
UserPojo sysUser = new ObjectMapper().readValue(request.getInputStream(), UserPojo.class);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(sysUser.getUsername(), sysUser.getPassword());
return authenticationManager.authenticate(authRequest);
}catch (Exception e){
try {
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
PrintWriter out = response.getWriter();
Map resultMap = new HashMap();
resultMap.put("code", HttpServletResponse.SC_UNAUTHORIZED);
resultMap.put("msg", "用户名或密码错误!");
out.write(new ObjectMapper().writeValueAsString(resultMap));
out.flush();
out.close();
}catch (Exception outEx){
outEx.printStackTrace();
}
throw new RuntimeException(e);
}
}


/**
* @author cheetah
* @description 登陆成功回调
* @date 2021/6/28 16:17
* @Param [request, response, chain, authResult]
* @return void
**/
public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
UserPojo user = new UserPojo();
user.setUsername(authResult.getName());
user.setRoles((List<RolePojo>)authResult.getAuthorities());
//通过私钥进行加密:token有效期一天
String token = JwtUtils.generateTokenExpireInMinutes(user, prop.getPrivateKey(), 24 * 60);
response.addHeader("Authorization", "Bearer "+token);
try {
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter out = response.getWriter();
Map resultMap = new HashMap();
resultMap.put("code", HttpServletResponse.SC_OK);
resultMap.put("msg", "认证通过!");
resultMap.put("token", token);
out.write(new ObjectMapper().writeValueAsString(resultMap));
out.flush();
out.close();
}catch (Exception outEx){
outEx.printStackTrace();
}
}
}

Security默认登录路径为/login,当我们调用该接口时,它会调用上边的attemptAuthentication方法;要自定义UserInfoService继承UserDetailsService实现loadUserByUsername方法;

public interface UserInfoService extends UserDetailsService {

}

@Service
@Transactional
public class UserInfoServiceImpl implements UserInfoService {

@Autowired
private SysUserInfoMapper userInfoMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserPojo user = userInfoMapper.queryByUserName(username);
return user;
}
}
其中的loadUserByUsername返回的是UserDetails类型,所以UserPojo继承UserDetails类

@Data
public class UserPojo implements UserDetails {

private Integer id;

private String username;

private String password;

private Integer status;

private List<RolePojo> roles;

@JsonIgnore
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//理想型返回 admin 权限,可自已处理这块
List<SimpleGrantedAuthority> auth = new ArrayList<>();
auth.add(new SimpleGrantedAuthority("ADMIN"));
return auth;
}

@Override
public String getPassword() {
return this.password;
}

@Override
public String getUsername() {
return this.username;
}

/**
* 账户是否过期
**/
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}

/**
* 是否禁用
*/
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}

/**
* 密码是否过期
*/
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}

/**
* 是否启用
*/
@JsonIgnore
@Override
public boolean isEnabled() {
return true;
}
}
当认证通过之后会在SecurityContext中设置Authentication对象,回调调用successfulAuthentication方法返回token信息

自定义token过滤器
public class TokenVerifyFilter extends BasicAuthenticationFilter {
private RsaKeyProperties prop;

public TokenVerifyFilter(AuthenticationManager authenticationManager, RsaKeyProperties prop) {
super(authenticationManager);
this.prop = prop;
}

public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
//如果携带错误的token,则给用户提示请登录!
chain.doFilter(request, response);
} else {
//如果携带了正确格式的token要先得到token
String token = header.replace("Bearer ", "");
//通过公钥进行解密:验证tken是否正确
Payload<UserPojo> payload = JwtUtils.getInfoFromToken(token, prop.getPublicKey(), UserPojo.class);
UserPojo user = payload.getUserInfo();
if(user!=null){
UsernamePasswordAuthenticationToken authResult = new UsernamePasswordAuthenticationToken(user.getUsername(), null, user.getAuthorities());
//将认证信息存到安全上下文中
SecurityContextHolder.getContext().setAuthentication(authResult);
chain.doFilter(request, response);
}
}
}
}