SpringBoot+SpringSecurity+Redis+jwt实现前后端分离携带验证码(实战)

愿天堂没有BUG

共 18007字,需浏览 37分钟

 ·

2022-01-13 16:35

写在前面的话

  • 小白记录第一次整合Springboot+Springsecurity+hutool+redis+jwt。

  • 本文章使用了大量hutool的工具类,请悉知。

  • 本文章中心是携带验证码+账号密码请求后端验证,使用Redis存储验证码。

  • 默认在阅读本篇文章的朋友们对Springboot、SpringSecurity、redis、jwt已有认知。

  • 因为本项目属于本人的一个练手项目,所以包含的一些pom依赖如不需要请自行剥离,仅需SpringSecurity、redis、jwt即可。

  • 如有疑问请评论区友好交流指点。

后续1(2021-12-05)

在写本项目的时候,我发现退出登录后,虽然SpringSecurity已经删除了凭证,但是jwt还是可以凭借token访问,这显然是不太完美的。结合百度,我想到了使用redis来存储token,实现退出登录后及时删除token。请滑到文档底部查看最新改动代码。

后续2(2021-12-07)

看到评论区朋友问有没有git地址,刚才上线脱敏了下配置,这就把地址开源出来了。
后端:e.coding.net/yueranzs/vu…
这个练手项目叫做新冠物资管理系统,b站看了视频,结合自己技术把前端后端都做了大变动。
前端:e.coding.net/yueranzs/vu…

后端目前图片存储使用到的技术是阿里云OSS(自封装OSSUtil),大家可以自行复制。有疑问或补充请评论区友好交流。
因为本身自己是个后端程序猿所以前端很多注释,应该是易懂的。

orz,不过都还没有做完,正在一步步学习中,前端架构是拜托了一个大佬教我学习更改的,更容易扩展,大家如想学习,可以down下来运行试试看。前端我可能没做脱敏数据,目前也在学习怎么把vue部署在自己的群晖NAS虚拟机上,最终结果出来我将直接公开域名,供大家访问。

引入相关依赖


1.8
UTF-8
UTF-8
2.3.7.RELEASE
1.2.4
5.7.16
5.1.0
3.4.1
2.2
0.11.2




org.springframework.boot
spring-boot-starter



org.springframework.boot
spring-boot-devtools
runtime
true


mysql
mysql-connector-java
runtime


org.springframework.boot
spring-boot-configuration-processor
true


org.projectlombok
lombok
true


org.springframework.boot
spring-boot-starter-test
test


org.junit.vintage
junit-vintage-engine






org.springframework.boot
spring-boot-starter-web




org.springframework.boot
spring-boot-starter-validation



org.springframework.boot
spring-boot-starter-aop





com.alibaba
druid-spring-boot-starter
${druid.version}





cn.hutool
hutool-all
${hutool.version}



org.apache.poi
poi
${poi.version}


org.apache.poi
poi-ooxml
${poi.version}




com.baomidou
mybatis-plus-boot-starter
${mybatis-plus.version}



com.baomidou
mybatis-plus-generator
${mybatis-plus.version}



org.apache.velocity
velocity-engine-core
${mybatis-plus-velocity.version}




org.springframework.boot
spring-boot-starter-data-redis




org.springframework.boot
spring-boot-starter-security




io.jsonwebtoken
jjwt-api
${jwt.version}


io.jsonwebtoken
jjwt-impl
${jwt.version}
runtime


io.jsonwebtoken
jjwt-jackson
${jwt.version}
runtime



复制代码

验证码存在redis中,并返回base64到前端

  • 本文章使用的是redis存储验证码,具体组成为key:code_xxx,value:英文数字(四位)

  • 验证码图返回的base64使用的是hutool工具类(强烈推荐)

LoginController

//randomCode是一个时间戳,由前端生成后请求后端,具体是防止redis中的key重复
@ApiOperation(value = "验证码",notes = "获取验证码")
@GetMapping("/getRandomCode")
public Result getRandomCode(@RequestParam String randomCode){
if (ObjectUtil.isEmpty(randomCode)) {
return Result.error(500,"请输入验证码!");
}
return Result.successData(loginService.getRandomCode(randomCode));
}
复制代码

LoginServiceImpl

