当引入spring security的依赖之后,spring security就会对我们项目的登录和权限进行接管,因此在接入我们系统之前,我们先使用一个demo进行实验。
实体
很显然,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防御关闭。