Spring Security

简介

Spring Security 基于 Spring 框架,提供了一套 Web 应用安全性(两方面)的完整解决方案。目前比较主流的用法也是Spring Boot/Spring Cloud + Spring Security

Web 应用的安全性包括:用户认证(Authentication)用户授权(Authorization)两个部分,这两点也是Spring Security重要核心功能。

认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户(一般采用用户名和密码的形式)

授权:经过认证后判断当前用户是否有权限进行某个操作

快速入门

  • 导入依赖

    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
  • 创建启动类、controller

  • 启动项目,需要输入账号密码(用户名user密码随机生成在日志中)

image-20220802182044471

可以在配置文件中修改账号密码(或者使用配置类):

1
2
spring.security.user.name=admin
spring.security.user.password=123

用户认证

对于用户认证,SpringSecurity提供了多种认证方式

  • HTTP BASIC authentication headers:基于IETF RFC 标准。
  • HTTP Digest authentication headers:基于IETF RFC 标准。
  • HTTP X.509 client certificate exchange:基于IETF RFC 标准。
  • LDAP:跨平台身份验证。
  • Form-based authentication:基于表单的身份验证。
  • Run-as authentication:用户用户临时以某一个身份登录。
  • OpenID authentication:去中心化认证。
  • Jasig Central Authentication Service:单点登录。
  • Automatic “remember-me” authentication:记住我登录(允许一些非敏感操作)。
  • Anonymous authentication:匿名登录。
  • ….等

基本登录验证

设置配置类
  • 首先进行密码加密配置(在 Spring Security 中,BCryptPasswordEncoder 就自带了盐,处理起来非常方便)

    • BCryptPasswordEncoder 就是 PasswordEncoder 接口的实现类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      public interface PasswordEncoder {
      //方法用来对明文密码进行加密,返回加密之后的密文。
      String encode(CharSequence rawPassword);
      //方法是一个密码校对方法,在用户登录的时候,将用户传来的明文密码和数据库中保存的密文密码作为参数,传入到这个方法中去,根据返回的 Boolean 值判断用户密码是否输入正确。
      boolean matches(CharSequence rawPassword, String encodedPassword);
      //是否还要进行再次加密,这个一般来说就不用了
      default boolean upgradeEncoding(String encodedPassword) {
      return false;
      }
      }
  • 在内存中存入用户:

    1
    2
    3
    auth.inMemoryAuthentication()
    .withUser("admin") //用户名
    .password("123").roles("admin"); //密码和角色
  • 配置忽略静态资源:

    1
    2
    3
    4
    5
    @Override
    public void configure(WebSecurity web) throws Exception {
    //web.ignoring() 用来配置忽略掉的 URL 地址,一般对于静态文件,我们可以采用此操作
    web.ignoring().antMatchers("/js/**", "/css/**","/images/**");
    }
  • 自定义表单页面(仅适用于前后端不分离):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
    .anyRequest().authenticated() //任何接口都需要进行认证
    .and() //以and进行配置分割
    //登录
    .formLogin()
    .loginPage("/login.html") //所使用的登录页面(该配置即是页面也是接口)
    .loginProcessingUrl("/doLogin") //配置登录参数提交接口
    .usernameParameter("name") //修改表单参数名
    .passwordParameter("passwd") //修改表单参数名
    .defaultSuccessUrl("/index") //登录成功跳转接口
    .failureForwardUrl("/error") //登录失败跳转接口
    .permitAll() //表示登录相关的页面/接口不要被拦截
    .and()
    //注销
    .logout()
    .logoutRequestMatcher(new AntPathRequestMatcher("/logout","POST"))
    .logoutSuccessUrl("/login")//注销成功跳转的接口
    .deleteCookies() //清楚cookie
    .clearAuthentication(true) //清除认证信息,默认可以不用配置会自动清楚
    .invalidateHttpSession(true) //使 HttpSession 失效,默认可以不用配置会自动清楚
    .permitAll()
    .and()
    .csrf().disable(); //关闭csrf
    }
    1. 注意登录表单的action必须是”/login.html”以及账号和密码输入框的name一个为username一个为password,不然就不到参会报错

    2. 登录成功的回调配置有两个:

    • defaultSuccessUrl :登录成功后返回刚才页面,可以设置第二个参数true/false,若为true就跟successForwardUrl一样的效果
    • successForwardUrl:不管从哪里进入的登录界面,只要登录成功都统一进入指定的接口
    1. 登录失败的回调配置:
    • failureForwardUrl:failureForwardUrl 是forward 跳转 ,failureForwardUrl异常信息存储在request中
    • failureUrl :failureUrl 是redirect 跳转,failureUrl认证失败异常信息存储在session中
    1. 修改注销url的配置:
    • logoutUrl:修改默认的注销 URL,默认为GET请求
    • logoutRequestMatcher :logoutRequestMatcher 方法不仅可以修改注销 URL,还可以修改请求方式,实际项目中,这个方法和 logoutUrl 任意设置一个即可