/**
* 获取验证码Base64
*
* @param randomCode
* @return
*/
@Transactional(propagation = Propagation.SUPPORTS,rollbackFor = Exception.class)
@Override
public String getRandomCode(String randomCode) {
//定义图形验证码的长、宽、验证码字符数、干扰元素个数
ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(90, 34, 4, 3);
//设置背景颜色
captcha.setBackground(Color.WHITE);
//验证图形验证码的有效性,返回boolean值
captcha.verify("60");
//将字符长存入redis,并判断redis中是否存在
//RedisUtil,我一会贴在下面
//TimeUnit是个枚举类,我这里选择是以秒计时,如60秒后过期清除当前验证码
boolean redisCode = RedisUtil.set("code_" + randomCode, captcha.getCode(), 过期时长, TimeUnit.SECONDS);
//如果存入redis中失败,抛出异常
//这里是自定义异常类,可以自行处理,不影响
if (!redisCode) {
new BusinessException(状态码, 返回提示信息);
}
//3.这里只返回Base64字符串用来展示
return captcha.getImageBase64Data();
}
复制代码

RedisUtil工具类

/**
* 掘金里无法导入整个redis工具类,我这里挑了几个需要的方法,仅供参考
* redis工具类
* @author yueranzs
* @date 2021-03-04 10:08
*/
public class RedisUtil {
//因为普通类无法直接使用RedisTemplate,这里用hutool中的SpringUtil来获取bean
//如没引入hutool的可以百度下springboot中java普通类怎么调用mapper或service中的接口
//关键注解@Component、@PostConstruct
private static final RedisTemplate redisTemplate = SpringUtil.getBean("redisTemplate");

/**
* 普通缓存放入
*
* @param key 键
* @param value 值
* @return true成功 false失败
*/
public static boolean set(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}

}

/**
* 删除缓存
*
* @param key 可以传一个值 或多个
*/
@SuppressWarnings("unchecked")
public static void del(String... key) {
if (key != null && key.length > 0) {
if (key.length == 1) {
redisTemplate.delete(key[0]);
} else {
redisTemplate.delete(CollectionUtils.arrayToList(key));
}
}
}
}
复制代码

异常类

/**
* @author yueranzs
* @date 2021-11-03 17:55
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class BusinessException extends RuntimeException{

@ApiModelProperty(value = "状态码")
private Integer code;

@ApiModelProperty(value = "错误信息")
private String errMsg;

}
复制代码

全局异常处理

/**
* 全局异常处理
* @author yueranzs
* @date 2021-11-01 11:55
*/
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {


/**
* 这里的意思是,只要捕获到BusinessException异常,那么就执行此方法
*/
@ExceptionHandler(BusinessException.class)
@ResponseBody
public Result error(BusinessException exception){
log.error(exception.getErrMsg());
return Result.error(exception.getCode(), exception.getErrMsg());
}



}
复制代码

封装返回类

/**
* 封装返回类
* @author yueranzs
* @date 2021-11-01 10:51
*/
@Data
public class Result {

@ApiModelProperty(value = "是否成功")
private Boolean success;

@ApiModelProperty(value = "响应码")
private Integer code;

@ApiModelProperty(value = "提示信息")
private String message;

@ApiModelProperty(value = "返回数据")
private Object data;

/**
* 构造方法私有化,里面的方法都是静态方法
* 达到保护属性的作用
*/
private Result(){

}

/**
* 这里使用链式编程
* @return
*/
public static Result ok(){
Result result = new Result();
result.setSuccess(true);
result.setCode(ResultCode.SUCCESS.getCode());
result.setMessage(ResultCode.SUCCESS.getMessage());
return result;
}
public static Result ok(Integer code,String message){
Result result = new Result();
result.setSuccess(true);
result.setCode(code);
result.setMessage(message);
return result;
}
public static Result error(){
Result result = new Result();
result.setSuccess(false);
//失败code
result.setCode(ResultCode.COMMON_FAIL.getCode());
//失败message
result.setMessage(ResultCode.COMMON_FAIL.getMessage());
return result;
}
public static Result error(Integer code,String message){
Result result = new Result();
result.setSuccess(false);
result.setCode(code);
result.setMessage(message);
return result;
}
public static Result successData(Object data){
Result result = new Result();
result.setSuccess(true);
//成功code
result.setCode(ResultCode.SUCCESS.getCode());
//成功message
result.setMessage(ResultCode.SUCCESS.getMessage());
result.setData(data);
return result;
}
public static Result errorData(Object data){
Result result = new Result();
result.setSuccess(false);
//失败code
result.setCode(ResultCode.COMMON_FAIL.getCode());
//失败message
result.setMessage(ResultCode.COMMON_FAIL.getMessage());
result.setData(data);
return result;
}

/**
* 自定义
* @param success
* @return
*/
public Result success(Boolean success){
this.setSuccess(success);
return this;
}
public Result message(String message){
this.setMessage(message);
return this;
}
public Result code(Integer code){
this.setCode(code);
return this;
}
public Result data(Object data){
this.setData(data);
return this;
}

}
复制代码

访问获取验证码接口

postman请求返回的数据和结构

前端页面展示情况

redis中存储情况

编写SecurityConfig配置类

