为啥 Spring Boot 不会重定向到 403 上的 Keycloak 登录页面?

Posted

技术标签:

【中文标题】为啥 Spring Boot 不会重定向到 403 上的 Keycloak 登录页面?【英文标题】:Why won't Spring Boot redirect to Keycloak login page on 403?为什么 Spring Boot 不会重定向到 403 上的 Keycloak 登录页面? 【发布时间】:2022-01-09 07:22:12 【问题描述】:

我正在尝试使用 Keycloak(单独的实例)提供的身份验证设置 Spring Boot api。全部在本地 docker swarm/compose 中运行。麻烦的是,当我将用户定向到由@RolesAllowed("ROLE_USER") 控制的/api/v3/login 时,我会返回带有消息There was an unexpected error (type=Forbidden, status=403) 的标准白标签错误页面。我希望浏览器被定向到 Keycloak 客户端登录页面。

下面的设置。

ApplicationConfiguration - 这样做是为了让我们从数据库而不是配置文件中提取 Keycloak 客户端配置。我们将拥有多个客户端,具体取决于用户的电子邮件域(通过 cookie 提供给我们):

@ComponentScan("com.mycompany")
@Configuration
@EnableJpaRepositories(basePackages = "com.mycompany")
@EntityScan("com.mycompany")
public class ApplicationConfiguration 
    ...
    @Bean
    public KeycloakConfigResolver keycloakConfigResolver() 
        return new CustomKeycloakConfigResolver();
    

CustomKeycloakConfigResolver:

public class CustomKeycloakConfigResolver implements KeycloakConfigResolver 
    @Autowired
    private KeycloakConfigService keycloakConfigService;
    ...
    @Override
    @Transactional
    public KeycloakDeployment resolve(final HttpFacade.Request request) 
        HttpFacade.Cookie cookie = request.getCookie("authDomain");
        if (cookie == null) 
            return generateNullDeployment();
        
        
        final Pageable defaultPaging = PageRequest.of(0,1,Sort.by("id").ascending());
        Page<KeycloakConfig> page = keycloakConfigService.readConfigsByFilter(
            "domain", cookie.getValue(), defaultPaging
        );
        
        if ((page == null) || (page.getContent().size() < 1)) 
            return generateNullDeployment();
        

        KeycloakConfig config = page.getContent().get(0);
        AdapterConfig adapterConfig = new AdapterConfig();
        adapterConfig.setRealm(config.getRealm());
        adapterConfig.setResource(config.getResource());
        adapterConfig.setPublicClient(config.getIsPublic());
        adapterConfig.setAuthServerUrl(config.getAuthServerUrl());
        adapterConfig.setSslRequired(
                config.getIsSslRequired() ? "all" : "none"
        );
        adapterConfig.setUseResourceRoleMappings(
                config.getUseResourceRoleMappings()
        );
        adapterConfig.setTokenStore(config.getTokenStore());
        adapterConfig.setBearerOnly(config.getBearerOnly());

        KeycloakDeployment keycloakDeployment =
                KeycloakDeploymentBuilder.build(adapterConfig);

        LOGGER.info("Keycloak Deployment Realm:    ", keycloakDeployment.getRealm());
        LOGGER.info("Keycloak Deployment Resource: ", keycloakDeployment.getResourceName());
        LOGGER.info("Keycloak Deployment URL:      ", keycloakDeployment.getAuthUrl());

        return keycloakDeployment;
    

注意 - 这一切似乎都在工作,尽管在一次调用中,此 resolve 方法会被调用数十次:

...
o.k.adapters.KeycloakConfigResolver      : Keycloak Deployment Realm:    SpringBootKeycloak
o.k.adapters.KeycloakConfigResolver      : Keycloak Deployment Resource: SpringBootKeycloak
o.keycloak.adapters.KeycloakDeployment   : Loaded URLs from http://auth-service:8080/auth/realms/SpringBootKeycloak/.well-known/openid-configuration
...
o.k.adapters.KeycloakConfigResolver      : Keycloak Deployment Realm:    SpringBootKeycloak
o.k.adapters.KeycloakConfigResolver      : Keycloak Deployment Resource: SpringBootKeycloak
o.keycloak.adapters.KeycloakDeployment   : Loaded URLs from http://auth-service:8080/auth/realms/SpringBootKeycloak/.well-known/openid-configuration
...

