试验 React 内联上下文与原生表单登录流程

This commit is contained in:
2026-09-25 19:44:39 +00:00
parent 8c8b2ad352
commit dcf634d805
23 changed files with 2253 additions and 22 deletions
@@ -0,0 +1,44 @@
package top.ddupan.iam.login.preview;
import java.time.Duration;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.http.CacheControl;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration(proxyBeanMethods = false)
@ImportRuntimeHints(PreviewConfiguration.Resources.class)
class PreviewConfiguration implements WebMvcConfigurer {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> auth
.requestMatchers("/error", "/preview", "/preview/**", "/assets/**", "/actuator/health/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults())
.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")))
.build();
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/assets/**").addResourceLocations("classpath:/ui/assets/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic().immutable());
}
static class Resources implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.resources().registerPattern("ui/**");
}
}
}
@@ -0,0 +1,146 @@
package top.ddupan.iam.login.preview;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.databind.json.JsonMapper;
/** An isolated UI experiment. It never creates an authenticated SecurityContext. */
@RestController
class PreviewController {
private static final String STATE = PreviewController.class.getName() + ".state";
private static final String SLOT = "__IAM_PAGE_CONTEXT__";
private final boolean enabled;
private final String shell;
private final JsonMapper json = JsonMapper.builder().build();
PreviewController(@Value("${iam.ui-preview.enabled:false}") boolean enabled) throws IOException {
this.enabled = enabled;
this.shell = new ClassPathResource("ui/index.html").getContentAsString(StandardCharsets.UTF_8);
if (shell.indexOf(SLOT) < 0 || shell.indexOf(SLOT) != shell.lastIndexOf(SLOT)) {
throw new IllegalStateException("Expected exactly one UI context slot");
}
}
@GetMapping(value = {"/preview", "/preview/verify", "/preview/complete"}, produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> page(HttpServletRequest request, CsrfToken csrf) {
requireEnabled();
var session = request.getSession();
synchronized (session) {
var state = state(session);
var path = request.getRequestURI().substring(request.getContextPath().length());
if (path.equals("/preview")) {
if (!state.step.equals("identity")) state.error = "";
state.step = "identity";
} else if (!path.equals(pathFor(state.step))) {
return redirect(pathFor(state.step));
}
var context = Map.of("step", state.step, "name", state.name, "error", state.error,
"action", switch (state.step) {
case "identity" -> "/preview/identify";
case "verification" -> "/preview/verify";
default -> "/preview/restart";
}, "csrf", Map.of("name", csrf.getParameterName(), "value", csrf.getToken()));
return ResponseEntity.ok().header("Cache-Control", "no-store")
.contentType(MediaType.TEXT_HTML).body(shell.replace(SLOT, htmlSafeJson(context)));
}
}
@PostMapping("/preview/identify")
ResponseEntity<String> identify(@RequestParam(defaultValue = "") String name, HttpSession session) {
requireEnabled();
synchronized (session) {
var state = state(session);
requireStep(state, "identity");
if (name.isBlank() || name.length() > 64) {
state.error = "称呼须为 1 到 64 个字符。";
return redirect("/preview");
}
state.name = name.strip();
state.error = "";
state.step = "verification";
return redirect("/preview/verify");
}
}
@PostMapping("/preview/verify")
ResponseEntity<String> verify(@RequestParam(defaultValue = "") String code, HttpSession session) {
requireEnabled();
synchronized (session) {
var state = state(session);
requireStep(state, "verification");
if (!code.equals("123456")) {
state.error = "演示码不正确,请输入 123456。";
return redirect("/preview/verify");
}
state.error = "";
state.step = "complete";
return redirect("/preview/complete");
}
}
@PostMapping("/preview/restart")
ResponseEntity<String> restart(HttpSession session) {
requireEnabled();
synchronized (session) {
session.removeAttribute(STATE);
return redirect("/preview");
}
}
private void requireEnabled() {
if (!enabled) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
private static void requireStep(State state, String step) {
if (!state.step.equals(step)) throw new ResponseStatusException(HttpStatus.CONFLICT, "页面已过期,请重新打开预览");
}
private static State state(HttpSession session) {
var state = (State) session.getAttribute(STATE);
if (state == null) {
state = new State();
session.setAttribute(STATE, state);
}
return state;
}
private static String pathFor(String step) {
return switch (step) {
case "verification" -> "/preview/verify";
case "complete" -> "/preview/complete";
default -> "/preview";
};
}
private static ResponseEntity<String> redirect(String path) {
return ResponseEntity.status(HttpStatus.SEE_OTHER).header("Location", path)
.header("Cache-Control", "no-store").build();
}
String htmlSafeJson(Object value) {
// JSON in a script data block still participates in HTML parsing.
return json.writeValueAsString(value).replace("<", "\\u003c")
.replace(">", "\\u003e").replace("&", "\\u0026")
.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029");
}
private static class State {
String step = "identity";
String name = "";
String error = "";
}
}
@@ -1,6 +1,7 @@
package top.ddupan.iam.login;
import io.micrometer.core.instrument.MeterRegistry;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -10,42 +11,114 @@ import org.springframework.boot.opentelemetry.autoconfigure.logging.otlp.OtlpLog
import org.springframework.boot.opentelemetry.autoconfigure.logging.otlp.Transport;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.grafana.LgtmStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@SpringBootTest(properties = "iam.ui-preview.enabled=true")
@AutoConfigureMockMvc
@AutoConfigureMetrics
@AutoConfigureTracing
@Testcontainers
class IamLoginApplicationTests {
// Field-based service connections are recreated by the test context in AOT mode.
@Container
@ServiceConnection
static final LgtmStackContainer grafanaLgtm = new LgtmStackContainer(
DockerImageName.parse(TestcontainersConfiguration.LGTM_IMAGE));
// Field-based service connections are recreated by the test context in AOT mode.
@Container
@ServiceConnection
static final LgtmStackContainer grafanaLgtm = new LgtmStackContainer(
DockerImageName.parse(TestcontainersConfiguration.LGTM_IMAGE));
@Autowired
OtlpLoggingConnectionDetails loggingConnectionDetails;
@Autowired
OtlpLoggingConnectionDetails loggingConnectionDetails;
@Autowired
@Qualifier("prometheusMeterRegistry")
MeterRegistry prometheus;
@Autowired
@Qualifier("prometheusMeterRegistry")
MeterRegistry prometheus;
@Test
void processCpuTimeCanBeRead() {
assertThat(prometheus.get("process.cpu.time").functionCounter().count()).isFinite().isNotNegative();
}
@Autowired
MockMvc mvc;
@Test
void loggingConnectionUsesRunningContainer() {
assertThat(grafanaLgtm.isRunning()).isTrue();
assertThat(loggingConnectionDetails.getUrl(Transport.HTTP))
.isEqualTo(grafanaLgtm.getOtlpHttpUrl() + "/v1/logs");
}
@Test
void previewHasInlineContextAndNoCache() throws Exception {
var result = mvc.perform(get("/preview"))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"))
.andReturn();
assertThat(result.getResponse().getContentAsString()).contains("login-context", "identity", "_csrf")
.doesNotContain("__IAM_PAGE_CONTEXT__");
}
@Test
void previewRejectsMissingCsrf() throws Exception {
mvc.perform(post("/preview/identify").param("name", "测试"))
.andExpect(status().isForbidden());
}
@Test
void previewChecksStepsAndEscapesScriptEndTags() throws Exception {
var session = new MockHttpSession();
mvc.perform(post("/preview/verify")
.session(session).with(csrf())
.param("code", "123456"))
.andExpect(status().isConflict());
mvc.perform(post("/preview/identify")
.session(session).with(csrf())
.param("name", "</script><script>alert(1)</script>"))
.andExpect(status().isSeeOther());
var html = mvc.perform(get("/preview/verify").session(session))
.andExpect(status().isOk()).andReturn()
.getResponse().getContentAsString();
assertThat(html).doesNotContain("</script><script>alert(1)</script>")
.contains("\\u003c/script\\u003e");
}
@Test
void previewRetriesAndCompletesWithoutAuthenticating() throws Exception {
var session = new MockHttpSession();
mvc.perform(post("/preview/identify")
.session(session).with(csrf())
.param("name", "测试"))
.andExpect(redirectedUrl("/preview/verify"));
mvc.perform(post("/preview/verify")
.session(session).with(csrf())
.param("code", "000000"))
.andExpect(redirectedUrl("/preview/verify"));
var retry = mvc.perform(get("/preview/verify").session(session))
.andReturn().getResponse();
assertThat(retry.getContentAsString(StandardCharsets.UTF_8)).contains("演示码不正确");
mvc.perform(post("/preview/verify")
.session(session).with(csrf())
.param("code", "123456"))
.andExpect(redirectedUrl("/preview/complete"));
mvc.perform(get("/").session(session)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized());
assertThat(session.getAttribute("SPRING_SECURITY_CONTEXT")).isNull();
}
@Test
void processCpuTimeCanBeRead() {
assertThat(prometheus.get("process.cpu.time").functionCounter().count()).isFinite().isNotNegative();
}
@Test
void loggingConnectionUsesRunningContainer() {
assertThat(grafanaLgtm.isRunning()).isTrue();
assertThat(loggingConnectionDetails.getUrl(Transport.HTTP))
.isEqualTo(grafanaLgtm.getOtlpHttpUrl() + "/v1/logs");
}
}