关于EnableGlobalMethodSecurity

  • 当我们想要开启spring方法级安全时,只需要在任何 @Configuration 实例上使用 @EnableGlobalMethodSecurity 注解就能达到此目的。同时这个注解为我们提供了prePostEnabled 、securedEnabled 和 jsr250Enabled 三种不同的机制来实现同一种功能。

  • 具体请访问链接,有详细解释:blog.csdn.net/chihaihai/a…

/**
* @author yueranzs
* @date 2021/11/22 13:56
*/
@Configuration
//开启springsecurity
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private LoginAuthenticationProvider loginAuthenticationProvider;
@Autowired
private LoginUserDetails loginUserDetails;
@Autowired
private AuthenticationDetailsSource authenticationDetailsSource;

/**
* SpringSecurity5.X要求必须指定密码加密方式,否则会在请求认证的时候报错
* 同样的,如果指定了加密方式,就必须您的密码在数据库中存储的是加密后的,才能比对成功
* @return
*/
@Bean
protected BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}

/**
* 注入自定义jwttoken过滤器
*/
@Bean
protected JwtAuthenticationTokenFilter authenticationTokenFilter() throws Exception{
return new JwtAuthenticationTokenFilter();
}

/**
* 角色继承,比如在一个系统中admin属于最高角色"超级管理员",那么他将拥有其他角色所有的权限
* 以>来设置
* admin > user > normal > ......
* @return
*/
@Bean
RoleHierarchy roleHierarchy(){
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
hierarchy.setHierarchy("ROLE_admin > ROLE_user");
return hierarchy;
}

/**
* 静态资源放行
*/
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers("/js/**", "/css/**","/images/**");
}

/**
* Springsecurity默认不携带验证码进行验证,所以这里我们需要重写相关配置类,一会请看代码
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//将自定义的Provider装配到Builder
auth.authenticationProvider(loginAuthenticationProvider);
//将自定义的loginserviceimpl装配到builder
auth.userDetailsService(loginUserDetails).passwordEncoder(new PasswordEncoder() {
@Override
public String encode(CharSequence rawPassword) {
return rawPassword.toString();
}

@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return encodedPassword.equals(rawPassword.toString());
}
});
}

@Override
protected void configure(HttpSecurity http) throws Exception {
//Springsecurity放行规则,permitAll是针对所有方法。
//目使用了swagger,所以需要将swagger相关的url放行。
//SpringseCurity的放行规则由上往下,如果前者已被拦截,
//不再执行,所以这就是为什么.anyRequest().authenticated()需要放在最后的原因。
http.authorizeRequests()
.antMatchers("/webjars/**").permitAll()
.antMatchers("/swagger-resources/**").permitAll()
.antMatchers("/v2/*").permitAll()
.antMatchers("/login/**").permitAll()
//剩下方法拦截
.anyRequest().authenticated()
.and()
.formLogin()
//登录页
.loginPage("/login.html")
//登录请求接口,如果url为空也会默认将loginPage的值赋值给url
//可能习惯性会认为需要自己写一个/login/loginUser的接口,
//但其实这里是交给SpringSecurity自己去检验的,默认情况下只需要携带form-data类型的账号密码提交即可。
//本项目将会对默认请求进行重写,使用存在redis中的验证码验证
.loginProcessingUrl("/login/loginUser")
//设置登录参数别名
//SpringSecurity默认情况下账号和密码的属性名为username、password。
//当然也可以跟我一样重新设置别名。(虽然设置的是一样的,orz)
.usernameParameter("username")
.passwordParameter("password")
//登录成功后的回调,我看其他博客写的是自定义返回类,因为我并没做其他操作,就简单一点吧,看后面代码。
//为什么是HttpResponseResult::loginSuccess而其他的却是->?
//因为登录成功接口我的形参和该方法形参一致,所以可以这样写
.successHandler(HttpResponseResult::loginSuccess)
//登录失败回调
.failureHandler((req, resp, e) -> HttpResponseResult.loginError(resp,e))
//权限不足回调
.accessDeniedHandler(HttpResponseResult::insufficientPermissions)
//自定义authenticationDetailsSource,目的是为了获取请求的验证码等信息
.authenticationDetailsSource(authenticationDetailsSource)
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((req, resp, auth) -> HttpResponseResult.noLogin(resp))
.and()
//设置无状态的连接,即不创建session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
//退出登录
.logout()
.logoutUrl("/login/logout")
.logoutSuccessHandler((req,resp,auth) ->HttpResponseResult.logout(resp))
.permitAll()
.and()
;

//使用自定义的jwttoken过滤器来进行验证
http.addFilterBefore(authenticationTokenFilter(),UsernamePasswordAuthenticationFilter.class);
//禁止页面缓存
http.headers().cacheControl();
}

}
复制代码

编写SpringSecurity的回调返回类

/**
* 针对返回响应的封装
* @author yueranzs
* @date 2021/11/22 14:13
*/
@Data
public class HttpResponseResult {


/**
* 基础返回
* @param resp
* @param jsonObject
* @throws IOException
*/
public static void base(HttpServletResponse resp,JSONObject jsonObject) throws IOException {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.println(jsonObject);
out.flush();
out.close();
}
/**
* 响应返回封装
* @param resp
* @param resultCode
* @return
*/
public static void data(HttpServletResponse resp,CustomizeResultCode resultCode) throws IOException {
JSONObject result = new JSONObject();
result.set("code",resultCode.getCode());
result.set("message",resultCode.getMessage());
base(resp,result);
}

/**
* 暂无凭证或是认证失败
* @param resp
*/
public static void noProof(HttpServletResponse resp) throws IOException {
data(resp,UserResultCode.USER_NOT_PROOF);
}

/**
* 登录失败
* @param resp
* @param exception security的认证异常
*/
public static void loginError(HttpServletResponse resp,AuthenticationException exception) throws IOException {
if (exception instanceof LockedException) {
//账户锁定
data(resp,UserResultCode.USER_ACCOUNT_LOCKED);
} else if (exception instanceof CredentialsExpiredException) {
//密码过期
data(resp,UserResultCode.USER_CREDENTIALS_EXPIRED);
} else if (exception instanceof AccountExpiredException) {
//账户过期
data(resp,UserResultCode.USER_ACCOUNT_EXPIRED);
} else if (exception instanceof DisabledException) {
//账户禁用
data(resp,UserResultCode.USER_ACCOUNT_DISABLE);
} else if (exception instanceof BadCredentialsException) {
//用户名或者密码输入错误
data(resp,UserResultCode.USER_LOGIN_ERROR_NO);
}else if (exception instanceof InternalAuthenticationServiceException){
//用户不存在
data(resp,UserResultCode.USER_ACCOUNT_NOT_EXIST);
}
}

/**
* 退出
* @param resp
*/
public static void logout(HttpServletResponse resp) throws IOException {
data(resp,UserResultCode.USER_LOGOUT_SUCCESS);
}

/**
* 登录成功
* @param req
* @param resp
* @param auth
*/
public static void loginSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication auth) throws IOException {
//生成token
JwtUtil jwtUtil = new JwtUtil();
Map user = new HashMap<>();
user.put("username",auth.getName());
//token我只包含了username,因为在下面自定义jwttoken过滤器里面会查询角色等信息
String token = jwtUtil.create(user);
base(resp,new JSONObject().set("code",200).set("data",token));
}

