继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

Spring Sceurity的开发1

MYYA
关注TA
已关注
手记 435
粉丝 75
获赞 326

在开发实际应用项目当中,肯定存在用户登录和授权的过程,之前我们使用自己开发的权限框架或者 Shiro 来做这块内容的扩展和延伸,今天使用 Spring 框架自身的权限框架来集成下,也就是 Spring Security。

Spring Security 核心功能包括以下三个部分:
1)认证,解决你是谁的问题,也即用户登录;
2)授权,解决你可以干什么的问题,并不是你登录就可以为所欲为;
3)攻击防护,解决防止别人伪造身份问题,

1、基于表单的认证

1.1第一印象

导入 springsecurity 的依赖包之后,我们的项目启动会自动开启基本安全认证,认证的用户名是 user,密码可以在控制台找到,形如


image


打开一个 URL,就可以看到需要登录验证


image


输入账号密码就可以正常使用。
当然这种情况是无法满足实际应用的,我们需要自己的用户名和密码来进行登录认证。

记下来首先配置一些 开启使用Springsecurity 的基本配置,在配置包里面新建一个类WebSecurityConfig,继承WebSecurityConfigurerAdapter,然后复写configure(HttpSecurity http)方法,具体代码如下

@Configurationpublic class WebSecurityConfig extends WebSecurityConfigurerAdapter{    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()//表单登录,身份认证
            .and()
            .authorizeRequests()//对请求授权
            .anyRequest()//任何请求
            .authenticated();//需要身份认证
    }
}

然后重启,进行访问,此时将会出现一个默认登录框,如下


image


账号密码和之前相同,可以测试,注意 URL 的跳转。

其实这个和之前没什么区别,也的确没什么区别,主要是原来默认的是http.httpBasic()改为了http.formLogin().

1.2基本原理

其实 Springsecurity 就是一组过滤器,形成过滤器练,所有的访问请求都会经过这些过滤器,这些过滤器在系统启动的时候自行配置到链中,无需开发者关心。
过滤器链上有很多过滤器,其中比较主要的几个
1、UserPasswordAuthenticationFilter、BaseicAuthenticationFilter 是认证用户身份的,每个过滤器就是一种验证方式。和上面对应的就是
http.httpBasic()---->BaseicAuthenticationFilter
http.formLogin()---->UserPasswordAuthenticationFilter
这两个过滤器都是检查请求里面是否包含过滤器需要的信息。比如先经过UserPasswordAuthenticationFilter这个过滤器,那么就需要先查看是否是一个登陆请求,是否包含用户名和密码,如果没有这些信息,那么就到BaseicAuthenticationFilter过滤器,会检查请求头里面是否包含需要的信息。
2、一旦通过认证,会有相应的标记进行记录,然后继续想后面的过滤器传递。最后到达链的终端是FilterSecurityInterceptor,他是整个过滤器链最终守门人,他决定该请求能否顺利的访问 Controller 里面的服务。也就是说前面的链不管结论如何都会走到最后整个过滤器,有他来决定是往后继续执行业务,还是抛出某个异常。
3、一旦有异常出现,会在倒数第二层的过滤器来处理这些异常,这个过滤器是ExceptionTranslationFilter。他会根据具体的异常会导向不同的页面。


image


上图中,除了第一类(绿色)的我们可以控制,第二第三(橙色和蓝色)是不可控制的,他们一定在过滤器链的末端。

深入源码
分别在绿色的 UserPasswordAuthenticationFilter,蓝色的异常处理过滤器,橙色的过滤器,以及自己的 Rest 服务  上打上断点。
首先在 Controller 的方法上打个断点

image


其次在FilterSecurityInterceptor的124行地方打断点
[图片上传失败...(image-c0c504-1527080640040)]
再次在ExceptionTranslationFilter 的123行的地方打上断点

image


最后在UsernamePasswordAuthenticationFilter的获取用户名和面膜的地方打上断点

image


然后我们开始运行一个请求,比如http://localhost:8888/users?username=123


