如何使用 Keycloak 保护 Angular 8 前端和使用网关、eureka 的 Java Spring Cloud 微服务后端

Posted

技术标签:

【中文标题】如何使用 Keycloak 保护 Angular 8 前端和使用网关、eureka 的 Java Spring Cloud 微服务后端【英文标题】:How to secure an Angular 8 frontend with Keycloak and a Java Spring Cloud microservice backend with gateway, eureka 【发布时间】:2020-07-05 14:16:07 【问题描述】:

提前,我为这个冗长的问题道歉!其实问题并没有那么长,但是我贴了很多我的代码片段,因为我真的不知道什么是相关的或不解决我的问题......

我一直在尝试用以下方法制作一个简单的 poc: - Angular 8 前端 - 用于身份验证的 Keycloak 服务器 - Spring 云后端架构: - 使用 Spring Cloud Security 保护的 Spring Cloud Gateway - Spring Cloud Netflix Eureka 服务器 - 一个 Spring Cloud 配置服务器 - 一些使用 Spring Security OAuth2 保护的 Springboot 微服务 不工作:我无法让我的 Angular 应用程序访问并从受保护的后端 uris 中获取任何数据。我收到401 Unauthorized 回复。如果我对 MS Spring secu 过滤器设置断点,我只是在 HttpServletRequest request

中没有任何标记

工作: - 通过 Angular 进行正面身份验证 - Angular 可以从后端未受保护的 uri 中获取数据 - Postman 对受保护的后端 uri 进行测试,将 OAuth2 授予类型设置为资源所有者密码凭据

我学习了很多教程,但我有一个更好的结果:https://blog.jdriven.com/2019/11/spring-cloud-gateway-with-openid-connect-and-token-relay/

以下是我认为相关的一段代码:

角度

我使用了这个 OAuth 库: https://www.npmjs.com/package/angular-oauth2-oidc

应用模块*
@NgModule(
  declarations: [
    AppComponent,
    BooksComponent,
    HeaderComponent,
    SideNavComponent
  ],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    HttpClientModule,
    AppRoutingModule,
    ReactiveFormsModule,
    OAuthModule.forRoot(
      resourceServer: 
        allowedUrls: ['http://localhost:4200'],
        sendAccessToken: true
      
    ),
    AuthConfigModule,
    MatFormFieldModule,
    MatInputModule,
    MatButtonModule
  ],
  providers: [
    TheLibraryGuard,
     provide: HTTP_INTERCEPTORS,
      useClass: DefaultOAuthInterceptor,
      multi: true
    
  ],
  entryComponents: [AppComponent],
  bootstrap: [AppComponent]
)
export class AppModule 

CustomAuthGuard*
@Injectable()
export class CustomAuthGuard implements CanActivate 

  constructor(private oauthService: OAuthService, protected router: Router) 
  

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): any 
    const hasIdToken = this.oauthService.hasValidIdToken();
    const hasAccessToken = this.oauthService.hasValidAccessToken();

    if (this.oauthService.hasValidAccessToken()) 
      return (hasIdToken && hasAccessToken);
    

    this.router.navigate([this.router.url]);
    return this.oauthService.loadDiscoveryDocumentAndLogin();
  

DefaultOAuthInterceptor*
@Injectable()
export class DefaultOAuthInterceptor implements HttpInterceptor 

  constructor(
    private authStorage: OAuthStorage,
    private oauthService: OAuthService,
    private errorHandler: OAuthResourceServerErrorHandler,
    @Optional() private moduleConfig: OAuthModuleConfig
  ) 
  

  private checkUrl(url: string): boolean 
    const found = this.moduleConfig.resourceServer.allowedUrls.find(u => url.startsWith(u));
    return !!found;
  

  public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> 

    console.log('INTERCEPTOR');

    const url = req.url.toLowerCase();

    if (!this.moduleConfig)  return next.handle(req); 
    if (!this.moduleConfig.resourceServer)  return next.handle(req); 
    if (!this.moduleConfig.resourceServer.allowedUrls)  return next.handle(req); 
    if (!this.checkUrl(url))  return next.handle(req); 

    const sendAccessToken = this.moduleConfig.resourceServer.sendAccessToken;

    if (sendAccessToken) 

      // const token = this.authStorage.getItem('access_token');
      const token = this.oauthService.getIdToken();
      const header = 'Bearer ' + token;

      console.log('TOKEN in INTERCEPTOR : ' + token);

      const headers = req.headers
        .set('Authorization', header);

      req = req.clone( headers );
    

    return next.handle(req)/*.catch(err => this.errorHandler.handleError(err))*/;

  

AuthConfig*
export const authConfig: AuthConfig = 

  issuer: environment.keycloak.issuer,
  redirectUri: environment.keycloak.redirectUri,
  clientId: environment.keycloak.clientId,
  dummyClientSecret: environment.keycloak.dummyClientSecret,
  responseType: environment.keycloak.responseType,
  scope: environment.keycloak.scope,
  requireHttps: environment.keycloak.requireHttps,
  // at_hash is not present in JWT token
  showDebugInformation: environment.keycloak.showDebugInformation,
  disableAtHashCheck: environment.keycloak.disableAtHashCheck
;


export class OAuthModuleConfig 
  resourceServer: OAuthResourceServerConfig = sendAccessToken: false;


export class OAuthResourceServerConfig 
  /**
   * Urls for which calls should be intercepted.
   * If there is an ResourceServerErrorHandler registered, it is used for them.
   * If sendAccessToken is set to true, the access_token is send to them too.
   */
  allowedUrls?: Array<string>;
  sendAccessToken = true;
  customUrlValidation?: (url: string) => boolean;

AuthConfigService*
@Injectable()
export class AuthConfigService 

  private decodedAccessToken: any;
  private decodedIDToken: any;

  constructor(
    private readonly oauthService: OAuthService,
    private readonly authConfig: AuthConfig
  ) 
  

  async initAuth(): Promise<any> 
    return new Promise((resolveFn, rejectFn) => 
      // setup oauthService
      this.oauthService.configure(this.authConfig);
      this.oauthService.setStorage(localStorage);
      this.oauthService.tokenValidationHandler = new NullValidationHandler();

      // subscribe to token events
      this.oauthService.events
        .pipe(filter((e: any) => 
          return e.type === 'token_received';
        ))
        .subscribe(() => this.handleNewToken());

      // continue initializing app or redirect to login-page

      this.oauthService.loadDiscoveryDocumentAndLogin().then(isLoggedIn => 
        if (isLoggedIn) 
          this.oauthService.setupAutomaticSilentRefresh();
          resolveFn();
         else 
          this.oauthService.initLoginFlow();
          rejectFn();
        
      );

    );
  

  private handleNewToken() 
    this.decodedAccessToken = this.oauthService.getAccessToken();
    this.decodedIDToken = this.oauthService.getIdToken();
  

AuthConfigModule*
@NgModule(
  imports: [ HttpClientModule, OAuthModule.forRoot() ],
  providers: [
    AuthConfigService,
     provide: AuthConfig, useValue: authConfig ,
    OAuthModuleConfig,
    
      provide: APP_INITIALIZER,
      useFactory: init_app,
      deps: [ AuthConfigService ],
      multi: true
    
  ]
)
export class AuthConfigModule  
environment.ts
export const environment = 
  production: false,
  envName: 'local',
  baseUrl: 'http://localhost:8081/',
  keycloak: 
    issuer: 'http://localhost:8080/auth/realms/TheLibrary',
    redirectUri: 'http://localhost:4200/',
    clientId: 'XXXXXXXXXXX',
    dummyClientSecret: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    responseType: 'code',
    scope: 'openid profile email',
    requireHttps: false,
    // at_hash is not present in JWT token
    showDebugInformation: true,
    disableAtHashCheck: true
  