/**
* 权限不足
* @param req
* @param resp
* @param e
*/
public static void insufficientPermissions(HttpServletRequest req, HttpServletResponse resp, AccessDeniedException e) throws IOException {
data(resp,UserResultCode.USER_INSUFFICIENT_PERMISSIONS);
}
}
复制代码

User类(pojo)

注意,下面用户表是mybatis-plus生成,如果想通过SpringSecurity验证,需要实现UserDetails

/**
*


* 用户表
*


*
* @author yueranzs
* @since 2021-11-04
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("tb_user")
@ApiModel(value="User对象", description="用户表")
public class User implements Serializable, UserDetails {

private static final long serialVersionUID = 1L;

@ApiModelProperty(value = "用户ID")
@TableId(value = "id", type = IdType.AUTO)
private Long id;

@ApiModelProperty(value = "用户名")
private String username;

@ApiModelProperty(value = "昵称")
private String nickname;

@ApiModelProperty(value = "邮箱")
private String email;

@ApiModelProperty(value = "头像")
private String avatar;
@ApiModelProperty(value = "头像临时签名")
@TableField(exist = false)
private String avatarUrl;

@ApiModelProperty(value = "联系电话")
private String phoneNumber;

@ApiModelProperty(value = "状态 0锁定 1有效")
private Integer status;

@ApiModelProperty(value = "创建时间")
private Date createTime;

@ApiModelProperty(value = "修改时间")
private Date modifiedTime;

@ApiModelProperty(value = "性别 0男 1女 2保密")
private Integer sex;

@ApiModelProperty(value = "盐")
private String salt;

@ApiModelProperty(value = "0:超级管理员,1:系统用户")
private Integer type;

@ApiModelProperty(value = "密码")
private String password;

@ApiModelProperty(value = "生日")
private Date birth;

@ApiModelProperty(value = "部门id")
private Long departmentId;

@ApiModelProperty(value = "逻辑删除")
private Integer deleted;

@ApiModelProperty(value = "角色信息")
//mybatis-plus中的注解,即在对数据库操作时忽略本字段
@TableField(exist = false)
private Set authorities;

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

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

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

@Override
public boolean isEnabled() {
return true;
}
}
复制代码

编写LoginAuthenticationDetailsSource类

/**
* 描述:自定义AuthenticationDetailsSource,将HttpServletRequest注入到AuthenticationDetails,使其能获取到请求中的验证码等其他信息
* @author yueranzs
* @date 2021/12/1 9:42
*/
@Component
public class LoginAuthenticationDetailsSource implements AuthenticationDetailsSource {
@Override
public WebAuthenticationDetails buildDetails(HttpServletRequest request) {
return new LoginWebAuthenticationDetails(request);
}
}
复制代码