首先断点停在FilterSecurityInterceptor这个类,运维前面的过滤器对这个 URL 都不care,由于我们配置了所有请求都需要身份验证,那么这关肯定过不去的。在执行beforeInvocation的时候跑出一个异常,这个异常跑出来之后,被ExceptionTranslationFilter过滤器捕获到了。然后对异常的处理,处理结果是一个重定向到一个登陆页面。
接下来进行登录,这次停在UsernamePasswordAuthenticationFilter这个过滤器上,说明登录请求被诸葛过滤器抓住了,并且开始进行验证登录结果。
再继续就又到橙色的FilterSecurityInterceptor类上,其实这之间由个跳转的,登录 URL 处理完毕,正常登录后又回到http://localhost:8888/users?username=123这个请求,其实是这个请求走到了橙色过滤器上。一旦InterceptorStatusToken token = super.beforeInvocation(fi);执行完毕,就到实际业务代码里面了。

1.2自定义用户认证逻辑

1.2.1用户信息获取

这部分功能被封装在UserDetailsService这个接口里面。这个接口只有一个方法
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
接受用户名,返回UserDetails对象。创建一个类,实现UserDetailsService,并且实现方法,代码实现如下,为了简便处理,忽略了数据层

@Componentpublic class CustomerUserDetailService implements UserDetailsService{    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {        //根据用户名去数据库查询用户信息
        //可以注入 jdbc,mybatis 等 DAO 
        //这里方便演示,直接在代码里面做了
        
        //User 对象已经实现了UserDetails
        //AuthorityUtils.commaSeparatedStringToAuthorityList 方法是以逗号分割产生一个授权集合
        User user=new User(username, "123456", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));        return user;
    }
}

这下可以使用自己的用户登录逻辑了。

1.2.2校验逻辑

主要是密码是否匹配,比如取出123456密码之后交给 Springsecurity 就可以了。
其次其他的一些校验,密码过期,用户冻结等。
主要看 UserDetails这个接口,里面包含了所有信息

image


后面4个布尔返回方法可以执行自己的校验逻辑
isAccountNonExpired----账号是否过期
isAccountNonLocked----账号是否冻结,可恢复
isCredentialsNonExpired----密码是否过期
isEnabled----账号是否删除,不可恢复
我们在构造 User 的时候使用有7参数的构造,改写


User user=new User(username, "123456", 
                true,//账号可用
                true,//账号不过期
                true,//密码不过期
                true,//账号没有锁定
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));        return user;
1.2.3加密解密

实际应用密码取出应该是一个加密的密码,而不是明文。Springsecurity 的密码加密在接口PasswordEncoder中处理。该接口有2个方法,分别是
String encode(CharSequence rawPassword);负责加密,用户注册的时候对明文加密,存到 DB。
boolean matches(CharSequence rawPassword, String encodedPassword);负责匹配
为了使用加密功能,这里使用一个美人的实现BCryptPasswordEncoder,把这个 bean 添加到配置中。

image


为了演示,这里直接使用

image


1.3个性化登录

1、自定义用户登录页
之前 SpringSecurity 自带的登录页当然不能正常使用,我们需要自行定制一个,首先在配置里面增加一行登录页的名称

image


然后开始构建这个页面

image


需要注意的是:这个 login.html 需要排除请求认证之外。

image


这样我们就能使用自己的登录页面了。

image


登录的请求地址是/userlogin,form是<form name="f" action="/userlogin" method="POST">。默认的 UsernamePasswordAuthenticationFilter过滤器验证的是

image

这样的请求,而我们现在改了,也需要修改配置

image


进行访问出现跨站防护的问题,如下图

image


这里暂时关闭这个功能


@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()//表单登录,身份认证
            .loginPage("/login.html")//设置登录页
            .loginProcessingUrl("/userlogin")//设置表单提交请求的 URL
            .and()
            .authorizeRequests()//对请求授权
            .antMatchers("/login.html").permitAll()//表示对这个 url 永远的通过
            .anyRequest()//任何请求
            .authenticated()//需要身份认证
            .and()
            .csrf().disable();//把 csdf 跨站防护关闭
    }

接下来针对不同类型的请求,应该返回不同的内容,如下图所示,一个请求是否需要认证(由 Springsecurity 决定),一旦需要认证,那么久转入一个 Controller 里面,进行判断,如果来源是 HTML,那么久跳转到登录页面,如果是 ajax 请求什么的,那么返回 JSON 内容什么的。


image


接下来实现他,首先编写 Controller

