Added OAuth2 API Tester tool and improved CORS/OAuth support:
- Introduced `OAuth2 API Tester` (HTML+JS) in `/tools`. - Updated security configuration: - Added comprehensive CORS configurations for OAuth endpoints. - Enhanced CSRF handling to exclude `/oauth2/token`. - Adjusted role handling to ensure case-insensitivity. - Fixed `RIGHT-MANAGEMENT` role in `UserController`. - Replaced logo asset in frontend.
This commit is contained in:
parent
68b688673c
commit
7ff657ba0a
8 changed files with 662 additions and 24 deletions
|
|
@ -1,18 +1,17 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="300px" height="114px" viewBox="0 0 300 114" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg id="Ebene_1_Kopie" data-name="Ebene 1 Kopie" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 297.43 345.13">
|
||||||
<title>KION_Group_logo</title>
|
<defs>
|
||||||
<g id="KION_Group_logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<style>
|
||||||
<g id="KION_Group_WEB_RGB_3color_positive_illu4_v_v_2-Copy-2" fill-rule="nonzero">
|
.cls-1 {
|
||||||
<path d="M75.3865,107.8552 C75.3865,112.168 77.8124,113.4189 81.8852,113.4189 L87.0952,113.4189 C92.1872,113.4189 94.3852,112.8882 94.3852,108.7502 L94.3852,104.3489 L84.6982,104.3489 L84.6982,107.4045 L89.866,107.4045 L89.866,108.4932 C89.866,109.9126 88.6804,109.9863 87.5032,109.9863 L82.9,109.9863 C80.6193,109.9863 80.0318,109.5504 80.0318,107.0191 L80.0318,104.6206 C80.0318,102.0977 80.6193,101.6597 82.9,101.6597 L93.5348,101.6597 L93.5348,98.2271 L81.8848,98.2271 C77.812,98.2271 75.3861,99.4801 75.3861,103.7908 L75.3865,107.8552 Z" id="Path" fill="#AE0055"></path>
|
fill: #5af0b4;
|
||||||
<path d="M121.2961,101.7987 L121.2961,105.2692 L128.9993,105.2692 C130.2228,105.2692 130.6672,104.8922 130.6672,103.8351 L130.6672,103.2307 C130.6672,101.9335 130.0186,101.7987 128.5908,101.7987 L121.2961,101.7987 Z M129.987,113.1051 L127.0619,108.7059 L121.2961,108.7059 L121.2961,113.1051 L116.7748,113.1051 L116.7748,98.36 L130.6461,98.36 C134.1334,98.36 135.0684,100.5143 135.0684,103.3383 C135.0684,105.5242 134.4072,107.5983 132.1202,108.1965 L135.875,113.1053 L129.987,113.1051 Z" id="Shape" fill="#AE0055"></path>
|
}
|
||||||
<path d="M157.0013,107.7528 C157.0013,112.072 159.4272,113.4176 163.4979,113.4176 L170.8642,113.4176 C174.9412,113.4176 177.365,112.072 177.365,107.7528 L177.365,103.718 C177.365,99.3989 174.9412,98.0469 170.8642,98.0469 L163.4979,98.0469 C159.4272,98.0469 157.0013,99.3989 157.0013,103.718 L157.0013,107.7528 Z M161.6488,104.5308 C161.6488,102.6966 162.0933,101.7722 164.4789,101.7722 L169.8889,101.7722 C172.2706,101.7722 172.7213,102.6966 172.7213,104.5308 L172.7213,106.94 C172.7213,109.0774 172.2706,109.6986 170.1313,109.6986 L164.2306,109.6986 C162.0932,109.6986 161.6488,109.0774 161.6488,106.94 L161.6488,104.5308 Z" id="Shape" fill="#AE0055"></path>
|
|
||||||
<path d="M200.5083,107.7737 C200.5083,111.44 202.3994,113.4174 206.5563,113.4174 L213.1563,113.4174 C217.3133,113.4174 219.2107,111.44 219.2107,107.7737 L219.2107,98.51 L214.742,98.51 L214.742,107.2493 C214.742,109.1256 214.0703,109.7448 212.2213,109.7448 L207.7337,109.7448 C205.8826,109.7448 205.2109,109.1256 205.2109,107.2493 L205.2109,98.51 L200.5085,98.51 L200.5083,107.7737 Z" id="Path" fill="#AE0055"></path>
|
.cls-2 {
|
||||||
<path d="M241.6548,113.1051 L246.1761,113.1051 L246.1761,109.3988 L255.1576,109.3988 C258.3585,109.3988 259.3125,106.6488 259.3125,104.45 L259.3125,103.635 C259.3125,100.75 258.2701,98.3598 253.9805,98.3598 L241.6548,98.3598 L241.6548,113.1051 Z M246.1761,101.7987 L252.8138,101.7987 C254.1805,101.7987 254.6712,102.2557 254.6712,103.2581 L254.6712,104.5089 C254.724827,104.910633 254.587236,105.314297 254.299414,105.599645 C254.011592,105.884993 253.606755,106.019094 253.2055,105.962 L246.1761,105.962 L246.1761,101.7987 Z" id="Shape" fill="#AE0055"></path>
|
fill: #002f54;
|
||||||
<rect id="Rectangle" fill="#000000" x="258.2133" y="0" width="41.7867" height="14.3262"></rect>
|
}
|
||||||
<rect id="Rectangle" fill="#000000" x="75.8114" y="28.5282" width="16.0678" height="50.9157"></rect>
|
</style>
|
||||||
<polygon id="Path" fill="#000000" points="39.108 28.532 18.919 47.23 15.845 47.23 15.845 28.532 0 28.532 0 79.448 15.845 79.448 15.845 59.899 19.079 59.899 40.369 79.448 64.949 79.448 33.713 51.975 60.805 28.532"></polygon>
|
</defs>
|
||||||
<polygon id="Path" fill="#000000" points="242.537 28.534 242.537 63.443 214.726 28.534 192.118 28.534 192.118 79.449 207.809 79.449 207.809 44.536 235.617 79.449 258.215 79.449 258.215 28.534"></polygon>
|
<polygon class="cls-1" points="201.84 201.83 201.84 257.02 201.85 257.02 249.64 229.43 249.64 229.42 297.43 201.83 297.43 257.02 249.65 284.61 249.64 284.61 201.84 312.21 154.05 339.8 154.05 174.24 201.84 146.64 249.64 119.05 297.43 91.46 297.43 146.64 249.64 174.23 249.64 174.24 201.84 201.83"/>
|
||||||
<path d="M123.038,49.8789 C123.038,43.5445 124.588,40.3667 132.8114,40.3667 L151.5514,40.3667 C159.7853,40.3667 161.3247,43.5445 161.3247,49.8789 L161.3247,58.09 C161.3247,65.4774 159.7853,67.6022 152.3979,67.6022 L131.9774,67.6022 C124.588,67.6022 123.038,65.4774 123.038,58.09 L123.038,49.8789 Z M107.008,60.8905 C107.008,75.8127 115.3746,80.4498 129.4355,80.4498 L154.927,80.4498 C168.9984,80.4498 177.3629,75.8127 177.3629,60.8905 L177.3629,47.0634 C177.3629,32.156 168.9984,27.5167 154.927,27.5167 L129.4357,27.5167 C115.3748,27.5167 107.008,32.156 107.008,47.0634 L107.008,60.8905 Z" id="Shape" fill="#000000"></path>
|
<polygon class="cls-2" points="289.44 82.78 289.44 82.79 241.65 110.38 193.85 137.97 146.06 165.57 98.27 137.97 50.47 110.38 2.68 82.79 2.68 82.78 50.47 55.19 98.26 27.6 98.27 27.6 98.27 27.59 146.06 0 193.85 27.59 193.85 27.6 146.06 55.19 98.27 82.78 98.27 82.79 146.06 110.38 193.85 82.79 193.86 82.79 241.65 55.19 241.66 55.19 289.44 82.78"/>
|
||||||
</g>
|
<polygon class="cls-2" points="143.38 289.94 143.38 345.13 95.59 317.54 47.79 289.94 0 262.35 0 96.79 47.79 124.38 47.79 234.76 95.58 262.35 95.59 262.35 143.38 289.94"/>
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<header>
|
||||||
<img alt="LCC logo" class="logo" src="../../../assets/logo.svg" width="125" height="125" />
|
<img alt="LCC logo" class="logo" src="../../../assets/logo.svg" width="75" height="75" />
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-if="showCalculation"><navigation-element class="navigationbox" to="/calculations">My calculations</navigation-element></li>
|
<li v-if="showCalculation"><navigation-element class="navigationbox" to="/calculations">My calculations</navigation-element></li>
|
||||||
|
|
@ -45,6 +45,11 @@ header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin: 3.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import java.util.Arrays;
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebMvc
|
@EnableWebMvc
|
||||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
@Profile("dev | test")
|
|
||||||
public class CorsConfig implements WebMvcConfigurer {
|
public class CorsConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
private final Environment environment;
|
private final Environment environment;
|
||||||
|
|
@ -47,6 +46,13 @@ public class CorsConfig implements WebMvcConfigurer {
|
||||||
.allowedHeaders("*")
|
.allowedHeaders("*")
|
||||||
.exposedHeaders("X-Total-Count", "X-Page-Count", "X-Current-Page")
|
.exposedHeaders("X-Total-Count", "X-Page-Count", "X-Current-Page")
|
||||||
.allowCredentials(true);
|
.allowCredentials(true);
|
||||||
|
|
||||||
|
// OAuth endpoints
|
||||||
|
registry.addMapping("/oauth/**")
|
||||||
|
.allowedOriginPatterns("http://localhost:*")
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.allowCredentials(true);
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
System.out.println("Applying PROD CORS configuration");
|
System.out.println("Applying PROD CORS configuration");
|
||||||
|
|
@ -58,6 +64,13 @@ public class CorsConfig implements WebMvcConfigurer {
|
||||||
.allowedHeaders("*")
|
.allowedHeaders("*")
|
||||||
.exposedHeaders("X-Total-Count", "X-Page-Count", "X-Current-Page")
|
.exposedHeaders("X-Total-Count", "X-Page-Count", "X-Current-Page")
|
||||||
.allowCredentials(true);
|
.allowCredentials(true);
|
||||||
|
|
||||||
|
// OAuth endpoints
|
||||||
|
registry.addMapping("/oauth/**")
|
||||||
|
.allowedOriginPatterns(allowedCors)
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.allowCredentials(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
|
@ -36,10 +37,15 @@ import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||||
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
|
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
|
||||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
|
@ -48,11 +54,16 @@ import java.util.function.Supplier;
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Value("${lcc.allowed_cors}")
|
||||||
|
private String allowedCors;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@Profile("!dev & !test") // Only active when NOT in dev profile
|
@Profile("!dev & !test") // Only active when NOT in dev profile
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtTokenService jwtTokenService) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtTokenService jwtTokenService) throws Exception {
|
||||||
http
|
http
|
||||||
|
.cors(cors -> cors.configurationSource(prodCorsConfigurationSource())) // Production CORS
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/oauth2/token").permitAll()
|
.requestMatchers("/oauth2/token").permitAll()
|
||||||
.requestMatchers("/api/**").authenticated()
|
.requestMatchers("/api/**").authenticated()
|
||||||
.requestMatchers("/api/dev/**").denyAll()
|
.requestMatchers("/api/dev/**").denyAll()
|
||||||
|
|
@ -66,7 +77,6 @@ public class SecurityConfig {
|
||||||
.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
.exceptionHandling(ex -> ex
|
.exceptionHandling(ex -> ex
|
||||||
.defaultAuthenticationEntryPointFor(
|
.defaultAuthenticationEntryPointFor(
|
||||||
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
|
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
|
||||||
|
|
@ -74,6 +84,7 @@ public class SecurityConfig {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf
|
||||||
|
.ignoringRequestMatchers("/oauth2/token") // CSRF für OAuth deaktivieren
|
||||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||||
.csrfTokenRequestHandler(new LccCsrfTokenRequestHandler())
|
.csrfTokenRequestHandler(new LccCsrfTokenRequestHandler())
|
||||||
.ignoringRequestMatchers("/login/oauth2/code/**")
|
.ignoringRequestMatchers("/login/oauth2/code/**")
|
||||||
|
|
@ -87,6 +98,45 @@ public class SecurityConfig {
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Profile("dev | test")
|
||||||
|
public CorsConfigurationSource devCorsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
configuration.setAllowedOriginPatterns(List.of("http://localhost:*"));
|
||||||
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production CORS Configuration
|
||||||
|
@Bean
|
||||||
|
@Profile("!dev & !test")
|
||||||
|
public CorsConfigurationSource prodCorsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
|
||||||
|
// Parse comma-separated origins from property
|
||||||
|
String[] origins = allowedCors.split(",");
|
||||||
|
for (int i = 0; i < origins.length; i++) {
|
||||||
|
origins[i] = origins[i].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
configuration.setAllowedOrigins(Arrays.asList(origins));
|
||||||
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||||
// Für Entra ID Tokens
|
// Für Entra ID Tokens
|
||||||
|
|
@ -106,18 +156,27 @@ public class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@Profile("dev | test")
|
@Profile("dev | test")
|
||||||
public SecurityFilterChain devSecurityFilterChain(HttpSecurity http, UserRepository userRepository) throws Exception {
|
public SecurityFilterChain devSecurityFilterChain(HttpSecurity http, UserRepository userRepository, JwtTokenService jwtTokenService) throws Exception {
|
||||||
return http
|
return http
|
||||||
|
.cors(cors -> cors.configurationSource(devCorsConfigurationSource())) // Dev CORS
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
|
.requestMatchers("/oauth2/token").permitAll()
|
||||||
.requestMatchers("/api/**").permitAll()
|
.requestMatchers("/api/**").permitAll()
|
||||||
.requestMatchers("/api/dev/**").permitAll()
|
.requestMatchers("/api/dev/**").permitAll()
|
||||||
.anyRequest().permitAll())
|
.anyRequest().permitAll())
|
||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf
|
||||||
|
.ignoringRequestMatchers("/oauth2/token") // CSRF für OAuth deaktivieren
|
||||||
|
.ignoringRequestMatchers("/api/**") // Optional: für alle API-Endpunkte
|
||||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||||
.csrfTokenRequestHandler(new LccCsrfTokenRequestHandler())
|
.csrfTokenRequestHandler(new LccCsrfTokenRequestHandler())
|
||||||
)
|
)
|
||||||
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
|
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
|
||||||
.addFilterBefore(new DevUserEmulationFilter(userRepository), BasicAuthenticationFilter.class)
|
.addFilterBefore(new DevUserEmulationFilter(userRepository), BasicAuthenticationFilter.class)
|
||||||
|
.addFilterBefore(
|
||||||
|
new SelfIssuedJwtFilter(jwtTokenService),
|
||||||
|
BearerTokenAuthenticationFilter.class
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ public class SelfIssuedJwtFilter extends OncePerRequestFilter {
|
||||||
List<String> groups = claims.get("groups", List.class);
|
List<String> groups = claims.get("groups", List.class);
|
||||||
if (groups != null) {
|
if (groups != null) {
|
||||||
groups.forEach(role ->
|
groups.forEach(role ->
|
||||||
authorities.add(new SimpleGrantedAuthority("ROLE_" + role))
|
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ public class UserController {
|
||||||
* @return A ResponseEntity containing the list of users, along with pagination headers.
|
* @return A ResponseEntity containing the list of users, along with pagination headers.
|
||||||
*/
|
*/
|
||||||
@GetMapping({"/", ""})
|
@GetMapping({"/", ""})
|
||||||
@PreAuthorize("hasRole('RIGHT-MANAGMENT')")
|
@PreAuthorize("hasRole('RIGHT-MANAGEMENT')")
|
||||||
public ResponseEntity<List<UserDTO>> listUsers(
|
public ResponseEntity<List<UserDTO>> listUsers(
|
||||||
@RequestParam(defaultValue = "20") @Min(1) int limit,
|
@RequestParam(defaultValue = "20") @Min(1) int limit,
|
||||||
@RequestParam(defaultValue = "1") @Min(1) int page) {
|
@RequestParam(defaultValue = "1") @Min(1) int page) {
|
||||||
|
|
@ -59,8 +59,8 @@ public class UserController {
|
||||||
* @return A ResponseEntity indicating the operation was successful.
|
* @return A ResponseEntity indicating the operation was successful.
|
||||||
*/
|
*/
|
||||||
@PutMapping({"/", ""})
|
@PutMapping({"/", ""})
|
||||||
@PreAuthorize("hasRole('RIGHT-MANAGMENT')")
|
@PreAuthorize("hasRole('RIGHT-MANAGEMENT')")
|
||||||
public ResponseEntity<Void> updateUser(UserDTO user) {
|
public ResponseEntity<Void> updateUser(@RequestParam UserDTO user) {
|
||||||
userService.updateUser(user);
|
userService.updateUser(user);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
561
tools/oauth2-tester/index.html
Normal file
561
tools/oauth2-tester/index.html
Normal file
|
|
@ -0,0 +1,561 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OAuth2 API Tester</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 700px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: #5AF0B4;
|
||||||
|
color: #002F54;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
margin-top: 30px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #d32f2f;
|
||||||
|
background: #ffebee;
|
||||||
|
border-color: #ef9a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #388e3c;
|
||||||
|
background: #e8f5e9;
|
||||||
|
border-color: #81c784;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
background-color: #c3cfdf;
|
||||||
|
color: #002F54;
|
||||||
|
border-left: 4px solid #002F54;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section h3 {
|
||||||
|
color: #002F54;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section p {
|
||||||
|
color: #002F54;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #388e3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-summary {
|
||||||
|
background-color: #c3cfdf;
|
||||||
|
color: #002F54;
|
||||||
|
border-left: 4px solid #002F54;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: none;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-summary strong {
|
||||||
|
color: #002F54;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #002F54;
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-button:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>OAuth2 API Tester</h1>
|
||||||
|
<p class="subtitle">Teste deine OAuth2 Client Credentials API-Integration</p>
|
||||||
|
|
||||||
|
<div class="info-section">
|
||||||
|
<h3>Hinweis</h3>
|
||||||
|
<p>Diese Anwendung verwendet den OAuth2 Client Credentials Flow. Stelle sicher, dass deine API diesen Flow unterstützt und CORS für diese Domain aktiviert ist.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="testForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tokenUrl">Token URL (OAuth2 Endpoint)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="tokenUrl"
|
||||||
|
placeholder="https://deine-app.de/oauth/token"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="clientId">Client ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="clientId"
|
||||||
|
placeholder="deine-client-id"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="clientSecret">Client Secret</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="clientSecret"
|
||||||
|
placeholder="dein-client-secret"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="apiUrl">API Endpoint URL (zu testende Ressource)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="apiUrl"
|
||||||
|
placeholder="https://deine-app.de/api/resource"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="submitBtn">
|
||||||
|
API Zugriff Testen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="loading" id="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Authentifizierung und API-Aufruf wird durchgeführt...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result" id="result">
|
||||||
|
<div class="result-header">
|
||||||
|
<div>
|
||||||
|
<span class="result-title">Ergebnis</span>
|
||||||
|
<span class="status-badge" id="statusBadge"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="errorSummary" class="error-summary"></div>
|
||||||
|
<button id="detailsButton" class="details-button" style="display: none;"></button>
|
||||||
|
<div class="result-box" id="resultBox">
|
||||||
|
<pre id="resultContent"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
|
document.getElementById('testForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const tokenUrl = document.getElementById('tokenUrl').value;
|
||||||
|
const clientId = document.getElementById('clientId').value;
|
||||||
|
const clientSecret = document.getElementById('clientSecret').value;
|
||||||
|
const apiUrl = document.getElementById('apiUrl').value;
|
||||||
|
|
||||||
|
// UI Updates
|
||||||
|
document.getElementById('loading').classList.add('active');
|
||||||
|
document.getElementById('result').classList.remove('active');
|
||||||
|
document.getElementById('submitBtn').disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Schritt 1: OAuth2 Token abrufen
|
||||||
|
const tokenResponse = await getAccessToken(tokenUrl, clientId, clientSecret);
|
||||||
|
|
||||||
|
if (!tokenResponse.success) {
|
||||||
|
displayResult({
|
||||||
|
phase: 'token',
|
||||||
|
message: 'Fehler beim Token-Abruf',
|
||||||
|
error: tokenResponse.error,
|
||||||
|
details: tokenResponse.details
|
||||||
|
}, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken = tokenResponse.access_token;
|
||||||
|
|
||||||
|
// Schritt 2: API Endpoint aufrufen
|
||||||
|
const apiResponse = await callApi(apiUrl, accessToken);
|
||||||
|
|
||||||
|
if (!apiResponse.success) {
|
||||||
|
displayResult({
|
||||||
|
phase: 'api',
|
||||||
|
message: 'Fehler beim API-Aufruf',
|
||||||
|
error: apiResponse.error,
|
||||||
|
response: apiResponse
|
||||||
|
}, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erfolg: Zeige nur die API-Antwort
|
||||||
|
displayResult(apiResponse.body, true);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
displayResult({
|
||||||
|
phase: 'unknown',
|
||||||
|
message: 'Unerwarteter Fehler',
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
}, false);
|
||||||
|
} finally {
|
||||||
|
document.getElementById('loading').classList.remove('active');
|
||||||
|
document.getElementById('submitBtn').disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getAccessToken(tokenUrl, clientId, clientSecret) {
|
||||||
|
try {
|
||||||
|
let headers = {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Credentials im Body (Standard-Methode)
|
||||||
|
let body = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`;
|
||||||
|
|
||||||
|
const response = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
data = { error: 'Keine JSON-Antwort vom Server' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Token-Abruf fehlgeschlagen (${response.status}): ${JSON.stringify(data)}`,
|
||||||
|
phase: 'token',
|
||||||
|
details: {
|
||||||
|
url: tokenUrl,
|
||||||
|
responseStatus: response.status,
|
||||||
|
responseData: data
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
access_token: data.access_token,
|
||||||
|
token_type: data.token_type,
|
||||||
|
expires_in: data.expires_in
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Netzwerkfehler beim Token-Abruf: ${error.message}. Möglicherweise ein CORS-Problem.`,
|
||||||
|
phase: 'token'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callApi(apiUrl, token) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
let data;
|
||||||
|
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
data = await response.json();
|
||||||
|
} else {
|
||||||
|
data = await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
body: data,
|
||||||
|
success: response.ok,
|
||||||
|
phase: 'api'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `API-Aufruf fehlgeschlagen: ${error.message}`,
|
||||||
|
phase: 'api'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayResult(result, success) {
|
||||||
|
const resultDiv = document.getElementById('result');
|
||||||
|
const resultBox = document.getElementById('resultBox');
|
||||||
|
const resultContent = document.getElementById('resultContent');
|
||||||
|
const statusBadge = document.getElementById('statusBadge');
|
||||||
|
const errorSummary = document.getElementById('errorSummary');
|
||||||
|
const detailsButton = document.getElementById('detailsButton');
|
||||||
|
|
||||||
|
resultDiv.classList.add('active');
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Erfolg: Zeige nur die API-Antwort
|
||||||
|
resultBox.classList.remove('error');
|
||||||
|
resultBox.classList.add('success');
|
||||||
|
statusBadge.textContent = 'Erfolgreich';
|
||||||
|
statusBadge.className = 'status-badge status-success';
|
||||||
|
|
||||||
|
errorSummary.style.display = 'none';
|
||||||
|
detailsButton.style.display = 'none';
|
||||||
|
|
||||||
|
// Stelle sicher, dass resultBox und resultContent sichtbar sind
|
||||||
|
resultBox.style.display = 'block';
|
||||||
|
resultContent.style.display = 'block';
|
||||||
|
resultContent.textContent = JSON.stringify(result, null, 2);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Fehler: Zeige Zusammenfassung mit Details-Button
|
||||||
|
resultBox.classList.remove('success');
|
||||||
|
resultBox.classList.add('error');
|
||||||
|
|
||||||
|
// Bestimme die Phase
|
||||||
|
let phaseText = '';
|
||||||
|
if (result.phase === 'token') {
|
||||||
|
phaseText = 'Token-Abruf';
|
||||||
|
statusBadge.textContent = 'Fehler beim Token-Abruf';
|
||||||
|
} else if (result.phase === 'api') {
|
||||||
|
phaseText = 'API-Aufruf';
|
||||||
|
statusBadge.textContent = 'Fehler beim API-Aufruf';
|
||||||
|
} else {
|
||||||
|
phaseText = 'Unbekannt';
|
||||||
|
statusBadge.textContent = 'Fehler';
|
||||||
|
}
|
||||||
|
statusBadge.className = 'status-badge status-error';
|
||||||
|
|
||||||
|
// Zeige Fehler-Zusammenfassung
|
||||||
|
errorSummary.style.display = 'block';
|
||||||
|
errorSummary.innerHTML = `
|
||||||
|
<strong>Phase:</strong> ${phaseText}<br>
|
||||||
|
<strong>Fehler:</strong> ${result.message || 'Unbekannter Fehler'}<br>
|
||||||
|
<strong>Details:</strong> ${result.error || 'Keine Details verfügbar'}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Details-Button
|
||||||
|
resultBox.style.display = 'block';
|
||||||
|
detailsButton.style.display = 'block';
|
||||||
|
detailsButton.onclick = function() {
|
||||||
|
const isHidden = resultContent.style.display === 'none';
|
||||||
|
resultContent.style.display = isHidden ? 'block' : 'none';
|
||||||
|
detailsButton.textContent = isHidden ? 'Details verbergen' : 'Vollständige Details anzeigen';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verstecke Details initial
|
||||||
|
resultContent.style.display = 'none';
|
||||||
|
resultContent.textContent = JSON.stringify(result, null, 2);
|
||||||
|
detailsButton.textContent = 'Vollständige Details anzeigen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
tools/oauth2-tester/run.bat
Normal file
1
tools/oauth2-tester/run.bat
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
python -m http.server 8000
|
||||||
Loading…
Add table
Reference in a new issue