当引入spring security的依赖之后,spring security就会对我们项目的登录和权限进行接管,因此在接入我们系统之前,我们先使用一个demo进行实验。

springsecurity 会话过期检查 springsecurity用户过期_spring boot

实体

很显然,spring security的实体类应该是User类,相较于之前的User类,引入Spring Security之后,我们要让User类实现UserDetails接口,要实现五个方法,分别是账号是否过期,账号是否锁定,凭证是否过期,账号是否可用,以及得到权限。
对于前四个方法我们不用做特殊处理,直接返回true即可。

// true: 账号未过期
@Override
public boolean isAccountNonExpired() {
    return true;
}

// true: 账号未锁定
@Override
public boolean isAccountNonLocked() {
    return true;
}

// true: 凭证未过期
@Override
public boolean isCredentialsNonExpired() {
    return true;
}

// true: 账号可用
@Override
public boolean isEnabled() {
    return true;
}

而对于获得权限的方法,我们主要根据数据库中user表的type字段得到权限。因为返回的是GrantedAuthority子类型的集合,而我们一个用户只有一个角色,因此我们声明一个list,往里面加入一个GrantedAuthority实例即可,采用匿名实现的方式。

// 得到权限
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> list = new ArrayList<>();
    list.add(new GrantedAuthority(){
        @Override
        public String getAuthority() {
            switch (type){
                case 1:
                    return "ADMIN";
                default:
                    return "USER";
            }
        }
    });
    return list;
}

服务

对于service层,我们需要UserService实现UserDetailsService接口,该接口主要实现一个方法,loadUserByUsername(),这里我们直接调用mapper接口即可。同时由于实体类已经实现了UserDetails接口,因此这里可以直接返回User类实例。

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    return this.findUserByName(username);
}

配置类

我们需要定义一个Security的配置类,该类继承自WebSecurityConfigurerAdapter,而我们需要重写一些方法。
首先是对静态资源我们不做拦截,该方法的参数是WebSecurity,我们设置对/resources目录下的资源都不做拦截。

@Override
public void configure(WebSecurity web) throws Exception {
    // 忽略静态资源的访问
    web.ignoring().antMatchers("/resources/**");
}

第二,就是要配置验证账号密码的方法了,该方法的参数是AuthenticationManagerBuilder实例,根据名字就知道这是一个构造AuthenticationManager的构造器,而AuthenticationManager是我们认证的核心接口。
本来我们可以使用spring security提供的passwordEncoder来进行验证,

// 内置的认证规则
auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("12345"));

但是我们在密码中加入了不同的盐因此不太方便,因此需要自定义认证规则。自定义规则我们需要使用authenticationProvider方法,然后传入一个AuthenticationProvider实例即可。这是因为AuthenticationProvider是ProviderManager的元素,即一个ProviderManager有很多AuthenticationProvider实例,而一个AuthenticationProvider实例负责一种认证,即ProviderManager将认证委托给某个AuthenticationProvider,这是一种委托模式。而ProviderManager又是AuthenticationManager接口的默认实现类。
总的来说我们需要AuthenticationManager接口来进行认证,这里我们使用它的默认实现类ProviderManager来进行认证,而该实现类需要AuthenticationProvider实例,因此我们使用builder构造器传入AuthenticationProvider即可。
同样的,我们采用匿名的方式实现AuthenticationProvider实例,在我们的实现中,我们需要实现两个方法,分别是authenticate()和supports(),其中supports方法用于得到当前AuthenticationProvider实例支持的是哪种认证方式,这里我们支持的是UsernamePasswordAuthenticationToken认证,即用户名密码认证。
而authenticate()方法则就是用于认证,其参数Authentication实例封装了认证信息,不同的认证方式封装了不同的信息,这里很显然是用户名和密码,我们通过getName和getCredentials得到用户名密码,然后进行验证,如果不正确抛出相应的异常即可,Filter会进行处理。如果认证通过,我们也需要返回一个Authentication实例,但是是一个认证通过的实例,也就是一个Token,这里我们使用的是UsernamePasswordAuthenticationToken类,该类的构造函数需要传入三个参数,分别是principal,即通过认证的主要信息;credentials,即证书;authorities,即权限。

