Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
a6b2c86076 | 2 weeks ago |
@ -0,0 +1,52 @@
|
|||||||
|
package com.ngskcloud.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "sa-token.sso-server")
|
||||||
|
public class SsoServerClientsProperties {
|
||||||
|
|
||||||
|
private Map<String, Client> clients = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
public Map<String, Client> getClients() {
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClients(Map<String, Client> clients) {
|
||||||
|
this.clients = clients == null ? new LinkedHashMap<>() : clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Client {
|
||||||
|
private String client;
|
||||||
|
private String allowUrl;
|
||||||
|
private String secretKey;
|
||||||
|
|
||||||
|
public String getClient() {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClient(String client) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAllowUrl() {
|
||||||
|
return allowUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAllowUrl(String allowUrl) {
|
||||||
|
this.allowUrl = allowUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSecretKey() {
|
||||||
|
return secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSecretKey(String secretKey) {
|
||||||
|
this.secretKey = secretKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
package com.ngskcloud.controller;
|
||||||
|
|
||||||
|
import com.ngskcloud.config.SsoServerClientsProperties;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/sso/dashboard")
|
||||||
|
public class SsoDashboardController {
|
||||||
|
|
||||||
|
private final SsoServerClientsProperties properties;
|
||||||
|
|
||||||
|
public SsoDashboardController(SsoServerClientsProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/clients")
|
||||||
|
public DashboardClientsResponse clients() {
|
||||||
|
List<SsoClientView> clients = properties.getClients().entrySet().stream()
|
||||||
|
.sorted(Map.Entry.comparingByKey())
|
||||||
|
.map(entry -> toView(entry.getKey(), entry.getValue()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new DashboardClientsResponse(clients.size(), Instant.now().toString(), clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SsoClientView toView(String id, SsoServerClientsProperties.Client client) {
|
||||||
|
String name = valueOrFallback(client.getClient(), id);
|
||||||
|
String allowUrl = valueOrFallback(client.getAllowUrl(), "-");
|
||||||
|
boolean secretConfigured = hasText(client.getSecretKey());
|
||||||
|
String status = hasText(client.getClient()) && secretConfigured ? "CONFIGURED" : "INCOMPLETE";
|
||||||
|
|
||||||
|
return new SsoClientView(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
allowUrl,
|
||||||
|
secretConfigured,
|
||||||
|
maskSecret(client.getSecretKey()),
|
||||||
|
status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskSecret(String secretKey) {
|
||||||
|
if (!hasText(secretKey)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (secretKey.length() <= 10) {
|
||||||
|
return "****";
|
||||||
|
}
|
||||||
|
return secretKey.substring(0, 7) + "****" + secretKey.substring(secretKey.length() - 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String valueOrFallback(String value, String fallback) {
|
||||||
|
return hasText(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasText(String value) {
|
||||||
|
return value != null && !value.trim().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DashboardClientsResponse(int total, String generatedAt, List<SsoClientView> clients) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SsoClientView(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String allowUrl,
|
||||||
|
boolean secretConfigured,
|
||||||
|
String secretPreview,
|
||||||
|
String status) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,323 @@
|
|||||||
<html>
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SSO 接入平台看板</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #172033;
|
||||||
|
--muted: #667085;
|
||||||
|
--line: #d9e0ea;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--ok: #138a56;
|
||||||
|
--warn: #b45309;
|
||||||
|
--shadow: 0 18px 40px rgba(23, 32, 51, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(1120px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 1fr 1fr auto;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 14px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-id {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 86px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok {
|
||||||
|
background: #e7f8ef;
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.warn {
|
||||||
|
background: #fff4df;
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty,
|
||||||
|
.error {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 28px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.topbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>hello word!!!</h1>
|
<main class="shell">
|
||||||
<p>this is a html page</p>
|
<section class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>SSO 接入平台看板</h1>
|
||||||
|
<p class="subtitle">查看当前认证中心已配置的平台接入状态</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="refreshButton" type="button" aria-label="刷新接入平台">刷新</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="summary" aria-label="接入概览">
|
||||||
|
<div class="metric">
|
||||||
|
<span>已配置平台</span>
|
||||||
|
<strong id="totalCount">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>密钥完整</span>
|
||||||
|
<strong id="secretCount">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>最后刷新</span>
|
||||||
|
<strong id="refreshTime">--</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="clientList" class="client-list" aria-live="polite"></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const clientList = document.querySelector("#clientList");
|
||||||
|
const totalCount = document.querySelector("#totalCount");
|
||||||
|
const secretCount = document.querySelector("#secretCount");
|
||||||
|
const refreshTime = document.querySelector("#refreshTime");
|
||||||
|
const refreshButton = document.querySelector("#refreshButton");
|
||||||
|
|
||||||
|
async function loadClients() {
|
||||||
|
refreshButton.disabled = true;
|
||||||
|
refreshButton.textContent = "刷新中";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/sso/dashboard/clients", {headers: {"Accept": "application/json"}});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
renderDashboard(data);
|
||||||
|
} catch (error) {
|
||||||
|
clientList.innerHTML = `<div class="error">接入平台数据加载失败:${escapeHtml(error.message)}</div>`;
|
||||||
|
} finally {
|
||||||
|
refreshButton.disabled = false;
|
||||||
|
refreshButton.textContent = "刷新";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboard(data) {
|
||||||
|
const clients = Array.isArray(data.clients) ? data.clients : [];
|
||||||
|
const configuredSecrets = clients.filter((client) => client.secretConfigured).length;
|
||||||
|
|
||||||
|
totalCount.textContent = String(data.total ?? clients.length);
|
||||||
|
secretCount.textContent = String(configuredSecrets);
|
||||||
|
refreshTime.textContent = formatTime(data.generatedAt);
|
||||||
|
|
||||||
|
if (clients.length === 0) {
|
||||||
|
clientList.innerHTML = `<div class="empty">当前没有配置 SSO 接入平台</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientList.innerHTML = clients.map(renderClient).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClient(client) {
|
||||||
|
const ready = client.status === "CONFIGURED";
|
||||||
|
const statusText = ready ? "已配置" : "待完善";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="client-card">
|
||||||
|
<div>
|
||||||
|
<div class="platform-name">${escapeHtml(client.name)}</div>
|
||||||
|
<div class="client-id">${escapeHtml(client.id)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label">允许地址</div>
|
||||||
|
<div class="value">${escapeHtml(client.allowUrl)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="label">密钥状态</div>
|
||||||
|
<div class="value">${client.secretConfigured ? escapeHtml(client.secretPreview) : "未配置"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="badge ${ready ? "ok" : "warn"}">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
loadClients();
|
||||||
|
refreshButton.addEventListener("click", loadClients);
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.ngskcloud.controller;
|
||||||
|
|
||||||
|
import com.ngskcloud.config.SsoServerClientsProperties;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.hamcrest.Matchers.not;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
class SsoDashboardControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsConfiguredClientsWithMaskedSecrets() throws Exception {
|
||||||
|
SsoServerClientsProperties.Client playedu = new SsoServerClientsProperties.Client();
|
||||||
|
playedu.setClient("playedu-client");
|
||||||
|
playedu.setAllowUrl("*");
|
||||||
|
playedu.setSecretKey("SSO-C2-kQwIOrYvnXmSDkwEiFngrKidMcdrgKor");
|
||||||
|
|
||||||
|
SsoServerClientsProperties properties = new SsoServerClientsProperties();
|
||||||
|
properties.setClients(new LinkedHashMap<>(Map.of("sso-client2", playedu)));
|
||||||
|
|
||||||
|
MockMvc mockMvc = MockMvcBuilders
|
||||||
|
.standaloneSetup(new SsoDashboardController(properties))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
mockMvc.perform(get("/sso/dashboard/clients"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.total").value(1))
|
||||||
|
.andExpect(jsonPath("$.clients[0].id").value("sso-client2"))
|
||||||
|
.andExpect(jsonPath("$.clients[0].name").value("playedu-client"))
|
||||||
|
.andExpect(jsonPath("$.clients[0].allowUrl").value("*"))
|
||||||
|
.andExpect(jsonPath("$.clients[0].secretConfigured").value(true))
|
||||||
|
.andExpect(jsonPath("$.clients[0].secretPreview").value("SSO-C2-****Kor"))
|
||||||
|
.andExpect(jsonPath("$.clients[0].status").value("CONFIGURED"))
|
||||||
|
.andExpect(content().string(not(containsString("kQwIOrYvnXmSDkwEiFngrKidMcdrg"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue