SpringBoot配置SpringSecurity,实现JSON登陆权限验证

[亡魂溺海] 提交于 2020-01-06 21:58:07

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>

官方例子:https://spring.io/guides/gs/securing-web/

1. 在项目中添加spring-security引用

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>

这个时候,启动项目,在浏览器中访问项目会跳到spring security提供的默认的登陆页面,这一般不是我们需要的,需要我们指定我们自己的登陆页面。

2. 修改默认的登陆页面

添加配置,如下面代码所示:

@EnableWebSecurity
@Configuration
public class GmSecurityConfig extends WebSecurityConfigurerAdapter{
    private Logger logger = LoggerFactory.getLogger(GmSecurityConfig.class);
    @Autowired
    private ApplicationContext appContext;
    @Override
    public void configure(WebSecurity web) throws Exception {
       //把静态资源忽略掉,不进行权限验证,登陆页面也不需要验证
        web.ignoring().antMatchers("/layout/index","/gm/easyui/**",
        "/gm/layui/**","/gm/server-option/**","/gm/jquery.min.js");
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin().loginPage("/layout/index") //设置登陆页面
        .loginProcessingUrl("/account/login") //设置处理登陆提交的path
        .and().authorizeRequests()
        .anyRequest().authenticated();  //所有的请求都登陆后才可以访问
    }
}

这里面记得把自定义的登陆页面地址也放到忽略验证的列表之中,要不然会由验证不通过,一直返复的跳转,谷歌浏览器会提示跳转次数太多异常。如果这样配置的话,登陆的提交需要使用form格式且用户名必须为username,密码为password提交才可以。但是有时候,我们在做前后端分离开发的时候,需要使用ajax applicaton/json的类型提交登陆,这个时候,这种配置方式就不适用了,需要重新添加一个过滤器。

3. 使用Ajax application/json方式提交登陆

根据Http的请求原理,如果服务想要根据请求的URI获取登陆时提交的信息,一定会使用一个过滤器拦截登陆提交的请求,然后从请求的数据中获取需要的用户名和密码的数据。在配置loginProcessingUrl时,在IDE中可以看到,它返回的对象是FormLoginConfigurer,在这个对象的构造方法中,添加了这个UsernamePasswordAuthenticationFilter过滤器,在这个过滤器中可以看到,它就是用来获取登陆提交中的用户名和密码的。但是它是从form的提交中获取的,如果使用ajax application/json的方式获取,就不能获取到登陆信息了,所以需要重写这个过滤器,并添加到配置中。 重写过滤器:

public class AjaxJsonUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private Logger logger = LoggerFactory.getLogger(AjaxJsonUsernamePasswordAuthenticationFilter.class);

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals("POST")) { // 必须以post方式提交
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String username = "";
        String password = "";
        try {
            if(request.getInputStream().markSupported()) {
                request.getInputStream().mark(Integer.MAX_VALUE);
            }
            String json = InputStreamUtil.readInputStream(request.getInputStream());
            if(request.getInputStream().markSupported()) {
                request.getInputStream().reset();
            }
            JSONObject param = JSONObject.parseObject(json);
            username = param.getString("username");
            password = param.getString("password");
        } catch (IOException e) {
            logger.error("读取登陆数据失败", e);
        }
        username = username.trim();
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

这里继承一下UsernamePasswordAuthenticationFilter,重写attemptAuthentication方法,从request中读取客户端的登陆请求数据,获得登陆验证需要的参数。

添加自定义AjaxJsonUsernamePasswordAuthenticationFilter配置

需要在GmSecurityConfig的configure(HttpSecurity http)方法中添加自定义的登陆请求处理过滤器,如下面代码所示:

        // 替换过滤器
        AjaxJsonUsernamePasswordAuthenticationFilter ajaxJsonLoginFilter = new AjaxJsonUsernamePasswordAuthenticationFilter();
        GmAuthenticationManager authenticationManager = new GmAuthenticationManager(accountService);
        //设置要处理的登陆请求的路径
        ajaxJsonLoginFilter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/account/login", "POST"));
       
        //替换掉旧的默认的登陆处理过滤器
        http.addFilterAt(ajaxJsonLoginFilter, UsernamePasswordAuthenticationFilter.class);

添加登陆认证类

当收到登陆请求时,AjaxJsonUsernamePasswordAuthenticationFilter过滤器会拦截此请求,并从中获得用户名和密码,那么接下来就是使用用户名和密码和数据库的信息进行验证是否一致,来确认登陆是否成功。添加下面的类:

public class GmAuthenticationManager implements AuthenticationManager{
    private Logger logger = LoggerFactory.getLogger(GmAuthenticationManager.class);
    private GmAccountService accountService;
    public GmAuthenticationManager(GmAccountService accountService) {
        this.accountService = accountService;
    }
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        if(authentication.getPrincipal() == null || authentication.getCredentials() == null) {
            throw new BadCredentialsException("登陆验证失败,用户名或密码为空");
        }
        String username = (String)authentication.getPrincipal();
        GmAccount gmAccount = accountService.getGmAccount(username);
        if(gmAccount == null) {
            throw new BadCredentialsException("账户不存在,username:" + username);
        }
        String password = (String)authentication.getCredentials();
        if(!gmAccount.getPassword().equals(password)) {
            throw new BadCredentialsException("用户或密码错误");
        }
        logger.info("{} 登陆成功",username);
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();//这里添加权限,先略过,后面添加。
        return new UsernamePasswordAuthenticationToken(authentication.getPrincipal(), authentication.getCredentials(), grantedAuthorities);
    }
}

