Spring Security 和 JSON 身份验证

Posted

技术标签:

【中文标题】Spring Security 和 JSON 身份验证【英文标题】:Spring Security and JSON Authentication 【发布时间】:2013-10-30 06:55:01 【问题描述】:

我在 spring/spring-mvc 中有一个完全使用 JSON 通信的应用程序。 现在我需要通过 JSON 使用 Spring Security 3(使用 LdapAuthenticationProvider)对我的应用程序进行身份验证。

默认的 spring 安全提交表单需要这样的 POST:

POST /myapp/j_spring_security_check HTTP/1.1
Accept-Encoding: gzip,deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 32
Host: 127.0.0.1:8080
Connection: Keep-Alive
User-Agent: Apache-HttpClient/4.1.1 (java 1.5)

j_username=myUsername&j_password=myPass

但我想像这样传递一个 JSON 对象:

"j_username":"myUsername","j_password":"myPass"

我读了很多帖子,比如this、this other 或this one,但运气不好,在所有 ajax 情况下都是像上面那样做一个 POST。

有什么想法吗?

【问题讨论】:

【参考方案1】:

您可以编写自己的安全过滤器来解析您的 JSON。

http://docs.spring.io/spring-security/site/docs/3.0.x/reference/core-web-filters.html

您可以使用 BasicAuthenticationFilter 作为参考:

http://docs.spring.io/spring-security/site/docs/3.0.x/apidocs/org/springframework/security/web/authentication/www/BasicAuthenticationFilter.html

【讨论】:

【参考方案2】:

根据凯文的建议, 并在阅读此帖子后:1、2、文档3,并感谢this 博客帖子, 我写了自己的 FORM_LOGIN_FILTER 来在认证前直接管理 JSON。 我将我的代码粘贴到社区。​​p>

目标是同时授予经典浏览器表单 POST 身份验证和基于 JSON 的身份验证。同样在 JSON 身份验证中,我想避免重定向到 loginSuccesful.htm

在上下文中:

<security:http use-expressions="true" auto-config="false" entry-point-ref="http403EntryPoint">      
    <security:intercept-url pattern="/logs/**" access="denyAll" />
    <!-- ... All other intercept URL -->

    <security:custom-filter ref="CustomUsernamePasswordAuthenticationFilter" position="FORM_LOGIN_FILTER "/>
    <security:logout
            invalidate-session="true"
            logout-success-url="/LogoutSuccessful.htm"
            delete-cookies="true"
    />
    <security:session-management>
        <security:concurrency-control max-sessions="1" error-if-maximum-exceeded="true" />
    </security:session-management>
    <security:access-denied-handler error-page="/accessDenied.htm" />
</security:http>

<bean id="CustomUsernamePasswordAuthenticationFilter" class="path.to.CustomUsernamePasswordAuthenticationFilter">
    <property name="authenticationManager" ref="authenticationManager" />
    <property name="authenticationSuccessHandler" ref="customSuccessHandler"/>
    <property name="authenticationFailureHandler" ref="failureHandler"/>
    <property name="filterProcessesUrl" value="/j_spring_security_check"/>
    <property name="usernameParameter" value="j_username"/>
    <property name="passwordParameter" value="j_password"/>
</bean>

<bean id="customSuccessHandler" class="path.to.CustomAuthenticationSuccessHandler">
    <property name="defaultTargetUrl" value="/login.htm" />
    <property name="targetUrlParameter" value="/LoginSuccessful.htm" />
</bean>

<bean id="failureHandler" class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
    <property name="defaultFailureUrl" value="/login.htm" />
</bean>

<bean id="http403EntryPoint" class="org.springframework.security.web.authentication.Http403ForbiddenEntryPoint" />

CustomUsernamePasswordAuthenticationFilter 类:

public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter
    private String jsonUsername;
    private String jsonPassword;

    @Override
    protected String obtainPassword(HttpServletRequest request) 
        String password = null; 

        if ("application/json".equals(request.getHeader("Content-Type"))) 
            password = this.jsonPassword;
        else
            password = super.obtainPassword(request);
        

        return password;
    

    @Override
    protected String obtainUsername(HttpServletRequest request)
        String username = null;

        if ("application/json".equals(request.getHeader("Content-Type"))) 
            username = this.jsonUsername;
        else
            username = super.obtainUsername(request);
        

        return username;
    

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        if ("application/json".equals(request.getHeader("Content-Type"))) 
            try 
                /*
                 * HttpServletRequest can be read only once
                 */
                StringBuffer sb = new StringBuffer();
                String line = null;

                BufferedReader reader = request.getReader();
                while ((line = reader.readLine()) != null)
                    sb.append(line);
                

                //json transformation
                ObjectMapper mapper = new ObjectMapper();
                LoginRequest loginRequest = mapper.readValue(sb.toString(), LoginRequest.class);

                this.jsonUsername = loginRequest.getUsername();
                this.jsonPassword = loginRequest.getPassword();
             catch (Exception e) 
                e.printStackTrace();
            
        

        return super.attemptAuthentication(request, response);
    

CustomAuthenticationSuccessHandler 类:

public class CustomAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler 

    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication auth
    )throws IOException, ServletException 

        if ("application/json".equals(request.getHeader("Content-Type"))) 
            /*
             * USED if you want to AVOID redirect to LoginSuccessful.htm in JSON authentication
             */         
            response.getWriter().print("\"responseCode\":\"SUCCESS\"");
            response.getWriter().flush();
         else 
            super.onAuthenticationSuccess(request, response, auth);
        
    

【讨论】:

这不是安全的。过滤器是一个 bean。您不应该将用户名和密码存储为成员,我建议将其保存为请求属性或不覆盖获取用户名和获取密码。看看我的解决方案 是的,我尝试了这个解决方案并遇到了这个确切的问题。使用请求属性而不是成员变量解决了这个问题。 你的方向是正确的,但这是一个糟糕的实现。正如@oe.elvik 提到的,过滤器中不能有实例变量!只需覆盖尝试身份验证并在那里读取用户名/密码。无需使用获取密码/获取用户名。也不要根据要求存储密码..或其他任何地方!【参考方案3】:

另一种方法,根据this的帖子,是直接在Controller中手动管理spring安全认证。 以这种方式管理 JSON 输入并避免登录重定向非常简单:

@Autowired
AuthenticationManager authenticationManager;

@ResponseBody
@RequestMapping(value="/login.json", method = RequestMethod.POST)
public JsonResponse mosLogin(@RequestBody LoginRequest loginRequest, HttpServletRequest request) 
    JsonResponse response = null;

    try 
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());
        token.setDetails(new WebAuthenticationDetails(request));

        Authentication auth = authenticationManager.authenticate(token);
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(auth);

        if(auth.isAuthenticated())
            HttpSession session = request.getSession(true);
            session.setAttribute("SPRING_SECURITY_CONTEXT", securityContext);

            LoginResponse loginResponse = new LoginResponse();
            loginResponse.setResponseCode(ResponseCodeType.SUCCESS);
            response = loginResponse;   
        else
            SecurityContextHolder.getContext().setAuthentication(null);

            ErrorResponse errorResponse = new ErrorResponse();
            errorResponse.setResponseCode(ResponseCodeType.ERROR);
            response = errorResponse;
           
     catch (Exception e)      
        ErrorResponse errorResponse = new ErrorResponse();
        errorResponse.setResponseCode(ResponseCodeType.ERROR);
        response = errorResponse;           
    
    return response;

【讨论】:

我更喜欢过滤器,上面基本上只是控制器中的一个请求,我觉得它不应该混合,过滤器很好地将安全性和控制器分开。个人意见。 我也有同样的看法,我更喜欢过滤器,因为它位于进入控制器之前管理的“安全层”中。从安全的角度来看,您认为这种方案的安全性不如过滤方案吗?是否存在潜在的安全风险? 不要过滤器存在不是注册路由的问题,spring 无法正确处理其他类型的错误或特性,如 OPTIONS 和 HEAD 请求,或显示所有可用的路线。【参考方案4】:
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter 
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        if (!request.getMethod().equals("POST")) 
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        

        LoginRequest loginRequest = this.getLoginRequest(request);

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());

        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    

    private LoginRequest getLoginRequest(HttpServletRequest request) 
        BufferedReader reader = null;
        LoginRequest loginRequest = null;
        try 
            reader = request.getReader();
            Gson gson = new Gson();
            loginRequest = gson.fromJson(reader, LoginRequest.class);
         catch (IOException ex) 
            Logger.getLogger(AuthenticationFilter.class.getName()).log(Level.SEVERE, null, ex);
         finally 
            try 
                reader.close();
             catch (IOException ex) 
                Logger.getLogger(AuthenticationFilter.class.getName()).log(Level.SEVERE, null, ex);
            
        

        if (loginRequest == null) 
            loginRequest = new LoginRequest();
        

        return loginRequest;
    

【讨论】:

好的,这是一个有效的解决方案。我班级的目标是尊重原始的尝试身份验证流程,检索和设置用户名和密码,然后在方法结束时调用 super.attemptAuthentication(...)。谢谢 是的,但是通过将用户名和密码保存为 bean 的成员,解决方案并不安全。尝试同时进行身份验证的两个用户可能会混合使用用户名和密码。一种解决方案也是使用 setAttribute 在请求对象中保存用户名和密码【参考方案5】:

如果您只想要登录请求的不同请求正文解析器,只需扩展 UsernamePasswordAuthenticationFilter 并覆盖 attemptAuthentication 方法。 默认情况下,UsernamePasswordAuthenticationFilter 将解析 url 编码数据并从中创建UsernamePasswordAuthenticationToken。现在您只需要制作解析器来解析您发送给应用程序的任何内容。

这是解析"username": "someusername", "password": "somepassword"的示例

public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter 

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException 
        try 
            BufferedReader reader = request.getReader();
            StringBuffer sb = new StringBuffer();
            String line = null;
            while ((line = reader.readLine()) != null) 
                sb.append(line);
            
            String parsedReq = sb.toString();
            if (parsedReq != null) 
                ObjectMapper mapper = new ObjectMapper();
                AuthReq authReq = mapper.readValue(parsedReq, AuthReq.class);
                return new UsernamePasswordAuthenticationToken(authReq.getUsername(), authReq.getPassword());
            
         catch (Exception e) 
            System.out.println(e.getMessage());
            throw new InternalAuthenticationServiceException("Failed to parse authentication request body");
        
        return null;
    

    @Data
    public static class AuthReq 
        String username;
        String password;
    


在sn-p中,请求体被提取为字符串并映射到对象AuthReq@Data注解来自lombok lib,它会生成seters和getter)。 你可以将UsernamePasswordAuthenticationToken 传递给默认的AuthenticationProvider

现在您可以扩展 WebSecurityConfigurerAdapter 并覆盖 cnofigure 方法来替换旧过滤器。

@Override
protected void configure(HttpSecurity http) throws Exception 
    http
        .authorizeRequests()
            .antMatchers("/", "/login", "/logout").permitAll()
            .anyRequest().authenticated()
        .and().addFilterAt(new CustomUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
        .formLogin().loginProcessingUrl("/login")
        .and()
        .csrf().disable();

使用addFilterAt 方法可以替换默认的UsernamePasswordAuthenticationFilter。不要忘记使用@EnableWebSecurity注解。

【讨论】:

也许春天变了。从 attemptAuthentication 返回 UsernamePasswordAuthenticationToken 似乎不正确。【参考方案6】:

看这个例子:https://github.com/fuhaiwei/springboot_security_restful_api

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter 

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private CustomLoginHandler customLoginHandler;

    @Autowired
    private CustomLogoutHandler customLogoutHandler;

    @Autowired
    private CustomAccessDeniedHandler customAccessDeniedHandler;

    protected void configure(AuthenticationManagerBuilder auth) throws Exception 
        auth.userDetailsService(userDetailsService);
    

    protected void configure(HttpSecurity http) throws Exception 
        http.authorizeRequests()
                .antMatchers("/api/admin/**").hasRole("ADMIN")
                .antMatchers("/api/basic/**").hasRole("BASIC")
                .antMatchers("/api/session").permitAll()
                .antMatchers(HttpMethod.GET).permitAll()
                .antMatchers("/api/**").hasRole("BASIC");

        http.formLogin();

        http.logout()
                .logoutUrl("/api/session/logout")
                .addLogoutHandler(customLogoutHandler)
                .logoutSuccessHandler(customLogoutHandler);

        http.exceptionHandling()
                .accessDeniedHandler(customAccessDeniedHandler)
                .authenticationEntryPoint(customAccessDeniedHandler);

        http.csrf()
                .ignoringAntMatchers("/api/session/**");

        http.addFilterBefore(new AcceptHeaderLocaleFilter(), UsernamePasswordAuthenticationFilter.class);

        http.addFilterAt(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        http.addFilterAfter(new CsrfTokenResponseHeaderBindingFilter(), CsrfFilter.class);
    

    private CustomAuthenticationFilter customAuthenticationFilter() throws Exception 
        CustomAuthenticationFilter filter = new CustomAuthenticationFilter();
        filter.setAuthenticationSuccessHandler(customLoginHandler);
        filter.setAuthenticationFailureHandler(customLoginHandler);
        filter.setAuthenticationManager(authenticationManager());
        filter.setFilterProcessesUrl("/api/session/login");
        return filter;
    

    private static void responseText(HttpServletResponse response, String content) throws IOException 
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
        response.setContentLength(bytes.length);
        response.getOutputStream().write(bytes);
        response.flushBuffer();
    

    @Component
    public static class CustomAccessDeniedHandler extends BaseController implements AuthenticationEntryPoint, AccessDeniedHandler 
        // NoLogged Access Denied
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException 
            responseText(response, errorMessage(authException.getMessage()));
        

        // Logged Access Denied
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException 
            responseText(response, errorMessage(accessDeniedException.getMessage()));
        
    

    @Component
    public static class CustomLoginHandler extends BaseController implements AuthenticationSuccessHandler, AuthenticationFailureHandler 
        // Login Success
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException 
            LOGGER.info("User login successfully, name=", authentication.getName());
            responseText(response, objectResult(SessionController.getJSON(authentication)));
        

        // Login Failure
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException 
            responseText(response, errorMessage(exception.getMessage()));
        
    

    @Component
    public static class CustomLogoutHandler extends BaseController implements LogoutHandler, LogoutSuccessHandler 
        // Before Logout
        @Override
        public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) 

        

        // After Logout
        @Override
        public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException 
            responseText(response, objectResult(SessionController.getJSON(null)));
        
    

    private static class AcceptHeaderLocaleFilter implements Filter 
        private AcceptHeaderLocaleResolver localeResolver;

        private AcceptHeaderLocaleFilter() 
            localeResolver = new AcceptHeaderLocaleResolver();
            localeResolver.setDefaultLocale(Locale.US);
        

        @Override
        public void init(FilterConfig filterConfig) 
        

        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException 
            Locale locale = localeResolver.resolveLocale((HttpServletRequest) request);
            LocaleContextHolder.setLocale(locale);

            chain.doFilter(request, response);
        

        @Override
        public void destroy() 
        
        




public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter 

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException 
        UsernamePasswordAuthenticationToken authRequest;
        try (InputStream is = request.getInputStream()) 
            DocumentContext context = JsonPath.parse(is);
            String username = context.read("$.username", String.class);
            String password = context.read("$.password", String.class);
            authRequest = new UsernamePasswordAuthenticationToken(username, password);
         catch (IOException e) 
            e.printStackTrace();
            authRequest = new UsernamePasswordAuthenticationToken("", "");
        
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    


【讨论】:

【参考方案7】:

这是上述解决方案的java配置:

@Override
protected void configure(HttpSecurity http) throws Exception 
    http.csrf().disable()
            .addFilterBefore(authenticationFilter(),UsernamePasswordAuthenticationFilter.class)
            .authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginPage("/login")
            .permitAll();


@Bean
public AuthenticationFilter authenticationFilter() throws Exception
    AuthenticationFilter authenticationFilter = new AuthenticationFilter();
    authenticationFilter.setUsernameParameter("username");
    authenticationFilter.setPasswordParameter("password");
    authenticationFilter.setAuthenticationManager(authenticationManager());
    authenticationFilter.setFilterProcessesUrl("/login");
    authenticationFilter.setAuthenticationSuccessHandler(successHandler());
    return authenticationFilter;


@Bean
public SuccessHandler successHandler()
    return new SuccessHandler();

【讨论】:

【参考方案8】:

我应用来自 fl4l 和 oe.elvik 的答案在 Spring Boot 应用程序中使用 JSON 凭据登录。我正在使用基于注释的 bean 配置。

在引用的答案中,创建了一个自定义过滤器,其中注入了身份验证管理器。为此,身份验证管理器必须作为 Spring Bean 存在。这是一个关于如何做到这一点的链接:https://***.com/a/21639553/3950535。

【讨论】:

以上是关于Spring Security 和 JSON 身份验证的主要内容,如果未能解决你的问题,请参考以下文章

Spring Security:仅为经过身份验证的用户执行 jQuery 代码

Spring Security WebFlux - 带有身份验证的主体

在 Spring Security 中处理基本身份验证的未经授权的错误消息

在 Spring Security 中处理基本身份验证的未经授权的错误消息

Spring Security和Angular 5

Spring Security整合JWT实现权限认证和授权