// AuthenticationManager: 认证的核心接口
// AuthenticationManagerBuilder: 用于构建AuthenticationManager对象
// ProviderManager: AuthenticationManager接口的默认实现类
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    // 内置的认证规则
    // auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("12345"));

    // 自定义认证规则
    // AuthenticationProvider: ProviderManager持有一组AuthenticationProvider,每个AuthenticationProvider负责一种认证
    // 委托模式:ProviderManager将认证委托给了AuthenticationProvider.
    auth.authenticationProvider(new AuthenticationProvider() {
        // Authentication: 用于封装认证信息的接口,不同的实现类代表不同的类型的认证信息
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            String username = authentication.getName();
            String password = (String) authentication.getCredentials();

            User user = userService.findUserByName(username);
            if (user == null){
                throw new UsernameNotFoundException("账号不存在!");
            }

            password = CommunityUtil.md5(password + user.getSalt());
            if (!user.getPassword().equals(password)){
                throw new BadCredentialsException("密码不正确!");
            }

            // principal: 认证的主要信息; credentials: 证书; authorities: 权限;
            return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
        }

        // 当前AuthenticationProvider支持哪种类型的认证
        @Override
        public boolean supports(Class<?> aClass) {
            // UsernamePasswordAuthenticationToken: Authentication接口常用的实现类
            return UsernamePasswordAuthenticationToken.class.equals(aClass);
        }
    });
}

最后一个重写方法,我们主要进行一些其他的配置,包括登录、登出、授权、处理验证码和记住我。该方法的参数是HttpSecurity实例
登录我们主要通过配置http.formLogin()来实现,loginPage()表示登录页面的路径,而loginProcessingUrl()表示表单发出的请求的路径,与form中的action对应,同时form中的method必须是post。successHandler和failureHandler分别表示验证成功和失败之后的处理,我们通过匿名的方式实例化AuthenticationSuccessHandler和AuthenticationFailureHandler。对于成功,我们直接重定向到主页即可,而失败的话,我们需要将错误通过request进行传递同时还是在登录页面,需要传递信息,因此需要进行转发。
登出我们需要配置logoutUrl,即登出的请求路径,以及成功登出的处理,这里我们就直接重定向到主页即可。于权限的配置,我们通过http.authorizeRequests()进行配置,通过antMatchers()配置路径,hasAnyAuthority()表示需要的权限。另外对于权限认证失败我们通过exceptionHandling().accessDeniedPage()进行重定向到/denied页面
验证码的认证我们需要通过增加Filter来实现,并且要在用户名密码认证之前进行,因此我们使用http.addFilterBefore()方法,并且第二个参数指定为UsernamePasswordAuthenticationFilter.class。而第一个参数我们需要一个filter实例,同样也采用匿名实现的方式,主要是实现doFilter()方法,在该方法中,如果当前请求是登录请求,那么我们就进行验证码校验,如果不对,就通过转发的方式回到登录页面,如果通过了,就然请求继续执行下去,即filterChain.doFilter(request,response)。
记住我我们主要通过http.rememberMe()来进行配置,tokenRepository()指定存储方式,这里我们采用存储在内存中的方式,tokenValiditySeconds()指定存储时间,userDetailsService()指定验证的方法。同时在login的form中checkbox的name必须是remember-me

