springSecurity+JWT+Redis权限认证(完整项目代码)

一、理解springSecurity

springSecurity是权限认证的安全框架,跟市面上的shiro一样,只不过它比shiro更加复杂,而且更灵活,更容易扩展。所有的安全框架基本上都是只做两件事:

1、认证:所谓的认证,简单来说就是你要有用户和密码登录之后才能进入页面

2、权限:所谓的权限,就是你进入页面之后,你有没有权限操作当前页面。假如你是这篇文章的发布者,你就有权限可以对内容修改;而普通用户只能阅读这篇文章,而不能修改。

其实在工作中,我们不一定要用到市场上提供的安全认证框架,而是自己内部自己写的一套权限认证的逻辑,但是不管项目中是否有用到第三方的安全框架,其实流程原理都是一样,只不过可能第三方提供的框架兼用和扩展性更好一点,我们学习完springSecurity后,不过你是否在项目中中使用它,对个人的提升还是很有帮助的。

JWT: 可以简单理解将用户信息通过加密成为字符串的算法。可以简单理解跟uuid一样,只不过uuid的字符串是无意义的,而jwt生成的字符串是有意义的,解析后可以得到携带储存的用户信息

redis:则是纯粹是为了减轻数据库压力,不要每次调用接口都需要访问数据库进行认证和授权。

二、springSecurity的简单使用

首先创建好一个springboot的项目,在pom文件引入springSecurity的依赖,然后访问自己写好的接口,都会跳转到springSecurity提供的登录页面,账号是user,密码在启动的控制台里可以看到,然后输入账号密码就可以访问接口了。随便提一下,springsecurity也提供了默认的注销页面。http://localhost:8080/logout

springSecurity+JWT+Redis权限认证(完整项目代码)_第1张图片

springSecurity+JWT+Redis权限认证(完整项目代码)_第2张图片

三、理解springSecurity的认证流程

很明显在实际的工作当中,我们是不可能用默认的登录和注销页面来进行登录和注销的,我们自己的项目中前端会有登录和注销页面的。所以显然我们还是要对security做相关的配置的,而不能用默认的配置的。当然,我们想要对security配置,我们要理解security的整个流程才好写好相关的配置。

springsecurity的认证授权过程是一个过滤器链,其中我们主要重点关心三个就可以了,其实在一般项目中我们主要实现三个过滤器中接口重写其中的方法就可以了

UsernamePasswordAuthenticationFilter: 用户进行登录时所使用到的过滤器

ExceptionTranslationFilter: 认证失败,或者授权失败所抛出的异常处理。

FilterSecurityInterceptor: 授权过程中使用到的过滤器

springSecurity+JWT+Redis权限认证(完整项目代码)_第3张图片

四、springsecurity详细使用过程

这里只抽取流程必要的代码,完整的项目代码和相关表的sql,我放在github上,url地址我会放在最后面

1、定义登录接口,使用authenticationManager的authenticate接口进行用户认证,

需要将AuthenticationManager注入到spring容器中,用于用户登录时调用方法验证。除此之外,springsecurity会把前端的密码明文加密后进行密码校验的,所以我们还要注入BCryptPasswordEncoder类到spring容器中,不然会校验失败的。

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
//使用AuthenticationManager的认证接口惊醒用户认证
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(query.getUserName(), query.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if (authenticate == null) {
    throw new CustomException("登录失败");
}

注意:这一步调用的认证方法,是需要我们重写的,所以这一步之后,是实现UserDetailsService的接口,重写里面的loadUserByUsername方法,把用户的基本信息和权限信息封装给Authentication返回。

@Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        // 查询用户认证信息
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
                .eq(User::getUserName, userName);
        User user = userMapper.selectOne(wrapper);
        if (user == null) {
            throw new CustomException("用户名或者密码错误");
        }

//        List permissions = ListUtil.toLinkedList("test", "admin");
        // 查询用户权限信息
        List<UserPermissionDto> list = menuMapper.getPermissionsByUserId(user.getUserId());
        List<String> permissions = null;
        if (CollUtil.isNotEmpty(list)) {
            permissions = list.stream().map(UserPermissionDto::getPerms)
                    .collect(Collectors.toList());
        }
        // 封装UserDetails
        LoginUser loginUser = new LoginUser(user, permissions);
        return loginUser;
    }

而LoginUser封装的信息,需要实现UserDetails接口,这里springsecurity是使用工厂方法模式创建对象的。

package com.example.security.model.vo;

import cn.hutool.core.collection.CollUtil;
import com.alibaba.fastjson.annotation.JSONField;
import com.example.security.model.entity.User;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @Author GoryLee
 * @Date 2022/12/5 01:57
 * @Version 1.0
 */