@RestControllerpublic class WebSecurityController {    //需要把当前的请求缓存到 session 里面
    private RequestCache requestCache=new HttpSessionRequestCache();    private RedirectStrategy redirectStrategy=new  DefaultRedirectStrategy();    @RequestMapping("/authlogin")    @ResponseStatus(code=HttpStatus.UNAUTHORIZED)//401 未授权
    public SimpleResponse requestAuthentication(HttpServletRequest request,HttpServletResponse response) throws IOException{        //拿到引发跳转的请求
        SavedRequest savedRequest = requestCache.getRequest(request, response);        if(savedRequest!=null){
            String redirectUrl = savedRequest.getRedirectUrl();//引发跳转的请求 URL
            if(redirectUrl.endsWith(".html")){//是否是以 HTML 结尾
                //跳转登录页 这个登录页可以配置到外面,使得程序更加灵活
                redirectStrategy.sendRedirect(request, response, "login.html");
            }
        }        return new SimpleResponse("访问服务需要认证,引导用户到登录页");
    }
}

代码重构
由于上面的登录页是写死在代码里面,需要移植到可配置层面,现在为了全局考虑,进行总体进行一个设计


image

1、构建 类 SecurityProperties

@ConfigurationProperties(prefix="cn.ts")public class SecurityProperties {    //以 cn.ts.web配置的读到这个对象里面
    private WebProperties web=new WebProperties();    public WebProperties getWeb() {        return web;
    }    public void setWeb(WebProperties webProperties) {        this.web = webProperties;
    }
    
}

2、构建 WebProperties

public class WebProperties {    //登录页 cn.ts.web.loginPage
    //如果用户配置这个值,就使用配置了的,否则使用默认的
    private String loginPage="/login.html";    public String getLoginPage() {        return loginPage;
    }    public void setLoginPage(String loginPage) {        this.loginPage = loginPage;
    }
    
}

3、构建配置类,装载SecurityProperties

@Configuration@EnableConfigurationProperties(SecurityProperties.class)public class CoreConfiguration {

}

4、修改 Controller 代码


image

5、修改SS 配置代码

@Configurationpublic class WebSecurityConfig extends WebSecurityConfigurerAdapter{    @Autowired private SecurityProperties securityProperties;    
    @Bean
    public PasswordEncoder passwordEncoder(){        return new BCryptPasswordEncoder();
    }    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()//表单登录,身份认证
            .loginPage("/authlogin")//设置登录页
            .loginProcessingUrl("/userlogin")//设置表单提交请求的 URL
            .and()
            .authorizeRequests()//对请求授权
            .antMatchers("/authlogin",                    "/login.html",//默认的登录页
                    securityProperties.getWeb().getLoginPage())
            .permitAll()//表示对这个 url 永远的通过
            .anyRequest()//任何请求
            .authenticated()//需要身份认证
            .and()
            .csrf().disable();//把 csdf 跨站防护关闭
    }
}

1.4登录成功和失败页面处理

在 SS 中处理登录成功之后的处理比较简单,只需要实现AuthenticationSuccessHandler即可。
我们来实现这个接口

