程序员L札记

V1

2022/10/20阅读:103主题:橙心

跟我一起学Spring Security(一)

spring security 版本 5.7.3

前言

之所以要写这个系列,一方面是因为方便自己以后做知识归类,另一方面是因为自己所在的项目组(包括之前的项目组),很多初中级开发人员会聚焦于业务开发,而对安全框架知之甚少,在一个项目组里,有关安全认证相关的代码,多半是由项目组中的高级开发人员编写。因此才会有这个系列的文章,希望可以帮助到初中级开发人员。

spring security是什么

Spring Security是一个提供身份验证、授权和针对常见攻击的保护的框架。它为保护命令式应用程序和响应式应用程序提供了一流的支持,是保护基于spring的应用程序的事实上标准。

开始

引入依赖

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

编写控制器

@RestController
public class UserController {

    @GetMapping("/user")
    public String user(){
        return "me";
    }
}

启动工程

当我们引入spring security依赖后,默认情况下spring boot已经添加了一些自动化配置,在没有添加定制化代码时,启动项目,会在控制台上看到如下日志:

记住这个密码,然后访问http://localhost:8080/user

此时会跳转到一个登录页,要求进行身份认证,用户名任意,密码使用刚才在控制台上看到的密码,点击登录,身份认证成功后会跳转到我们之前的请求。

此时我们已经成功将spring security引入到我们的项目中,并成功的将我们要访问的资源保护了起来。但是,以上默认行为是远远不够的,在实际的项目开发中,需要我们自定义一些配置,比如:登录页、登录成功后的处理等等,下面介绍一种基于表单的身份认证,并一步步加入我们的自定义配置。

开发基于表单的身份认证

自定义用户认证逻辑

处理用户信息获取逻辑

实现UserDetailsService接口

@Component
public class CustomUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //从数据库中,根据用户名查询用户信息,假设从数据库中查询出来的是以下用户信息
        //这个User对象是spring security默认实现的,我们也可以自定义一个类实现UserDetails接口
        return new User(username, "123", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

处理用户校验逻辑

实现UserDetails接口, 此时上面的loadUserByUsername方法就可以返回我们自定义的CustomUser对象

public class CustomUser implements UserDetails {
    
    private String username;
    
    private String password;
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        //获取权限
        return null;
    }

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

    @Override
    public String getUsername() {
        return this.password;
    }

    @Override
    public boolean isAccountNonExpired() {
        //校验账户是否过期
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        //校验账号是否锁定
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        //校验密码是否过期
        return false;
    }

    @Override
    public boolean isEnabled() {
        //校验账号是否开启
        return false;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

处理密码加密解密

private PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

spring security 5.x版本之后推荐以上方式PasswordEncoder对密码进行加解密

个性化用户认证流程

配置

@Configurable
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.formLogin(formLogin -> formLogin
                        .loginPage("/login.html")//自定义登录页面
                        .loginProcessingUrl("/doLogin")//自定义登录请求的url,默认/login
                        .successHandler(customAuthenticationSuccessHandler)//自定义登录成功后的处理
                        .failureHandler(customAuthenticationFailureHandler)//自定义登录失败后的处理
                )//开启表单认证
                .authorizeRequests(authorize -> authorize
                        .antMatchers("/login.html""/favicon.ico").permitAll()//不需要经过身份认证的资源
                        .anyRequest().authenticated()//其他任何请求都需要经过身份认证
                )//授权请求
                .csrf().disable();
        return http.build();
    }

}

创建SecurityConfig类,并在类上标注@Configurable和@EnableWebSecurity注解,配置一个SecurityFilterChain类型的bean。

spring security 5.x版本后已经不推荐继承WebSecurityConfigurerAdapter类,在当前版本,如果是继承的方式配置以上信息,已经不起作用了。

自定义登录页面

http.loginPage("url或者html页")

spring security默认跳转到/login url, 通过以上配置可以自定义跳转到一个html页面或者一个url上。实际项目中可以配置一个接口地址,然后根据客户端的来源,定制化返回html页面或者json数据。

自定义登录成功处理

实现AuthenticationSuccessHandler接口

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private ObjectMapper objectMapper;

    private RequestCache requestCache = new HttpSessionRequestCache();

    /*
     * (non-Javadoc)
     *
     * @see org.springframework.security.web.authentication.
     * AuthenticationSuccessHandler#onAuthenticationSuccess(javax.servlet.http.
     * HttpServletRequest, javax.servlet.http.HttpServletResponse,
     * org.springframework.security.core.Authentication)
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {

        logger.info("登录成功");

        response.setContentType("application/json;charset=UTF-8");
        String type = authentication.getClass().getSimpleName();
        response.getWriter().write(objectMapper.writeValueAsString(type));
    }

}

将该类注入到SecurityConfig类中,并配置到formLogin.successHandler(customAuthenticationSuccessHandler)方法中,如果登录成功后,就会进入这个自定义的成功处理器中。默认情况下,spring security登录成功后的处理是重定向到之前的请求,比如此案例中,访问的是/user接口,在身份认证未通过时,会跳转到我们自定义的login.html页面,输入账号密码,点击登录,通过身份认证后,就会重新跳转到/user接口。

自定义登录失败处理

实现AuthenticationFailureHandler接口

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private ObjectMapper objectMapper;

    /* (non-Javadoc)
     * @see org.springframework.security.web.authentication.AuthenticationFailureHandler#onAuthenticationFailure(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, org.springframework.security.core.AuthenticationException)
     */
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {

        logger.info("登录失败");

        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage()));

    }
}