@Data
public class LoginUser implements UserDetails {

    private User user;

    private List<String> permissions;

    public LoginUser(User user, List<String> permissions) {
        this.user = user;
        this.permissions = permissions;
    }

    @JSONField(serialize = false )
    private List<SimpleGrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if(CollUtil.isNotEmpty(authorities)){
            return authorities;
        }

        return permissions.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return user != null ? user.getPassword() : null;
    }

    @Override
    public String getUsername() {
        return user != null ? user.getUserName() : null;
    }

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

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

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

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

然后,我们回到登录接口,如果认证成功后,我们要通过JWT生成token返回给前端,以后每次前端调用后端的接口都需要携带这个token进行校验。除此之后,我们还需要把用户基本信息和权限信息存在redis中。

 @PostMapping("/login")
    public Result<Map<String, String>> login(@RequestBody UserBo query) {

        //使用AuthenticationManager的认证接口惊醒用户认证
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(query.getUserName(), query.getPassword());
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        if (authenticate == null) {
            throw new CustomException("登录失败");
        }
        // 认证成功则通过jwt创建token
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String token = JwtUtil.createToken(loginUser.getUser().getUserId(), loginUser.getUser().getUserName());
        Map<String, String> data = new HashMap<>();
        data.put("token", token);

        // 把用户信息存入到redis
        String key = "login:" + loginUser.getUser().getUserId();
        if (redisUtil.containsKey(key)) {
            redisUtil.del(key);
        }
        redisUtil.set(key, loginUser);
//        User user = (User)redisUtil.get("token_" + loginUser.getUser().getId());
        return Result.createSuccess(data);

    }
2、springsecurity的配置

完成登录接口之后,我们还要做相关配置给接口放行,不然的话,直接返回还是会被拦截下来的。自定义类继承WebSecurityConfigurerAdapter,重写configure(HttpSecurity http)方法,包括上面登录时所需要注入的两个bean也是在这里定义的。后续还有认证前的过滤器,异常处理结果过滤器还有跨域全是在这里配置的。

 package com.example.security.config;

import com.example.security.handler.AuthenticationEntryPointHandler;
import com.example.security.handler.CustomAccessDeniedHandler;
import com.example.security.handler.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * @Author GoryLee
 * @Date 2022/12/5 20:24
 * @Version 1.0
 */
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private AuthenticationEntryPointHandler authenticationEntryPointHandler;

    @Autowired
    private CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf功能
                .csrf().disable()
                //不通过session获取securityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 允许匿名访问,登录后不能访问
                .antMatchers("/user/login").anonymous()
                // 无论登录还是未登录都能够访问
                .antMatchers("/user/addUser").permitAll()
                // 除了上面的路径,其他都需要鉴权访问
                .anyRequest().authenticated();

        // 添加过滤器
        http
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        //异常处理
        http
                .exceptionHandling()
                // 认证异常处理器
                .authenticationEntryPoint(authenticationEntryPointHandler)
                // 授权异常处理器
                .accessDeniedHandler(customAccessDeniedHandler);

        // 添加跨域处理
        http
                .cors();

    }

    /**
     * 注入AuthenticationManager到spring容器中,用于用户登录时调用方法验证
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

3、定义接口访问前的过滤器,即第一步认证。

通过上面两步,我们可以登录成功并且可以获取到token。接下来我们访问其他接口就要携带这个token,所以我们要定义过滤器,解析token对用户认证授权。继承OncePerRequestFilter,重写doFilterInternal方法。

package com.example.security.handler;

import cn.hutool.core.util.StrUtil;
import com.example.security.exception.CustomException;
import com.example.security.model.vo.LoginUser;
import com.example.security.utils.RedisUtil;
import example.common.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * token认证过滤器
 * @Author GoryLee
 * @Date 2022/12/6 21:34
 * @Version 1.0
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private RedisUtil redisUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 获取token
        String token = request.getHeader("token");
        if (StrUtil.isEmpty(token)) {
            filterChain.doFilter(request, response);
            return;
        }
        // 解析token获取userId
        Long userId;
        try {
            Claims claims = JwtUtil.parseToken(token);
            userId = claims.get("userId", Long.class);
        } catch (Exception e) {
            throw new CustomException("非法token");
        }
        // 从redis中获取用户信息
        LoginUser loginUser = redisUtil.get("login:" + userId);
        if(loginUser == null ){
            throw new CustomException("用户未登录");
        }
        // 存入SecurityContextHolder认证
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}

在配置类中配置过滤器

				// 添加过滤器
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
4、对接口权限校验,可使用springsecurity提供的校验方法,也使用我们自定义的校验方法

首先,在我们自定义的配置类上面加上

@EnableGlobalMethodSecurity(prePostEnabled = true)

其次,我们要接口接口的方法上面加上,hasAuthority是springsecurity提供的方法,sys:admin则是所需要的权限字符串

@PreAuthorize("hasAuthority('sys:admin')")

进入SecurityExpressionRoot,我们可以看到springsecurity提供的方法

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3SsO2Pu9-1670679608191)(/Users/liguanyu/Documents/图片/typora截图/image-20221210210446130.png)]

当然,我们也可以自己定义校验规则来校验,我们只需要定义一个校验方法的类,把他注入容器中

package com.example.security.expression;

import cn.hutool.core.util.StrUtil;
import com.example.security.model.vo.LoginUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * @Author GoryLee
 * @Date 2022/12/7 22:33
 * @Version 1.0
 */
