Spring Security 实战干货:玩转自定义登陆

1. 前言

前面的关于 Spring Security 相关的文章只是一个预热。为了接下来更好的实战,若是你错过了请从 Spring Security 实战系列 开始。安全访问的第一步就是认证(Authentication),认证的第一步就是登陆。今天咱们要经过对 Spring Security 的自定义,来设计一个可扩展,可伸缩的 form 登陆功能。html

2. form 登陆的流程

下面是 form 登陆的基本流程:前端

只要是 form 登陆基本都能转化为上面的流程。接下来咱们看看 Spring Security 是如何处理的。java

3. Spring Security 中的登陆

昨天 Spring Security 实战干货:自定义配置类入口WebSecurityConfigurerAdapter 中已经讲到了咱们一般的自定义访问控制主要是经过 HttpSecurity 来构建的。默认它提供了三种登陆方式:web

  • formLogin() 普通表单登陆
  • oauth2Login() 基于 OAuth2.0 认证/受权协议
  • openidLogin() 基于 OpenID 身份认证规范

以上三种方式通通是 AbstractAuthenticationFilterConfigurer 实现的,spring

4. HttpSecurity 中的 form 表单登陆

启用表单登陆经过两种方式一种是经过 HttpSecurityapply(C configurer) 方法本身构造一个 AbstractAuthenticationFilterConfigurer 的实现,这种是比较高级的玩法。 另外一种是咱们常见的使用 HttpSecurityformLogin() 方法来自定义 FormLoginConfigurer 。咱们先搞一下比较常规的第二种。数据库

4.1 FormLoginConfigurer

该类是 form 表单登陆的配置类。它提供了一些咱们经常使用的配置方法:json

  • loginPage(String loginPage) : 登陆 页面而并非接口,对于先后分离模式须要咱们进行改造 默认为 /login
  • loginProcessingUrl(String loginProcessingUrl) 实际表单向后台提交用户信息的 Action,再由过滤器UsernamePasswordAuthenticationFilter 拦截处理,该 Action 其实不会处理任何逻辑。
  • usernameParameter(String usernameParameter) 用来自定义用户参数名,默认 username
  • passwordParameter(String passwordParameter) 用来自定义用户密码名,默认 password
  • failureUrl(String authenticationFailureUrl) 登陆失败后会重定向到此路径, 通常先后分离不会使用它。
  • failureForwardUrl(String forwardUrl) 登陆失败会转发到此, 通常先后分离用到它。 可定义一个 Controller (控制器)来处理返回值,可是要注意 RequestMethod
  • defaultSuccessUrl(String defaultSuccessUrl, boolean alwaysUse) 默认登录成功后跳转到此 ,若是 alwaysUsetrue 只要进行认证流程并且成功,会一直跳转到此。通常推荐默认值 false
  • successForwardUrl(String forwardUrl) 效果等同于上面 defaultSuccessUrlalwaysUsetrue 可是要注意 RequestMethod
  • successHandler(AuthenticationSuccessHandler successHandler) 自定义认证成功处理器,可替代上面全部的 success 方式
  • failureHandler(AuthenticationFailureHandler authenticationFailureHandler) 自定义失败成功处理器,可替代上面全部的 failure 方式
  • permitAll(boolean permitAll) form 表单登陆是否放开

知道了这些咱们就能来搞个定制化的登陆了。安全

5. Spring Security 聚合登陆 实战

接下来是咱们最激动人心的实战登陆操做。 有疑问的可认真阅读 Spring 实战 的一系列预热文章。app

5.1 简单需求

咱们的接口访问都要经过认证,登录错误后返回错误信息(json),成功后前台能够获取到对应数据库用户信息(json)(实战中记得脱敏)。cors

咱们定义处理成功失败的控制器:

@RestController
 @RequestMapping("/login")
 public class LoginController {
     @Resource
     private SysUserService sysUserService;
 
     /** * 登陆失败返回 401 以及提示信息. * * @return the rest */
     @PostMapping("/failure")
     public Rest loginFailure() {
 
         return RestBody.failure(HttpStatus.UNAUTHORIZED.value(), "登陆失败了,老哥");
     }
 
     /** * 登陆成功后拿到我的信息. * * @return the rest */
     @PostMapping("/success")
     public Rest loginSuccess() {
           // 登陆成功后用户的认证信息 UserDetails会存在 安全上下文寄存器 SecurityContextHolder 中
         User principal = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
         String username = principal.getUsername();
         SysUser sysUser = sysUserService.queryByUsername(username);
         // 脱敏
         sysUser.setEncodePassword("[PROTECT]");
         return RestBody.okData(sysUser,"登陆成功");
     }
 }