编写LoginWebAuthenticationDetails类

/**
* 描述:自定义WebAuthenticationDetails,将验证码和用户名、密码一同带入AuthenticationProvider中
* @author yueranzs
* @date 2021/12/1 9:38
*/
public class LoginWebAuthenticationDetails extends WebAuthenticationDetails {
private static final long serialVersionUID = 6975601077710753878L;
/*验证码value*/
private final String code;
/*验证码key*/
private final String randomCode;
public LoginWebAuthenticationDetails(HttpServletRequest request) {
super(request);
//这里的code是指验证码真实code,即redis中的验证码value,可自行修改成自己项目的属性名
code = request.getParameter("code");
//redis中的验证码key,可自行修改成自己项目的属性名
randomCode = request.getParameter("randomCode");
}

public String getRandomCode() {
return randomCode;
}
public String getCode() {
return code;
}

@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(super.toString()).append("; code: ").append(this.getCode());
sb.append(super.toString()).append("; randomCode: ").append(this.getRandomCode());
return sb.toString();
}
}
复制代码

编写LoginUserDetails类

/**
* @author yueranzs
* @date 2021/12/1 11:38
*/
@Component
public class LoginUserDetails implements UserDetailsService {

@Autowired
private UserService userService;

/**
* 这里是根据username(账号)去查询数据库,然后进行检验
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//mybatis-plus的语句,意思是查询单个的用户根据用户名(username)和伪删除(delflag)来查
User user = userService.getOne(new QueryWrapper().lambda().select(User::getId,User::getUsername, User::getPassword)
.eq(User::getUsername, username)
.eq(User::getDeleted, ResultCode.NODELETE.getCode()));
if (ObjectUtil.isNull(user)) {
//用户不存在,抛出SpringSecurity异常
throw new InternalAuthenticationServiceException(UserResultCode.USER_ACCOUNT_NOT_EXIST.getMessage());
}
//查询角色
List roles = userService.getRolesByUserId(user.getId());
Set authorities = new HashSet();
//注意:SpringSecurity授权分两种:角色和权限
//角色授权:在授权时,前缀必须加上"ROLE_",一般使用AuthorityUtils.commaSeparatedStringToAuthorityList(字符串,用逗号添加多个role)
//AuthorityUtils.commaSeparatedStringToAuthorityList就不需要自己加"ROLE_"了
//权限授权:不需要加前缀

//后面的hasRole和hasAuthority千万不要搞错了,Role是角色,Authority是权限,我当初就是看错了,找了很久的问题,后面看代码
roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleName())));

//千万要记得查询到角色信息后记得设置
user.setAuthorities(authorities);

//返回
return user;
}
}
复制代码

编写LoginAuthenticationProvider类

/**
* 描述:自定义SpringSecurity的认证器
* @author yueranzs
* @date 2021/12/1 9:44
*/
@Component
public class LoginAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

@Autowired
private LoginUserDetails loginUserDetails;

@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

}

@Override
protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
return null;
}

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//用户名
String username = authentication.getName();
//密码
String password = authentication.getCredentials().toString();
LoginWebAuthenticationDetails loginWebAuthenticationDetails= (LoginWebAuthenticationDetails)authentication.getDetails();
//验证码key
String randomCode = loginWebAuthenticationDetails.getRandomCode();
//验证码value
String code = loginWebAuthenticationDetails.getCode();
//验证码是否为空
if (ObjectUtil.isEmpty(randomCode) || ObjectUtil.isEmpty(code)) {
throw new NullPointerException("请输入验证码");
}
//检验验证码是否正确
if (!validateVerifyRandomCode(randomCode,code)) {
throw new BusinessException(UserResultCode.REDIS_CODE.getCode(), UserResultCode.REDIS_CODE.getMessage());
}
User user = (User) loginUserDetails.loadUserByUsername(username);
//密码是否一致
if (!user.getPassword().equals(SecureUtil.md5(password))) {
//密码错误,不过因为安全性的问题所以返回此异常,意思是用户名或者密码错误
throw new BadCredentialsException(UserResultCode.USER_CREDENTIALS_ERROR.getMessage());
}
//删除redis的验证码
RedisUtil.del("code_" + randomCode);

