一、项目环境
- 后端技术栈:SpringBoot, SpringSecurity, jwt
- 后端软体:IntelliJ IDEA2020, jdk1.8
- 数据库:mySql
二、文章主题
- 内容概述:为将Project07与SpringSecurity/Shiro+jwt整合,主要参考了MarkerHub_前后端分离后台管理系统进行改写,本文是关于该视频P30~46内容的学习整理,前端内容在传送门。
- 项目源码:shoppingProject01_pub : version7.1
三、用户登录验证&用户身份认证 实现过程
待实现的SpringSecurity后台逻辑如图1所示。
Step1:SpringBoot整合Security,jwt
- porm.xml中引入相关jar包,引入后访问任意实现了的后端接口,会跳转到如图2所示的界面。用户名默认是"user";密码在SpringBoot终端有显示。
<!-- springboot security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
Step2:整合验证码过滤器的 用户首次登录认证功能实现
1)首次登录认证:用户名、密码、验证码,完成登录。
2)图片验证码校验逻辑如图3所示。前后端不分离项目中图片验证码保存在session中;本项目为前后端分离,故将图片验证码保存在redis中。
前后端不分离项目验证码图片可直接返回给前端url,前后端分离项目生成图片验证码的controller代码如下:
// 生成图片验证码
@GetMapping("/getCaptcha")
public Result getCaptcha() throws IOException {
String imageKey = UUID.randomUUID().toString(); // 图片验证码在redis中的key
String imageCode = VerifyCodeUtils.generateVerifyCode(4);
// 为了测试
imageKey = "aaaaa";
imageCode = "11111";
// 将图片转为base64
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
VerifyCodeUtils.outputImage(120, 30, byteArrayOutputStream, imageCode);
String str = "data:image/png;base64,";
String base64Img = str + Base64Utils.encodeToString(byteArrayOutputStream.toByteArray());
redisUtil.hset(Const.CAPTCHA_KEY,imageKey,imageCode,300); // redis存储了图片验证码信息
return Result.succ(
MapUtil.builder()
.put("imageKey",imageKey)
.put("captchaImg",base64Img)
.build()
);
}
3)解决前后端分离的跨域问题:
1)):写跨域配置类
2)): 写Security配置类
package com.salieri.config;
import com.salieri.security.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
LoginFailureHandler loginFailureHandler;
@Autowired
LoginSuccessHandler loginSuccessHandler;
@Autowired
CaptchaFilter captchaFilter;
@Bean
JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager());
return jwtAuthenticationFilter;
}
@Autowired
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean // 构造用户密码的加密形式,这里用于Security判断登录时解析密码成明文
BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
UserDetailServiceImpl userDetailService; // 用于依据用户名在数据库中查询用户密码(Security)
// Security放行的白名单
private static final String[] URL_WHITELIST = {
"/eb/login",
"/eb/logout",
"/user/getCaptcha",
"/user/mailReg",
"/user/activationMail",
"/user/nickReg",
"/favicon.ico"
};
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable() // 解决了跨域问题
// 登录配置
.formLogin()
.successHandler(loginSuccessHandler) // 见图1,这是认证成功处理器
.failureHandler(loginFailureHandler) // 认证失败处理器
// 退出
// .and()
// .logout()
// .logoutSuccessHandler(jwtLogoutSuccessHandler)
// 禁用session
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 配置拦截规则
.and()
.authorizeRequests()
.antMatchers(URL_WHITELIST).permitAll() // 白名单链接要放行
.anyRequest().authenticated()
// 异常处理器
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 见图1,这是认证异常处理器
.accessDeniedHandler(jwtAccessDeniedHandler) // 权限异常处理器
// 配置自定义的过滤器
.and()
.addFilter(jwtAuthenticationFilter()) // 见图1,这是BasicAuthentication
// 在用户名、密码校验过滤器前要加入图片验证码过滤器
.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService);
}
}
4)写LoginFailureHandler(认证失败处理器)、LoginSuccessHandler(认证成功处理器)、CatpchaFilter(图片验证码处理器)、JwtLogoutSuccessHandler(退出处理器)。
Step3:基于jwt的用户二次token认证功能实现
1)二次token认证:请求头携带jwt进行身份认证。
2)写JwtUtils并在application.properties中写相应的参数。
3) 在LoginSuccessHandler中将jwt放置到返回给前端响应的请求头
// 注入到spring中
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
JwtUtils jwtUtils;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
ServletOutputStream outputStream = response.getOutputStream();
// 生成jwt,并放置到请求头中
String jwt = jwtUtils.generateToken(authentication.getName());
response.setHeader(jwtUtils.getHeader(),jwt);
Result result = Result.succ("");
outputStream.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
outputStream.flush();
outputStream.close();
}
}
4)写BasicAuthicationFilter(身份认证过滤器,当用户发起的不是登录请求时,对请求头中的jwt进行解析,完成自动登录功能)
// 用户访问登录以外页面时的jwt认证
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
@Autowired
JwtUtils jwtUtils;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String jwt = request.getHeader(jwtUtils.getHeader());
if (StrUtil.isBlankOrUndefined(jwt)) {
// 过滤器链直接往后面走,这是为了放行白名单
chain.doFilter(request,response);
return;
}
// 解析jwt
Claims claim = jwtUtils.getClaimByToken(jwt);
if ( claim == null ) { // jwtUtils解析不合法就会返回空
throw new JwtException("token异常");
}
// 判断jwt是否过期
if (jwtUtils.isTokenExpired(claim)) {
throw new JwtException("token已过期");
}
// 通过主体拿到用户名称
String username = claim.getSubject();
// 获取用户的权限信息
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username,null,null);
// 设置认证主体以完成自动登录
SecurityContextHolder.getContext().setAuthentication(token);
// 让过滤器链继续往后走
chain.doFilter(request,response);
}
}
5)写异常处理Filter(JwtAuthenticationEntryPotin认证失败异常;JwtAccessDeniedHandler权限不足异常)
6)实现用户登录时查库、匹配库中用户数据
1)) 自定义AccountUser(作为匹配成功的用户信息返回),写UserDetailServiceImpl
注:在Security配置类要让Security知道数据库中用户密码的加密形式
package com.salieri.security;
import com.salieri.pojo.User;
import com.salieri.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
// 看,这个service是security中的
@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
UserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.getByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户名或密码不正确");
}
// AccountUser是自定义的
return new AccountUser(user.getId(),user.getUsername(),user.getPassword(),getUserAuthority(user.getId()));
}
// 获取用户权限信息(角色权限、网站操作权限)
public List<GrantedAuthority> getUserAuthority(int userId) {
return null;
}
}
2)) 在Security配置类中配置重写后的UserDetailService。
四、用户权限获取 实现过程
1) 初步设计的项数据库信息userinfo,roleinfo如图4、5所示:
Question: 我们在哪里赋予用户权限?
Place1:用户登录,调用UserDetailService.loadUserByUsername()方法时,可以返回用户的权限信息。
Place2:接口调用进行身份认证过滤器时JWTAuthenticationFilter,需要返回用户权限信息。
2)实现过程
1)) 在userDetailserviceImpl文件中相关代码如下:
@Autowired
UserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.getUserByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户名或密码不正确");
}
return new AccountUser(user.getId(),user.getUsername(),user.getPassword(),getUserAuthority(user.getUsername())); // 授权时传递用户角色
}
// 获取用户权限信息(角色权限、网站操作权限)
public List<GrantedAuthority> getUserAuthority(String username) {
// 角色(ROLE_normal)、网站操作权限( sys:item:list )
String authority = userService.getUserAuthorityInfo(username); // ROLE_normal, sys:item:list, ...
return AuthorityUtils.commaSeparatedStringToAuthorityList(authority);
}
2)) 在UserServiceImpl中相关代码如下:
@Override
public String getUserAuthorityInfo(String username) {
// ROLE_normal, sys:item:list, ...
String authority = "";
// 获取到角色,加前缀
User user = userDAO.getUserByUsername(username);
authority = "ROLE_" + user.getRole();
// 获取网站操作权限相关的编码
String permsList = roleDAO.getPermsByRolename(user.getRole());
authority = authority.concat(",").concat(permsList);
return authority;
}
3)) 在JWTAuthenticationFilter中相关代码如下:
// 获取用户的权限信息
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username,null,userDetailService.getUserAuthority(user.getUsername()));
- 给用户的权限信息加缓存:
1)) 在UserServiceImpl中相关代码如下:
@Override
public String getUserAuthorityInfo(String username) {
// ROLE_normal, sys:item:list, ...
String authority = "";
if ( redisUtil.hasKey("GrantedAuthority:"+username) ) {
authority = (String)redisUtil.get("GrantedAuthority:"+username);
} else {
// 获取到角色,加前缀
User user = userDAO.getUserByUsername(username);
authority = "ROLE_" + user.getRole();
// 获取网站操作权限相关的编码
String permsList = roleDAO.getPermsByRolename(user.getRole());
authority = authority.concat(",").concat(permsList);
// 对当前用户相关权限字符串进行缓存
redisUtil.set("GrantedAuthority:"+username,authority,60*60);
}
return authority;
}
这引发了一个问题,什么时候清除缓存?
设计一个清除缓存的接口:
@Override
public void clearUserAuthorityInfo(String username) {
redisUtil.del("GrantedAuthority:"+username);
}