SpringBoot集合SpringSecurity流程及代码详解和导图详解

  最近入手做Java项目,使用SpringBoot和安全框架SpringSecurity,之前也总结了很多问题解决的博客,可以查看之前博客,这篇是决定把整个流程及大致代码记录一下,当然我只贴关键代码流程哦。

一、流程和代码详解

1、首先需要导入核心依赖

<!-- spring security-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

2、然后看一下代码结构

  最主要的就是 security 包下的内容咯。

3、WebSecurityConfig类

  最重要的就是 WebSecurityConfig 类咯,这个类得继承至 WebSecurityConfigurerAdapter 类,并且得加上 @EnableWebSecurity 注解,即启用web安全。

  WebSecurityConfig类使用了@EnableWebSecurity注解 ,以启用SpringSecurity的Web安全支持,并提供Spring MVC集成。它还扩展了WebSecurityConfigurerAdapter,并覆盖了一些方法来设置Web安全配置的一些细节。

  比如可以覆写configure(HttpSecurity)方法定义了哪些URL路径应该被保护,哪些不应该。

  项目实例代码:

@Configuration
@EnableWebSecurity   // 开启web安全支持
public class WebSecurityConfig  extends WebSecurityConfigurerAdapter {
    ......
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().cors().and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            // user
            .authorizeRequests().antMatchers(HttpMethod.GET,"/user/code").permitAll()
            .antMatchers(HttpMethod.POST, "/user/resetPassword", "/user/alipayNotify").permitAll()
            .anyRequest().authenticated()  // 以上配置的路径不需要认证,anyRequest其他任何都需要认证
            .and().exceptionHandling().authenticationEntryPoint(authenticationEntryPoint()) // 异常时走这个自定义的提示
            .and()
            .addFilterBefore(  // 添加自定义登录拦截过滤器
                new JWTLoginFilter(
                    new AntPathRequestMatcher("/login", HttpMethod.POST.name()),
                    authenticationManager()
                ),
                UsernamePasswordAuthenticationFilter.class
            )
            .addFilterBefore(new JWTAuthenticationFilter(),
                UsernamePasswordAuthenticationFilter.class);
       // 添加JWT权限认证拦截器,用于将每个接口拦截进行token验证,将token里的信息拿取用户并放入安全上下文 }
  ......
}

4、JWTLoginFilter 自定义登录过滤器

  JWTLoginFilter主要是用来处理自定义登录的业务逻辑处理。我们的登录是第一次验证码登录,之后用户也可以设置密码用密码登录。

// 需要继承AbstractAuthenticationProcessingFilter类,然后覆写下面这3个方法,IDE会自动给提示
public class JWTLoginFilter extends AbstractAuthenticationProcessingFilter {
    public JWTLoginFilter(RequestMatcher requestMatcher, AuthenticationManager authManager) {
        super(requestMatcher);
        setAuthenticationManager(authManager);
    }
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req,
                                                HttpServletResponse res) throws AuthenticationException, IOException {
        ......// 登录是否含手机号,从请求流转换为User实例
        User voUser = new ObjectMapper().readValue(req.getInputStream(), User.class);
        if (voUser == null || voUser.getPhoneNum() == null) {
            throw new AuthenticationServiceException("请输入手机号");
        }
     // 通过 反射 获取到UserService,然后从数据库里取到当前请求用户数据 UserService userService
= SpringUtil.getBean(UserService.class); User dbUser = userService.getUserByPhoneNum(voUser.getPhoneNum()); // 如果是密码登录,需要校验密码 if (StringUtils.isBlank(voUser.getCode())) { if (dbUser == null) { throw new AuthenticationServiceException("用户不存在,请使用验证码注册"); } ...... voUser.setUserId(dbUser.getUserId()); //添加用户id,登录方式到session,方便后续验证 req.setAttribute("userId", dbUser.getUserId()); return getAuthenticationManager().authenticate( new UsernamePasswordAuthenticationToken( voUser.getUserId(), voUser.getPassword() ) ); } else { // 验证码登录:需要校验验证码 ......if (!userService.verifySmsCode(voUser.getPhoneNum(), voUser.getCode())) { throw new AuthenticationServiceException("短信验证码错误"); } if(dbUser == null) { userService.saveUserPhone(voUser); dbUser = userService.getUserByPhoneNum(voUser.getPhoneNum()); } req.setAttribute("loginType", 1); req.setAttribute("userId", dbUser.getUserId());
       // 由于可以验证码登录,用户在库里也可能没有密码,所以无法用上面 security 自带校验方式
       // 自定义校验方式,只要验证码校验成功,那么就通过用户Id,去
TokenAuthenticationService里生成 token TokenAuthenticationService.addAuthentication(res, String.valueOf(dbUser.getUserId())); OperationInfo info = OperationInfo.success("登录成功"); HttpResponseUtil.setResponseMessage(res, info); return null; } }
   // 这个是需要覆写 security 自带的校验成功方法,也就是登录校验成功之后,去