return this.createSuccessAuthentication(user,authentication,user);
}

/**
* 验证用户输入的验证码
* @param randomCode 验证码key
* @param code 验证码value
* @return
*/
public boolean validateVerifyRandomCode(String randomCode,String code){
//验证码是否一致
Object redisCode = RedisUtil.get("code_" + randomCode);
return ObjectUtil.equals(code, redisCode);
}
}
复制代码

编写Jwt配置类

/**
* jwt配置类
* @author yueranzs
* @date 2021/12/4 9:57
*/
@Data
@ToString
@Configuration
//与配置文件中的数据关联起来(这个注解会默认自动匹配jwt开头的配置)
@ConfigurationProperties(prefix = "jwt")
public class JwtConfig {

/*request Headers : Authorization*/
private String header;

/*Base64对该令牌进行编码*/
private String base64Secret;

/*令牌过期时间 此处单位/毫秒 */
private Long tokenValidityInSeconds;

}
复制代码

JwtUtil工具类

注意,本工具类建立在hutool工具类的基础上,仅供参考,部分属性值请视自己情况定

/**
* jwt工具类
* @author yueranzs
* @date 2021/11/25 15:55
*/
@Component
public class JwtUtil {

private static JwtConfig jwtConfig;

@Autowired
private void setJwtConfig(JwtConfig jwtConfig){
JwtUtil.jwtConfig = jwtConfig;
}

/**
* 生成jwt
* @param payload 数据主体
* @return
*/
public String create(Map payload){
//每个jwt都默认生成一个到期时间
payload.put("expire_time", DateUtil.current() + jwtConfig.getTokenValidityInSeconds());
//生成私钥
JWTSigner jwtSigner = JWTSignerUtil.hs256(jwtConfig.getBase64Secret().getBytes(StandardCharsets.UTF_8));
//生成token
return JWTUtil.createToken(payload,jwtSigner);
}

/**
* 解析jwt
* @param token
* @return
*/
public JSONObject parse(String token){
return JWTUtil.parseToken(token).getPayload().getClaimsJson();
}

/**
* 校验token是否正确
* @param token
* @return
*/
public boolean verifyToken(String token){
//先判断是否到期,再判断是否正确
if (expiredToken(token)) {
return JWTUtil.verify(token,jwtConfig.getBase64Secret().getBytes(StandardCharsets.UTF_8));
}
return false;
}

/**
* 校验token是否过期
* @param token
* @return
*/
public boolean expiredToken(String token){
return DateUtil.current() < getExpiredToken(token);
}

/**
* 获取token过期时间
* @param token
* @return
*/
public long getExpiredToken(String token){
return Long.parseLong(parse(token).get("expire_time").toString());
}

/**
* 获取登录人账号
* @param token
* @return
*/
public String getUserNameToken(String token){
return parse(token).get("username").toString();
}

/**
* 获取登录人角色集合
* @param token
* @return
*/
public Set getRolesToken(String token){
return (Set) parse(token).get("authorities");
}

}
复制代码

applicaiton.yml中进行追加jwt信息

jwt:
# 请求头,就是在header中携带的令牌名称,任意名字都可以
header: Authorization
# 盐值
base64-secret: jwt加密的密钥,任意填写
# 过期时间 ,单位/毫秒
token-validity-in-seconds: 过期时间
复制代码

编写JwtAuthenticationTokenFilter过滤类

/**
* jwttokenfilter
* @author yueranzs
* @date 2021/12/4 10:14
*/
@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

@Resource
private UserDetailsService userDetailsService;

@Resource
private JwtUtil jwtUtil;

@Resource
private JwtConfig jwtConfig;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestUrl = request.getRequestURI();
String authToken = request.getHeader(jwtConfig.getHeader());
String userName = null;
if (ObjectUtil.isNotEmpty(authToken)) {
userName = jwtUtil.getUserNameToken(authToken);
}

log.info("进入jwt自定义token过滤器");
log.info("自定义token过滤器获得用户名为:" + userName);