前后端分离登录

方式一:

自定义过滤器

因为Spring Security 默认是通过 key/value 的形式来传递登录参数,因为它处理的方式就是 request.getParameter,但是很多时候我们是通过json来传递数据的,就需要自定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//判断是否是post请求
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
//获取session中存的正确验证码(也可以存redis中)
String verify_code = (String) request.getSession().getAttribute("verify_code");
//如果是json类型
if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)) {
Map<String, String> loginData = new HashMap<>();
try {
loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class);
} catch (IOException e) {
}finally {
String code = loginData.get("code");
checkCode(response, code, verify_code);
}
String username = loginData.get(getUsernameParameter());
String password = loginData.get(getPasswordParameter());
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
//生成UsernamePasswordAuthenticationToken用于验证
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
} else {
//如果不是json,就用key/value形式
checkCode(response, request.getParameter("code"), verify_code);
return super.attemptAuthentication(request, response);
}
}

public void checkCode(HttpServletResponse resp, String code, String verify_code) {
if (code == null || verify_code == null || "".equals(code) || !verify_code.toLowerCase().equals(code.toLowerCase())) {
//验证码不正确
throw new AuthenticationServiceException("验证码不正确");
}
}
}
配置过滤器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Bean
LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
String s = new ObjectMapper().writeValueAsString(ResponseEntityDemo.successWithData(authentication.getPrincipal()));
out.write(s);
out.flush();
out.close();
}
});
loginFilter.setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
ResponseEntityDemo responseEntityDemo = ResponseEntityDemo.failed(exception.getMessage());
if (exception instanceof LockedException) {
responseEntityDemo.setMessage("账户被锁定,请联系管理员!");
} else if (exception instanceof CredentialsExpiredException) {
responseEntityDemo.setMessage("密码过期,请联系管理员!");
} else if (exception instanceof AccountExpiredException) {
responseEntityDemo.setMessage("账户过期,请联系管理员!");
} else if (exception instanceof DisabledException) {
responseEntityDemo.setMessage("账户被禁用,请联系管理员!");
} else if (exception instanceof BadCredentialsException) {
responseEntityDemo.setMessage("用户名或者密码输入错误,请重新输入!");
}
out.write(new ObjectMapper().writeValueAsString(responseEntityDemo));
out.flush();
out.close();
}
});
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl("/doLogin");
return loginFilter;
}

方式二:

从数据库中获取用户信息

image-20220803212446730

