springboot整合springsecurity从Hello World到源码解析(五):springsecurity+jwt整合restful服务

cover

上一章我们详细介绍了springsecurity的基础结构,并且在第三章我们已经知道了springsecurity中的
基础配置,但是那是基于all in one模式的,前端页面和后端集成在一起,然而事实上现在越来越多的开发模式是前后端分离,后端提供的restful接口,那我们本章就来学习下
springsecurity+jwt整合restful服务。

jwt

首先我们先了解下什么是jwt,jwt全称是json web tokens,它是基于RFC 7519开放标准用于双方安全展示信息的一种方式。通俗说就是是用于服务端和客户端相互交换信息的一种凭证。
这个凭证我们就叫token,在传统的认证模式中,我们普遍的做法是这样的:
用户登录-》服务端生成session-》写入sessionId到cookie-》浏览器携带该session访问服务端-》服务器基于该sessionId查找信息-》认证通过(找到了)
这种做法存在如下问题:

  1. 服务端需一定资源保存session信息,用户多时资源消耗较大
  2. 扩展性不好,当我们的服务端需要集群时,因session保存在服务端,此时无法定位session,造成登录失效(传统解决办法:iphash,session写入redis等)
  3. 跨域问题,当我们访问A网站时,此时不想再登录就能够访问关联网站B。(传统解决办法:写入持久层,A,B同时访问)
    虽然上面面对的问题我们普遍都有解决办法,但是显然都不怎么 ”友好“,所以这个时候就有一种干脆的解决办法了,服务端不再保存session,这样就轻松解决了上面所有问题。
    服务端只颁发token,那么现在的流程变成了这样:
    用户登录-》服务端颁发token-》客户端保存token(放入cooken或者header)-》携带token访问服务端-》服务端验证token(通过)-》调用api-》获取信息
    那么既然是服务端颁发的token,那肯定要保证该token的安全(只有该服务端颁发的token才认),唯一(不能伪造),而jwt则是用来生成这个安全的token的。 jwt的组成如下:
  • header(头),保存算法,类型
  • payload(负载),用户的信息,如id,用户名等等
  • signature(签名),将生成的token编码(加密)
    他们之间用 “.”号隔开,例如:xxxxx.yyyyy.zzzzz
    token

整合rest服务

我们知道,在restful服务中,服务端不再直接生成页面了,而是只返回数据(json),客户端渲染,而我们前面的例子已经知道了springsecurity默认也是直接生成整个页面的,所以这里的关键
就成了我们需要自己定义返回数据形式了,主要是错误处理,那我们接下来就开始实践。

实践

pom

新建一个springboot项目,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
55
56
57
58
59
60
61
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-security-demos</artifactId>
<groupId>cn.jsbintask</groupId>
<version>1.0.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>security-jwt-restful</artifactId>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

</dependencies>
</project>

数据源

定义数据源,application.yml文件以及sql脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server:
port: 8080

spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
username: root
password: jason
url: jdbc:mysql://localhost:3306/springsecurity_demos?useSSL=false

jpa:
show-sql: true
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true

logging:
level:
org.springframework.*: debug

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
43
44
CREATE DATABASE springsecurity_demos;
USE springsecurity_demos;


SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`role_name` varchar(16) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`description` varchar(64) 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;

-- ----------------------------
-- Records of role
-- ----------------------------
INSERT INTO `role` VALUES (1, 'user', 'this is a user role.');
INSERT INTO `role` VALUES (2, 'admin', 'this is a admin role.');

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(16) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`password` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`age` int(11) NULL DEFAULT NULL,
`address` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`role_id` int(11) NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, 'jsbintask', '123456', 22, 'China, Wuhan', 1);

SET FOREIGN_KEY_CHECKS = 1;

接着定义实体类domain,repository等,参考之前的博客,值得注意的是,此处我们的AuthUser稍有不同:

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
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AuthUser implements UserDetails {
private String username;
private String password;
private List<Role> roles;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.isEmpty() ? Collections.EMPTY_LIST :
// ROLE_ 是springsecurity对于角色的默认前缀,如果不加,验证会失败
(roles.parallelStream().map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRoleName())).collect(Collectors.toList()));
}

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

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

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

配置securityConfig

我们和前面章节一样,定义一个SecurityConfig类,继承WebSecurityConfigurerAdapter

1
2
3
4
5
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

接下来就是开始详细配置了,前面我们学习基础配置的时候已经知道,配置的重点是自定义
UserDetailsService,并且我们通过源码的方式知道了是UsernamePasswordAuthenticationFilter帮我们认证了用户,并且生成了页面,转发等等。
但是!在本例中,因为我们是整合restful服务,返回的都是json数据,所以我们不再需要这些。而既然要返回json数据,那我们先定义好一个通用的数据类,ResultVO:

1
2
3
4
5
6
7
8
9
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResultVO<T> {
// code请自己定义,例如 10成功,20需要登陆 30无权限等等
private Integer code;
private String msg;
private T data;
}

而既然我们的服务端是生成jwt,那现在登录的接口就成了生成jwt了,我们首先编写JwtUtil工具类:

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
public class JwtUtil {
private static final String secret = "jsbintask@gmail.com";

public static String generateToken(String username, List<Role> roles) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", roles.parallelStream().map(Role::getRoleName).collect(Collectors.joining(",")));

return Jwts.builder()
.setClaims(claims)
.setSubject(username)
//创建时间
.setIssuedAt(new Date())
//过期时间,我们设置为 五分钟
.setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))
//签名,通过密钥保证安全性
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}

public static AuthUser parseToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
String username = claims.getSubject();
String roles = (String) claims.get("roles");

//因为生成的时候没有放入密码,所以不需要密码
return new AuthUser(username, null, Arrays.stream(roles.split(",")).map(name -> {
Role role = new Role();
role.setRoleName(name);
return role;
}).collect(Collectors.toList()));
}
}

编写生成token的controller,UserController:

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
@RestController
public class UserController {
@Resource
private UserRepository userRepository;
@Resource
private RoleRepository roleRepository;

@GetMapping("/token")
public ResultVO login(String username, String password) {
User user = userRepository.findByUsername(username);

if (user == null || !user.getPassword().equals(password)) {
ResultVO<Object> result = new ResultVO<>();
result.setCode(10);
result.setMsg("用户名或密码错误");
return result;
}

ResultVO<Object> success = new ResultVO<>();
//用户名密码正确,生成token给客户端
success.setCode(0);
List<Role> roles = Collections.singletonList(roleRepository.findById(user.getId()).get());
success.setData(JwtUtil.generateToken(username, roles));

return success;
}
}

我们在定义一个如果异常处理,用于处理请求没有携带token以及一个禁止访问处理器,用于返回没有权限的用户并且全部作为bean
TokenExceptionHandler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class TokenExceptionHandler implements AuthenticationEntryPoint {

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 直接返回 json错误
ResultVO<Object> result = new ResultVO<>();
//20,标识没有token
result.setCode(20);
result.setMsg("请求无效,没有有效token");

ObjectMapper objectMapper = new ObjectMapper();

response.getWriter().write(objectMapper.writeValueAsString(result));
}
}

AccessDeniedHandler:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class AccessDeniedHandler implements org.springframework.security.web.access.AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 返回我们的自定义json
ObjectMapper objectMapper = new ObjectMapper();
ResultVO<Object> result = new ResultVO<>();
//50,标识有token,但是该用户没有权限
result.setCode(50);
result.setMsg("请求无效,没有有效token");
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}

除此之外,前面我们已经通过debug(第四章)得知,在UsernamePasswordAuthenticationFilter中,springsecurity加入了SecurityContext,既然我们现在不用了,那我们要自己定义拦截器并且加入
securityContext以便springsecurity作权限处理,所以我们自定义拦截器:

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
@Component
public class JwtTokenFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("token");

//获取token,并且解析token,如果解析成功,则放入 SecurityContext
if (token != null) {
try {
AuthUser authUser = JwtUtil.parseToken(token);
//todo: 如果此处不放心解析出来的 authuser,可以再从数据库查一次,验证用户身份:

//解析成功
if (SecurityContextHolder.getContext().getAuthentication() == null) {
//我们依然使用原来filter中的token对象
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());

SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
} catch (Exception e) {
logger.info("解析失败,可能是伪造的或者该token已经失效了(我们设置失效5分钟)。");
}
}

filterChain.doFilter(request, response);
}
}

前面已经说了,我们不再需要UserDetailsService和UsernamePasswordAuthenticationFilter,当然密码加密器也不再需要,那现在我们的websecurity如下:

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
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private TokenExceptionHandler tokenExceptionHandler;
@Resource
private AccessDeniedHandler accessDeniedHandler;
@Resource
private JwtTokenFilter jwtTokenFilter;

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// 因为我们的token是无状态的,不需要跨站保护
.csrf().disable()
// 添加异常处理,以及访问禁止(无权限)处理
.exceptionHandling().authenticationEntryPoint(tokenExceptionHandler).accessDeniedHandler(accessDeniedHandler).and()

// 我们不再需要session了
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()

//定义拦截页面,所有api全部需要认证
.authorizeRequests()

.anyRequest().authenticated();

//最后,我们定义 filter,用来替换原来的UsernamePasswordAuthenticationFilter
httpSecurity.addFilterAt(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class);
}

@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
// 让我们获取 token的api不走springsecurity的过滤器,大道开放
.antMatchers(HttpMethod.GET, "/token");
}
}

值得注意的是,我们把 /token(登录)这个api完全开放了,因为它根本不需要走过滤器链,接下来我们再写一个Controller用于测试,叫PermissionController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
@RequestMapping
public class PermissionController {

@GetMapping("/permission")
public ResultVO loginTest(@AuthenticationPrincipal AuthUser authUser) {
ResultVO<String> resultVO = new ResultVO<>();
resultVO.setCode(0);

resultVO.setData("你成功访问了该api,这代表你已经登录,你是: " + authUser);
return resultVO;
}

@GetMapping("/permission")
@PreAuthorize("hasRole('user')")
public ResultVO loginTest() {
ResultVO<String> resultVO = new ResultVO<>();
resultVO.setCode(0);

resultVO.setData("你成功访问了需要有 user 角色的api。");
return resultVO;
}
}

该controller一共两个api,一个为登录可访问,一个为登陆后还需要有 user角色的api。

测试

接下来就是见证奇迹的时候了,首先我们直接访问 /8080,我们猜想应该是错误code=20,因为所有api都需要token,结果如下:
/8080
果然和我们想的一样(乱码原因是我们手动new的jackson对象,没有设置编码),同时我们debug JwtTokenFilter,果然在过滤器链中找到了我们的filter,并且替换了原来的UsernamePasswordAuthenticationFilter
不见了:
/filter
接下来我们登录,访问 http://localhost:8080/token?username=jsbintask&password=12345,用户名
密码根据自己数据库调整,结果如下:用户名密码错误:
/token?
然后我们输入正确的用户名密码,如下:
/token?username=jsbintask&password=123456
果然,我们得到了code=0,代表成功了,并且拿到了token,接下来我们用该token去访问我们的PermissionController:
访问不需要身份的 /normal:
/normal
成功!并且看到了我们的信息。
接下来访问 /role,需要user身份,我们创建一个没有该身份的用户:

1
INSERT INTO `springsecurity_demos`.`user`(`id`, `address`, `age`, `password`, `role_id`, `username`) VALUES (2, 'Wuhan, China', 22, '123456', 22, 'jsbintask2');

同样登陆后,访问:
/denied
果然,我们得到了我们自定义的json数据 coe=50,接下来我们切换有该身份的用户:
/role
果然成功了!,这样,我们的目的就全部达到了。 restful服务也完全整合好了

总结

本章,我们首先介绍了jwt是什么,以及和传统session的不同,然后我们通过一个完整的例子展示了前面章节所学以及在实际中该如何保护我们的 api。并且成功的达到了我们的目的。
本章源码:https://github.com/jsbintask22/spring-security-demos.git
到此,我们的spingsecurity系列的文章就写完了,同系列文章地址:https://jsbintask.cn/tags/springsecurity/
关注我:
qrcode

×

谢谢你支持我分享知识

扫码支持
扫码打赏,心意已收

打开微信扫一扫,即可进行扫码打赏哦

文章目录
  1. 1. jwt
  2. 2. 整合rest服务
  3. 3. 实践
    1. 3.1. pom
    2. 3.2. 数据源
    3. 3.3. 配置securityConfig
    4. 3.4. 测试
  4. 4. 总结
欢迎扫描左方二维码跟作者交流.