@Override
protected void configure(HttpSecurity http) throws Exception {
    // 登录相关的配置
    http.formLogin()
            .loginPage("/loginpage")
            .loginProcessingUrl("/login")
            .successHandler(new AuthenticationSuccessHandler() {
                @Override
                public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                    response.sendRedirect(request.getContextPath() + "/index");
                }
            })
            .failureHandler(new AuthenticationFailureHandler() {
                @Override
                public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                    request.setAttribute("error", e.getMessage());
                    request.getRequestDispatcher("/loginpage").forward(request,response);
                }
            });
    http.logout()
            .logoutUrl("/logout")
            .logoutSuccessHandler(new LogoutSuccessHandler() {
                @Override
                public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                    response.sendRedirect(request.getContextPath() + "/index");
                }
            });

    // 对授权进行配置
    http.authorizeRequests()
            .antMatchers("/letter").hasAnyAuthority("USER","ADMIN")
            .antMatchers("/admin").hasAnyAuthority("ADMIN")
            .and().exceptionHandling().accessDeniedPage("/denied");

    // 增加Filter,处理验证码
    http.addFilterBefore(new Filter() {
        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
            HttpServletRequest request = (HttpServletRequest) servletRequest;
            HttpServletResponse response = (HttpServletResponse) servletResponse;
            if (request.getServletPath().equals("/login")){
                String verifyCode = request.getParameter("verifyCode");
                if (verifyCode == null || !verifyCode.equalsIgnoreCase("1234")){
                    request.setAttribute("error","验证码错误!");
                    request.getRequestDispatcher("/loginpage").forward(request,response);
                    return;
                }
            }
            // 让请求继续向下执行
            filterChain.doFilter(request,response);
        }
    }, UsernamePasswordAuthenticationFilter.class);

    // 记住我
    http.rememberMe()
            .tokenRepository(new InMemoryTokenRepositoryImpl())
            .tokenValiditySeconds(3600 * 24)
            .userDetailsService(userService);
}

controller

认证成功之后,SecurityContextHolder会将结果存入SecurityContext中,我们可以从中取得。

// 认证成功后,结果会通过SecurityContextHolder存入SecurityContext中
Object obj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (obj instanceof User){
    model.addAttribute("loginUser",obj);
}

加入项目