将该类注入到SecurityConfig类中,并配置到formLogin.failureHandler(customAuthenticationFailureHandler)方法中,如果登录失败,就会进入这个自定义的失败处理器。默认情况下,spring security登录失败的处理是跳转到登录页面。

生成图片验证码

实际项目中,在登录页面,除了需要用户名、密码外,还会有一个图片验证码,下面介绍一下,如何将图片验证码,加入到身份认证中。

根据随机数生成图片验证码

/**
 * 验证码信息封装类
 * 
 * @author zhailiang
 *
 */
public class ValidateCode implements Serializable {
 
 /**
  * 
  */
 private static final long serialVersionUID = 1588203828504660915L;

 private String code;
 
 private LocalDateTime expireTime;
 
 public ValidateCode(String code, int expireIn){
  this.code = code;
  this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
 }
 
 public ValidateCode(String code, LocalDateTime expireTime){
  this.code = code;
  this.expireTime = expireTime;
 }
 
 public boolean isExpried() {
  return LocalDateTime.now().isAfter(expireTime);
 }

 public String getCode() {
  return code;
 }

 public void setCode(String code) {
  this.code = code;
 }

 public LocalDateTime getExpireTime() {
  return expireTime;
 }

 public void setExpireTime(LocalDateTime expireTime) {
  this.expireTime = expireTime;
 }
 
}

/**
 * 图片验证码
 * @author zhailiang
 *
 */
public class ImageCode extends ValidateCode {
 
 /**
  * 
  */
 private static final long serialVersionUID = -6020470039852318468L;
 
 private BufferedImage image; 
 
 public ImageCode(BufferedImage image, String code, int expireIn){
  super(code, expireIn);
  this.image = image;
 }
 
 public ImageCode(BufferedImage image, String code, LocalDateTime expireTime){
  super(code, expireTime);
  this.image = image;
 }
 
 public BufferedImage getImage() {
  return image;
 }

 public void setImage(BufferedImage image) {
  this.image = image;
 }

}

@RestController
public class ValidateCodeController {

 /**
  * 验证码放入session时的前缀
  */
 String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";

 /**
  * 操作session的工具类
  */
 private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

 @GetMapping("/code/image")
 public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
  ImageCode imageCode = generate();
  save(imageCode, new ServletWebRequest(request, response));
  send(imageCode, new ServletWebRequest(request, response));
 }

 private ImageCode generate() {
  int width = 60;
  int height = 20;
  BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);

  Graphics g = image.getGraphics();

  Random random = new Random();

  g.setColor(getRandColor(200, 250));
  g.fillRect(0, 0, width, height);
  g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
  g.setColor(getRandColor(160, 200));
  for (int i = 0; i < 155; i++) {
   int x = random.nextInt(width);
   int y = random.nextInt(height);
   int xl = random.nextInt(12);
   int yl = random.nextInt(12);
   g.drawLine(x, y, x + xl, y + yl);
  }

  String sRand = "";
  for (int i = 0; i < 4; i++) {
   String rand = String.valueOf(random.nextInt(10));
   sRand += rand;
   g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
   g.drawString(rand, 13 * i + 6, 16);
  }

  g.dispose();

  return new ImageCode(image, sRand, 60);
 }
}

  1. 定一个ValidateCode以及ImageCode类用于封装图片验证码相关信息;
  2. 定义一个ValidateCodeController,并提供一个createCode接口,请求地址为/code/image,这个地址一会会在登录页面中用到;
  3. 定义一个生成验证码的方法

将随机数保存到session中

private void save(ImageCode imageCode, ServletWebRequest request){
 sessionStrategy.setAttribute(request, SESSION_KEY_PREFIX+"IMAGE", imageCode);
}

将生成的图片写到接口的响应中

private void send(ImageCode imageCode, ServletWebRequest request) throws IOException {
 ImageIO.write(imageCode.getImage(), "JPEG", request.getResponse().getOutputStream());
}

login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录页</title>
</head>
<body>
    <form action="/doLogin" method="post">
        <div>
            <label for="username" >用户名:</label>
            <input type="text" id="username" name="username">
        </div>
        <div>
            <label for="password" >密码:</label>
            <input type="password" id="password" name="password">
        </div>
        <div>
            <label for="imageCode">图片验证码:</label>
            <input type="text" id="imageCode" name="imageCode">
            <img src="/code/image"/>
        </div>
        <div>
            <button type="submit">登录</button>
        </div>
    </form>
</body>
</html>

将/code/image请求地址加入到配置中

