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>
|
||||
<h1>hello word!!!</h1>
|
||||
<p>this is a html page</p>
|
||||
<main class="shell">
|
||||
<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>
|
||||
</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