接下来我们要将Spring Security加入到我们的community项目中。
首先,我们要将之前实现的LoginRequiredInterceptor注释掉,让Spring Security来接管登录。
在配置方面,我们只要实现两个配置方法即可,对于登录验证我们已经实现了,不需要Spring Security接管。
静态资源配置和之前一样。

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/resources/**");
}

主要是在权限方面的配置。首先我们要使用http.authorizeRequests()来配置权限,对于一些路径需要用户必须登录,因此角色必须是USER、ADMIN、MODERATOR才可以。
然后我们要配置权限不够时的处理,通过http.exceptionHandling()来进行配置。权限不够分为未登录和登录之后权限不够的情况。
未登录我们使用authenticationEntryPoint()来进行认证,通过实例化一个AuthenticationEntryPoint实例,实现commence()方法即可,在该方法中,我们需要对是否是异步请求进行区分,通过获得request头的x-requested-with字段来进行判断,如果是XMLHttpRequest,那么就是异步请求,对于异步请求我们使用response的Write返回一个JSON字符串即可,而如果不是异步请求的话直接重定向到登录页即可。
对于权限不够,我们使用accessDeniedHandler()方法,实例化AccessDeniedHandler,实现handle方法即可,同样按是否是异步进行分,异步还是发一个JSON字符串,不是异步就重定向到denied页面。
最后,我们要对logout进行配置,在spring security中对logout路径进行了拦截,但是我们已经自己实现了logout,因此我们将spring security的logout路径更换掉,防止拦截了我们项目的请求。

@Override
protected void configure(HttpSecurity http) throws Exception {
    // 授权
    http.authorizeRequests()
            .antMatchers(
                    "/user/setting",
                    "/user/upload",
                    "/discuss/add",
                    "/comment/add/**",
                    "/letter/**",
                    "/notice/**",
                    "/like",
                    "/follow",
                    "/unfollow"
            )
            .hasAnyAuthority(
                    AUTHORITY_USER,
                    AUTHORITY_ADMIN,
                    AUTHORITY_MODERATOR
            )
            .anyRequest().permitAll()
            .and().csrf().disable();

    // 权限不够时的认证
    http.exceptionHandling()
            .authenticationEntryPoint(new AuthenticationEntryPoint() {
                // 这里是没登陆时的处理
                @Override
                public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                    String xRequestedWith = request.getHeader("x-requested-with");
                    if ("XMLHttpRequest".equals(xRequestedWith)){
                        // 异步
                        response.setContentType("application/plain;charset=utf-8");
                        PrintWriter writer = response.getWriter();
                        writer.write(CommunityUtil.getJSONString(403,"你还没有登录"));
                    } else {
                        response.sendRedirect(request.getContextPath() + "/login");
                    }
                }
            })
            .accessDeniedHandler(new AccessDeniedHandler() {
                // 这里是权限不足时的处理
                @Override
                public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
                    String xRequestedWith = request.getHeader("x-requested-with");
                    if ("XMLHttpRequest".equals(xRequestedWith)){
                        // 异步
                        response.setContentType("application/plain;charset=utf-8");
                        PrintWriter writer = response.getWriter();
                        writer.write(CommunityUtil.getJSONString(403,"你没有访问此功能的权限!"));
                    } else {
                        response.sendRedirect(request.getContextPath() + "/denied");
                    }
                }
            });
    // Security底层默认会拦截logout请求进行处理
    // 覆盖它默认的逻辑,才能执行我们的逻辑
    http.logout().logoutUrl("/securitylogout");
}

这样基本上,我们就将spring security配置好了,但是由于我们是自己实现登录认证的,因此SecurityContext中并没有存入用户认证之后的Token,这样spring security就无法对当前用户进行认证,因此我们还需要将认证信息加入到SecurityContext中。
首先我们要在UserService中加入得到用户权限的方法,同样的也是将type转成GrantedAuthority实例加入到List中返回即可,同之前一样.

// 得到用户的权限
public Collection<? extends GrantedAuthority> getAuthorities(int userId){
    User user = this.findUserById(userId);
    List<GrantedAuthority> list = new ArrayList<>();
    list.add(new GrantedAuthority() {
        @Override
        public String getAuthority() {
            switch (user.getType()){
                case 1:
                    return AUTHORITY_ADMIN;
                case 2:
                    return AUTHORITY_MODERATOR;
                default:
                    return AUTHORITY_USER;
            }
        }
    });
    return list;
}

那么,我们何时将权限加入到SecurityContext中内。实际上,我们是通过拦截器在controller请求之前对凭证进行认证的,那么很自然的我们可以在这里凭证认证完成之后加入权限.实例化一个UsernamePasswordAuthenticationToken,这和我们在demo中的实现是一样的,然后实例化一个SecurityContextImpl,传入authentication即可完成SecurityContextHolder的Context的设置。

// 构建用户认证的结果,并存入SecurityContext,以便于security进行授权
Authentication authentication = new UsernamePasswordAuthenticationToken(
        user,user.getPassword(), userService.getAuthorities(user.getId()));
SecurityContextHolder.setContext(new SecurityContextImpl(authentication));

同hostholder一样,在完成请求之后我们也要将权限清空。

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    hostHolder.clear();
    SecurityContextHolder.clearContext();
}

在登出那边也要进行清除.

@RequestMapping(path = "/logout",method = RequestMethod.GET)
public String logout(@CookieValue("ticket") String ticket){
    userService.logout(ticket);
    // 清理权限
    SecurityContextHolder.clearContext();

    return "redirect:/login";
}

csrf

spring security加入了防止csrf攻击,具体就是在表单中加入一个_csrf的token,这样就能防止cookie被盗用冒充身份的攻击了。而表单之外的csrf防御我们可以这样实现。
首先在HTML中,我们在meta中声明csrf令牌

访问该页面时,在此处生成csrf令牌
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">

然后,在JS中我们要将csrf令牌加入到请求头中即可。

var token = $("meta[name='_csrf']").attr("content");
var header = $("meta[name='_csrf_header']").attr("content");
$(document).ajaxSend(function (e, xhr, options) {
	xhr.setRequestHeader(header,token);
});

但是由于我们是开发因此可以先将csrf防御关闭。