无论如何,最后,我们有一个有效的 KeycloakDeployment,其中 http://auth-service:8080/auth 作为身份验证登录 URL。

应用的安全配置是:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(jsr250Enabled = true)
public class SecurityConfiguration
        extends KeycloakWebSecurityConfigurerAdapter 
    @Override
    protected void configure(final HttpSecurity http) throws Exception 
        super.configure(http);
        http
                .csrf().disable()
                .antMatcher("/**")
                .authorizeRequests();
...

所以所有请求都被授权。 API 端点是:

...
    @RolesAllowed("ROLE_USER")
    @GetMapping(
            value = "/login",
            produces = MediaType.APPLICATION_JSON_VALUE
    )
    @ResponseBody
    public Map<String, String> login() 
        final Map<String, String> response = new HashMap<String, String>();
        response.put("status", "OK");
        return response;
    
...

所以我真的只是想看看用户是否经过身份验证。

现在 - 我期望发生的是用户进来,没有经过身份验证,并被定向到 Keycloak 登录页面。相反,我只得到 403 Forbidden white label 页面。

我认为这是因为在安全配置中我使用了.authorizeRequests(),即使用户没有经过身份验证,这也会为用户提供“匿名”角色。但在我的一生中,我似乎无法获得正确的调用组合,因此当用户点击该登录端点但实际上并未登录时,他们将被定向到 KeycloakDeployment 的登录页面。


更新:我想我已经解决了这个谜团的一部分。

我在类路径上有一个旧的 AuthenticationEntryPoint 类

@ControllerAdvice
public class CustomAuthenticationEntryPoint
        implements AuthenticationEntryPoint 

尽管我从未使用 .authenticationEntryPoint() 指定它,但 Spring Boot 神奇的自动配置似乎已经找到并正在使用它。

我已完全禁用它,现在我至少从 /api/v3/login 重定向到 /sso/login。但是 /sso/login 不再使用 CustomKeycloakConfigResolver,这很重要,因为没有它我们就没有 KeycloakDeployment,这意味着我们会因异常而失败

rest-api_1            | 2021-12-02 21:59:20.871  WARN 12 --- [nio-8080-exec-5] o.keycloak.adapters.KeycloakDeployment   : Failed to load URLs from null/realms/null/.well-known/openid-configuration
rest-api_1            |
rest-api_1            | java.lang.IllegalStateException: Target host is null
rest-api_1            |     at org.apache.http.util.Asserts.notNull(Asserts.java:52) ~[httpcore-4.4.14.jar!/:4.4.14]

【问题讨论】:

可以做个小测试吗?在 SecurityConfiguration 中显式配置时是否有效:http.authorizeRequests() .antMatchers("/login").hasRole("ROLE_USER") .anyRequest().permitAll(); 另外,检查您的 Keycloak 中的角色名称是否相同,并且该角色是否已分配给用户。有时它可能会被忽略。 必须将 hasRole 更改为 .hasRole("USER") 或启动失败(例外,角色不应以“ROLE_”开头,因为它已被假定)。但还是一样的结果。我不认为名字很重要——因为我从来没有被引导到 Keycloak 登录页面,所以我从来没有得到任何角色的 JWT。但我还是仔细检查了。 如果您以同样的方式将角色名称更改为@RolesAllowed("USER"),会发生什么? 感谢@roccobaroccoSC。注释本身不是问题,但老实说我不确定是什么。春天提供了太多给猫剥皮的方法,但它们似乎并不都能很好地相互配合。无论如何-要使注释正常工作,您只需要安全配置类上的注释@EnableGlobalMethodSecurity(jsr250Enabled = true),就可以了。我最终剥离了所有内容,并在其余 API 上重新从头开始,它正在工作,尽管我们会看到当我再次遇到问题时重新添加东西。 【参考方案1】:

根据要求,我正在分享我的配置(匿名)。不同之处在于,授权映射在 SecurityConfigurerAdapter 中,而不是在像您这样的注释中。