因为需要从数据库中获取用户信息,则不需要InMemoryUserDetailsManager(将用户存入内存数据源中),需要自定义UserDetailsService的实现类

  • 定义UserDetails对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    package com.wht.entity;

    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;

    import java.util.Collection;

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class LoginUser implements UserDetails {

    private User user;


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
    return null;
    }

    @Override
    public String getPassword() {
    return user.getPassword();
    }

    @Override
    public String getUsername() {
    return user.getUserName();
    }

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

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

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

    @Override
    public boolean isEnabled() {
    return true;
    }
    }
  • 再定义自定义UserDetailsService的实现类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    package com.wht.service;

    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.wht.entity.LoginUser;
    import com.wht.entity.User;
    import com.wht.mapper.UserMapper;
    import org.springframework.beans.factory.annotation.Autowired;
    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.Objects;

    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    //根据用户名查询用户信息
    User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUserName,username));
    //如果查询不到数据就通过抛出异常来给出提示
    if(Objects.isNull(user)){
    throw new RuntimeException("用户名或密码错误");
    }
    //TODO 根据用户查询权限信息 添加到LoginUser中

    //封装成UserDetails对象返回
    return new LoginUser(user);
    }
    }
  • 将AuthenticationManager注入进容器:

    1
    2
    3
    4
    5
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
    }
  • 定义service来处理具体登录逻辑:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisUtil redisUtil;

    @Override
    public ResponseEntityDemo login(User user) {
    //AuthenticationManager authenticate进行用户认证
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
    Authentication authenticate = authenticationManager.authenticate(authenticationToken);
    //如果认证没通过,给出对应的提示
    if(Objects.isNull(authenticate)){
    throw new RuntimeException("登录失败");
    }
    //如果认证通过了,使用userid生成一个jwt jwt存入ResponseResult返回
    LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
    String userid = loginUser.getUser().getId().toString();
    String jwt = JwtUtil.createJWT(userid);
    Map<String,String> map = new HashMap<>();
    map.put("token",jwt);
    //把完整的用户信息存入redis userid作为key
    redisUtil.set("login:"+userid,loginUser);
    return ResponseEntityDemo.successWithData(map);
    }
  • 配置SpringSecurity

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http
    //关闭csrf
    .csrf().disable()
    //不通过Session获取SecurityContext
    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    .and()
    .authorizeRequests()
    // 对于登录接口 允许匿名访问
    .antMatchers("/user/login").anonymous()
    // 除上面外的所有请求全部需要鉴权认证
    .anyRequest().authenticated();
    }
  • 继续配置过滤器将用户封装成Authentication放入SecurityContextHolder中方便后面的认证流程使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    package com.wht.Filter;


    import com.wht.entity.LoginUser;
    import com.wht.utils.JwtUtil;
    import com.wht.utils.RedisUtil;
    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.util.StringUtils;
    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;
    import java.util.Objects;

    @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 (!StringUtils.hasText(token)) {
    //放行
    filterChain.doFilter(request, response);
    return;
    }
    //解析token
    String userid;
    try {
    Claims claims = JwtUtil.parseJWT(token);
    userid = claims.getSubject();
    } catch (Exception e) {
    e.printStackTrace();
    throw new RuntimeException("token非法");
    }
    //从redis中获取用户信息
    String redisKey = "login:" + userid;
    LoginUser loginUser = (LoginUser) redisUtil.get(redisKey);
    if(Objects.isNull(loginUser)){
    throw new RuntimeException("用户未登录");
    }
    //存入SecurityContextHolder
    //TODO 获取权限信息封装到Authentication中
    UsernamePasswordAuthenticationToken authenticationToken =
    new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    //放行
    filterChain.doFilter(request, response);
    }
    }
  • 配置过滤器:

    1
    2
    //在UsernamePasswordAuthenticationFilter过滤器认证之前拦截
    http.addFilterBefore(jwtAuthenticationTokenFilter,UsernamePasswordAuthenticationFilter.class);

    用户注销

只需要提供一个接口,删除掉存入redis中的用户信息即可

  • controller

    1
    2
    3
    4
    @RequestMapping("/logout")
    public ResponseEntityDemo logout(){
    return loginServcie.logout();
    }
  • service

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Override
    public ResponseEntityDemo logout() {
    //获取SecurityContextHolder中的用户id
    UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    Long userid = loginUser.getUser().getId();
    //删除redis中的值
    redisUtil.del("login:"+userid);
    return ResponseEntityDemo.successWithoutData();
    }