;

网关

application.yml*
spring:
    application:
        name: gateway-service
    cloud:
        config:
            uri: http://localhost:8888
        discovery:
            enabled: true
        gateway:
#            default-filters:
#                - TokenRelay
            routes:
                -   id: THELIBRARY-MS-BOOK
                    uri: lb://thelibrary-ms-book
                    predicates:
                        - Path=/api/**
                    filters:
                        - TokenRelay=
            globalcors:
                corsConfigurations:
                    '[/**]':
                        allowedOrigins: "*"
                        allowedMethods:
                            - GET
                            - POST
                            - DELETE
                            - PUT
                        add-to-simple-url-handler-mapping: true
    security:
        oauth2:
            client:
                provider:
                    keycloak:
                        issuer-uri: http://localhost:8080/auth/realms/TheLibrary
                        user-name-attribute: preferred_username
                registration:
                    keycloak:
                        client-id: xxxxxxxxxxxxxxxxxx
                        client-secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx

eureka:
    client:
        serviceUrl:
            defaultZone: http://localhost:8761/eureka/

management:
    endpoints:
        web:
            exposure:
                include: "*"

server:
    port: 8081

logging:
    level:
        org:
            springframework:
                cloud.gateway: DEBUG
                http.server.reactive: DEBUG
                web.reactive: DEBUG
SpringBootApplication*
@SpringBootApplication
@CrossOrigin("*")
public class GatewayApplication 

//  @Autowired
//  private TokenRelayGatewayFilterFactory filterFactory;
//
//  @Bean
//  public RouteLocator myRoutes(RouteLocatorBuilder builder) 
//      return builder.routes()
//                     .route(route -> route
//                                         .path("/api/**")
////                                           .filters(f -> f.hystrix(config -> config.setName("d").setFallbackUri( "forward:/defaultBook"  )))
//                                         .filters(f -> f.filter( filterFactory.apply() ))
//                                         .uri("lb://thelibrary-ms-book")
//                                         .id( "ms-books" ))
//              .build();
//  

    @Bean
    DiscoveryClientRouteDefinitionLocator discoveryClientRouteDefinitionLocator(
            ReactiveDiscoveryClient reactiveDiscoveryClient,
            DiscoveryLocatorProperties discoveryLocatorProperties )
        return new DiscoveryClientRouteDefinitionLocator(reactiveDiscoveryClient, discoveryLocatorProperties);
    

    public static void main( String[] args ) 
        SpringApplication.run( GatewayApplication.class, args );
    

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain( ServerHttpSecurity http,
                                                             ReactiveClientRegistrationRepository clientRegistrationRepository) 

        // Require authentication for all requests
        http.cors().and().authorizeExchange().anyExchange().permitAll();

        // Allow showing /home within a frame
//      http.headers().frameOptions().mode( XFrameOptionsServerHttpHeadersWriter.Mode.SAMEORIGIN);

        // Disable CSRF in the gateway to prevent conflicts with proxied service CSRF
        http.csrf().disable();
        return http.build();
    

微服务

application.yml*
spring:
    application:
        name: thelibrary-ms-book
    cloud:
        config:
            uri: http://localhost:8888
            profile: local, prod
        discovery:
            enabled: true
    data:
        rest:
            return-body-on-create: true
            return-body-on-update: true
    rabbitmq:
        host: localhost
        username: user
        password: user

    security:
        oauth2:
            resourceserver:
                jwt:
                    issuer-uri: http://localhost:8080/auth/realms/TheLibrary
                    jwk-set-uri: http://localhost:8080/auth/realms/TheLibrary/.well-known/openid-configuration

eureka:
    client:
        serviceUrl:
            defaultZone: http://localhost:8761/eureka/

management:
    endpoints:
        web:
            exposure:
                include: "*"

server:
    port: 8090
    servlet:
        context-path: /api/

logging:
    level:
        org:
            hibernate:
                SQL: DEBUG
                type:
                    descriptor:
                        sql:
                            BasicBinder: TRACE
安全配置*
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@AllArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter 

    @Override
    protected void configure(HttpSecurity http) throws Exception 
        // Validate tokens through configured OpenID Provider
        http.cors().and().oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter());
        http.cors().and().authorizeRequests().mvcMatchers("/books").hasRole("admin");
        // Allow showing pages within a frame
        http.headers().frameOptions().sameOrigin();
    

    private JwtAuthenticationConverter jwtAuthenticationConverter() 
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        // Convert realm_access.roles claims to granted authorities, for use in access decisions
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
        return jwtAuthenticationConverter;
    

    @Bean
    public JwtDecoder jwtDecoderByIssuerUri( OAuth2ResourceServerProperties properties) 
        String issuerUri = properties.getJwt().getIssuerUri();
        NimbusJwtDecoder jwtDecoder = ( NimbusJwtDecoder ) JwtDecoders.fromIssuerLocation(issuerUri);
        // Use preferred_username from claims as authentication name, instead of UUID subject
        jwtDecoder.setClaimSetConverter(new UsernameSubClaimAdapter());
        return jwtDecoder;
    

KeycloakRealmRoleConverter*
class KeycloakRealmRoleConverter implements Converter< Jwt, Collection< GrantedAuthority > > 

    @Override
    @SuppressWarnings("unchecked")
    public Collection<GrantedAuthority> convert(final Jwt jwt) 
        final Map<String, Object> realmAccess = (Map<String, Object>) jwt.getClaims().get("realm_access");
        return (( List<String> ) realmAccess.get("roles")).stream()
                .map(roleName -> "ROLE_" + roleName)
                .map( SimpleGrantedAuthority::new)
                .collect( Collectors.toList());
    

用户名SubClaimAdapter*
class UsernameSubClaimAdapter implements Converter< Map<String, Object>, Map<String, Object>> 

    private final MappedJwtClaimSetConverter delegate = MappedJwtClaimSetConverter.withDefaults( Collections.emptyMap());

    @Override
    public Map<String, Object> convert(Map<String, Object> claims) 
        Map<String, Object> convertedClaims = this.delegate.convert(claims);
        String username = (String) convertedClaims.get("preferred_username");
        convertedClaims.put("sub", username);
        return convertedClaims;
    

相关依赖*
        <springboot-version>2.2.5.RELEASE</springboot-version>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Hoxton.SR3</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>

我有一个非常标准的 Cleint Keycloak 配置,相关的是: - 访问类型:机密 - 启用标准流量:开 - 隐式流启用:关闭 - 启用直接访问授权:开 - 启用服务帐户:开 - 授权启用:开

我真的尝试了很多东西,但我已经没有任何想法了......

有人可以看看并告诉我我做错了什么吗? 我将不胜感激! :)

非常感谢您的宝贵时间! :)

【问题讨论】:

【参考方案1】:

这就是解决我的问题的方法!

1 - 在 Angular 中:更正 DefaultOAuthInterceptor

删除这部分:

    if (!this.moduleConfig)  return next.handle(req); 
    if (!this.moduleConfig.resourceServer)  return next.handle(req); 
    if (!this.moduleConfig.resourceServer.allowedUrls)  return next.handle(req); 
    if (!this.checkUrl(url))  return next.handle(req); 

无论出于何种原因,其中一个条件始终为真,然后该方法的其余部分永远不会执行。 (警告:我真的不知道跳过这段代码的后果)

所以最后的拦截器是:

@Injectable()
export class DefaultOAuthInterceptor implements HttpInterceptor 
  constructor(
    private authStorage: OAuthStorage,
    private oAuthService: OAuthService,
    private errorHandler: OAuthResourceServerErrorHandler,
    @Optional() private moduleConfig: OAuthModuleConfig
  ) 
  

  private checkUrl(url: string): boolean 
    if (this.moduleConfig.resourceServer.customUrlValidation) 
      return this.moduleConfig.resourceServer.customUrlValidation(url);
    

    if (this.moduleConfig.resourceServer.allowedUrls) 
      return !!this.moduleConfig.resourceServer.allowedUrls.find(u =>
        url.startsWith(u)
      );
    

    return true;
  

  public intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> 
    const url = req.url.toLowerCase();

    // if (
    //   !this.moduleConfig ||
    //   !this.moduleConfig.resourceServer ||
    //   !this.checkUrl(url)
    // ) 
    //   return next.handle(req);
    // 

    const sendAccessToken = this.moduleConfig.resourceServer.sendAccessToken;

    if (!sendAccessToken) 
      return next
        .handle(req)
        .pipe(catchError(err => this.errorHandler.handleError(err)));
    

    return merge(
      of(this.oAuthService.getAccessToken()).pipe(
        filter(token => (token ? true : false))
      ),
      this.oAuthService.events.pipe(
        filter(e => e.type === 'token_received'),
        timeout(this.oAuthService.waitForTokenInMsec || 0),
        catchError(_ => of(null)), // timeout is not an error
        map(_ => this.oAuthService.getAccessToken())
      )
    ).pipe(
      take(1),
      mergeMap(token => 
        if (token) 
          const header = 'Bearer ' + token;
          const headers = req.headers.set('Authorization', header);
          req = req.clone(headers);
        

        return next
          .handle(req)
          .pipe(catchError(err => this.errorHandler.handleError(err)));
      )
    );
  

2 - 在 GATEWAY 中,添加一个 CorsWebFilter 在 Angular 拦截器正常工作的情况下,无论 spring 云网关文档中的 yaml 配置如何,我仍然遇到了 CORS 问题。

我必须添加一个简单的 CorsWebFilter,因为这个链接说 https://github.com/spring-cloud/spring-cloud-gateway/issues/840:

@Configuration
public class PreFlightCorsConfiguration 

    @Bean
    public CorsWebFilter corsFilter() 
        return new CorsWebFilter(corsConfigurationSource());
    

    @Bean
    CorsConfigurationSource corsConfigurationSource() 
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration().applyPermitDefaultValues();
        config.addAllowedMethod( HttpMethod.GET);
        config.addAllowedMethod( HttpMethod.PUT);
        config.addAllowedMethod( HttpMethod.POST);
        config.addAllowedMethod(HttpMethod.DELETE);
        source.registerCorsConfiguration("/**", config);
        return source;
    

就是这样!它现在就像一个魅力:) 希望这会有所帮助:)

【讨论】:

一段时间过去了,我发现问题似乎已解决。无论如何,使用这种基础设施,我会搜索无法识别身份验证的确切位置。我不是 OAuth2 专家,但我从一年前就开始使用 keycloak,有时它对某些输入参数可能很挑剔。在这种情况下,我建议使用客户端(例如 Postman)进行一些 Http 调试,尝试自己检查每个部分。在您的情况下,从客户端到网关,从网关到微服务。检查响应头,它们通常包含 403 错误的原因。

以上是关于如何使用 Keycloak 保护 Angular 8 前端和使用网关、eureka 的 Java Spring Cloud 微服务后端的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 keyCloak 保护 Angular(accessType-Public)和 Nodejs 应用程序(accesType-bearer-only)

Angular - Spring Boot - Keycloak - 401错误

带有 Keycloak 的 Angular/Spring Boot 抛出 403

Keycloak Angular 2 - 检查身份验证状态 Keycloak 对象

keycloak-angular:页面重新加载后 isLoggedIn() 始终为 false

Keycloak + Spring Cloud Gateway + Angular 9