如何使用 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
- AppModule*
@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();
}
}
- 默认OAuth拦截器*
@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 配置,相关的是:
- 访问类型:机密
- 启用标准流程:开
- 启用隐式流:关闭
- 启用直接访问授权:开
- 启用服务帐户:开
- 授权已启用: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;
}
}
就是这样!它现在就像一个魅力:)
希望这有帮助:)
提前,我为这个愚蠢的问题道歉!事实上,问题并没有那么长,但是我发布了很多我的代码片段,因为我真的不知道什么是相关的或不相关的来解决我的问题......
我一直在尝试制作一个简单的 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
- AppModule*
@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();
}
}
- 默认OAuth拦截器*
@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 配置,相关的是: - 访问类型:机密 - 启用标准流程:开 - 启用隐式流:关闭 - 启用直接访问授权:开 - 启用服务帐户:开 - 授权已启用: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;
}
}
就是这样!它现在就像一个魅力:) 希望这有帮助:)