30 changed files with 14035 additions and 0 deletions
@ -0,0 +1 @@ |
|||||||
|
spring-security.version=6.3.0 |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
plugins { |
||||||
|
id "org.springframework.boot" version "3.2.2" |
||||||
|
id "io.spring.dependency-management" version "1.1.0" |
||||||
|
id "java" |
||||||
|
} |
||||||
|
|
||||||
|
group = project.rootProject.group |
||||||
|
version = project.rootProject.version |
||||||
|
|
||||||
|
java { |
||||||
|
sourceCompatibility = JavaVersion.VERSION_17 |
||||||
|
} |
||||||
|
|
||||||
|
repositories { |
||||||
|
mavenCentral() |
||||||
|
maven { url "https://repo.spring.io/milestone" } |
||||||
|
maven { url "https://repo.spring.io/snapshot" } |
||||||
|
} |
||||||
|
|
||||||
|
ext { |
||||||
|
set("springCloudVersion", "2023.0.2") |
||||||
|
} |
||||||
|
|
||||||
|
dependencyManagement { |
||||||
|
imports { |
||||||
|
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
dependencies { |
||||||
|
implementation "org.springframework.boot:spring-boot-starter-web" |
||||||
|
implementation "org.springframework.boot:spring-boot-starter-security" |
||||||
|
implementation "org.springframework.boot:spring-boot-starter-oauth2-client" |
||||||
|
implementation "org.springframework.cloud:spring-cloud-starter-gateway-mvc" |
||||||
|
} |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2020-2024 the original author or authors. |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
package sample; |
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication; |
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication; |
||||||
|
|
||||||
|
/** |
||||||
|
* @author Joe Grandja |
||||||
|
* @since 1.4 |
||||||
|
*/ |
||||||
|
@SpringBootApplication |
||||||
|
public class BackendForSpaClientApplication { |
||||||
|
|
||||||
|
public static void main(String[] args) { |
||||||
|
SpringApplication.run(BackendForSpaClientApplication.class, args); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,52 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2020-2024 the original author or authors. |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
package sample.config; |
||||||
|
|
||||||
|
import java.util.Arrays; |
||||||
|
import java.util.Collections; |
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value; |
||||||
|
import org.springframework.context.annotation.Bean; |
||||||
|
import org.springframework.context.annotation.Configuration; |
||||||
|
import org.springframework.http.HttpHeaders; |
||||||
|
import org.springframework.web.cors.CorsConfiguration; |
||||||
|
import org.springframework.web.cors.CorsConfigurationSource; |
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; |
||||||
|
|
||||||
|
/** |
||||||
|
* @author Joe Grandja |
||||||
|
* @since 1.4 |
||||||
|
*/ |
||||||
|
@Configuration(proxyBeanMethods = false) |
||||||
|
public class CorsConfig { |
||||||
|
|
||||||
|
@Value("${app.base-uri}") |
||||||
|
private String appBaseUri; |
||||||
|
|
||||||
|
@Bean |
||||||
|
public CorsConfigurationSource corsConfigurationSource() { |
||||||
|
CorsConfiguration config = new CorsConfiguration(); |
||||||
|
config.addAllowedHeader("X-XSRF-TOKEN"); |
||||||
|
config.addAllowedHeader(HttpHeaders.CONTENT_TYPE); |
||||||
|
config.setAllowedMethods(Arrays.asList("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")); |
||||||
|
config.setAllowedOrigins(Collections.singletonList(this.appBaseUri)); |
||||||
|
config.setAllowCredentials(true); |
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); |
||||||
|
source.registerCorsConfiguration("/**", config); |
||||||
|
return source; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,64 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2020-2024 the original author or authors. |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
package sample.config; |
||||||
|
|
||||||
|
import org.springframework.cloud.gateway.server.mvc.common.Shortcut; |
||||||
|
import org.springframework.cloud.gateway.server.mvc.filter.SimpleFilterSupplier; |
||||||
|
import org.springframework.security.core.Authentication; |
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; |
||||||
|
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; |
||||||
|
import org.springframework.security.oauth2.core.OAuth2AccessToken; |
||||||
|
import org.springframework.web.servlet.function.HandlerFilterFunction; |
||||||
|
import org.springframework.web.servlet.function.ServerRequest; |
||||||
|
import org.springframework.web.servlet.function.ServerResponse; |
||||||
|
|
||||||
|
import static org.springframework.cloud.gateway.server.mvc.common.MvcUtils.getApplicationContext; |
||||||
|
|
||||||
|
/** |
||||||
|
* Custom {@code HandlerFilterFunction}'s registered in META-INF/spring.factories and used in application.yml. |
||||||
|
* |
||||||
|
* @author Joe Grandja |
||||||
|
* @since 1.4 |
||||||
|
*/ |
||||||
|
public interface GatewayFilterFunctions { |
||||||
|
|
||||||
|
@Shortcut |
||||||
|
static HandlerFilterFunction<ServerResponse, ServerResponse> relayTokenIfExists(String clientRegistrationId) { |
||||||
|
return (request, next) -> { |
||||||
|
Authentication principal = (Authentication) request.servletRequest().getUserPrincipal(); |
||||||
|
OAuth2AuthorizedClientRepository authorizedClientRepository = getApplicationContext(request) |
||||||
|
.getBean(OAuth2AuthorizedClientRepository.class); |
||||||
|
OAuth2AuthorizedClient authorizedClient = authorizedClientRepository.loadAuthorizedClient( |
||||||
|
clientRegistrationId, principal, request.servletRequest()); |
||||||
|
if (authorizedClient != null) { |
||||||
|
OAuth2AccessToken accessToken = authorizedClient.getAccessToken(); |
||||||
|
ServerRequest bearerRequest = ServerRequest.from(request) |
||||||
|
.headers(httpHeaders -> httpHeaders.setBearerAuth(accessToken.getTokenValue())).build(); |
||||||
|
return next.handle(bearerRequest); |
||||||
|
} |
||||||
|
return next.handle(request); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
class FilterSupplier extends SimpleFilterSupplier { |
||||||
|
|
||||||
|
FilterSupplier() { |
||||||
|
super(GatewayFilterFunctions.class); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,118 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2020-2024 the original author or authors. |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
package sample.config; |
||||||
|
|
||||||
|
import java.util.LinkedHashMap; |
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value; |
||||||
|
import org.springframework.context.annotation.Bean; |
||||||
|
import org.springframework.context.annotation.Configuration; |
||||||
|
import org.springframework.http.HttpStatus; |
||||||
|
import org.springframework.http.MediaType; |
||||||
|
import org.springframework.security.config.Customizer; |
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity; |
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; |
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint; |
||||||
|
import org.springframework.security.web.SecurityFilterChain; |
||||||
|
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; |
||||||
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint; |
||||||
|
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; |
||||||
|
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; |
||||||
|
import org.springframework.security.web.authentication.logout.CompositeLogoutHandler; |
||||||
|
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; |
||||||
|
import org.springframework.security.web.authentication.logout.LogoutHandler; |
||||||
|
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; |
||||||
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository; |
||||||
|
import org.springframework.security.web.csrf.CsrfLogoutHandler; |
||||||
|
import org.springframework.security.web.csrf.CsrfTokenRepository; |
||||||
|
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; |
||||||
|
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; |
||||||
|
import org.springframework.security.web.util.matcher.RequestMatcher; |
||||||
|
|
||||||
|
/** |
||||||
|
* @author Joe Grandja |
||||||
|
* @since 1.4 |
||||||
|
*/ |
||||||
|
@Configuration(proxyBeanMethods = false) |
||||||
|
@EnableWebSecurity |
||||||
|
public class SecurityConfig { |
||||||
|
|
||||||
|
@Value("${app.base-uri}") |
||||||
|
private String appBaseUri; |
||||||
|
|
||||||
|
@Bean |
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { |
||||||
|
CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); |
||||||
|
CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler(); |
||||||
|
/* |
||||||
|
IMPORTANT: |
||||||
|
Set the csrfRequestAttributeName to null, to opt-out of deferred tokens, resulting in the CsrfToken to be loaded on every request. |
||||||
|
If it does not exist, the CookieCsrfTokenRepository will automatically generate a new one and add the Cookie to the response. |
||||||
|
See the reference: https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#deferred-csrf-token
|
||||||
|
*/ |
||||||
|
csrfTokenRequestAttributeHandler.setCsrfRequestAttributeName(null); |
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
http |
||||||
|
.authorizeHttpRequests(authorize -> |
||||||
|
authorize |
||||||
|
.anyRequest().authenticated() |
||||||
|
) |
||||||
|
.csrf(csrf -> |
||||||
|
csrf |
||||||
|
.csrfTokenRepository(cookieCsrfTokenRepository) |
||||||
|
.csrfTokenRequestHandler(csrfTokenRequestAttributeHandler) |
||||||
|
) |
||||||
|
.cors(Customizer.withDefaults()) |
||||||
|
.exceptionHandling(exceptionHandling -> |
||||||
|
exceptionHandling |
||||||
|
.authenticationEntryPoint(authenticationEntryPoint()) |
||||||
|
) |
||||||
|
.oauth2Login(oauth2Login -> |
||||||
|
oauth2Login |
||||||
|
.successHandler(new SimpleUrlAuthenticationSuccessHandler(this.appBaseUri))) |
||||||
|
.logout(logout -> |
||||||
|
logout |
||||||
|
.addLogoutHandler(logoutHandler(cookieCsrfTokenRepository)) |
||||||
|
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK)) |
||||||
|
) |
||||||
|
.oauth2Client(Customizer.withDefaults()); |
||||||
|
// @formatter:on
|
||||||
|
return http.build(); |
||||||
|
} |
||||||
|
|
||||||
|
private AuthenticationEntryPoint authenticationEntryPoint() { |
||||||
|
AuthenticationEntryPoint authenticationEntryPoint = |
||||||
|
new LoginUrlAuthenticationEntryPoint("/oauth2/authorization/messaging-client-oidc"); |
||||||
|
MediaTypeRequestMatcher textHtmlMatcher = |
||||||
|
new MediaTypeRequestMatcher(MediaType.TEXT_HTML); |
||||||
|
textHtmlMatcher.setUseEquals(true); |
||||||
|
|
||||||
|
LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<>(); |
||||||
|
entryPoints.put(textHtmlMatcher, authenticationEntryPoint); |
||||||
|
|
||||||
|
DelegatingAuthenticationEntryPoint delegatingAuthenticationEntryPoint = new DelegatingAuthenticationEntryPoint(entryPoints); |
||||||
|
delegatingAuthenticationEntryPoint.setDefaultEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); |
||||||
|
return delegatingAuthenticationEntryPoint; |
||||||
|
} |
||||||
|
|
||||||
|
private LogoutHandler logoutHandler(CsrfTokenRepository csrfTokenRepository) { |
||||||
|
return new CompositeLogoutHandler( |
||||||
|
new SecurityContextLogoutHandler(), |
||||||
|
new CsrfLogoutHandler(csrfTokenRepository)); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2020-2024 the original author or authors. |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
package sample.web; |
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value; |
||||||
|
import org.springframework.stereotype.Controller; |
||||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||||
|
|
||||||
|
/** |
||||||
|
* @author Joe Grandja |
||||||
|
* @since 1.4 |
||||||
|
*/ |
||||||
|
@Controller |
||||||
|
public class DefaultController { |
||||||
|
|
||||||
|
@Value("${app.base-uri}") |
||||||
|
private String appBaseUri; |
||||||
|
|
||||||
|
@GetMapping("/") |
||||||
|
public String root() { |
||||||
|
return "redirect:" + this.appBaseUri; |
||||||
|
} |
||||||
|
|
||||||
|
// '/authorized' is the registered 'redirect_uri' for authorization_code
|
||||||
|
@GetMapping("/authorized") |
||||||
|
public String authorized() { |
||||||
|
return "redirect:" + this.appBaseUri; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
org.springframework.cloud.gateway.server.mvc.filter.FilterSupplier=\ |
||||||
|
sample.config.GatewayFilterFunctions.FilterSupplier |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
server: |
||||||
|
port: 8080 |
||||||
|
|
||||||
|
logging: |
||||||
|
level: |
||||||
|
root: INFO |
||||||
|
org.springframework.web: INFO |
||||||
|
org.springframework.security: INFO |
||||||
|
org.springframework.security.oauth2: INFO |
||||||
|
|
||||||
|
spring: |
||||||
|
security: |
||||||
|
oauth2: |
||||||
|
client: |
||||||
|
registration: |
||||||
|
messaging-client-oidc: |
||||||
|
provider: spring |
||||||
|
client-id: messaging-client |
||||||
|
client-secret: secret |
||||||
|
authorization-grant-type: authorization_code |
||||||
|
redirect-uri: "http://127.0.0.1:8080/login/oauth2/code/{registrationId}" |
||||||
|
scope: openid,profile |
||||||
|
client-name: messaging-client-oidc |
||||||
|
messaging-client-authorization-code: |
||||||
|
provider: spring |
||||||
|
client-id: messaging-client |
||||||
|
client-secret: secret |
||||||
|
authorization-grant-type: authorization_code |
||||||
|
redirect-uri: "http://127.0.0.1:8080/authorized" |
||||||
|
scope: message.read,message.write |
||||||
|
client-name: messaging-client-authorization-code |
||||||
|
provider: |
||||||
|
spring: |
||||||
|
issuer-uri: http://localhost:9000 |
||||||
|
cloud: |
||||||
|
gateway: |
||||||
|
mvc: |
||||||
|
routes: |
||||||
|
- id: userinfo |
||||||
|
uri: http://localhost:9000 |
||||||
|
predicates: |
||||||
|
- Path=/userinfo |
||||||
|
filters: |
||||||
|
- TokenRelay= |
||||||
|
- id: messages |
||||||
|
uri: http://localhost:8090 |
||||||
|
predicates: |
||||||
|
- Path=/messages |
||||||
|
filters: |
||||||
|
- RelayTokenIfExists=messaging-client-authorization-code |
||||||
|
|
||||||
|
app: |
||||||
|
base-uri: http://127.0.0.1:4200 |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
# Editor configuration, see https://editorconfig.org |
||||||
|
root = true |
||||||
|
|
||||||
|
[*] |
||||||
|
charset = utf-8 |
||||||
|
indent_style = space |
||||||
|
indent_size = 2 |
||||||
|
insert_final_newline = true |
||||||
|
trim_trailing_whitespace = true |
||||||
|
|
||||||
|
[*.ts] |
||||||
|
quote_type = single |
||||||
|
ij_typescript_use_double_quotes = false |
||||||
|
|
||||||
|
[*.md] |
||||||
|
max_line_length = off |
||||||
|
trim_trailing_whitespace = false |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. |
||||||
|
|
||||||
|
# Compiled output |
||||||
|
/dist |
||||||
|
/tmp |
||||||
|
/out-tsc |
||||||
|
/bazel-out |
||||||
|
|
||||||
|
# Node |
||||||
|
/node_modules |
||||||
|
npm-debug.log |
||||||
|
yarn-error.log |
||||||
|
|
||||||
|
# IDEs and editors |
||||||
|
.idea/ |
||||||
|
.project |
||||||
|
.classpath |
||||||
|
.c9/ |
||||||
|
*.launch |
||||||
|
.settings/ |
||||||
|
*.sublime-workspace |
||||||
|
|
||||||
|
# Visual Studio Code |
||||||
|
.vscode/* |
||||||
|
!.vscode/settings.json |
||||||
|
!.vscode/tasks.json |
||||||
|
!.vscode/launch.json |
||||||
|
!.vscode/extensions.json |
||||||
|
.history/* |
||||||
|
|
||||||
|
# Miscellaneous |
||||||
|
/.angular/cache |
||||||
|
.sass-cache/ |
||||||
|
/connect.lock |
||||||
|
/coverage |
||||||
|
/libpeerconnection.log |
||||||
|
testem.log |
||||||
|
/typings |
||||||
|
|
||||||
|
# System files |
||||||
|
.DS_Store |
||||||
|
Thumbs.db |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
# SpaClient |
||||||
|
|
||||||
|
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.11. |
||||||
|
|
||||||
|
## Development server |
||||||
|
|
||||||
|
Run `ng serve` for a dev server. Navigate to `http://127.0.0.1:4200/`. The application will automatically reload if you change any of the source files. |
||||||
|
|
||||||
|
## Code scaffolding |
||||||
|
|
||||||
|
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. |
||||||
|
|
||||||
|
## Build |
||||||
|
|
||||||
|
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. |
||||||
|
|
||||||
|
## Running unit tests |
||||||
|
|
||||||
|
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). |
||||||
|
|
||||||
|
## Running end-to-end tests |
||||||
|
|
||||||
|
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. |
||||||
|
|
||||||
|
## Further help |
||||||
|
|
||||||
|
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. |
||||||
@ -0,0 +1,103 @@ |
|||||||
|
{ |
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", |
||||||
|
"version": 1, |
||||||
|
"newProjectRoot": "projects", |
||||||
|
"projects": { |
||||||
|
"spa-client": { |
||||||
|
"projectType": "application", |
||||||
|
"schematics": { |
||||||
|
"@schematics/angular:component": { |
||||||
|
"style": "scss" |
||||||
|
} |
||||||
|
}, |
||||||
|
"root": "", |
||||||
|
"sourceRoot": "src", |
||||||
|
"prefix": "app", |
||||||
|
"architect": { |
||||||
|
"build": { |
||||||
|
"builder": "@angular-devkit/build-angular:application", |
||||||
|
"options": { |
||||||
|
"outputPath": "dist/spa-client", |
||||||
|
"index": "src/index.html", |
||||||
|
"browser": "src/main.ts", |
||||||
|
"polyfills": [ |
||||||
|
"zone.js" |
||||||
|
], |
||||||
|
"tsConfig": "tsconfig.app.json", |
||||||
|
"inlineStyleLanguage": "scss", |
||||||
|
"assets": [ |
||||||
|
{ |
||||||
|
"glob": "**/*", |
||||||
|
"input": "public" |
||||||
|
} |
||||||
|
], |
||||||
|
"styles": [ |
||||||
|
"src/styles.scss" |
||||||
|
], |
||||||
|
"scripts": [] |
||||||
|
}, |
||||||
|
"configurations": { |
||||||
|
"production": { |
||||||
|
"budgets": [ |
||||||
|
{ |
||||||
|
"type": "initial", |
||||||
|
"maximumWarning": "500kB", |
||||||
|
"maximumError": "1MB" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"type": "anyComponentStyle", |
||||||
|
"maximumWarning": "2kB", |
||||||
|
"maximumError": "4kB" |
||||||
|
} |
||||||
|
], |
||||||
|
"outputHashing": "all" |
||||||
|
}, |
||||||
|
"development": { |
||||||
|
"optimization": false, |
||||||
|
"extractLicenses": false, |
||||||
|
"sourceMap": true |
||||||
|
} |
||||||
|
}, |
||||||
|
"defaultConfiguration": "production" |
||||||
|
}, |
||||||
|
"serve": { |
||||||
|
"builder": "@angular-devkit/build-angular:dev-server", |
||||||
|
"configurations": { |
||||||
|
"production": { |
||||||
|
"buildTarget": "spa-client:build:production" |
||||||
|
}, |
||||||
|
"development": { |
||||||
|
"buildTarget": "spa-client:build:development", |
||||||
|
"host": "127.0.0.1" |
||||||
|
} |
||||||
|
}, |
||||||
|
"defaultConfiguration": "development" |
||||||
|
}, |
||||||
|
"extract-i18n": { |
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n" |
||||||
|
}, |
||||||
|
"test": { |
||||||
|
"builder": "@angular-devkit/build-angular:karma", |
||||||
|
"options": { |
||||||
|
"polyfills": [ |
||||||
|
"zone.js", |
||||||
|
"zone.js/testing" |
||||||
|
], |
||||||
|
"tsConfig": "tsconfig.spec.json", |
||||||
|
"inlineStyleLanguage": "scss", |
||||||
|
"assets": [ |
||||||
|
{ |
||||||
|
"glob": "**/*", |
||||||
|
"input": "public" |
||||||
|
} |
||||||
|
], |
||||||
|
"styles": [ |
||||||
|
"src/styles.scss" |
||||||
|
], |
||||||
|
"scripts": [] |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,38 @@ |
|||||||
|
{ |
||||||
|
"name": "spa-client", |
||||||
|
"version": "0.0.0", |
||||||
|
"scripts": { |
||||||
|
"ng": "ng", |
||||||
|
"start": "ng serve", |
||||||
|
"build": "ng build", |
||||||
|
"watch": "ng build --watch --configuration development", |
||||||
|
"test": "ng test" |
||||||
|
}, |
||||||
|
"private": true, |
||||||
|
"dependencies": { |
||||||
|
"@angular/animations": "^18.2.0", |
||||||
|
"@angular/common": "^18.2.0", |
||||||
|
"@angular/compiler": "^18.2.0", |
||||||
|
"@angular/core": "^18.2.0", |
||||||
|
"@angular/forms": "^18.2.0", |
||||||
|
"@angular/platform-browser": "^18.2.0", |
||||||
|
"@angular/platform-browser-dynamic": "^18.2.0", |
||||||
|
"@angular/router": "^18.2.0", |
||||||
|
"rxjs": "~7.8.0", |
||||||
|
"tslib": "^2.3.0", |
||||||
|
"zone.js": "~0.14.10" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@angular-devkit/build-angular": "^18.2.11", |
||||||
|
"@angular/cli": "^18.2.11", |
||||||
|
"@angular/compiler-cli": "^18.2.0", |
||||||
|
"@types/jasmine": "~5.1.0", |
||||||
|
"jasmine-core": "~5.2.0", |
||||||
|
"karma": "~6.4.0", |
||||||
|
"karma-chrome-launcher": "~3.2.0", |
||||||
|
"karma-coverage": "~2.2.0", |
||||||
|
"karma-jasmine": "~5.1.0", |
||||||
|
"karma-jasmine-html-reporter": "~2.1.0", |
||||||
|
"typescript": "~5.5.2" |
||||||
|
} |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 628 B |
@ -0,0 +1,58 @@ |
|||||||
|
<div> |
||||||
|
<nav class="navbar navbar-expand-lg bg-light"> |
||||||
|
<div class="container-fluid"> |
||||||
|
<a class="navbar-brand" href="#"> |
||||||
|
<img src="/spring-security.svg" width="40" height="32"> |
||||||
|
</a> |
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> |
||||||
|
<span class="navbar-toggler-icon"></span> |
||||||
|
</button> |
||||||
|
<div class="collapse navbar-collapse" id="navbarSupportedContent"> |
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> |
||||||
|
<li class="nav-item"> |
||||||
|
<a class="nav-link active" aria-current="page" href="/">Home</a> |
||||||
|
</li> |
||||||
|
<li *ngIf="isAuthenticated" class="nav-item dropdown"> |
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">Authorize</a> |
||||||
|
<ul class="dropdown-menu"> |
||||||
|
<li><a class="dropdown-item" href="#" (click)="authorizeMessages()">Messages</a></li> |
||||||
|
</ul> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
<div class="d-flex"> |
||||||
|
@if (isAuthenticated) { |
||||||
|
<div> |
||||||
|
<span class="fs-6 px-3">{{ userName }}</span> |
||||||
|
<button class="btn btn-outline-dark" (click)="logout()">Logout</button> |
||||||
|
</div> |
||||||
|
} @else { |
||||||
|
<div> |
||||||
|
<button class="btn btn-outline-dark" (click)="login()">Login</button> |
||||||
|
</div> |
||||||
|
} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</nav> |
||||||
|
</div> |
||||||
|
<div class="container"> |
||||||
|
<div *ngIf="messages.length > 0" class="row py-5 justify-content-start"> |
||||||
|
<div class="col"> |
||||||
|
<table class="table table-striped caption-top"> |
||||||
|
<caption>Messages</caption> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th scope="col">#</th> |
||||||
|
<th scope="col">Message</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
<tr *ngFor="let message of messages; let index = index"> |
||||||
|
<th scope="row">{{ index + 1 }}</th> |
||||||
|
<td>{{ message }}</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,75 @@ |
|||||||
|
import {Component, OnInit} from '@angular/core'; |
||||||
|
import {NgIf, NgForOf} from '@angular/common'; |
||||||
|
import {HttpClient} from '@angular/common/http'; |
||||||
|
import {catchError, of} from 'rxjs'; |
||||||
|
import {environment} from "./environment"; |
||||||
|
|
||||||
|
@Component({ |
||||||
|
selector: 'app-root', |
||||||
|
standalone: true, |
||||||
|
imports: [NgIf, NgForOf], |
||||||
|
templateUrl: './app.component.html', |
||||||
|
styleUrl: './app.component.scss' |
||||||
|
}) |
||||||
|
export class AppComponent implements OnInit { |
||||||
|
isAuthenticated: boolean = false; |
||||||
|
userName: string = ''; |
||||||
|
messages: string[] = []; |
||||||
|
|
||||||
|
constructor(private http: HttpClient) { |
||||||
|
} |
||||||
|
|
||||||
|
ngOnInit(): void { |
||||||
|
this.getUserInfo(); |
||||||
|
this.getMessages(); |
||||||
|
} |
||||||
|
|
||||||
|
login(): void { |
||||||
|
// The Backend is configured to trigger login when unauthenticated
|
||||||
|
window.location.href = environment.backendBaseUrl; |
||||||
|
} |
||||||
|
|
||||||
|
logout(): void { |
||||||
|
this.http.post('/logout', null) |
||||||
|
.pipe(catchError((error) => { |
||||||
|
console.error(error); |
||||||
|
return of(null); |
||||||
|
})) |
||||||
|
.subscribe(() => { |
||||||
|
this.isAuthenticated = false; |
||||||
|
this.userName = ''; |
||||||
|
this.messages = []; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
getUserInfo(): void { |
||||||
|
this.http.get<any>('/userinfo') |
||||||
|
.pipe(catchError((error) => { |
||||||
|
console.error(error); |
||||||
|
return of(null); |
||||||
|
})) |
||||||
|
.subscribe((userInfo) => { |
||||||
|
if (userInfo) { |
||||||
|
this.isAuthenticated = true; |
||||||
|
this.userName = userInfo.sub; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
authorizeMessages(): void { |
||||||
|
// Trigger the Backend to perform the authorization_code grant flow.
|
||||||
|
// After authorization is complete, the Backend will redirect back to this app.
|
||||||
|
window.location.href = environment.backendBaseUrl + "/oauth2/authorization/messaging-client-authorization-code"; |
||||||
|
} |
||||||
|
|
||||||
|
getMessages(): void { |
||||||
|
this.http.get<string[]>('/messages') |
||||||
|
.pipe(catchError((error) => { |
||||||
|
console.error(error); |
||||||
|
return of([]); |
||||||
|
})) |
||||||
|
.subscribe((messages) => { |
||||||
|
this.messages = messages; |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; |
||||||
|
import { provideRouter } from '@angular/router'; |
||||||
|
import {provideHttpClient, withInterceptors} from '@angular/common/http'; |
||||||
|
|
||||||
|
import { routes } from './app.routes'; |
||||||
|
import { withCredentialsInterceptor } from './http.interceptors'; |
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = { |
||||||
|
providers: [ |
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }), |
||||||
|
provideRouter(routes), |
||||||
|
provideHttpClient(withInterceptors([withCredentialsInterceptor])) |
||||||
|
] |
||||||
|
}; |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
import { Routes } from '@angular/router'; |
||||||
|
|
||||||
|
export const routes: Routes = []; |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
export const environment = { |
||||||
|
backendBaseUrl: 'http://127.0.0.1:8080', |
||||||
|
}; |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
import {HttpRequest, HttpHandlerFn, HttpEvent} from '@angular/common/http'; |
||||||
|
import {Observable} from 'rxjs'; |
||||||
|
import {environment} from "./environment"; |
||||||
|
|
||||||
|
/* |
||||||
|
IMPORTANT: |
||||||
|
|
||||||
|
By default, the HttpClient passes the CSRF token via the X-XSRF-TOKEN header using its built-in interceptor. |
||||||
|
However, this DOES NOT WORK when absolute URLs are used in HttpClient calls. |
||||||
|
Hence, the reason for this interceptor, as it prepends the Backend base URL to the relative URL. |
||||||
|
Ensure you only use relative URLs in HttpClient calls for mutating requests (e.g. POST), |
||||||
|
otherwise operations such as /logout will not work. |
||||||
|
|
||||||
|
See the reference for further information: |
||||||
|
https://angular.dev/best-practices/security#httpclient-xsrf-csrf-security
|
||||||
|
*/ |
||||||
|
|
||||||
|
export function withCredentialsInterceptor(request: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> { |
||||||
|
request = request.clone({ |
||||||
|
url: environment.backendBaseUrl + request.url, |
||||||
|
withCredentials: true // This is required to ensure the Session Cookie is passed in every request to the Backend
|
||||||
|
}); |
||||||
|
return next(request); |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="utf-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||||
|
<title>SPA with Backend and Spring Cloud Gateway</title> |
||||||
|
<base href="/"> |
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous"> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<app-root></app-root> |
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js" integrity="sha384-oBqDVmMz9ATKxIep9tiCxS/Z9fNfEXiDAYTujMAeBAsjFuCZSmKbSSUnQlmh/jp3" crossorigin="anonymous"></script> |
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser'; |
||||||
|
import { appConfig } from './app/app.config'; |
||||||
|
import { AppComponent } from './app/app.component'; |
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, appConfig) |
||||||
|
.catch((err) => console.error(err)); |
||||||
@ -0,0 +1 @@ |
|||||||
|
/* You can add global styles to this file, and also import other style files */ |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ |
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ |
||||||
|
{ |
||||||
|
"extends": "./tsconfig.json", |
||||||
|
"compilerOptions": { |
||||||
|
"outDir": "./out-tsc/app", |
||||||
|
"types": [] |
||||||
|
}, |
||||||
|
"files": [ |
||||||
|
"src/main.ts" |
||||||
|
], |
||||||
|
"include": [ |
||||||
|
"src/**/*.d.ts" |
||||||
|
] |
||||||
|
} |
||||||
@ -0,0 +1,33 @@ |
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ |
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ |
||||||
|
{ |
||||||
|
"compileOnSave": false, |
||||||
|
"compilerOptions": { |
||||||
|
"outDir": "./dist/out-tsc", |
||||||
|
"strict": true, |
||||||
|
"noImplicitOverride": true, |
||||||
|
"noPropertyAccessFromIndexSignature": true, |
||||||
|
"noImplicitReturns": true, |
||||||
|
"noFallthroughCasesInSwitch": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"isolatedModules": true, |
||||||
|
"esModuleInterop": true, |
||||||
|
"sourceMap": true, |
||||||
|
"declaration": false, |
||||||
|
"experimentalDecorators": true, |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"importHelpers": true, |
||||||
|
"target": "ES2022", |
||||||
|
"module": "ES2022", |
||||||
|
"lib": [ |
||||||
|
"ES2022", |
||||||
|
"dom" |
||||||
|
] |
||||||
|
}, |
||||||
|
"angularCompilerOptions": { |
||||||
|
"enableI18nLegacyMessageIdFormat": false, |
||||||
|
"strictInjectionParameters": true, |
||||||
|
"strictInputAccessModifiers": true, |
||||||
|
"strictTemplates": true |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ |
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ |
||||||
|
{ |
||||||
|
"extends": "./tsconfig.json", |
||||||
|
"compilerOptions": { |
||||||
|
"outDir": "./out-tsc/spec", |
||||||
|
"types": [ |
||||||
|
"jasmine" |
||||||
|
] |
||||||
|
}, |
||||||
|
"include": [ |
||||||
|
"src/**/*.spec.ts", |
||||||
|
"src/**/*.d.ts" |
||||||
|
] |
||||||
|
} |
||||||
Loading…
Reference in new issue