@Componentpublic class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler{    @Autowired
    private ObjectMapper objectMapper;    /**
     * 
     * Authentication封装了我们的登录信息,包括登录之前信息和 UserDetail 
     * @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 {
        
        System.out.println("登录成功");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
        
    }

}

这里就是把authentication包装成 JSON 格式返回到前端。

接下来配置这个成功处理器,注入这个处理器到 SS 配置类,设置如下图


image

有了成功处理,现在开始定制失败处理的类,与上面类似,不过实现的是AuthenticationFailureHandler,该接口里面需要实现的方法是onAuthenticationFailure,其里面参数AuthenticationException可以看下图,是他的子类实现。

image


而我们自定义的失败处理类和上面成功类相似,唯一不同的是返回状态需要修改为500


@Componentpublic class MyAuthenticationFailureHandler implements AuthenticationFailureHandler{    @Autowired
    private ObjectMapper objectMapper;    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception) throws IOException, ServletException {
        System.out.println("登录失败");
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());//500错误
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(exception));
    }
}

同时也需要在 SS 配置类上配置,如下图所示


image

然后启动服务器,故意输入错误密码,可以看到打出一大片错误信息,同时返回状态码是500,控制台也打出登录错误。
成功登录一次,可以看到控制台打出成功登录信息。


image

为了让成功和失败能够同时处理页面跳转和返回 JSON两种方案,需要重构下上面两个实现。
于是我们先构建一个枚举

public enum LoginType {
    REDIRECT,
    JSON
}

在 WebProperties 里面引入这个属性

public class WebProperties {    //登录页 cn.ts.web.loginPage
    //如果用户配置这个值,就使用配置了的,否则使用默认的
    private String loginPage="/login.html";    
    private LoginType loginType=LoginType.JSON;    public String getLoginPage() {        return loginPage;
    }    public void setLoginPage(String loginPage) {        this.loginPage = loginPage;
    }    public LoginType getLoginType() {        return loginType;
    }    public void setLoginType(LoginType loginType) {        this.loginType = loginType;
    }
    
    
}

接下来,需要变动成功处理器,改成继承一个默认的处理器SavedRequestAwareAuthenticationSuccessHandler
具体代码如下

@Componentpublic class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler{    @Autowired
    private ObjectMapper objectMapper;    @Autowired
    private SecurityProperties securityProperties;    /**
     * 
     * Authentication封装了我们的登录信息,包括登录之前信息和 UserDetail 
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {
        
        System.out.println("登录成功");        if(LoginType.JSON.equals(securityProperties.getWeb().getLoginType())){
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(objectMapper.writeValueAsString(authentication));
        }else{            //父类的方法就是跳转
            super.onAuthenticationSuccess(request, response, authentication);
        }
    }
}

同样的做法,修改失败处理

@Componentpublic class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler{    @Autowired
    private ObjectMapper objectMapper;    @Autowired
    private SecurityProperties securityProperties;    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception) throws IOException, ServletException {
        System.out.println("登录失败");        if(LoginType.JSON.equals(securityProperties.getWeb().getLoginType())){
            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());//500错误
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(objectMapper.writeValueAsString(exception));
        }else{            //跳到父类的错误页面上
            super.onAuthenticationFailure(request, response, exception);
        }
        
    }
}

配置文件


image

上面分析了若干接口,以及如何使用,接下来仔细分析下源码是怎么把这些东西串起来的。
源码分析

1、认证处理流程说明

image


首先来看看这个流程涉及哪些类,是如何进行的。

image


这张图涉及到了核心一些类,接下来我们就就着这样的流程看源码。
我们开始登录,首先进入的是绿色的UsernamePasswordAuthenticationFilter,是 SS 过滤器链上的一个过滤器,处理用户表单登录,他根据用户名和密码构造了一个UsernamePasswordAuthenticationToken类。


UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username, password);

这个对象向上寻找,可以追溯到他是Authentication的一个实现。再看他的构造函数

public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {        super(null);//调用父类的空构造,权限设置为空
        this.principal = principal;        this.credentials = credentials;
        setAuthenticated(false);//还没有进行验证
    }

然后来到this.getAuthenticationManager().authenticate(authRequest)最后一行,也就来到上图的第二个类的位置``AuthenticationManager。这个类本身不包含验证逻辑,负责管理下面的 Provider 类。真正工作的类是ProviderManager,他的authenticate方法是拿到 Provider,真正校验的逻辑是在 Provider 里面。for (AuthenticationProvider provider : getProviders()) {`在这个地方,需要循环多个 Provider,哪个支持当前的登录就使用哪个,比如用户名密码登录,或者微信登录是使用不同的Provider 的。
具体的 Provider 是 DAOAUthenticationProvider。该类进行具体实现,该类的父类又调用了我们自己编写的 UserDetailService,这就和我们自定义代码集合起来了。
仔细跟进代码,成功后会调用我们自定义的成功代码,失败代码是在每个异常地方都会调用。

2、认证结果如何再多个请求之间共享


image


主要是 SecurityContent 和 SecurityContentHolder ,SecurityContentHolder是一个本地线程变量;最后一个过滤器是过滤器链的最前端,请求进入时候,检查 session 信息到线程,出去的时候把线程信息保存到 session。

3、获取用户认证信息
可以通过 SecurityContentHolder.getContext().getAuthentication()获得。
也可以在方法参数上直接使用


image


还可以使用部分信息


image

1.5 图像验证码

需要三个步骤:
1、根据随机数生成图片
2、随机数存入 session
3、图片写到接口响应

开始建立一个包,专门负责验证码,首先图像验证码的基本类。

public class ImageCode {    private BufferedImage image;//图片
    private String code;//验证码
    private LocalDateTime expireTime;//过期时间
    public BufferedImage getImage() {        return image;
    }    public void setImage(BufferedImage image) {        this.image = image;
    }    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;
    }    public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) {        super();        this.image = image;        this.code = code;        this.expireTime = expireTime;
    }    public ImageCode(BufferedImage image, String code, int expireInt) {        super();        this.image = image;        this.code = code;        this.expireTime = LocalDateTime.now().plusSeconds(expireInt);
    }
    
}

下面开始编写图像验证码的控制器

@RestControllerpublic class ValidateCodeController {    
    private static final String SESSION_KEY="SESSION_KEY_IMAGE_CODE";    
    //工具类
    private SessionStrategy sessionStrategy=new HttpSessionSessionStrategy();    @GetMapping("/image/code")    public void createCode(HttpServletRequest request,HttpServletResponse response) throws IOException{        //1创建 ImageCode 对象
        ImageCode imageCode=createImageCode(request);        //2从 request 拿到 session,把imageCode存入到 SESSION_KEY 当中
        sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode);        //3输出到响应
        ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
    }    private ImageCode createImageCode(HttpServletRequest request) {
        ...省略        return ...;
    }
}

如何使得验证码在何时使用以及在哪里处理验证码过程。首先我们修改下登录页,添加图形验证码

image


展现的样子

image


然后,我们需要一个过滤器,构建ValidateCodeFilter
详细代码如下


//OncePerRequestFilter 保证过滤器每次只被调用一次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 {        //判断拦截的请求 URL,只是登录的 URL
        //并且是 POST 请求
        if(StringUtils.equals("/userlogin", request.getRequestURI())
                && StringUtils.equalsIgnoreCase("post", request.getMethod())){            try {
                validate(new ServletWebRequest(request));
            } catch (ImageCodeException e) {//自定义异常进行捕获
                //一旦出现异常,使用authenticationFailureHandler来处理
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);                return;
            }
        }else{
            filterChain.doFilter(request, response);
        }
        
    }    private void validate(ServletWebRequest request) throws ImageCodeException, ServletRequestBindingException {        //分别拿到 session 和请求里面的验证码信息
        ImageCode codeInSession=(ImageCode)sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY);
        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");        
        if(StringUtils.isEmpty(codeInRequest)){            throw new ImageCodeException("验证码不能为空");
        }        
        if(codeInSession==null){            throw new ImageCodeException("验证码不存在");
        }        
        if(codeInSession.isExpire()){
            sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);            throw new ImageCodeException("验证码过期");
        }        if(!StringUtils.equals(codeInSession.getCode(), codeInRequest)){            throw new ImageCodeException("验证码不匹配");
        }        //清除 session 里面的 ImageCode
        sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
        
    }    public AuthenticationFailureHandler getAuthenticationFailureHandler() {        return authenticationFailureHandler;
    }    public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) {        this.authenticationFailureHandler = authenticationFailureHandler;
    }    public SessionStrategy getSessionStrategy() {        return sessionStrategy;
    }    public void setSessionStrategy(SessionStrategy sessionStrategy) {        this.sessionStrategy = sessionStrategy;
    }

}

之后需要把这个过滤器添加到过滤器链上

image


添加到UsernamePasswordAuthenticationFilter过滤器之前。


1.6 图像验证码拦截URL 可配置

我们在ImageCodeProperties 里面添加一个 url 属性


image


这个是一个 url 集合,以逗号分隔的,形如


image

1.7 记住我功能实现

1、原理


image


(1)、用户超过登录之后,会调用一个叫记住我的服务,该服务调用 TokenRepository 生成 token,然后将 token 写入浏览器 Cookie 和保存到数据库;
(2)、第二天发起请求的时候,会结果一个叫记住我验证过滤器,他会从记住我服务里面读取token 信息,然后再调用 UserDetailService,完成登录;
其中记住我的过滤器在基本认证过滤器之后,如下图的位置。


image

2、实现
2.1 首先在登录页面增加记住我的 checkbox。


image


注意红色框的内容必须是这样。

然后配置 TokenRepository,在之前的 SS 配置类中进行,这里需要数据源注入

@Autowired
    private DataSource dataSource;    @Bean
    public PersistentTokenRepository persistentTokenRepository(){
        JdbcTokenRepositoryImpl jdbcTokenRepositoryImpl=new JdbcTokenRepositoryImpl();
        jdbcTokenRepositoryImpl.setDataSource(dataSource);        return jdbcTokenRepositoryImpl;
    }    //为了后面获取用户信息使用
    @Autowired
    private CustomerUserDetailService customerUserDetailService;

需要预先创建一个表

create table persistent_logins (
username varchar(64) not null, 
series varchar(64) primary key, 
token varchar(64) not null, 
last_used timestamp not null)

2.2 配置记住我的过期时间
也是在WebProperties 里面增加一个属性

private int rememberMeSecond=3600;//set/get方法

2.3 配置记住我到 SS 配置


image

到此为止,配置记住我就完成了。
可以测试,成功登录后,数据库添加一条信息。


image

2、基于短信验证码的认证

2.1 开发短信验证码接口

基于图像验证码的接口改造一个发送短信验证码,在ValidateCodeController里面增加一个短信验证码的方法

@Autowired
    private SMSSender smsSender;@GetMapping("/sms/code")    public void createSMS(HttpServletRequest request,HttpServletResponse response) throws IOException, ServletRequestBindingException{        //1创建 ImageCode 对象
        SMSCode  smsCode=createSMSCode(new ServletWebRequest(request, response));        //2从 request 拿到 session,把imageCode存入到 SESSION_KEY 当中
        sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, smsCode);        //3短信供应商发送
        String mobile=ServletRequestUtils.getRequiredStringParameter(request, "mobile");        //这里模拟
        smsSender.sendSMS(mobile, "");
    }    private SMSCode createSMSCode(ServletWebRequest servletWebRequest) {
        SMSCode code=new SMSCode("1234", 60000);        return code;
    }

其中短信供应商也应该封装起来,这里粗略的封装下

public interface SMSSender {    public void sendSMS(String phone,String code);
}

实现

@Component("smsSender")public class DianxinSMSSender implements SMSSender{    @Override
    public void sendSMS(String phone, String code) {
        System.out.println("想手机"+phone+"发送验证码"+code);
    }
}

便捷前端页面

<form name="f" action="/mobilelogin" method="POST">
        <table>
            <tbody>
                <tr>
                    <td>手机号:</td>
                    <td><input type="text" name="mobile" value="13688888888"></td>
                </tr>
                <tr>
                    <td>手机验证码:</td>
                    <td><input  name="smsCode"><a href="/sms/code?mobile=13688888888">发送验证码</a></td>
                </tr>
                <tr>
                    <td colspan="2"><input name="submit" type="submit" value="登录"></td>
                </tr>
            </tbody>
        </table>
    </form>

2.2 校验短信验证码和登录

和用户密码登录类似构造自己的过滤器,但是短信验证码校验是放在过滤器之前,方便通用。

image


接下来开始逐个实现
SMSCodeAuthenticationToken模仿UsernamePasswordAuthenticationToken代码,稍作修改


//封装登录信息public class SMSCodeAuthenticationToken extends AbstractAuthenticationToken {    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;    // ~ Instance fields
    // ================================================================================================

    private final Object principal;//未验证之前是手机号,验证之后是用户信息
    //private Object credentials;//密码,由于在这之前已经验证过了,所以不需要这个属性

    // ~ Constructors
    // ===================================================================================================

    /**
     * This constructor can be safely used by any code that wishes to create a
     * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
     * will return <code>false</code>.
     *
     */
    public SMSCodeAuthenticationToken(String mobile) {        super(null);        this.principal = mobile;
        setAuthenticated(false);
    }    /**
     * This constructor should only be used by <code>AuthenticationManager</code> or
     * <code>AuthenticationProvider</code> implementations that are satisfied with
     * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
     * authentication token.
     *
     * @param principal
     * @param credentials
     * @param authorities
     */
    public SMSCodeAuthenticationToken(Object principal, Object credentials,
            Collection<? extends GrantedAuthority> authorities) {        super(authorities);        this.principal = principal;        super.setAuthenticated(true); // must use super, as we override
    }    // ~ Methods
    // ========================================================================================================

    