TokenAuthenticationService里生成 token @Override protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain, Authentication auth) throws IOException { TokenAuthenticationService.addAuthentication(res, auth.getName()); OperationInfo info = OperationInfo.success("登录成功"); HttpResponseUtil.setResponseMessage(res, info); }    // 这个是需要覆写 security 自带的校验失败方法,也就是登录失败之后,给出提示,这个OperationInfo也就是我们项目定义的返回操作类 @Override protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse res, AuthenticationException failed) throws IOException { OperationInfo info; if (failed instanceof BadCredentialsException) { info = OperationInfo.failure("用户密码错误"); } else { info = OperationInfo.failure(failed.getMessage()); } HttpResponseUtil.setResponseMessage(res, info); } }

5、TokenAuthenticationService 生成及解析token

  接上面我们都用到了 TokenAuthenticationService 去生成 token,那么我们看一下这个Service的主要作用,其实就是生成 JWT token,和解析JWT token。

@Slf4j
@Component
public class TokenAuthenticationService {
    private static final long EXPIRATIONTIME = 604_800_000; // 7 days
    private static String SECRET;  // 签名
    private static final String TOKEN_PREFIX = "Bearer";
    private static final String HEADER_STRING = "Authorization";

    @Resource
    private EnvService envService;  // 自定义工具类,用于判断环境从而生成不同的 secret
@PostConstruct // 这个注解可以了解下,加载servlet的时候运行
public void setSecret() { if (envService.isProd()) { SECRET = "TokenSecret"; } else { SECRET = "TestTokenSecret"; } } public static Authentication getAuthentication(HttpServletRequest req, HttpServletResponse res) { //如果header没有auth头,从cookie获取token String token = req.getHeader(HEADER_STRING); Cookie[] cookies = req.getCookies(); if (cookies != null && cookies.length > 0) { for (Cookie cookie : cookies) { if (Objects.equals(cookie.getName(), "token")) { try { token = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8.name()); } catch (UnsupportedEncodingException e) { log.error(LogUtil.getStack(e)); } } } } if (StringUtils.isNotBlank(token) && token.length() != 32) { // parse the token. Claims body = Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token.replace(TOKEN_PREFIX, "")) .getBody(); String username = body.getSubject(); String role = (String) body.get("roleName"); long exp = body.getExpiration().getTime(); long now = System.currentTimeMillis(); if (username != null) { if (exp - 864_00_000 < now) { //1 day left refresh 如果过期时间只在1天内,那么就重新生成一个新token给用户端,避免过期 addAuthentication(res, username, role); } return new UsernamePasswordAuthenticationToken(username, null, RoleConfig.getAuthoritiesWithoutPrefix(role)); } } return null; } public static void addAuthentication(HttpServletResponse res, String username, String role) { String JWT = Jwts.builder() .setSubject(username) .setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME)) .claim("roleName", role) .signWith(SignatureAlgorithm.HS512, SECRET) .compact(); res.addHeader(HEADER_STRING, TOKEN_PREFIX + " " + JWT); } public static void addAuthentication(HttpServletResponse res, String username) { Integer userId = Integer.valueOf(username); UserService userService = SpringUtil.getBean(UserService.class); User user = userService.getUserById(userId); String role = RoleConfig.ROLE_PREFIX + user.getRoleName(); addAuthentication(res, username, role); } }

  然后看一下 EnvService ,这个就是用来判断环境的

@AllArgsConstructor
@Service
public class EnvService {
    private Environment environment;

    public boolean isProd() {
        for (String activeProfile : environment.getActiveProfiles()) {
          return StringUtils.contains(activeProfile, "prod");
        }
        return false;
    }
}

  然后再看下 RoleConfig,这个就是将从 token 中拿到的角色、权限等放到列表中,分别是带前缀和不带前缀。因为 security 里默认得带前缀 "Role_",而返回给用户端的就不需要这个前缀了。

  并且我们在这个类上开启了 security 的方法级管控。

@Configuration
@EnableGlobalMethodSecurity( // 开启了Security的方法级管控
    prePostEnabled = true,
    securedEnabled = true,
    jsr250Enabled = true
)
public class RoleConfig extends GlobalMethodSecurityConfiguration {
    public static final String ROLE_PREFIX = "ROLE_";

    public static Collection<GrantedAuthority> getAuthorities(String roleName, List<String> permissions) {
        List<GrantedAuthority> authList = new ArrayList<>();
        authList.add(new SimpleGrantedAuthority(ROLE_PREFIX + roleName));
        if (permissions != null) {
            authList.addAll(
                permissions.stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList())
            );
        }
        return authList;
    }

    public static Collection<GrantedAuthority> getAuthoritiesWithoutPrefix(String roleName, List<String> permissions) {
        List<GrantedAuthority> authList = new ArrayList<>();
        authList.add(new SimpleGrantedAuthority(roleName));
        if (permissions != null) {
            authList.addAll(
                permissions.stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList())
            );
        }
        return authList;
    }
}

6、自定义UserDetailsService

  关于自定义登录拦截还有一个最重要的就是:自定义UserDetailsService

@Configuration
@EnableWebSecurity
public class WebSecurityConfig  extends WebSecurityConfigurerAdapter {
    @Autowired
    private ExamUserDetailsService examUserDetailsService;

    @Bean
    public AuthenticationEntryPoint authenticationEntryPoint() {
        return new EmcsAuthenticationEntryPoint();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
       ......
    }

    @Override  // 这个configure就是用来定义密码校验的,可以使用security自带的BCryptPasswordEncoder,也可以使用自定义的
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(examUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
    }
}

  这个自定义的UserService里最重要的就是要覆写 loadUserByUsername 方法,这个是源码里面看来的

@Service
public class ExamUserDetailsService implements UserDetailsService {
    @Autowired
    private UserService userService;
   // 覆写 loadUserByUsername 方法,从数据库里取出用户数据,这个参数username,其实就是userId
   // 下面这个是 security 的 User,他的角色默认是带 Role_ 前缀的,所以 RoleConfig.getAuthorities 的角色就是需要带 Role_ 前缀
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Integer userId = Integer.valueOf(username); User user = userService.getUserById(userId); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } return new org.springframework.security.core.userdetails.User( username, user.getPassword(), true, true, true, true, RoleConfig.getAuthorities(user.getRoleName()) ); } }

  这样自定义登录拦截的业务就差不多了。

7、JWTAuthenticationFilter 拦截器

  这个拦截器是请求过滤,所有的请求都会走这个拦截。这个拦截器的主要作用是:进行token验证,并把解析的用户信息放入安全上下文。

public class JWTAuthenticationFilter extends GenericFilterBean {
    @Override  // 覆写 doFilter 方法,走我们的自定义业务逻辑
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        // 可以取到用户信息的(有正确token)将用户信息放入上下文
        try {
            Authentication auth = TokenAuthenticationService.getAuthentication((HttpServletRequest) request, (HttpServletResponse) response);
            SecurityContextHolder.getContext().setAuthentication(auth);
        } catch (MalformedJwtException | SignatureException | ExpiredJwtException | UnsupportedJwtException e) {
       // 需要捕获一下上面4个异常,如果异常就将上下文设为null,类似于游客访问 SecurityContextHolder.getContext().setAuthentication(
null); } finally{ filterChain.doFilter(request, response); } } }

8、UserUtils 安全上下文信息工具类

......  // 主要是这三个重要的类
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;

public class UserUtils {public static User getCurrentUser() {
        Integer userId = getCurrentUserId();
        if (userId == null) {
            return null;
        }
        UserService userService = SpringUtil.getBean(UserService.class);
        return userService.getUserById(userId);
    }
  
public static Integer getCurrentUserId() { Authentication auth = getAuth(); // 从安全上下文拿到凭证,取到 userId if (auth == null) { return null; } String userId = (String) auth.getPrincipal(); if (userId == null || StringUtils.equalsIgnoreCase("anonymousUser", userId)) { return null; } return Integer.valueOf(userId); }
   // 从安全上下文拿到角色
public static String getRole() { GrantedAuthority[] authorities = getAuth().getAuthorities().toArray(new GrantedAuthority[1]); if (authorities.length > 0) { for (GrantedAuthority authority : authorities) { String authorityName = authority.getAuthority(); if (authorityName.startsWith(RoleConfig.ROLE_PREFIX)) { return authorityName.substring(5); } } } return null; }
   // 从 安全上下文获取到 凭证
private static Authentication getAuth() { return SecurityContextHolder.getContext().getAuthentication(); } }

  主要是从安全上下文拿到 凭证、拿到userId之类的信息,然后通过这个信息,就可以查数据库拿到很多数据。

9、AuthenticationEntryPoint:自定义未登录的返回状态

  还有一个需要注意的是 security 登录不成功默认返回的是它自带的 403 之类的信息,我们如果需要自定义成我们想要的结构的话,就需要实现AuthenticationEntryPoint,然后覆写 commence 方法。

@Configuration
@EnableWebSecurity
public class WebSecurityConfig  extends WebSecurityConfigurerAdapter {
    
    @Bean
    public AuthenticationEntryPoint authenticationEntryPoint() {
        return new EmcsAuthenticationEntryPoint();
    }
   ......
}

  先在 WebSecurityConfig 里注册AuthenticationEntryPoint,然后使用我们自定义的EmcsAuthenticationEntryPoint

public class EmcsAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        OperationInfo info = OperationInfo.failure("请登录后操作");
        HttpResponseUtil.setResponseMessage(response, info);
    }
}

二、导图流程解析

  这个导图就是上面这个流程介绍,其中单点登录,是我们有2个平台,一个墨天轮平台,一个内部知识库平台,登录放在墨天轮平台,所以内部知识库平台只需要一个过滤器 JWTAuthenticationFilter 即可。

posted @ 2021-06-23 22:47  古兰精  阅读(1247)  评论(0编辑  收藏  举报