@Component("customExpression")
public class CustomSecurityExpressionRoot {


    public final boolean hasAuthority(String per) {
        // 从redis中获取用户权限 或者SecurityContextHolder中获取
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        List<String> permissions = loginUser.getPermissions();
        for (String permission : permissions) {
            if(StrUtil.contains(permission,per)){
                return true;
            }
        }
        return false;
    }
}

然后在接口注解中使用sel表达式来使用该方法就可以了,如下使用

@PreAuthorize("@customExpression.hasAuthority('sys')")
5、重写异常结果过滤器,把异常信息按照统一的格式返回给前端

到目前为止,我们就已经完成认证和授权两步,但是还有一个问题,就是认证失败或者授权失败了,我们会发现接口返回的是500,信息是一串英文字符串,这不是我们想要的结果的。

5.1 web工具类,把响应信息按照统一格式返回
@Component
@Slf4j
public class WebUtil {

    /**
     * 认证授权异常处理
     * @param response
     * @param data
     * @return
     */
    public static String renderString(HttpServletResponse response, String data){
        try {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().println(data);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

}
5.2 实现AuthenticationEntryPoint接口,认证过程中失败,捕获到异常,封装成统一的响应体格式返回
package com.example.security.handler;

import cn.hutool.http.HttpStatus;
import com.alibaba.fastjson.JSON;
import com.example.security.utils.WebUtil;
import example.common.model.Result;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 认证过程中失败,捕获到异常,封装成统一的响应体格式返回
 * @Author GoryLee
 * @Date 2022/12/7 20:44
 * @Version 1.0
 */
@Component
public class AuthenticationEntryPointHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        Result<Object> result = Result.createError(HttpStatus.HTTP_UNAUTHORIZED, "认证失败请重新登录");
        String jsonString = JSON.toJSONString(result);
        WebUtil.renderString(response,jsonString);
    }
}
5.3 实现AccessDeniedHandler接口

注意:接口权限认证失败抛出的异常AccessDeniedException继承RuntimeException,如果使用全局异常捕获RuntimeException,会返回全局异常捕获的信息,而不会返回自定义的权限异常信息

package com.example.security.handler;

import cn.hutool.http.HttpStatus;
import com.alibaba.fastjson.JSON;
import com.example.security.utils.WebUtil;
import example.common.model.Result;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 授权过程中失败,捕获到异常,封装成统一的响应体格式返回
 * 注意:接口权限认证失败抛出的异常AccessDeniedException继承RuntimeException
 * 如果使用全局异常捕获RuntimeException,会返回全局异常捕获的信息,而不会返回自定义的权限异常信息
 * @Author GoryLee
 * @Date 2022/12/7 20:51
 * @Version 1.0
 */
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        Result<Object> result = Result.createError(HttpStatus.HTTP_FORBIDDEN, "授权失败,你没权限操作");
        String jsonString = JSON.toJSONString(result);
        WebUtil.renderString(response,jsonString);
    }
}
5.4 在配置类中配置异常结果处理器
//异常处理
http
        .exceptionHandling()
        // 认证异常处理器
        .authenticationEntryPoint(authenticationEntryPointHandler)
        // 授权异常处理器
        .accessDeniedHandler(customAccessDeniedHandler);
6、注销接口(可有可无)

在企业项目中,页面注销有两种方式

1、把前端的localstorecache清空,把token清掉,就可以完成了注销,这也符合jwt的无状态的开发理念。

2、把前端的localstorecache清空,把token清掉,通过后台的接口清除redis缓存

@GetMapping("/logout")
public Result<String> logout() {
    UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
    User user = (User)authentication.getPrincipal();
    redisUtil.del("login:"+user.getUserId());
    return Result.createSuccess("注销成功");
}
五、总结

上面中主要讲springsecurity在企业如何正确使用,核心的流程的代码已经贴上了。

完整的项目代码URL地址:https://gitee.com/gorylee/learnDemo/tree/master/securityDemo

你可能感兴趣的:(java,spring,boot)