    public Object getPrincipal() {        return this.principal;
    }    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {        if (isAuthenticated) {            throw new IllegalArgumentException(                    "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }        super.setAuthenticated(false);
    }    @Override
    public void eraseCredentials() {        super.eraseCredentials();
    }    @Override
    public Object getCredentials() {        return null;
    }
}

SMSCodeAuthenticationFilter同样模仿UsernamePasswordAuthenticationFilter

public class SMSCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {    public static final String MOBILE = "mobile";    private String mobileParameter = MOBILE;    private boolean postOnly = true;    public SMSCodeAuthenticationFilter() {        super(new AntPathRequestMatcher("/mobilelogin", "POST"));
    }    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {        if (postOnly && !request.getMethod().equals("POST")) {            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }

        String mobile = obtainMobile(request);        if (mobile == null) {
            mobile = "";
        }

        mobile = mobile.trim();

        SMSCodeAuthenticationToken authRequest = new SMSCodeAuthenticationToken(mobile);        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);        return this.getAuthenticationManager().authenticate(authRequest);
    }    protected String obtainMobile(HttpServletRequest request) {        return request.getParameter(mobileParameter);
    }    protected void setDetails(HttpServletRequest request, SMSCodeAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }    public void setMobileParameter(String mobileParameter) {
        Assert.hasText(mobileParameter, "mobileParameter parameter must not be empty or null");        this.mobileParameter = mobileParameter;
    }    public void setPostOnly(boolean postOnly) {        this.postOnly = postOnly;
    }

}

SMSCodeAuthenticationProvider类的代码如下

public class SMSCodeAuthenticationProvider implements AuthenticationProvider{    private UserDetailsService userDetailsService;    
    //使用 UserDetailService 获取用户信息重新组装 Authentication
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SMSCodeAuthenticationToken token=(SMSCodeAuthenticationToken)authentication;
        UserDetails user=userDetailsService.loadUserByUsername((String)token.getPrincipal());        if(user==null){            throw new InternalAuthenticationServiceException("无法获取用户信息");
        }
        SMSCodeAuthenticationToken result=new SMSCodeAuthenticationToken(user,user.getAuthorities());
        result.setDetails(token.getDetails());//需要把之前的 Detail 设置到新的 Token 里面
        return result;
    }    //检查参数是不是我们定义的 SMSCodeAuthenticationToken
    @Override
    public boolean supports(Class<?> authentication) {        return SMSCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }    public UserDetailsService getUserDetailsService() {        return userDetailsService;
    }    public void setUserDetailsService(UserDetailsService userDetailsService) {        this.userDetailsService = userDetailsService;
    }

}

