作为一名Java开发者,提起权限相关的功能,大家应该都知道Shiro和SpringSecurity两个安全框架。这两个安全框架的使用者都比较多,两者各有千秋,做技术选型选型综合考虑选择合适自己团队的就行。
SpringSecurity提供了强大的身份认证和授权功能,其作为Spring生态中的一个模块,可以做到与其他组件的无缝集成。
在本篇文章中我会介绍下在项目中如何实现SpringSecurity+JWT实现项目的登录认证及授权的功能。项目环境如下:
- JDK1.8
- SpringBoot 2.3.12.RELEASE
- Mysql
- Redis
一 前后端交互流程
在开始代码展示前,我们先介绍下前后端的交互流程,如下
- 前端校验本地是否存储了token,如果没有存储,跳转登录页面
- 用户在登录界面输入账号和密码,请求登录接口,后端返回token
- 在后续的请求中前端需要在请求头中携带获取的token
- 服务端要对请求携带的token进行校验,如果token无效或者已过期,返回前端401错误码,如果是没有相关权限返回403
- 前端接收到401的错误码,需要清除本地保存的token并跳转至登录页面
了解了交互流程后,也就知道咱们作为服务端需要做哪些工作了,剩下的就是看跟SpringSecurity的集成工作了。
二 数据库表结构
通常在权限控制这里会涉及五张表,账号表、角色表、权限表、账号角色关系表、角色权限关系表。这里给出一份简单的简单的建表语句,大家在实际应用中结合自己的业务功能,增加相关字段即可。
1 账号表
CREATE TABLE `pe_admin` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`username` varchar(50) NOT NULL DEFAULT '' COMMENT '用户名',
`password` varchar(100) NOT NULL DEFAULT '' COMMENT '密码',
`nickname` varchar(50) NOT NULL DEFAULT '' COMMENT '昵称',
`avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '头像',
`email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱',
`latest_login_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近登陆时间',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态',
`gender` tinyint NOT NULL DEFAULT '0' COMMENT '性别 1男 2女',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '编辑时间',
`is_delete` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_username` (`username`) USING BTREE
) ENGINE=InnoDB COMMENT='管理员表';
INSERT INTO `pe_admin` (`username`, `password`, `nickname`, `avatar`, `email`, `status`, `gender`) VALUES ('yhzb', '$2a$10$dMknjZX69EbdgpUAQIpaU.OvhbuqR.8qfCkEEpGdN0UZeW1DxCDmi', 'Bug搬运小能手', '', 'bugporter@163.com', 1, 1);
2 角色表
CREATE TABLE `pe_role` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称',
`permission` varchar(50) NOT NULL DEFAULT '' COMMENT '权限',
`remark` varchar(100) NOT NULL DEFAULT '' COMMENT '备注',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '编辑时间',
`is_delete` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='角色表';
INSERT INTO `pe_role` (`name`, `permission`, `remark`) VALUES ('超级管理员', 'superAdmin', '超级管理员,拥有系统最高权限');
3 菜单表
CREATE TABLE `pe_menu` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`type` tinyint NOT NULL DEFAULT '0' COMMENT '类型 1目录 2菜单 3按钮',
`parent_id` int NOT NULL DEFAULT '0' COMMENT '上级ID',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称',
`title` varchar(50) NOT NULL DEFAULT '' COMMENT '标题',
`path` varchar(50) NOT NULL DEFAULT '' COMMENT '路径',
`icon` varchar(50) NOT NULL DEFAULT '' COMMENT '图标',
`component` varchar(100) NOT NULL DEFAULT '' COMMENT '前端组件',
`creator_id` int NOT NULL DEFAULT '0' COMMENT '创建人ID',
`creator_name` varchar(50) NOT NULL DEFAULT '' COMMENT '创建人姓名',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modifier_id` int NOT NULL DEFAULT '0' COMMENT '编辑人ID',
`modifier_name` varchar(50) NOT NULL DEFAULT '' COMMENT '编辑人姓名',
`modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '编辑时间',
`is_delete` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='菜单表';
4 账号角色关系表
CREATE TABLE `pr_admin_role` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`admin_id` int NOT NULL DEFAULT '0' COMMENT '管理员ID',
`role_id` int NOT NULL DEFAULT '0' COMMENT '角色ID',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '编辑时间',
`is_delete` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除',
PRIMARY KEY (`id`),
KEY `idx_admin_role` (`admin_id`,`role_id`) USING BTREE
) ENGINE=InnoDB COMMENT='管理员角色关系表';
INSERT INTO `pr_admin_role` (`admin_id`, `role_id`) VALUES (1, 1);
5 角色权限关系表
CREATE TABLE `pr_role_menu` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`role_id` int NOT NULL DEFAULT '0' COMMENT '角色ID',
`menu_id` int NOT NULL DEFAULT '0' COMMENT '菜单ID',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '编辑时间',
`is_delete` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='角色菜单关系表';
执行完上面的SQL语句,数据库里也就初始化了一个账号,用户名:yhzb 密码:123456 并且拥有superAdmin权限。
三 相关代码
接下来到编写代码的阶段了,在这部分我会一点点的将相关代码进行粘贴出来,尽量做到让大家复制粘贴出来即可使用。
1 引入依赖
我们需要在项目中引入SpringSecurity和JWT的相关依赖,如下:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.12.RELEASE</version>
<relativePath/>
</parent>
<properties>
<jjwt.version>0.9.1</jjwt.version>
</properties
<dependencies>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
2 登录接口
登录接口的请求参数是账号和密码,响应数据是JWT生成的token,代码如下:
@ApiOperation(value = "登录", httpMethod = "POST")
@PostMapping(value = "/login", produces = MediaType.APPLICATION_JSON_VALUE)
public R<String> login(@Validated @RequestBody LoginReqVO loginReqVO) {
// 通过用户名查找账号
Admin admin = adminService.getByUsername(loginReqVO.getUsername());
// 账号不存在,或者密码不匹配,抛出异常
if (admin == null || !passwordEncoder.matches(loginReqVO.getPassword(), admin.getPassword())) {
throw new BaseException(BaseResultCode.USERNAME_OR_PASSWORD_ERROR);
}
// 将认证成功的用户存储到SpringSecurity的上下文中
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginReqVO.getUsername(), loginReqVO.getPassword());
Authentication authenticate = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authenticate);
// 生成JWT TOKEN
TokenUserDTO tokenUserDTO = new TokenUserDTO();
tokenUserDTO.setUsername(loginReqVO.getUsername());
tokenUserDTO.setUuid(UUIDUtil.createUUID());
tokenUserDTO.setTimestamp(System.currentTimeMillis());
String token = TokenUtil.generateToken(tokenProperties.getSecret(), tokenUserDTO);
// 将TOKEN存储到Redis中,用于实现TOKEN的实效和续期
String tokeCacheKey = RedisConstant.TOKEN_PREFIX + token;
yhzbRedisClient.set(tokeCacheKey, admin, tokenProperties.getExpiration());
return R.success(tokenProperties.getPrefix() + token);
}
在登录接口中需要处理的逻辑如下:
- 校验账号和密码
- 将认证成功的用户添加到SpringSecurity的上下文中
- 生成TOKEN
- 将TOKEN保存到Redis中,并返回给前端
这里将TOKEN保存到Redis中,是因为在我的示例中,我没有使用JWT自己的过期时间,而是通过Redis的过期时间来实现TOKEN的过期时间。这样设计的目的是因为在后面的请求中,我会对合法的TOKEN进行续期操作,很多场景使用的是双TOKEN的形式实现的续期功能,个人感觉使用Redis的形式更为简单。
3 TokenUtil代码
TokenUtil
类中提供了,创建TOKEN和解析TOKEN的方法,代码如下:
@Slf4j
public class TokenUtil {
/**
* 创建jwt token
*
* @param secret 签名
* @param userDTO 用户信息
* @return token
*/
public static String generateToken(String secret, TokenUserDTO userDTO) {
String token = Jwts.builder()
.setSubject(userDTO.getUsername())
.claim("uuid", userDTO.getUuid())
.claim("timestamp", userDTO.getTimestamp())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
return token;
}
/**
* 从token中获取用户名
*
* @param secret 签名
* @param token token
* @return 用户名
*/
public static String getUsernameFromToken(String secret, String token) {
Claims claims = getClaimsFromToken(secret, token);
return claims != null ? claims.getSubject() : null;
}
private static Claims getClaimsFromToken(String secret, String token) {
Claims claims;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
log.warn("getClaimsFromToken exception", e);
claims = null;
}
return claims;
}
}
4 TokenProperties代码
这是一个Token相关的配置类,一下相关的配置存放到配置中心里,修改也方便,其源码如下:
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "token")
public class TokenProperties implements Serializable {
/**
* TOKEN的header头
*/
private String header = "Authorization";
/**
* 返回给前端的TOKEN前缀
*/
private String prefix = "Bearer ";
/**
* 生成TOKEN的密钥
*/
private String secret = "YHZB";
/**
* TOKEN有效时长 S
*/
private Long expiration = 7 * 24 * 3600L;
}
5 登出接口
登出结构逻辑很简单,从请求头中获取到TOKEN,然后从Redis中删除即可,代码如下:
@ApiOperation(value = "退出登录", httpMethod = "POST")
@PostMapping(value = "/logout", produces = MediaType.APPLICATION_JSON_VALUE)
public R<Void> logout() {
try {
String token = this.getToken();
String tokenKey = RedisConstant.TOKEN_PREFIX + token;
yhzbRedisClient.del(tokenKey);
} catch (Exception ignore) {
}
return R.success();
}
/**
* 获取token
*
* @return token
*/
protected String getToken() {
String token = request.getHeader(tokenProperties.getHeader());
if (StringUtils.isBlank(token) || !token.startsWith(tokenProperties.getPrefix())) {
throw new BaseException(BaseResultCode.ACCESS_TOKEN_INVALID);
}
token = token.replace(tokenProperties.getPrefix(), "");
String redisKey = RedisConstant.TOKEN_PREFIX + token;
Object cacheData = yhzbRedisClient.get(redisKey);
if (cacheData == null) {
throw new BaseException(BaseResultCode.ACCESS_TOKEN_INVALID);
}
return token;
}
6 UserDetails和UserDetailsService实现类
UserDetails
和UserDetailsService
是SpringSecurity
中的两个核心类,他们在用户认证过程中发挥着至关重要的作用。
UserDetails
是SpringSecurity中用户表示用户信息的接口,SpringSecurity可以通过该类获取到登录用户的账号、密码、角色及权限等信息。
UserDetailsService
是SpringSecurity中用于加载用户信息的接口,在该接口中定义了一个loadUserByUsername
的方法,我们可以在该方法中通过用户名从数据库中获取用户的基本信息及权限,并将其组装成一个UserDetails
对象返回。
6.1 自定义UserDetails
@Setter
@Getter
public class YhzbAuthUser implements UserDetails {
private Integer id;
private String username;
private String password;
private String nickname;
private Integer gender;
private String avatar;
private String email;
private Collection<SimpleGrantedAuthority> authorities;
public YhzbAuthUser() {
}
public YhzbAuthUser(Integer id, String username, String password, String nickname, Integer gender,
String avatar, String email, Collection<SimpleGrantedAuthority> authorities) {
this.id = id;
this.username = username;
this.password = password;
this.nickname = nickname;
this.gender = gender;
this.avatar = avatar;
this.email = email;
this.authorities = authorities;
}
public YhzbAuthUser(YhzbAuthUserBuilder builder) {
this.username = builder.username;
this.password = builder.password;
this.authorities = builder.authorities;
this.id = builder.id;
this.nickname = builder.nickname;
this.gender = builder.gender;
this.avatar = builder.avatar;
this.email = builder.email;
}
@Override
@JsonIgnore
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;
}
public static class YhzbAuthUserBuilder {
private Integer id;
private final String username;
private final String password;
private String nickname;
private Integer gender;
private String avatar;
private String email;
private Collection<SimpleGrantedAuthority> authorities;
public YhzbAuthUserBuilder(String username, String password, Collection<SimpleGrantedAuthority> authorities) {
this.username = username;
this.password = password;
this.authorities = authorities;
}
public YhzbAuthUserBuilder withId(Integer id) {
this.id = id;
return this;
}
public YhzbAuthUserBuilder withNickname(String nickname) {
this.nickname = nickname;
return this;
}
public YhzbAuthUserBuilder withGender(Integer gender) {
this.gender = gender;
return this;
}
public YhzbAuthUserBuilder withAvatar(String avatar) {
this.avatar = avatar;
return this;
}
public YhzbAuthUser build() {
return new YhzbAuthUser(this);
}
}
}
6.2 自定义UserDetailsService
@Slf4j
@Service
public class YhzbUserDetailServiceImpl implements UserDetailsService {
@Resource
private IAdminService adminService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Admin admin = adminService.getByUsername(username);
if (admin == null) {
throw new BaseException(BaseResultCode.USERNAME_OR_PASSWORD_ERROR);
}
List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
List<String> permissionList = adminService.getPermissionList(admin.getId());
if (CollectionUtils.isNotEmpty(permissionList)) {
permissionList.forEach(permission -> authorityList.add(new SimpleGrantedAuthority(permission)));
}
YhzbAuthUser.YhzbAuthUserBuilder builder = new YhzbAuthUser.YhzbAuthUserBuilder(admin.getUsername(), admin.getPassword(), authorityList)
.withId(admin.getId())
.withNickname(admin.getNickname())
.withAvatar(admin.getAvatar())
.withGender(GenderEnum.MALE.getCode());
return builder.build();
}
}
7 TokenFilter
该类用于拦截请求校验TOKEN,对合法的TOKEN进行放行并进行续期,其源码如下:
@Slf4j
public class TokenFilter extends OncePerRequestFilter {
private final YhzbRedisClient yhzbRedisClient;
private final TokenProperties tokenProperties;
private final UserDetailsService userDetailsService;
public TokenFilter(YhzbRedisClient yhzbRedisClient, TokenProperties tokenProperties, UserDetailsService userDetailsService) {
this.yhzbRedisClient = yhzbRedisClient;
this.tokenProperties = tokenProperties;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
String header = request.getHeader(tokenProperties.getHeader());
if (StringUtils.isBlank(header) || !header.startsWith(tokenProperties.getPrefix())) {
filterChain.doFilter(request, response);
return;
}
String token = header.replace(tokenProperties.getPrefix(), "");
String redisKey = RedisConstant.TOKEN_PREFIX + token;
Object object = yhzbRedisClient.get(redisKey);
if (object != null) {
String username = TokenUtil.getUsernameFromToken(tokenProperties.getSecret(), token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
yhzbRedisClient.expire(redisKey, tokenProperties.getExpiration());
if (userDetails != null) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
log.info("TokenFilter authentication for 【{}】, 【{}】", authenticationToken.getName(), requestURI);
}
}
filterChain.doFilter(request, response);
}
}
8 认证或鉴权失败处理类
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
// token校验失败 返回401
String message = e == null ? "unauthorized" : e.getMessage();
httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, message);
}
}
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
// 访问没有授权的接口 返回403
httpServletResponse.sendError(HttpServletResponse.SC_FORBIDDEN, e.getMessage());
}
}
这里需要注意一点,如果在使用了全局异常处理器的项目中,返回的是业务自定义的错误码。想要http状态码是403的话,只需在全局异常处理器中添加如下内容:
@ExceptionHandler(AccessDeniedException.class)
public R<Object> handlerAccessDeniedException(AccessDeniedException exception) {
// return R.fail(RiseCommonResultCode.NOT_PERMISSION, "没有权限进行该操作");
throw exception;
}
9 Security配置类
该类是对接中的关键配置类,在这里我们配置我们Web应用需要放开的接口及安全配置,我们自定义的这个类需要继承WebSecurityConfigurerAdapter
并实现protected void configure(HttpSecurity http)
方法,其源码如下:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private static final String[] PERMIT_ALL_URL = {
"/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "webSocket/**",
"/swagger-ui.html", "/swagger-resources/**", "/webjars/**",
"/*/api-docs", "/v2/api-docs-ext", "/druid/**",
"/api/auth/login", "/api/auth/logout"
};
@Resource
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Resource
private JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Resource
private UserDetailsService userDetailsService;
@Resource
private YhzbRedisClient yhzbRedisClient;
@Resource
private TokenProperties tokenProperties;
@Bean
public GrantedAuthorityDefaults grantedAuthorityDefaults() {
return new GrantedAuthorityDefaults("");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).accessDeniedHandler(jwtAccessDeniedHandler)
.and().headers().frameOptions().disable()
.and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().authorizeRequests().antMatchers(PERMIT_ALL_URL).permitAll()
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated();
}
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
public TokenFilter tokenFilter() {
return new TokenFilter(yhzbRedisClient, tokenProperties, userDetailsService);
}
}
到这里我们的相关代码就编写完成了,我们便成功的为我们的服务增加了一层安全防护,此时未登录的用户请求未放开的接口便会返回401状态码,使用PostMan测试如下:
我们在调用登录接口获取到TOKEN:
将登录接口获取到的TOKEN添加到第一步调用接口的请求头中,便可以正常获取到数据了:
我们还可以为我们的每个接口设置一个权限,使用SpringSecurity中提供的@PreAuthorize注解添加到对应的接口上面,便可为该接口添加相应的权限,当访问该接口的用户没有对应的权限时则会返回403状态码,示例如下:
在示例中我这里有两个接口,一个接口允许具有superAdmin权限的用户访问,一个接口支持具有category:add权限的用户访问。
大家是否还记得我们初始化的用户是具有superAdmin权限的,并且在上面的测试中我们也是可以正常访问那个分页列表的接口。
我们再测试一下调用这个新增接口是否等得到符合我们所期望的结果,PostMan测试结果如下:
将superAdmin权限添加到对应接口再测测试就可以正常请求了,在上面的代码中我们使用的hasRole配置的权限,在SpringSecurity中提供了4种方法,分别如下:
- hasAuthority
- hasAnyAuthority
- hasRole
- hasAnyRole
今天的文章到这里就结束了,在这篇文章中我们介绍了下SpringSecurity的使用,看完这篇文章应该能顺利的使用SpringSecurity为我们的接口加上防护了。