然后将经认证类配置到AjaxJsonUsernamePasswordAuthenticationFilter 过滤器之中:

        GmAuthenticationManager authenticationManager = new GmAuthenticationManager(accountService);
        ajaxJsonLoginFilter.setAuthenticationManager(authenticationManager);

添加认证成功和认证失败返回

由于使用了application/json的方式登陆,那么登陆成功或失败之后也应该返回相应的json信息。这些信息可以在认证过滤器中返回。添加如下代码:

        //处理认证成功之后的返回
        ajaxJsonLoginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            JSONObject data = new JSONObject();
            data.put("code", 0);
            data.put("msg", "登陆成功");
            this.response(response, data);
        });
        //处理认证失败之后的返回
        ajaxJsonLoginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
            JSONObject data = new JSONObject();
            data.put("code", -1);
            data.put("msg", "登陆失败:" + exception.getMessage());
            this.response(response, data);
        });

    //返回数据
    private void response(HttpServletResponse response, JSONObject data) {
        String str = data.toJSONString();
        try {
            response.getOutputStream().write(str.getBytes("utf8"));
            response.getOutputStream().flush();
        } catch (IOException e) {
           logger.error("数据返回错误",e);
        }
    }

Spring Security验证过程

  1. 将接收到登陆提交的用户名和密码添加到UsernamePasswordAuthenticationToken 类的实例中,这里可以称之为token。
  2. 将上面的token传到AuthenticationManager 的实现之中,进行身份认证。
  3. AuthenticationManager 会返回一个认证成功的token.
  4. 创建security context,将token存储起来:SecurityContextHolder.getContext().setAuthentication(…​)

Spring Secutiry的拦截请求的过滤器为 FilterChainProxy

当一个请求过来时,首先在FilterChainProxy这个过滤器中查找会拦截并处理此次请求Path的过滤器DefaultSecurityFilterChain。在WebSecurityConfigurationspringSecurityFilterChain方法中构建返回FilterChainProxy实例

The request was rejected because the URL was not normalized 异常

在使用Spring Security的时候,在页面跳转的时候,遇到了一个异常:The request was rejected because the URL was not normalized

 org.apache.juli.logging.DirectJDKLog - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception org.springframework.security.web.firewall.Req
uestRejectedException: The request was rejected because the URL was not normalized.
	at org.springframework.security.web.firewall.StrictHttpFirewall.getFirewalledRequest(StrictHttpFirewall.java:296) ~[spring-security-web-5.1.4.RELEASE.jar:5.1.4.RELEASE]
	at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:194) ~[spring-security-web-5.1.4.RELEASE.jar:5.1.4.RELEASE]
	at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:178) ~[spring-security-web-5.1.4.RELEASE.jar:5.1.4.RELEASE]
	at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:357) ~[spring-web-5.1.5.RELEASE.jar:5.1.5.RELEASE]
	at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:270) ~[spring-web-5.1.5.RELEASE.jar:5.1.5.RELEASE]
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) ~[tomcat-embed-core-9.0.16.jar:9.0.16]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) ~[tomcat-embed-core-9.0.16.jar:9.0.16]