http.formLogin(formLogin -> formLogin
                        .loginPage("/login.html")//自定义登录页面
                        .loginProcessingUrl("/doLogin")//自定义登录请求的url,默认/login
                        .successHandler(customAuthenticationSuccessHandler)//自定义登录成功后的处理
                        .failureHandler(customAuthenticationFailureHandler)//自定义登录失败后的处理
                )//开启表单认证
                .authorizeRequests(authorize -> authorize
                        .antMatchers("/login.html""/favicon.ico""/code/image").permitAll()//不需要经过身份认证的资源
                        .anyRequest().authenticated()//其他任何请求都需要经过身份认证
                )//授权请求
                .csrf().disable();

测试

启动服务,访问/user接口,会重定向到/login.html,如下图:

此时已经成功将图片验证码加入到表单登录中,下面开始编写验证码的校验逻辑

在认证流程中加入图形验证码的校验

创建ValidateCodeFilter

public class ValidateCodeFilter extends OncePerRequestFilter {

    private AuthenticationFailureHandler authenticationFailureHandler;

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (StringUtils.pathEquals("/doLogin", request.getRequestURI()) && "post".equalsIgnoreCase(request.getMethod())) {
            try {
                validate(new ServletWebRequest(request));
            } catch (ValidateCodeException e) {
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        filterChain.doFilter(request, response);
    }

    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        ImageCode imageCode = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY_PREFIX);

        String imageCodeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");

        if (!StringUtils.hasText(imageCodeInRequest)) {
            throw new ValidateCodeException("验证码不能为空");
        }

        if (imageCode == null) {
            throw new ValidateCodeException("验证码不存在");
        }

        if (imageCode.isExpried()) {
            sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY_PREFIX);
            throw new ValidateCodeException("验证码已过期");
        }

        if (!imageCode.getCode().equals(imageCodeInRequest)) {
            throw new ValidateCodeException("验证码不匹配");
        }
        sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY_PREFIX);
    }

    public AuthenticationFailureHandler getAuthenticationFailureHandler() {
        return authenticationFailureHandler;
    }

    public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) {
        this.authenticationFailureHandler = authenticationFailureHandler;
    }
}

将ValidateCodeFilter加入到UsernamePasswordAuthenticationFilter之前

@Configurable
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin(formLogin -> formLogin
                        .loginPage("/login.html")//自定义登录页面
                        .loginProcessingUrl("/doLogin")//自定义登录请求的url,默认/login
                        .successHandler(customAuthenticationSuccessHandler)//自定义登录成功后的处理
                        .failureHandler(customAuthenticationFailureHandler)//自定义登录失败后的处理
                )//开启表单认证
                .authorizeRequests(authorize -> authorize
                        .antMatchers("/login.html""/favicon.ico""/code/image").permitAll()//不需要经过身份认证的资源
                        .anyRequest().authenticated()//其他任何请求都需要经过身份认证
                )//授权请求
                .csrf().disable();
        return http.build();
    }

}

实现“记住我”功能

记住我基本原理

当认证成功后,会调用RememberMeServices服务,RememberMeServices内部会委托给PersistentTokenRepository的实现类将token写入数据库(默认是写入内存中),同时会将token写入浏览器cookie中。当第二次请求时,会经过RememberMeAuthenticationFilter,接着调用RememberMeServices从cookie中读取token,然后内部委托给PersistentTokenRepository的实现类,从数据库中查询token,然后从token中获取用户名,最后调用UserDetailsService进行认证。

实现

@Configurable
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private UserDetailsService userDetailsService;


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin(formLogin -> formLogin
                        .loginPage("/login.html")//自定义登录页面
                        .loginProcessingUrl("/doLogin")//自定义登录请求的url,默认/login
                        .successHandler(customAuthenticationSuccessHandler)//自定义登录成功后的处理
                        .failureHandler(customAuthenticationFailureHandler)//自定义登录失败后的处理
                )//开启表单认证
                .rememberMe()
                .tokenRepository(persistentTokenRepository())
                .tokenValiditySeconds(3600)
                .userDetailsService(userDetailsService)
                .and()
                .authorizeRequests(authorize -> authorize
                        .antMatchers("/login.html", "/favicon.ico", "/code/image").permitAll()//不需要经过身份认证的资源
                        .anyRequest().authenticated()//其他任何请求都需要经过身份认证
                )//授权请求
                .csrf().disable()
;
        return http.build();
    }

    /**
     * 记住我功能的token存取器配置
     *
     * @return
     */

    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        //启动时创建数据库表(下次启动改为false)
        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }

}

login.html中添加”记住我”复选框,属性名默认叫remember-me,当然可以通过配置进行修改。

<div>
    <label for="remember-me">记住我:</label>
    <input type="checkbox" id="remember-me" name="remember-me">
</div>

ok,添加以上代码,就实现了"记住我"功能。

欢迎关注我的公众号:程序员L札记

更多原创文章,请扫码关注我的微信公众号
更多原创文章,请扫码关注我的微信公众号

分类:

后端

标签:

后端

作者介绍

程序员L札记
V1