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

How to secure an Angular 8 frontend with Keycloak and a Java Spring Cloud microservice backend with gateway, eureka

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

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

中没有任何标记

正在工作: - 前端认证 Angular - Angular 可以从后端未受保护的 uris 中获取数据 - Postman 测试受保护的后端 URI,OAuth2 授权类型设置为资源所有者密码凭据

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

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

ANGULAR

我使用了这个 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 {
}
@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();
  }
}
@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))*/;

  }
}
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;
}
@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();
  }
}
@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 { }
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
  }
};

网关

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
@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();
    }
}

微服务

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;
    }
}
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());
    }
}
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 配置,相关的是: - 访问类型:机密 - 启用标准流程:开 - 启用隐式流:关闭 - 启用直接访问授权:开 - 启用服务帐户:开 - 授权已启用:ON

我真的尝试了很多东西,但我再也不知道了...

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

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

这是解决我问题的方法!

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 拦截器正常工作的情况下,我仍然遇到 CORS 问题,无论 spring 云网关文档中的 yaml 配置如何。

我不得不添加一个简单的 CorsWebFilter,因为 link 说 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;
    }
}

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