通过查看异常位置,及debug定位,发现是因为Spring Security添加StrictHttpFirewall类,对请求的路径进行了更加严格的检测,检测方法是

private static boolean isNormalized(String path) {
		if (path == null) {
			return true;
		}

		if (path.indexOf("//") > -1) {
			return false;
		}

		for (int j = path.length(); j > 0;) {
			int i = path.lastIndexOf('/', j - 1);
			int gap = j - i;

			if (gap == 2 && path.charAt(i + 1) == '.') {
				// ".", "/./" or "/."
				return false;
			} else if (gap == 3 && path.charAt(i + 1) == '.' && path.charAt(i + 2) == '.') {
				return false;
			}

			j = i;
		}

		return true;
	}

如果这个方法返回的是false,就会报这个异常,我的问题是因为路径中出现了双斜杠(//),比如/my-server//css/a.css。

取消csrf验证

默认情况下,Spring Security是会开启csrf验证的,除了GET, HEAD, TRACE, OPTIONS这几个请求方法之外的请求都会被验证,所以如果不使用这个验证,可以取消csrf验证。

 http.csrf().disable();

添加csrf验证

csrf(跨站请求伪造)验证就是为了防止第三方网页向自己的服务器发送请求。它的原理很简单,就是在网页渲染的时候,由服务器生成一个唯一的字段串,一般称为token(令牌),然后加载到网页上面,在向服务器提交请求的时候,必须带着token一起提交,否则拒绝请求。 Spring Security自动实现这个功能,但是在使用的时候,需要自己添加一些配置。既然是服务器生成的token,那么必须有token的存储方式和获取方式。Spring Security提供了CsrfTokenRepository接口,并提供了默认的几种实现: 这里使用第二个,以session的方式存储token。在GmSecurityConfig的配置方法中添加:

        HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
        //重命令存储session时的key的名字,默认的名字太长了
        csrfTokenRepository.setSessionAttributeName("_csrf");
       http.csrf().csrfTokenRepository(new HttpSessionCsrfTokenRepository())

csrf的拦截是在CsrfFilter类中实现的,但是需要注意的一点是,如果请求的路径被添加了忽略验证的集合里面,不会触发CsrfFilter的拦截,也就不会触发csrf的token生成。另外,生成csrf的token存储到session中时,注意需要知道存储的session key的值,在网页中需要用到,上面代码中就是重新命令了一下,网上很多例子都是错误的,就是因为这个名字对应不上。 在网页添加csrf数据的方式有两种,一种是在form中添加。一个是以application/json方式提交请求时,在http头里面添加。原理基本上是一样的,这里以后者举例说明一下。本例使用的页面是thymeleaf,在需要用到的csrf验证的网页上面添加:

<!-- CSRF -->
<th:block th:if="${session._csrf != null}"> //一定要加这个判断,否则在生成页面时,如果token还没有生成,会报错
<meta name="_csrf"  th:content="${session._csrf.token}" />
<!-- default header name is X-CSRF-TOKEN -->
<meta name="_csrf_header" th:content="${session._csrf.headerName}" />
</th:block>

这段代码可以放到一个全局网页上,每个业务页面都引用它。 然后在ajax提交的时候,需要获取这两个值,并放在提交的header里面

function postAjax(url, json, success) {
	var csrfToken = $("meta[name='_csrf']").attr("content");
	var csrfHeader = $("meta[name='_csrf_header']").attr("content");
	$.ajax({
		type : "POST",
		url : url,
		data : JSON.stringify(json),
		dataType : "json",
		contentType : "application/json",
		beforeSend : function(request) {
			
			if (csrfToken && csrfHeader) {
				// 添加 CSRF Token
				request.setRequestHeader(csrfHeader, csrfToken);
			}
		},
		success : function(data) {
			if (data.code < 0) {
				window.location.href = "/xinyue-gm";
			} else {
				success(data);
			}
		},
		error : function(data) {
			alert(url + "请求错误:" + JSON.stringify(data));
		}
	});
}

关注公众号,发送csrf获取项目源码

关注公众号,发送csrf获取项目源码

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!