用户授权

大致流程

在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。

所以我们只需要做好两步:

  • 把当前登录用户的权限信息也存入Authentication。
  • 设置我们的资源所需要的权限即可。

授权配置

配置方式

  • 给资源分配权限:

    1
    2
    3
    4
    http.authorizeRequests()
    .antMatchers("/admin/**").hasRole("admin") //只允许admin进入
    .antMatchers("/user/**").hasRole("user") //允许有user角色的进入
    .anyRequest().authenticated() //一定要放最后

    如果要让admin有user权限,可以使用角色继承,使上级可能具备下级的所有权限

    1
    2
    3
    4
    5
    6
    @Bean
    RoleHierarchy roleHierarchy() {
    RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
    hierarchy.setHierarchy("ROLE_admin > ROLE_user");
    return hierarchy;
    }

    注意,在配置时,需要给角色手动加上 ROLE_ 前缀。上面的配置表示 ROLE_admin 自动具备 ROLE_user 的权限。

注解方式

  1. 添加权限

    • 先开启相关配置:@EnableGlobalMethodSecurity(prePostEnabled = true)

    • 给接口添加权限限制:@PreAuthorize(hasAuthority('权限名'))

      • hasAuthority:只有有这个权限才能访问
      • hasAnyAuthority:只要有其中任一一个权限即可访问
      • hasRole:只有有这个角色才能访问(但是会有一个ROLE_前缀)
      • hasAnyRole:只要有其中任一一个角色即可访问(同样会有ROLE_前缀)
      1
      2
      3
      4
      5
      @GetMapping("/hello")
      @PreAuthorize("hasAuthority('test')")
      public String hello() {
      return "hello";
      }

      权限判断可以使用自定义方法:

      • 先定义方法:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        @Component("ex")
        public class ExpressionRoot {

        public boolean hasAuthority(String authority){
        //获取当前用户的权限
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        List<String> permissions = loginUser.getPermissions();
        //判断用户权限集合中是否存在authority
        return permissions.contains(authority);
        }
        }
      • 在授权时使用:

        1
        @PreAuthorize("@ex.hasAuthority('system:dept:list')")
  2. 获取权限

    • 给UserDetails添加权限属性(保证登录时将权限存入):

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      //存储权限信息
      private List<String> permissions;


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


      //存储SpringSecurity所需要的权限信息的集合
      @JSONField(serialize = false)
      private List<GrantedAuthority> authorities;

      @Override
      public Collection<? extends GrantedAuthority> getAuthorities() {
      if(authorities!=null){
      return authorities;
      }
      //把permissions中字符串类型的权限信息转换成GrantedAuthority对象存入authorities中
      authorities = permissions.stream().
      map(SimpleGrantedAuthority::new)
      .collect(Collectors.toList());
      return authorities;
      }
    • 在登录时从数据库查询权限列表封装成loginUser

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      @Override
      public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      //根据用户名查询用户信息
      User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUserName,username));
      //如果查询不到数据就通过抛出异常来给出提示
      if(Objects.isNull(user)){
      throw new RuntimeException("用户名或密码错误");
      }
      //封装成UserDetails对象返回
      return new LoginUser(user,menuMapper.selectPermsByUserId(user.getId()));
      }
    • 在认证过滤时封装进Authentication

      1
      2
      UsernamePasswordAuthenticationToken authenticationToken =
      new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());

      统一处理认证与授权异常

在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。

​ 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。

​ 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。

直接可以在配置类中进行配置(也可以实现接口)

1
2
3
4
5
6
7
8
//配置异常处理器
http.exceptionHandling()
.authenticationEntryPoint((req, resp, authException) -> {
WebUtils.renderString(resp,JSON.toJSONString(ResponseEntityDemo.failed(ResultCode.UNAUTHORIZED)));
})
.accessDeniedHandler((req, resp, authException) -> {
WebUtils.renderString(resp,JSON.toJSONString(ResponseEntityDemo.failed(ResultCode.FORBIDDEN)));
});