import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver;
import org.keycloak.adapters.springsecurity.KeycloakSecurityComponents;
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;

// https://www.baeldung.com/spring-boot-keycloak#securityconfig
// https://newbedev.com/when-to-use-spring-security-s-antmatcher

@Configuration
@EnableWebSecurity
@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class)
class MyAppWebSecurityConfigurerAdapter extends KeycloakWebSecurityConfigurerAdapter

    private static final String COM_MY_COMPANY_MY_APP_SERVER_SECURITY_DISABLED = "com.mycompany.myapp.server.security.DISABLED";
    
    Logger log = LoggerFactory.getLogger(getClass());
    
    // Tasks the SimpleAuthorityMapper to make sure roles are not prefixed with ROLE_.
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception
    
        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
        auth.authenticationProvider(keycloakAuthenticationProvider);
    

    @Bean
    public KeycloakSpringBootConfigResolver keycloakConfigResolver()
    
        return new KeycloakSpringBootConfigResolver();
    

    @Bean
    @Override
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy()
    
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    
    
    @Override
    protected void configure(HttpSecurity http) throws Exception
    
        super.configure(http);

        if (isDisabled())
        
            // Only for debugging purposes. Keycloak is off!
            
            // Starting server with security off:
            // mvn spring-boot:run -Dspring-boot.run.jvmArguments="-Dcom.mycompany.myapp.server.security.DISABLED=true"
            
            log.warn("STARTING WITHOUT WEB-SECURITY. ONLY FOR DEBUGGING!!!");
            log.warn("STARTING WITHOUT WEB-SECURITY. ONLY FOR DEBUGGING!!!");
            log.warn("STARTING WITHOUT WEB-SECURITY. ONLY FOR DEBUGGING!!!");
            
            http
                .csrf().disable()
                .authorizeRequests().anyRequest().permitAll()
                ;
        
        else
        
            // Production
            log.info("PRODUCTION MODE. Security is on.");

            // Docu: https://docs.spring.io/spring-security/site/docs/current/reference/html5/
            // Example: https://***.com/questions/52825679/how-to-use-hasrole-in-spring-security
            http
            .authorizeRequests()                                                                
                .antMatchers(
                    "/resources/**",
                    "/about",
                    "/error",
                    "/sso/login",
                    "/lobby",
                    "/sandbox/**"
                ).permitAll()                  
                .antMatchers("/component/admin").hasRole("myapp-manager")
                .antMatchers("/component/**").hasRole("myapp-user")
              //.antMatchers("/dbadmin").access("hasRole('myapp-user') and hasRole('myapp-db-admin')") // matching of >1 role
                .anyRequest().authenticated()                                                   
            .and()
                .logout()
                .addLogoutHandler(keycloakLogoutHandler())
                .logoutUrl("/sso/logout").permitAll()
                .logoutSuccessUrl("/lobby")
            //.and()
            //    .exceptionHandling().accessDeniedPage("/403")
                ;
            
        
        
    
    
    private boolean isDisabled()
    
        String x = System.getProperty(COM_MY_COMPANY_MY_APP_SERVER_SECURITY_DISABLED);

        log.debug("isDisabled: Системно свойство =", COM_MY_COMPANY_MY_APP_SERVER_SECURITY_DISABLED, x);
        return "true".equals(x);
    


我创建了一个 Servlet,用于调试和测试配置。它用于登录/注销并重定向到几个资源以测试授权。

import java.security.Principal;
import java.util.Collections;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.keycloak.AuthorizationContext;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.adapters.springsecurity.account.SimpleKeycloakAccount;
import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("")
public class LobbyController 

    private Logger log = LoggerFactory.getLogger(getClass());

    @ResponseStatus( HttpStatus.OK )
    @GetMapping(path = "/lobby", produces = "text/html")
    public String lobby(Principal principal, HttpServletRequest request, HttpServletResponse response)
    
        log.debug("lobby");
        log.trace("lobby: request.getAttributeNames: ", Collections.list(request.getAttributeNames()));
        
        String info = ""; 

        CsrfToken _csrf = (CsrfToken) request.getAttribute("_csrf");
        log.debug("_csrf: , ", _csrf.getParameterName(), _csrf.getToken());
        
        KeycloakSecurityContext keycloakSecurityContext = (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName());
        boolean isLoggedIn = keycloakSecurityContext != null;
        
        if (isLoggedIn)
        
            KeycloakAuthenticationToken keycloakPrincipal = (KeycloakAuthenticationToken) principal;
            SimpleKeycloakAccount details = (SimpleKeycloakAccount) keycloakPrincipal.getDetails();
            
            info
                = "<PRE>"
                
                + "<B>You are logged in.<B><BR/>"
                + "Realm: " + keycloakSecurityContext.getRealm() + "<BR/>"
                + "TokenString                : <input value='" + keycloakSecurityContext.getTokenString() + "'/><BR/>"
                + "IdTokenString              : <input value='" + keycloakSecurityContext.getIdTokenString() + "'/><BR/>"
                + "IdToken.Type               : " + keycloakSecurityContext.getIdToken().getType()             + "<BR/>"
                + "IdToken.Subject            : " + keycloakSecurityContext.getIdToken().getSubject()          + "<BR/>"
                + "IdToken.Id                 : " + keycloakSecurityContext.getIdToken().getId()               + "<BR/>"
                + "IdToken.getAccessTokenHash : " + keycloakSecurityContext.getIdToken().getAccessTokenHash()  + "<BR/>"
                + "IdToken.Email              : " + keycloakSecurityContext.getIdToken().getEmail()            + "<BR/>"
                + "IdToken.Name               : " + keycloakSecurityContext.getIdToken().getName()             + "<BR/>"
                + "IdToken.PreferredUsername  : " + keycloakSecurityContext.getIdToken().getPreferredUsername()+ "<BR/>"
                + "principal                  : <textarea>" + principal.toString() + "</textarea><BR/>"
                + "principal.details.principal: " + details.getPrincipal() + "<BR/>"
                + "principal.details.roles    : " + details.getRoles() + "<BR/>"

                + "</PRE>"
                ;

            AuthorizationContext authzContext = keycloakSecurityContext.getAuthorizationContext();
            if (authzContext!=null)
                info 
                    += "<PRE>"

                    + "authzContext.hasResourcePermission(/lobby)           : " + authzContext.hasResourcePermission("/lobby")            + "<BR/>"
                    + "authzContext.hasResourcePermission(/component/person): " + authzContext.hasResourcePermission("/component/person") + "<BR/>"
                    + "authzContext.hasResourcePermission(/component/admin) : " + authzContext.hasResourcePermission("/component/admin")  + "<BR/>"
                    + "authzContext.hasScopePermission(email)               : " + authzContext.hasScopePermission("email")                + "<BR/>"
    
                    + "</PRE>"
                    ;
        
        else
        
            info 
                = "<PRE>"
                + "<B>You are logged out.<B><BR/>"
                + "</PRE>"
                ;
        
        
        info
            += "<A href='/component/person'>Person component</A><BR/>"
            + "<A href='/component/admin'>Admin component</A><BR/>"
            + "<A href='/sandbox/foo'>Sandbox</A><BR/>"
        ;
        
        if (isLoggedIn)
            info += "<FORM method='POST' action='/sso/logout'><input type='hidden' name='" + _csrf.getParameterName() + "' value='" + _csrf.getToken() + "' /><input type='submit' value='Log out'/></FORM><BR/>";
        
        return info;
    


【讨论】:

以上是关于为啥 Spring Boot 不会重定向到 403 上的 Keycloak 登录页面?的主要内容,如果未能解决你的问题,请参考以下文章

为啥我的 mvc 项目重定向到默认的 403 错误页面,而不是我重定向到的页面,而不是在本地的 IIS 上?

通过 Okta 进行身份验证后,会话 cookie 不会发送到 Spring Boot 应用程序

spring boot 重定向到页面登录

Spring Boot工程支持HTTP和HTTPS,HTTP重定向HTTPS

(spring-boot) http 到 https 重定向,405 方法不允许消息

Spring boot & Spring security 总是重定向到 Login