Spring-Security-自定义用户认证

35次阅读

共计 12718 个字符,预计需要花费 32 分钟才能阅读完成。

在 Spring Boot 集成 Spring Security 这篇文章中,我们介绍了如何在 Spring Boot 项目中快速集成 Spring Security,同时也介绍了如何更改系统默认生成的用户名和密码。接下来本文将基于 Spring Boot 集成 Spring Security 这篇文章中所创建的项目,进一步介绍在 Spring Security 中如何实现自定义用户认证。

阅读更多关于 Angular、TypeScript、Node.js/Java、Spring 等技术文章,欢迎访问我的个人博客 —— 全栈修仙之路

一、自定义认证过程

本项目所使用的开发环境及主要框架版本:

  • java version “1.8.0_144”
  • spring boot 2.2.0.RELEASE
  • spring security 5.2.0.RELEASE

1.0 配置项目 pom.xml 文件

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.0.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.semlinker</groupId>
    <artifactId>custom-user-authentication</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>custom-user-authentication</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
      
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- 省略 spring-boot-starter-test、spring-security-test 及 spring-boot-devtools -->
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

1.1 自定义用户模型

首先创建一个 MyUser 类,用于存储模拟的用户信息(实际开发中一般从数据库中获取真实的用户信息):

// com/semlinker/domain/MyUser.java
@Data
public class MyUser implements Serializable {
    private static final long serialVersionUID = -1090551705063344205L;

    private String userName;
    private String password;
    private boolean accountNonExpired = true; // 表示账号是否未过期
    private boolean accountNonLocked = true; // 表示账号是否未锁定
    private boolean credentialsNonExpired = true; // 表示用户凭证未过期,比如用户密码
    private boolean enabled = true; // 表示用户是否启用
}

1.2 自定义 Security 配置类及 PasswordEncoder 对象

接着配置 PasswordEncoder 对象,顾名思义该对象用于密码加密。在下面的 UserDetailsService 服务中需要用到此对象,因此这里我们需要提前做好配置。PasswordEncoder 是一个密码加密接口,在 Spring Security 中有许多实现类,比如 BCryptPasswordEncoder、Pbkdf2PasswordEncoder 和 LdapShaPasswordEncoder 等。

当然我们也可以自定义 PasswordEncoder,但 Spring Security 中实现的 BCryptPasswordEncoder 功能已经足够强大,它对相同的密码进行加密后可以生成不同的结果,这样就大大提高了系统的安全性。即尽管系统中使用相同密码的某些用户不小心泄露了密码,也不会导致其他用户密码泄露。既然 BCryptPasswordEncoder 功能那么强大,我们肯定直接使用它,具体的配置方式如下:

// com/semlinker/config/WebSecurityConfig.java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();
    }
}

1.3 自定义 UserDetailsService 服务

自定义 UserDetailsService 服务,需要实现 UserDetailsService 接口,该接口只包含一个 loadUserByUsername 方法,用于通过 username 来加载匹配的用户。当找不到 username 对应用户时,会抛出 UsernameNotFoundException 异常。UserDetailsService 接口的定义如下:

// org/springframework/security/core/userdetails/UserDetailsService.java
public interface UserDetailsService {UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

loadUserByUsername 方法返回 UserDetails 对象,这里的 UserDetails 也是一个接口,它的定义如下:

// org/springframework/security/core/userdetails/UserDetails.java
public interface UserDetails extends Serializable {Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();
    String getUsername();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();}

顾名思义,UserDetails 表示详细的用户信息,这个接口涵盖了一些必要的用户信息字段,具体的实现类对它进行了扩展。以上方法的具体作用如下:

  • getPassword():用于获取密码;
  • getUsername():用于获取用户名;
  • isAccountNonExpired():用于判断账号是否未过期;
  • isAccountNonLocked():用于判断账号是否未锁定;
  • isCredentialsNonExpired():用于判断用户凭证是否未过期,即密码是否未过期;
  • isEnabled():用于判断用户是否可用。

介绍完上述内容,下面我们来创建一个 MyUserDetailsService 类并实现 UserDetailsService 接口,具体如下:

// com/semlinker/service/MyUserDetailsService.java
@Service
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {MyUser myUser = new MyUser();
        myUser.setUserName(username);
        myUser.setPassword(this.passwordEncoder.encode("hello"));

        // 使用 Spring Security 内部 UserDetails 的实现类 User,来创建 User 对象
        return new User(username, myUser.getPassword(), myUser.isEnabled(),
                myUser.isAccountNonExpired(), myUser.isCredentialsNonExpired(),
                myUser.isAccountNonLocked(),
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

1.4 配置 UserDetailsService Bean 及配置 AuthenticationManagerBuilder 对象

在 Spring Security 中使用我们自定义的 MyUserDetailsService,还需要在 WebSecurityConfig 类中进行配置:

// com/semlinker/config/WebSecurityConfig.java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    UserDetailsService myUserDetailService() {return new MyUserDetailsService();
    }

    protected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(myUserDetailService()).passwordEncoder(passwordEncoder());
    }
}