//当userName不为空时进行校验token是否为有效token
//ObjectUtil.isNotEmpty()和ObjectUtil.isNull()是hutool中的方法。
/*
前者意思是指对象是否不为空,和isNotNull()不同。
比如"",isNotNull()会返回true而isNotEmpty()会返回false。
userName是字符串所以使用isNotEmpty(),该方法也很适合集合判空
*/
/*
getAuthentication()使用isNull()原因是:
通过前面几个代码块的代码,可以看出是存储授权信息的
这里的意思是如果用户名不为空并且授权信息又有值,那么就直接跳过,反之就是进入下面的if内部
*/
if (ObjectUtil.isNotEmpty(userName) && ObjectUtil.isNull(SecurityContextHolder.getContext().getAuthentication())) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(userName);
User user = (User) userDetails;
//检验token
if (!jwtUtil.verifyToken(authToken)) {
throw new BusinessException(500,"token已过期");
}else if (StrUtil.equals(userName,user.getUsername())){
/**
* UsernamePasswordAuthenticationToken继承AbstractAuthenticationToken实现Authentication
* 所以当在页面中输入用户名和密码之后首先会进入到UsernamePasswordAuthenticationToken验证(Authentication),
* 然后生成的Authentication会被交由AuthenticationManager来进行管理
* 而AuthenticationManager管理一系列的AuthenticationProvider,
* 而每一个Provider都会通UserDetailsService和UserDetail来返回一个
* 以UsernamePasswordAuthenticationToken实现的带用户名和密码以及权限的Authentication
*/
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//将authentication放入SecurityContextHolder中
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request,response);
}
}
复制代码

hasRole、hasAuthority

关于这两只的区别可以看链接:Spring Security 中的 hasRole 和 hasAuthority 有区别吗? - 云+社区 - 腾讯云 (tencent.com)

/**
* 前面代码块中我说过这两个注解千万不要混淆,虽然在使用上,都并不需要加前缀
* 但我之前没注意清楚,在给用户授权时我写了ROLE_admin,但是使用的是hasAuthority
* 也就导致我怎么都访问不了这个方法,后面半信半疑hasAuthority('ROLE_admin')才能访问
* 再后来发现是自己用错方法了,换上hasRole('admin')就没问题
*
* @PreAuthorize可以看我第一个分享的链接
* hashRole和hasAuthority在springsecurity4的时候才有了ROLE_前缀区分,早期几乎是一模一样的
* @return
*/
@PreAuthorize("hasAuthority('admin')")
@ApiOperation(value = "测试一下",notes = "测试一下")
@GetMapping("/test")
public Result test(){
return Result.successData("hahah");
}
复制代码

运行效果

登录成功

登录失败

token过期

暂无权限

ps:其他的一些状态码暂未测试,目前这些也已足以,后续如有其他需要补充的我会再来添代码。就先这样吧。谢谢阅读。

后续1-改造代码(2021-12-05)

调整SpringSecurity的回调返回类中loginSuccess()

//加上下面注解,因为需要获取jwtConfig的过期时间
@Component
public class HttpResponseResult {

//关于jwtConfig的都是需要新增的代码
private static JwtConfig jwtConfig;

@Autowired
private void setJwtConfig(JwtConfig jwtConfig){
HttpResponseResult.jwtConfig = jwtConfig;
}

/**
* 登录成功
* @param req
* @param resp
* @param auth
*/
public static void loginSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication auth) throws IOException {
//生成token
JwtUtil jwtUtil = new JwtUtil();
Map user = new HashMap<>();
user.put("username",auth.getName());
String token = jwtUtil.create(user);

//将token存入redis
//这里的key结构为:"token_" + userName
//token失效时间和jwt失效时间保持一致
//jwtConfig.getTokenValidityInSeconds():获取jwt失效时间
RedisUtil.set("token_" + auth.getName(), token, jwtConfig.getTokenValidityInSeconds(), TimeUnit.SECONDS);

base(resp,new JSONObject().set("code",200).set("data",token));
}

}
复制代码

调整SecurityConfig部分代码

将关于退出登录的security方法全部删除,为什么?
本来是想在退出方法里进行删除redis的token,但是因为存储token的key一部分是获取当前userName,而只要访问了logout()就默认进入了security的过滤器,如果想改变的话会比较麻烦,所以我打算自行实现,很方便。


只需注释"//退出登录,这里划重点,我全部注释了"下面代码即可。

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/webjars/**").permitAll()
.antMatchers("/swagger-resources/**").permitAll()
.antMatchers("/v2/*").permitAll()
.antMatchers("/login/getRandomCode","/login/getUserAvatar").permitAll()
// .antMatchers("/admin/**").hasRole("admin")
// .antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()
.and()
.formLogin()
//登录页
.loginPage("/login.html")
//登录请求接口,如果url为空也会默认将loginPage的值赋值给url
.loginProcessingUrl("/login/loginUser")
//设置登录参数别名
.usernameParameter("username")
.passwordParameter("password")
.successHandler(HttpResponseResult::loginSuccess)
.failureHandler((req, resp, e) -> HttpResponseResult.loginError(resp,e))
.authenticationDetailsSource(authenticationDetailsSource)
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((req, resp, auth) -> HttpResponseResult.noProof(resp))
.accessDeniedHandler(HttpResponseResult::insufficientPermissions)
.and()
//设置无状态的连接,即不创建session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
//退出登录,这里划重点,我全部注释了
/*.logout()
.logoutUrl("/login/logout")
.logoutSuccessHandler(HttpResponseResult::logout)
.permitAll()
.and()*/
;

