如何使用 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 对象