程序员L札记
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);
}
}
-
定一个ValidateCode以及ImageCode类用于封装图片验证码相关信息; -
定义一个ValidateCodeController,并提供一个createCode接口,请求地址为 /code/image
,这个地址一会会在登录页面中用到; -
定义一个生成验证码的方法
将随机数保存到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札记

作者介绍