复制代码

而后 咱们自定义配置覆写 void configure(HttpSecurity http) 方法进行以下配置(这里须要禁用crsf):

@Configuration
 @ConditionalOnClass(WebSecurityConfigurerAdapter.class)
 @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
 public class CustomSpringBootWebSecurityConfiguration {
 
     @Configuration
     @Order(SecurityProperties.BASIC_AUTH_ORDER)
     static class DefaultConfigurerAdapter extends WebSecurityConfigurerAdapter {
         @Override
         protected void configure(AuthenticationManagerBuilder auth) throws Exception {
             super.configure(auth);
         }
 
         @Override
         public void configure(WebSecurity web) throws Exception {
             super.configure(web);
         }
 
         @Override
         protected void configure(HttpSecurity http) throws Exception {
             http.csrf().disable()
                     .cors()
                     .and()
                     .authorizeRequests().anyRequest().authenticated()
                     .and()
                     .formLogin()
                     .loginProcessingUrl("/process")
                     .successForwardUrl("/login/success").
                     failureForwardUrl("/login/failure");
 
         }
     }
 }
复制代码

使用 Postman 或者其它工具进行 Post 方式的表单提交 http://localhost:8080/process?username=Felordcn&password=12345 会返回用户信息:

{
     "httpStatus": 200,
     "data": {
         "userId": 1,
         "username": "Felordcn",
         "encodePassword": "[PROTECT]",
         "age": 18
     },
     "msg": "登陆成功",
     "identifier": ""
 }
复制代码

把密码修改成其它值再次请求认证失败后 :

{
      "httpStatus": 401,
      "data": null,
      "msg": "登陆失败了,老哥",
      "identifier": "-9999"
  }
复制代码

6. 多种登陆方式的简单实现

就这么完了了么?如今登陆的花样繁多。常规的就有短信、邮箱、扫码 ,第三方是之后我要讲的不在今天范围以内。 如何应对想法多的产品经理? 咱们来搞一个可扩展各类姿式的登陆方式。咱们在上面 2. form 登陆的流程 中的 用户断定 之间增长一个适配器来适配便可。 咱们知道这个所谓的 断定就是 UsernamePasswordAuthenticationFilter

咱们只须要保证 uri 为上面配置的/process 而且可以经过 getParameter(String name) 获取用户名和密码便可

我忽然以为能够模仿 DelegatingPasswordEncoder 的搞法, 维护一个注册表执行不一样的处理策略。固然咱们要实现一个 GenericFilterBeanUsernamePasswordAuthenticationFilter 以前执行。同时制定登陆的策略。

6.1 登陆方式定义

定义登陆方式枚举 ``。

public enum LoginTypeEnum {
  
      /** * 原始登陆方式. */
      FORM,
      /** * Json 提交. */
      JSON,
      /** * 验证码. */
      CAPTCHA
  
  }
复制代码

6.2 定义前置处理器接口

定义前置处理器接口用来处理接收的各类特点的登陆参数 并处理具体的逻辑。这个借口其实有点随意 ,重要的是你要学会思路。我实现了一个 默认的 form' 表单登陆 和 经过RequestBody放入json` 的两种方式,篇幅限制这里就不展现了。具体的 DEMO 参见底部。

public interface LoginPostProcessor {
   
   
   
       /** * 获取 登陆类型 * * @return the type */
       LoginTypeEnum getLoginTypeEnum();
   
       /** * 获取用户名 * * @param request the request * @return the string */
       String obtainUsername(ServletRequest request);
   
       /** * 获取密码 * * @param request the request * @return the string */
       String obtainPassword(ServletRequest request);
   
   }
复制代码

6.3 实现登陆前置处理过滤器

该过滤器维护了 LoginPostProcessor 映射表。 经过前端来断定登陆方式进行策略上的预处理,最终仍是会交给 UsernamePasswordAuthenticationFilter 。经过 HttpSecurityaddFilterBefore(preLoginFilter, UsernamePasswordAuthenticationFilter.class)方法进行前置。

package cn.felord.spring.security.filter;
 
 import cn.felord.spring.security.enumation.LoginTypeEnum;
 import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
 import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.util.Assert;
 import org.springframework.util.CollectionUtils;
 import org.springframework.web.filter.GenericFilterBean;
 
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
 import javax.servlet.ServletRequest;
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 
 import static org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_PASSWORD_KEY;
 import static org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY;
 
 /** * 预登陆控制器 * * @author Felordcn * @since 16 :21 2019/10/17 */
 public class PreLoginFilter extends GenericFilterBean {
 
 
     private static final String LOGIN_TYPE_KEY = "login_type";
 
 
     private RequestMatcher requiresAuthenticationRequestMatcher;
     private Map<LoginTypeEnum, LoginPostProcessor> processors = new HashMap<>();
 
 
     public PreLoginFilter(String loginProcessingUrl, Collection<LoginPostProcessor> loginPostProcessors) {
         Assert.notNull(loginProcessingUrl, "loginProcessingUrl must not be null");
         requiresAuthenticationRequestMatcher = new AntPathRequestMatcher(loginProcessingUrl, "POST");
         LoginPostProcessor loginPostProcessor = defaultLoginPostProcessor();
         processors.put(loginPostProcessor.getLoginTypeEnum(), loginPostProcessor);
 
         if (!CollectionUtils.isEmpty(loginPostProcessors)) {
             loginPostProcessors.forEach(element -> processors.put(element.getLoginTypeEnum(), element));
         }
 
     }
 
 
     private LoginTypeEnum getTypeFromReq(ServletRequest request) {
         String parameter = request.getParameter(LOGIN_TYPE_KEY);
 
         int i = Integer.parseInt(parameter);
         LoginTypeEnum[] values = LoginTypeEnum.values();
         return values[i];
     }
 
 
     /** * 默认仍是Form . * * @return the login post processor */
     private LoginPostProcessor defaultLoginPostProcessor() {
         return new LoginPostProcessor() {
 
 
             @Override
             public LoginTypeEnum getLoginTypeEnum() {
 
                 return LoginTypeEnum.FORM;
             }
 
             @Override
             public String obtainUsername(ServletRequest request) {
                 return request.getParameter(SPRING_SECURITY_FORM_USERNAME_KEY);
             }
 
             @Override
             public String obtainPassword(ServletRequest request) {
                 return request.getParameter(SPRING_SECURITY_FORM_PASSWORD_KEY);
             }
         };
     }
 
 
     @Override
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
         ParameterRequestWrapper parameterRequestWrapper = new ParameterRequestWrapper((HttpServletRequest) request);
         if (requiresAuthenticationRequestMatcher.matches((HttpServletRequest) request)) {
 
             LoginTypeEnum typeFromReq = getTypeFromReq(request);
 
             LoginPostProcessor loginPostProcessor = processors.get(typeFromReq);
 
 
             String username = loginPostProcessor.obtainUsername(request);
 
             String password = loginPostProcessor.obtainPassword(request);
 
 
             parameterRequestWrapper.setAttribute(SPRING_SECURITY_FORM_USERNAME_KEY, username);
             parameterRequestWrapper.setAttribute(SPRING_SECURITY_FORM_PASSWORD_KEY, password);
 
         }
 
         chain.doFilter(parameterRequestWrapper, response);
 
 
     }
 }
复制代码

6.4 验证

经过 POST 表单提交方式 http://localhost:8080/process?username=Felordcn&password=12345&login_type=0 能够请求成功。或者如下列方式也能够提交成功:

更多的方式 只须要实现接口 LoginPostProcessor 注入 PreLoginFilter

7. 总结

今天咱们经过各类技术的运用实现了从简单登陆到可动态扩展的多种方式并存的实战运用。相信对你来讲会有不小的收货 ,本次 **代码DEMO可经过关注公众号:Felordcn 回复 ss03 获取,后面会更加精彩。

关注公众号:Felordcn获取更多资讯

我的博客:https://felord.cn

相关文章
相关标签/搜索