最后SMSCodeFilter雷同之前的图形验证码的过滤器,需要修改部分代码即可

//OncePerRequestFilter 保证过滤器每次只被调用一次public class SMSCodeFilter extends OncePerRequestFilter{    private AuthenticationFailureHandler authenticationFailureHandler;    
    private SessionStrategy sessionStrategy=new HttpSessionSessionStrategy();    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {        //判断拦截的请求 URL,只是登录的 URL
        //并且是 POST 请求
        if(StringUtils.equals("/mobilelogin", request.getRequestURI())
                && StringUtils.equalsIgnoreCase("post", request.getMethod())){            try {
                validate(new ServletWebRequest(request));
            } catch (ImageCodeException e) {//自定义异常进行捕获
                //一旦出现异常,使用authenticationFailureHandler来处理
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);                return;
            }
        }
        filterChain.doFilter(request, response);
        
        
    }    private void validate(ServletWebRequest request) throws ServletRequestBindingException {        //分别拿到 session 和请求里面的验证码信息
        SMSCode codeInSession=(SMSCode)sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY_SMS);
        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "smsCode");        
        if(StringUtils.isEmpty(codeInRequest)){            throw new ImageCodeException("验证码不能为空");
        }        
        if(codeInSession==null){            throw new ImageCodeException("验证码不存在");
        }        
        if(codeInSession.isExpire()){
            sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY_SMS);            throw new ImageCodeException("验证码过期");
        }        if(!StringUtils.equals(codeInSession.getCode(), codeInRequest)){            throw new ImageCodeException("验证码不匹配");
        }        //清除 session 里面的 ImageCode
        sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY_SMS);
        
    }    public AuthenticationFailureHandler getAuthenticationFailureHandler() {        return authenticationFailureHandler;
    }    public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) {        this.authenticationFailureHandler = authenticationFailureHandler;
    }    public SessionStrategy getSessionStrategy() {        return sessionStrategy;
    }    public void setSessionStrategy(SessionStrategy sessionStrategy) {        this.sessionStrategy = sessionStrategy;
    }
    
    

}