在以上 configure 方法中,我们配置了自定义的 MyUserDetailsService 和 PasswordEncoder 对象。

1.5 创建相关 Controller 及自定义登录页和首页

在 Spring Security 中 DefaultLoginPageGeneratingFilter 过滤器会为我们生成默认登录界面:

相信很多小伙伴都“看不惯”这个页面,下面我们就来对这个页面进行“整容”。

HomeController 类
// com/semlinker/controller/HomeController.java
@Controller
public class HomeController {@GetMapping("/")
    public String index() {return "index";}

}
UserController 类
// com/semlinker/controller/UserController.java
@Controller
public class UserController {@GetMapping("/login")
    public String login() {return "login";}

}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Semlinker 修仙之路首页 </title>
</head>
<body>
   <h3> 欢迎您来到 Semlinker 修仙之路首页 </h3>
</body>
</html>
login.html
<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>Semlinker 修仙之路登录页 </title>
</head>
<body>
<form class="login-form" method="post" action="/login">
    <h1>Login</h1>
    <div class="form-field">
        <i class="fas fa-user"></i>
        <input type="text" name="username" id="username" class="form-field" 
               placeholder=" " required>
        <label for="username">Username</label>
    </div>
    <div class="form-field">
        <i class="fas fa-lock"></i>
        <input type="password" name="password" id="password" class="form-field" 
               placeholder=" " required>
        <label for="password">Password</label>
    </div>
    <button type="submit" value="Login" class="btn">Login</button>
</form>
</body>
</html>

1.6 配置默认的登录页

在创建完登录页之后,还需要在 WebSecurityConfig 类中进行配置才能生效,对应的配置方式如下:

// com/semlinker/config/WebSecurityConfig.java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    // 省略前面已设置的内容
    protected void configure(HttpSecurity http) throws Exception {http.formLogin()
            .loginPage("/login");
    }
}

完成上述配置后,我们来测试一下效果,首先启动 Spring Boot 应用,待启动完成后在浏览器中打开 http://localhost:8080/login 地址,若一切顺利的话,你将看到以下界面:

(页面来源于 https://codepen.io/alphardex/…)

接下来我们来执行登录操作,这里的用户名可以是任意的,密码是前面我们所设置的 hello。但当我们输入正确的用户名和密码点击登录之后,映入眼帘的却是以下的异常页面:

Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.

Mon Oct 28 14:27:25 CST 2019
There was an unexpected error (type=Forbidden, status=403).
Forbidden

这是什么原因呢?为啥被禁止访问了,小伙伴们先别急,首先打开当前项目 src/main/resources/ 目录下的 application.properties 文件,然后输入以下配置信息:

logging.level.org.springframework.security.web.FilterChainProxy=DEBUG

待完成配置之后,重启一下应用,然后重新执行一次上述的登录操作。如果没猜错的话,你重新执行登录,输入的用户名和密码也没有错,但仍看见 Whitelabel Error Page 页面。其实刚才我们已经启用的 Security FilterChainProxy 的 DEBUG 调试模式,所以我们来看一下控制台输出的异常信息:

通过上图可以发现 /login 请求,经过 CsrfFilter 过滤器就不再往下继续执行了。这里的 CsrfFilter 过滤器是用来处理跨站请求伪造攻击的过滤器,跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF,是一种挟制用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。

现在我们已经大致知道原因了,由于我们的登录页暂不需要开启 Csrf 防御,所以我们先把 Csrf 过滤器禁用掉:

// com/semlinker/config/WebSecurityConfig.java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {protected void configure(HttpSecurity http) throws Exception {http.formLogin()
                .loginPage("/login")
                .and().csrf().disable();}
}

更新完 WebSecurityConfig 配置类,再重新跑一次前面的登录流程,这次当你点击登录之后,你将会在当前页面看到 欢迎您来到 Semlinker 修仙之路首页 这行内容。

二、处理不同类型的请求

默认情况下,当用户通过浏览器访问被保护的资源时,会默认自动重定向到预设的登录地址。这对于传统的 Web 项目来说,是没有多大问题,但这种方式就不适用于前后端分离的项目。对于前后端分离的项目,服务端一般只需要对外提供返回 JSON 格式的 API 接口。

针对上述的问题,有如下一种方案可供参考。即根据请求是否以 .html 为结尾来对应不同的处理方法。如果是以 .html 结尾,那么重定向到登录页面,否则返回”访问的资源需要身份认证!”信息,并且 HTTP 状态码为 401(HttpStatus.UNAUTHORIZED)。

要实现上述的功能,我们先来定义一个 WebSecurityController 类,具体实现如下:

// com/semlinker/controller/WebSecurityController.java
@Slf4j
@RestController
public class WebSecurityController {
    // 原请求信息的缓存及恢复
    private RequestCache requestCache = new HttpSessionRequestCache();

    // 用于执行重定向操作
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    /**
     * 默认的登录页,用于处理不同的登录认证逻辑
     *
     * @param request
     * @param response
     * @return
     */
    @RequestMapping("/authentication/require")
    @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    public String requireAuthenication(HttpServletRequest request, 
      HttpServletResponse response) throws Exception {SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest != null) {String targetUrl = savedRequest.getRedirectUrl();
            log.info("引发跳转的请求是:" + targetUrl);
            if (StringUtils.endsWithIgnoreCase(targetUrl, ".html")) {redirectStrategy.sendRedirect(request, response, "/login.html");
            }
        }

        return "访问的服务需要身份认证,请引导用户到登录页";
    }
}

接着将 formLogin 的默认登录页,修改为 /authentication/require,并通过 antMatchers 方法设置免拦截:

// com/semlinker/config/WebSecurityConfig.java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {protected void configure(HttpSecurity http) throws Exception {http.formLogin()
                .loginPage("/authentication/require")
                .and()
                .authorizeRequests()
                .antMatchers("/authentication/require", "/login.html").permitAll()
                .anyRequest().authenticated()
                .and().csrf().disable()
        ;
    }
}

同时也要修改一下前面定义的 UserController 类,让其支持 /login.html 路径映射:

// com/semlinker/controller/UserController.java
@Controller
public class UserController {@GetMapping({"login", "/login.html"})
    public String login() {return "login";}

}

完成上述调整后,到我们访问 http://localhost:8080/index 的时候,页面会自动跳转到 http://localhost:8080/authentication/require,并且输出 “ 访问的服务需要身份认证,请引导用户到登录页 ”。而当我们访问 http://localhost:8080/index.html 的时候,页面会跳转到登录页面。

三、自定义处理登录成功和失败逻辑

在前后端分离项目中,当用户登录成功或登录失败时,需要向前端返回相应的信息,而不是直接进行页面跳转。针对前后端分离的场景,可以利用 Spring Security 中的 AuthenticationSuccessHandlerAuthenticationFailureHandler 这两个接口或继承 SimpleUrlAuthenticationSuccessHandlerSimpleUrlAuthenticationFailureHandler 类来实现自定义登录成功和登录失败的处理逻辑。

3.1 自定义登录成功处理逻辑

这里我们选用继承 SimpleUrlAuthenticationSuccessHandler 类,来实现自定义登录成功处理逻辑:

// com/semlinker/handler/MyAuthenctiationSuccessHandler.java
@Slf4j
@Component
public class MyAuthenctiationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
      HttpServletResponse response, Authentication authentication)
        throws IOException, ServletException {log.info("登录成功");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
    }
}

3.2 自定义登录失败处理逻辑

同样我们也选用继承 SimpleUrlAuthenticationFailureHandler 类,来实现自定义登录失败处理逻辑:

// com/semlinker/handler/MyAuthenctiationFailureHandler.java
@Slf4j
@Component
public class MyAuthenctiationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, 
      HttpServletResponse response,AuthenticationException exception) 
        throws IOException, ServletException {log.info("登录失败");
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage()));
    }
}

3.3 配置 MyAuthenctiationSuccessHandler 和 MyAuthenctiationFailureHandler

最后要让自定义处理登录成功和失败逻辑生效,还需要在 WebSecurityConfig 类中配置 FormLoginConfigurer 对象的 successHandler 和 failureHandler 属性,到目前为止 WebSecurityConfig 类的完整配置如下:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private MyAuthenctiationFailureHandler myAuthenctiationFailureHandler;

    @Autowired
    private MyAuthenctiationSuccessHandler myAuthenctiationSuccessHandler;

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

    @Bean
    UserDetailsService myUserDetailService() {return new MyUserDetailsService();
    }

    protected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(myUserDetailService()).passwordEncoder(passwordEncoder());
    }

    protected void configure(HttpSecurity http) throws Exception {http.formLogin()
                .loginPage("/login")
                .successHandler(myAuthenctiationSuccessHandler)
                .failureHandler(myAuthenctiationFailureHandler)
                .and()
                .authorizeRequests()
                .antMatchers("/authentication/require", "/login").permitAll()
                .anyRequest().authenticated()
                .and().csrf().disable()
        ;
    }
}

前面本文已经介绍了在 Spring Security 中实现自定义用户认证的流程,在学习过程中如果小伙伴们遇到其它问题的话,建议可以开启 FilterChainProxy 的 DEBUG 模式进行日志排查。

本文项目地址:Github – custom-user-authentication

四、参考资源

  • MrBird – Spring Security 自定义用户认证
  • Woodwhale – SpringBoot + Spring Security 学习笔记(一)自定义基本使用及个性化登录配置

正文完
 0