http.addFilterBefore(authenticationTokenFilter(),UsernamePasswordAuthenticationFilter.class);
//禁止页面缓存
http.headers().cacheControl();
}
复制代码

新增UserUtil工具类

方便获取当前登陆人信息

/**
* @author yueranzs
* @date 2021/12/5 12:24
*/
public class UserUtil {


/**
* 获取当前登陆人信息
* @return
*/
public static User getUser(){
return (User)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
/**
* 获取当前登录人账号
* @return
*/
public static String getUserName(){
return getUser().getUsername();
}
/**
* 获取当前登录人编号
* @return
*/
public static Long getUserId(){
return getUser().getId();
}

/**
* 获取当前登录人角色信息
* @return
*/
public static Set getUserRole(){
return getUser().getAuthorities();
}

}
复制代码

LoginController中新增logout()退出登录方法

@ApiOperation(value = "退出登录",notes = "退出登录")
@GetMapping("/logout")
public JSONObject logout(){
return loginService.logout();
}
复制代码

LoginService实现类

/**
* 退出登录
*
* @return
*/
@Override
public JSONObject logout() {
//仅作返回退出登录的结果
JSONObject object = new JSONObject();
//查找redis中是否存在此token,如果不为空(isNotEmpty的用法上面说过)就删除该token
if (ObjectUtil.isNotEmpty(RedisUtil.get("token_" + UserUtil.getUserName()))) {
//清除token,注意顺序不要弄反了
//要先清除redis的token才能清除认证对象,不然是无法通过UserUtil获取到userName的
RedisUtil.del("token_" + UserUtil.getUserName());
//只需要清除认证对象,因为在springsecurity中并没有设置session,所以不需要清空
SecurityContextHolder.getContext().setAuthentication(null);

//退出登录成功code
object.set("code",UserResultCode.USER_LOGOUT_SUCCESS.getCode());

//退出登录成功message
object.set("message",UserResultCode.USER_LOGOUT_SUCCESS.getMessage());
return object;
}
//退出登录失败code
object.set("code",UserResultCode.USER_LOGOUT_ERROR.getCode());

//退出登录失败message
object.set("message",UserResultCode.USER_LOGOUT_ERROR.getMessage());
return object;
}
复制代码

调整JwtAuthenticationTokenFilter

/**
* jwttokenfilter
* @author yueranzs
* @date 2021/12/4 10:14
*/
@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

@Resource
private UserDetailsService userDetailsService;

@Resource
private JwtUtil jwtUtil;

@Resource
private JwtConfig jwtConfig;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestUrl = request.getRequestURI();
String authToken = request.getHeader(jwtConfig.getHeader());
String userName = null;
if (ObjectUtil.isNotEmpty(authToken)) {
userName = jwtUtil.getUserNameToken(authToken);
}

log.info("进入jwt自定义token过滤器");
log.info("自定义token过滤器获得用户名为:" + userName);

//当userName不为空时进行校验token是否为有效token
if (ObjectUtil.isNotEmpty(userName) && ObjectUtil.isNull(SecurityContextHolder.getContext().getAuthentication())) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(userName);
User user = (User) userDetails;
//检验token,新增下面的if判断
if(ObjectUtil.isEmpty(RedisUtil.get("token_" + userName))){
throw new BusinessException(500,"token已被清除");
} else if (!jwtUtil.verifyToken(authToken)) {
throw new BusinessException(500,"token已过期");
}else if (StrUtil.equals(userName,user.getUsername())) {
/**
* UsernamePasswordAuthenticationToken继承AbstractAuthenticationToken实现Authentication
* 所以当在页面中输入用户名和密码之后首先会进入到UsernamePasswordAuthenticationToken验证(Authentication),
* 然后生成的Authentication会被交由AuthenticationManager来进行管理
* 而AuthenticationManager管理一系列的AuthenticationProvider,
* 而每一个Provider都会通UserDetailsService和UserDetail来返回一个
* 以UsernamePasswordAuthenticationToken实现的带用户名和密码以及权限的Authentication
*/
//清除密码
user.setPassword(null);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//将authentication放入SecurityContextHolder中
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request,response);
}
}
复制代码

运行结果

后面的话

那么就先补充到这里吧,等后续还缺漏什么的我会继续更新,谢谢观看。


作者:yueranzs
链接:https://juejin.cn/post/7037792636807151630
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。



浏览 155
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报