最终配置以上代码,使其可以正常工作
分连部分,首先配置前三个,配到核心包里面

@Componentpublic class SMSCodeAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>{    @Autowired private AuthenticationFailureHandler myAuthenticationFailureHandler;    @Autowired private AuthenticationSuccessHandler myAuthenticationSuccessHandler;    @Autowired private UserDetailsService userDetailsService;    
    @Override
    public void configure(HttpSecurity http) throws Exception {
        SMSCodeAuthenticationFilter sMSCodeAuthenticationFilter=new SMSCodeAuthenticationFilter();
        sMSCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        sMSCodeAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
        sMSCodeAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler);
        
        SMSCodeAuthenticationProvider sMSCodeAuthenticationProvider=new SMSCodeAuthenticationProvider();
        sMSCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
        
        http.authenticationProvider(sMSCodeAuthenticationProvider)
        .addFilterAfter(sMSCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

再把SMSCodeFilter类似ValidateCodeFilter在 SS 配置中进行

image


最后把SMSCodeAuthenticationConfig也添加到 SS 配智中。
先引入,最后 apply(sMSCodeAuthenticationConfig);
最后贴出完整代码


@Configurationpublic class WebSecurityConfig extends WebSecurityConfigurerAdapter{    @Autowired
    private SecurityProperties securityProperties;    
    @Bean
    public PasswordEncoder passwordEncoder(){        return new BCryptPasswordEncoder();
    }    
    @Autowired
    private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;    @Autowired
    private MyAuthenticationFailureHandler myAuthenticationFailureHandler;    
    @Autowired
    private DataSource dataSource;    @Bean
    public PersistentTokenRepository persistentTokenRepository(){
        JdbcTokenRepositoryImpl jdbcTokenRepositoryImpl=new JdbcTokenRepositoryImpl();
        jdbcTokenRepositoryImpl.setDataSource(dataSource);        //jdbcTokenRepositoryImpl.setCreateTableOnStartup(true);//执行一次,创建表,也可以自行创建表
        return jdbcTokenRepositoryImpl;
    }    @Autowired
    private UserDetailsService userDetailsService;    
    @Autowired
    private SMSCodeAuthenticationConfig sMSCodeAuthenticationConfig;    
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        System.out.println("Config");
        ValidateCodeFilter validateCodeFilter=new ValidateCodeFilter();
        validateCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
        
        SMSCodeFilter smsCodeFilter=new SMSCodeFilter();
        smsCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
        
        
        http
            .addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
            .formLogin()//表单登录,身份认证
            .loginPage("/authlogin")//设置登录页
            .loginProcessingUrl("/userlogin")//设置表单提交请求的 URL
            
            .successHandler(myAuthenticationSuccessHandler)
            .failureHandler(myAuthenticationFailureHandler)
            
            .and()
            .rememberMe()
            .tokenRepository(persistentTokenRepository())
            .tokenValiditySeconds(securityProperties.getWeb().getRememberMeSecond())
            .userDetailsService(userDetailsService)
            
            .and()
            .authorizeRequests()//对请求授权
            .antMatchers("/authlogin","/mobilelogin",                    "/login.html",//默认的登录页
                    "/image/code",                    "/sms/code",
                    securityProperties.getWeb().getLoginPage())
            .permitAll()//表示对这个 url 永远的通过
            .anyRequest()//任何请求
            .authenticated()//需要身份认证
            .and()
            .csrf().disable()//把 csdf 跨站防护关闭
            .apply(sMSCodeAuthenticationConfig);
    }
}



作者:breezedancer
链接:https://www.jianshu.com/p/96e67ceb8fd8

打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP