Spring Authorization Server自定义表单登录授权码模式触发NPE问题
问题现象
运行Spring OAuth2 Authorization Server自定义表单登录示例,使用authorization_code授权类型时,在登录页面输入凭证后,DefaultErrorController抛出NullPointerException。堆栈跟踪如下:
2023-06-24T14:09:53.415+05:30 ERROR 8240 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "errorMessage" is null] with root cause java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "errorMessage" is null at com.sample.authservice.controller.DefaultErrorController.handleError(DefaultErrorController.java:25) ~[classes/:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na] at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na] at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na] at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:207) ~[spring-web-6.0.10.jar:6.0.10] at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:152) ~[spring-web-6.0.10.jar:6.0.10] at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) ~[spring-webmvc-6.0.10.jar:6.0.10] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:884) ~[spring-webmvc-6.0.10.jar:6.0.10] at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:797) ~[spring-webmvc-6.0.10.jar:6.0.10] at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-6.0.10.jar:6.0.10] at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1081) ~[spring-webmvc-6.0.10.jar:6.0.10] at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:974) ~[spring-webmvc-6.0.10.jar:6.0.10] at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1011) ~[spring-webmvc-6.0.10.jar:6.0.10] at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903) ~[spring-webmvc-6.0.10.jar:6.0.10] at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564) ~[tomcat-embed-core-10.1.10.jar:6.0] at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) ~[spring-webmvc-6.0.10.jar:6.0.10]
使用Postman执行authorization_code流程,登录页面正常弹出,输入凭证后跳转至错误页面。调试发现HttpServletRequest中未设置RequestDispatcher.ERROR_MESSAGE属性。
代码配置
AuthorizationServerConfig
@Configuration(proxyBeanMethods = false) public class AuthServerConfig { @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 http // Redirect to the login page when not authenticated from the // authorization endpoint .exceptionHandling((exceptions) -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ) // Accept access tokens for User Info and/or Client Registration .oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); } @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("messaging-client") .clientSecret("{noop}secret") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client") .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out") .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); return new InMemoryRegisteredClientRepository(oidcClient); } @Bean public JWKSource<SecurityContext> jwkSource() { KeyPair keyPair = generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet = new JWKSet(rsaKey); return new ImmutableJWKSet<>(jwkSet); } private static KeyPair generateRsaKey() { KeyPair keyPair; try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); keyPair = keyPairGenerator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } return keyPair; } @Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); } @Bean public JWKSource<SecurityContext> jwkSource() { RSAKey rsaKey = Jwks.generateRsa(); JWKSet jwkSet = new JWKSet(rsaKey); return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); } @Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); } }
DefaultSecurityConfig
@EnableWebSecurity @Configuration(proxyBeanMethods = false) public class DefaultSecurityConfig { @Bean public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll() .anyRequest().authenticated() ) // .formLogin(Customizer.withDefaults()); .formLogin(formLogin -> formLogin .loginPage("/login") ) .oauth2Login(oauth2Login -> oauth2Login .loginPage("/login") ); return http.build(); } @Bean public UserDetailsService users() { UserDetails user = User.withDefaultPasswordEncoder() .username("user") .password("password") .roles("USER") .build(); return new InMemoryUserDetailsManager(user); } @Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } @Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); } }
LoginController
@Controller public class LoginController { @GetMapping("/login") public String login() { return "login"; } }
DefaultErrorController
@Controller public class DefaultErrorController implements ErrorController { @RequestMapping("/error") public String handleError(Model model, HttpServletRequest request) { String errorMessage = getErrorMessage(request); if (errorMessage.startsWith("[access_denied]")) { model.addAttribute("errorTitle", "Access Denied"); model.addAttribute("errorMessage", "You have denied access."); } else { model.addAttribute("errorTitle", "Error"); model.addAttribute("errorMessage", errorMessage); } return "error"; } private String getErrorMessage(HttpServletRequest request) { return (String) request.getAttribute(RequestDispatcher.ERROR_MESSAGE); } }
环境详情
- OS: Windows 10
- 工具: Maven, JDK 17.0.4, IntelliJ Idea
- Spring Boot版本: 3.1.1
- Spring-Oauth2-Authorization-Server版本: 1.1.1
调试更新
在DefaultErrorController的return (String) request.getAttribute(RequestDispatcher.ERROR_MESSAGE);行添加调试器,出现以下异常:
((ApplicationHttpRequest)((StrictFirewalledRequest)((Servlet3SecurityContextHolderAwareRequestWrapper)((ResourceUrlEncodingRequestWrapper)request).request).request).request).mapping = Cannot cast 'org.springframework.security.web.savedrequest.SavedRequestAwareWrapper' to 'org.springframework.security.web.firewall.StrictHttpFirewall$StrictFirewalledRequest'
排查方向建议
- 修复DefaultErrorController的空指针问题
直接处理errorMessage为null的情况,避免调用startsWith方法时抛出NPE:
@RequestMapping("/error") public String handleError(Model model, HttpServletRequest request) { String errorMessage = getErrorMessage(request); if (errorMessage != null && errorMessage.startsWith("[access_denied]")) { model.addAttribute("errorTitle", "Access Denied"); model.addAttribute("errorMessage", "您已拒绝授权访问。"); } else { model.addAttribute("errorTitle", "错误"); model.addAttribute("errorMessage", errorMessage != null ? errorMessage : "未知错误"); } return "error"; }
- 删除AuthServerConfig中重复的Bean定义
AuthServerConfig里重复定义了jwkSource、jwtDecoder、authorizationServerSettings三个Bean,这会导致Spring容器初始化异常,保留其中一套定义即可,比如删除后面重复的三个Bean:
// 移除以下重复代码块 /* @Bean public JWKSource<SecurityContext> jwkSource() { RSAKey rsaKey = Jwks.generateRsa(); JWKSet jwkSet = new JWKSet(rsaKey); return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); } @Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); } */
- 完善表单登录配置
在DefaultSecurityConfig中显式指定表单登录的处理路径,并确保该路径被允许访问,同时明确SecurityFilterChain的执行顺序:
@Bean @Order(2) // 明确顺序,晚于授权服务器的FilterChain public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/assets/**", "/webjars/**", "/login", "/login/oauth2/code/**").permitAll() .anyRequest().authenticated() ) .formLogin(formLogin -> formLogin .loginPage("/login") .loginProcessingUrl("/login") // 显式指定登录提交路径 .permitAll() ) .oauth2Login(oauth2Login -> oauth2Login .loginPage("/login") ); return http.build(); }
- 验证授权码流程的回调链路
确认RegisteredClient中的redirectUri与实际回调地址一致,且该地址已被加入permitAll列表,避免登录后回调被拦截导致跳转错误。
内容的提问来源于stack exchange,提问作者Anuj Upadhyay
相关产品推荐
相关产品推荐

