Commit 603e4fc4 authored by Sacen's avatar Sacen

完善统一认证与会话安全并改版前端界面

新增数字证书登录、登录 IP 绑定、会话与授权撤销、密码修改和账号安全策略;登录页与应用中心改为浅色界面,桌面网格调整为 8 列并优化自适应布局;同时增强用户导入校验与审计日志。
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
parent 0c09b3d1
......@@ -3,7 +3,7 @@
"configurations": [
{
"name": "zhihui-web",
"runtimeExecutable": "C:/nvm4w/nodejs/node.exe",
"runtimeExecutable": "E:/install/npm/node.exe",
"runtimeArgs": [
"zhihui-web/node_modules/vite/bin/vite.js",
"zhihui-web",
......
This diff is collapsed.
<?xml version="1.0" encoding="utf-8"?><authinfo><liblist><lib type="CSP" version="1.0" dllname="TWljcm9zb2Z0IEVuaGFuY2VkIENyeXB0b2dyYXBoaWMgUHJvdmlkZXIgdjEuMA=="><algid val="SHA1" sm2_hashalg="SHA1" /></lib><lib type="CSP" version="1.0" dllname="TWljcm9zb2Z0IFN0cm9uZyBDcnlwdG9ncmFwaGljIFByb3ZpZGVy"><algid val="SHA1" sm2_hashalg="SHA1" /></lib><lib type="CSP" version="1.0.0.1" dllname="ZVNhZmUgQ3J5cHRvZ3JhcGhpYyBTZXJ2aWNlIFByb3ZpZGVyIHYxLjA="><algid val="SHA1" sm2_hashalg="SHA1" /></lib><lib type="SKF" version="1.0" dllname="L29wdC9HQVNTL2xpYlNLRi9saWJTS0ZfR0FfYXBpLnNv"><algid val="SHA1" sm2_hashalg="SM3" /></lib><lib type="SKF" version="1.7.8.3" dllname="R0FLRVlfU0tGLmRsbA=="><algid val="SHA1" sm2_hashalg="SM3" /></lib><lib type="SKF" version="1.0" dllname="L29wdC9HQVNTL2xpYlNLRi9saWJTS0ZfR0FGX2FwaS5zbw=="><algid val="SHA1" sm2_hashalg="SM3" /></lib><lib type="SKF" version="1.8.10" dllname="R0FGS0VZX1NLRi5kbGw="><algid val="SHA1" sm2_hashalg="SM3" /></lib></liblist></authinfo>
\ No newline at end of file
This diff is collapsed.
# ============================================================
# 智汇平台 .env(含数字证书登录配置)
# 复制为 .env 后按现场情况修改
# ============================================================
# ---------- MySQL ----------
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=zhihui
DB_USER=zhihui
DB_PASSWORD=CHANGE_ME
# ---------- Redis ----------
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=3
# ---------- 门户地址 ----------
# 必须与浏览器地址栏完全一致;HTTPS 时写成 https://...
SERVER_PORT=8092
ZHIHUI_ISSUER=http://localhost:8092
# ---------- 初始管理员和密钥 ----------
# 仅首次创建 admin 时生效;已有 admin 时不会覆盖
ZHIHUI_ADMIN_PASSWORD=Admin@123
# 生产环境必须改成独立强随机值;设置后不要再改,否则已保存的应用密钥无法解密
ZHIHUI_CLIENT_SECRET_KEY=CHANGE_ME_RANDOM_32_BYTES
# ---------- 示例子系统 ----------
ZHIHUI_DEMO_ENABLED=true
ZHIHUI_DEMO_APP_URL=http://localhost:8081
ZHIHUI_DEMO_CLIENT_ID=demo-subsystem
ZHIHUI_DEMO_CLIENT_SECRET=demo-secret-2026
ZHIHUI_DEMO2_ENABLED=true
ZHIHUI_DEMO2_APP_URL=http://localhost:8082
ZHIHUI_DEMO2_CLIENT_ID=demo-subsystem-2
ZHIHUI_DEMO2_CLIENT_SECRET=demo-secret-2026-2
# ---------- 安全 ----------
ZHIHUI_COOKIE_SECURE=false
ZHIHUI_TRUST_FORWARDED_HEADERS=false
ZHIHUI_LOGIN_RATE_LIMIT_ENABLED=true
ZHIHUI_LOGIN_MAX_FAILURES_PER_USER_IP=5
ZHIHUI_LOGIN_MAX_FAILURES_PER_IP=20
ZHIHUI_LOGIN_FAILURE_WINDOW=15m
# ============================================================
# 数字证书登录(吉大正元网关旁路模式)
# ============================================================
ZHIHUI_CERT_AUTH_ENABLED=true
# 生产网关地址,通常以 /MessageService 结尾
# 示例:https://10.0.0.10:8443/MessageService
ZHIHUI_CERT_GATEWAY_URL=https://CHANGE_ME_GATEWAY_HOST:PORT/MessageService
# 网关管理员分配的应用标识
ZHIHUI_CERT_APP_ID=CHANGE_ME_APP_ID
# 开发商 demo 的网关参数(来自 bsdemo_v2.1/demo/WEB-INF/jitgwmessage.properties):
# ZHIHUI_CERT_GATEWAY_URL=http://192.168.0.110:6180/MessageService
# ZHIHUI_CERT_APP_ID=testApp
# 本机 Debugger 测试:
# ZHIHUI_CERT_GATEWAY_URL=http://127.0.0.1:6180/MessageService
# ZHIHUI_CERT_APP_ID=testApp
# 初始化参数二选一(必须配置其中一项,否则登录页不会显示证书入口):
# 已附带从开发商 demo login.jsp 提取的 XML:
ZHIHUI_CERT_INIT_PARAM_FILE=cert/initParam-demo.xml
# 也可以直接写在 .env(不推荐,XML 含引号,容易转义出错):
# ZHIHUI_CERT_INIT_PARAM=...
# 生产环境请用网关“认证管理 -> Key类型管理”导出的参数替换该文件内容
# UKey 是否需要输入 PIN
ZHIHUI_CERT_PIN_CODE=false
ZHIHUI_CERT_ACCESS_CONTROL=false
# 测试环境使用 http 网关时必须改为 true;生产使用 https 时保持 false
ZHIHUI_CERT_ALLOW_INSECURE_HTTP=false
# 网关属性模式,默认 portion
ZHIHUI_CERT_ATTRIBUTES_MODE=portion
# 用户证书 SubjectDN 中身份证号所在字段,默认 CN
ZHIHUI_CERT_SUBJECT_DN_ID_ATTRIBUTE=CN
# 网关调用超时和证书挑战有效期
ZHIHUI_CERT_CONNECT_TIMEOUT=5s
ZHIHUI_CERT_REQUEST_TIMEOUT=15s
ZHIHUI_CERT_CHALLENGE_TTL=2m
ZHIHUI_CERT_MAX_RESPONSE_BYTES=262144
ZHIHUI_CERT_MAX_SIGNED_DATA_BYTES=524288
# 网关 HTTPS 使用内部 CA 时配置根证书(PKCS12 或 JKS)
# 如果网关证书由公共 CA 签发,保持注释即可
# ZHIHUI_CERT_TRUST_STORE=D:/zhihui-dev/cert/gateway-trust.p12
# ZHIHUI_CERT_TRUST_STORE_PASSWORD=CHANGE_ME
# ZHIHUI_CERT_TRUST_STORE_TYPE=PKCS12
# 可选:PNXClient 证书控件下载地址;
# 建议使用相对路径(如 /downloads/PNXClient.exe)或 https 地址
ZHIHUI_CERT_CLIENT_DOWNLOAD_URL=
# ============================================================
# 本机 Debugger 测试示例(仅测试环境使用,生产不要照搬)
# ============================================================
# ZHIHUI_CERT_GATEWAY_URL=http://127.0.0.1:6180/MessageService
# ZHIHUI_CERT_APP_ID=testApp
# ZHIHUI_CERT_ALLOW_INSECURE_HTTP=true
# ZHIHUI_CERT_INIT_PARAM=
# ZHIHUI_CERT_INIT_PARAM_FILE=C:/path/to/initParam.txt
# ZHIHUI_CERT_TRUST_STORE=
# ZHIHUI_CERT_TRUST_STORE_PASSWORD=
......@@ -16,7 +16,7 @@ DEMO_CALLBACK="${DEMO_URL}/sso/callback"
SMOKE_USER="${ZHIHUI_SMOKE_USER:-smoke_subsystem}"
SMOKE_PASSWORD="${ZHIHUI_SMOKE_PASSWORD:-Smoke@123}"
SMOKE_POLICE="${ZHIHUI_SMOKE_POLICE:-SMK660909}"
SMOKE_ID_CARD="${ZHIHUI_SMOKE_ID_CARD:-110101199001011234}"
SMOKE_ID_CARD="${ZHIHUI_SMOKE_ID_CARD:-110101199001019999}"
SMOKE_PHONE="${ZHIHUI_SMOKE_PHONE:-13800138000}"
TMP="$(mktemp -d)"
......@@ -104,24 +104,6 @@ exchange_code() {
-o "$output" || fail "令牌交换失败"
}
assert_no_identity_claims() {
local token_file="$1" label="$2"
python - "$token_file" "$label" <<'PY'
import base64, json, sys
data = json.load(open(sys.argv[1], encoding='utf-8'))
token = data.get('id_token')
if not token:
raise SystemExit('no id_token')
payload = token.split('.')[1]
payload += '=' * (-len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload))
for key in ('police_number', 'id_card_number', 'phone_number'):
if claims.get(key):
raise SystemExit(f'{sys.argv[2]} unexpectedly has {key}')
print('ok')
PY
}
echo "== 1. 健康检查 =="
curl -fsS --max-time 5 "$BASE_URL/actuator/health" >/dev/null || fail "门户未启动"
pass "门户可访问:$BASE_URL"
......@@ -131,45 +113,35 @@ login "$ADMIN_JAR" "$ADMIN_USER" "$ADMIN_PASSWORD"
[ "$(me_username "$ADMIN_JAR")" = "$ADMIN_USER" ] || fail "/api/me 返回异常"
pass "登录成功:$ADMIN_USER"
echo "== 3. admin 进入子系统由本地校验拒绝 =="
echo "== 3. admin 请求子系统授权由门户拒绝 =="
STATUS="$(authorize_status "$ADMIN_JAR" "$TMP/admin-authz.html")"
[ "$STATUS" = "302" ] || fail "admin 未获得授权跳转(HTTP $STATUS)"
LOCATION="$(grep -i '^location:' "$TMP/authz.headers" | sed 's/^[Ll]ocation: //' | tr -d '\r')"
CODE="$(printf '%s' "$LOCATION" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')"
[ -n "$CODE" ] || fail "admin 未取得授权码:$LOCATION"
exchange_code "$CODE" "$TMP/admin-token.json"
assert_no_identity_claims "$TMP/admin-token.json" "admin" >/dev/null \
|| fail "admin 的 ID Token 不应包含身份 claims"
[ "$STATUS" = "403" ] || fail "admin 未按预期被门户拒绝(HTTP $STATUS)"
pass "admin 请求 OAuth 授权被门户拒绝(403)"
if curl -s --max-time 3 -o /dev/null "$DEMO_URL/"; then
cp "$ADMIN_JAR" "$TMP/admin-demo-cookies"
FINAL_STATUS="$(curl -s -L --max-redirs 10 -b "$TMP/admin-demo-cookies" -c "$TMP/admin-demo-cookies" \
-H "Accept: text/html" -o "$TMP/admin-demo.html" -w '%{http_code}' "$DEMO_URL/")"
[ "$FINAL_STATUS" = "200" ] || fail "admin 访问示例子系统返回 HTTP $FINAL_STATUS"
grep -q "当前账号未开通本系统" "$TMP/admin-demo.html" \
|| fail "admin 未显示与普通账号一致的未开通提示"
pass "admin 进入子系统时显示普通账号同款无权限提示"
[ "$FINAL_STATUS" = "403" ] || fail "admin 访问示例子系统返回 HTTP $FINAL_STATUS"
grep -q "无法访问应用" "$TMP/admin-demo.html" \
|| fail "admin 未显示门户拒绝页面"
pass "admin 进入子系统时被门户拒绝"
else
echo " [SKIP] 示例子系统未启动($DEMO_URL),跳过 admin 页面校验"
fi
echo "== 4. demo 进入子系统由本地校验拒绝 =="
echo "== 4. demo 请求子系统授权由门户拒绝 =="
login "$TMP/demo.cookies" "$DEMO_USER" "$DEMO_PASSWORD"
STATUS="$(authorize_status "$TMP/demo.cookies" "$TMP/demo-authz.html")"
[ "$STATUS" = "302" ] || fail "demo 未获得授权跳转(HTTP $STATUS)"
LOCATION="$(grep -i '^location:' "$TMP/authz.headers" | sed 's/^[Ll]ocation: //' | tr -d '\r')"
CODE="$(printf '%s' "$LOCATION" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')"
[ -n "$CODE" ] || fail "demo 未取得授权码:$LOCATION"
exchange_code "$CODE" "$TMP/demo-token.json"
assert_no_identity_claims "$TMP/demo-token.json" "demo" >/dev/null \
|| fail "demo 的 ID Token 不应包含身份 claims"
[ "$STATUS" = "403" ] || fail "demo 未按预期被门户拒绝(HTTP $STATUS)"
pass "demo 请求 OAuth 授权被门户拒绝(403)"
if curl -s --max-time 3 -o /dev/null "$DEMO2_URL/"; then
cp "$TMP/demo.cookies" "$TMP/demo2-cookies"
FINAL_STATUS="$(curl -s -L --max-redirs 10 -b "$TMP/demo2-cookies" -c "$TMP/demo2-cookies" \
-H "Accept: text/html" -o "$TMP/demo-no-local.html" -w '%{http_code}' "$DEMO2_URL/")"
[ "$FINAL_STATUS" = "200" ] || fail "demo 访问第二个子系统返回 HTTP $FINAL_STATUS"
grep -q "当前账号未开通本系统" "$TMP/demo-no-local.html" \
|| fail "demo 未显示与普通账号一致的未开通提示"
pass "demo 进入子系统时显示普通账号同款无权限提示"
[ "$FINAL_STATUS" = "403" ] || fail "demo 访问第二个子系统返回 HTTP $FINAL_STATUS"
grep -q "无法访问应用" "$TMP/demo-no-local.html" \
|| fail "demo 未显示门户拒绝页面"
pass "demo 进入子系统时被门户拒绝"
else
echo " [SKIP] 第二个子系统未启动($DEMO2_URL),跳过 demo 页面校验"
fi
......@@ -215,6 +187,22 @@ login "$SMOKE_JAR" "$SMOKE_POLICE" "$SMOKE_PASSWORD"
[ "$(me_username "$SMOKE_JAR")" = "$SMOKE_USER" ] || fail "普通账号 /api/me 返回异常"
pass "普通账号可用警号登录"
echo "== 6.1 将示例应用添加到测试账号 =="
MARKET="$(curl -fsS -b "$SMOKE_JAR" "$BASE_URL/api/market")"
APP_IDS="$(printf '%s' "$MARKET" | python -c '
import json, sys
apps = json.load(sys.stdin)
print(" ".join(str(item["id"]) for item in apps if item.get("appCode") in ("demo", "demo2")))
')"
[ -n "$APP_IDS" ] || fail "智能体广场中没有示例应用"
SMOKE_CSRF="$(cookie_xsrf "$SMOKE_JAR")" || fail "测试账号缺少 XSRF token"
for app_id in $APP_IDS; do
STATUS="$(curl -s -b "$SMOKE_JAR" -c "$SMOKE_JAR" -o /dev/null -w '%{http_code}' \
-X POST "$BASE_URL/api/apps/$app_id/show" -H "X-XSRF-TOKEN: $SMOKE_CSRF")"
[ "$STATUS" = "200" ] || fail "添加示例应用失败(app=$app_id, HTTP $STATUS)"
done
pass "测试账号已添加示例应用到个人门户"
echo "== 7. 普通账号 OIDC 授权 =="
STATUS="$(authorize_status "$SMOKE_JAR" "$TMP/smoke-authz.html")"
[ "$STATUS" = "302" ] || fail "普通账号未获得授权跳转(HTTP $STATUS)"
......
This diff is collapsed.
......@@ -31,6 +31,7 @@ zhihui:
client-secret: ${ZHIHUI_DEMO_CLIENT_SECRET:demo-secret-2026}
redirect-uri: ${ZHIHUI_DEMO_REDIRECT_URI:http://localhost:8081/sso/callback}
post-logout-redirect-uri: ${ZHIHUI_DEMO_POST_LOGOUT_URI:http://localhost:8081/}
session-revalidate-interval: ${ZHIHUI_SSO_SESSION_REVALIDATE_INTERVAL:60s}
logging:
level:
......
package com.zhihui.portal;
import com.zhihui.portal.cert.CertificateAuthProperties;
import com.zhihui.portal.config.PortalSecurityProperties;
import com.zhihui.portal.config.ZhihuiProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableConfigurationProperties({ZhihuiProperties.class, PortalSecurityProperties.class})
@EnableScheduling
@EnableConfigurationProperties({ZhihuiProperties.class, PortalSecurityProperties.class,
CertificateAuthProperties.class})
public class ZhihuiApplication {
public static void main(String[] args) {
......
package com.zhihui.portal.app;
import com.zhihui.portal.auth.PortalAccounts;
import com.zhihui.portal.auth.SecurityUser;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 在 OAuth 授权码流程入口校验应用状态和个人应用关系,防止绕过门户应用目录直接进入未开通应用。
*/
public class AppAuthorizationFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(AppAuthorizationFilter.class);
private static final RequestMatcher AUTHORIZE_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/oauth2/authorize");
private final PortalAppRepository appRepository;
private final JdbcTemplate jdbcTemplate;
public AppAuthorizationFilter(PortalAppRepository appRepository, JdbcTemplate jdbcTemplate) {
this.appRepository = appRepository;
this.jdbcTemplate = jdbcTemplate;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !AUTHORIZE_MATCHER.matches(request);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !(authentication.getPrincipal() instanceof SecurityUser user)) {
filterChain.doFilter(request, response);
return;
}
String[] clientIds = request.getParameterValues("client_id");
if (clientIds == null || clientIds.length == 0) {
filterChain.doFilter(request, response);
return;
}
if (clientIds.length != 1 || !StringUtils.hasText(clientIds[0])) {
deny(request, response, HttpServletResponse.SC_BAD_REQUEST, "授权请求参数不合法");
return;
}
String clientId = clientIds[0].trim();
PortalApp app = appRepository.findByClientId(clientId).orElse(null);
if (app == null) {
deny(request, response, HttpServletResponse.SC_FORBIDDEN, "该应用未在门户登记");
return;
}
boolean active = app.getStatus() != null && app.getStatus() == 1;
boolean selected = isSelected(user.getId(), app.getId());
boolean portalAccount = PortalAccounts.isUsernameAccount(user.getUsername());
if (!active || !selected || portalAccount) {
log.warn("OAuth 授权被拒绝: username={}, clientId={}, active={}, selected={}, portalAccount={}",
user.getUsername(), clientId, active, selected, portalAccount);
deny(request, response, HttpServletResponse.SC_FORBIDDEN, "当前账号未开通该应用,请从应用中心添加后再进入");
return;
}
filterChain.doFilter(request, response);
}
private boolean isSelected(Long userId, Long appId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM user_app_selection WHERE user_id = ? AND app_id = ?",
Integer.class, userId, appId);
return count != null && count > 0;
}
private static void deny(HttpServletRequest request, HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setCharacterEncoding("UTF-8");
if (acceptsJson(request)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"access_denied\",\"message\":\"" + message + "\"}");
return;
}
response.setContentType("text/html;charset=UTF-8");
response.getWriter().write("""
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,">
<title>无法访问应用</title>
<style>
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#eef3f9;
font-family:"Noto Sans SC","Microsoft YaHei",sans-serif;color:#16263a}
.box{max-width:480px;margin:24px;padding:36px 40px;background:#fff;
border-top:3px solid #b3262e;box-shadow:0 18px 50px rgba(13,31,51,.1)}
h1{margin:0 0 14px;font-size:21px;font-weight:600}
p{margin:0 0 22px;font-size:14px;line-height:1.9;color:#5f7288}
a{display:inline-block;padding:10px 20px;background:#1b4a78;color:#fff;
text-decoration:none;font-size:14px;border-radius:4px}
</style></head><body><div class="box"><h1>无法访问应用</h1><p>%s</p>
<a href="/spa/">返回应用中心</a></div></body></html>
""".formatted(message));
}
private static boolean acceptsJson(HttpServletRequest request) {
String accept = request.getHeader("Accept");
return accept != null && accept.contains("application/json");
}
}
......@@ -9,6 +9,7 @@ import com.zhihui.portal.app.dto.AppDtos.SecretResponse;
import com.zhihui.portal.app.dto.AppDtos.UpdateAppRequest;
import com.zhihui.portal.app.dto.AppDtos.UpdateIntegrationRequest;
import com.zhihui.portal.common.BizException;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
......@@ -49,17 +50,20 @@ public class AppService {
private final PasswordEncoder passwordEncoder;
private final JdbcTemplate jdbcTemplate;
private final ClientSecretCipher secretCipher;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public AppService(PortalAppRepository appRepository,
RegisteredClientRepository registeredClientRepository,
PasswordEncoder passwordEncoder,
JdbcTemplate jdbcTemplate,
ClientSecretCipher secretCipher) {
ClientSecretCipher secretCipher,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.appRepository = appRepository;
this.registeredClientRepository = registeredClientRepository;
this.passwordEncoder = passwordEncoder;
this.jdbcTemplate = jdbcTemplate;
this.secretCipher = secretCipher;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
}
public List<AppResponse> listAll() {
......@@ -69,7 +73,7 @@ public class AppService {
}
/**
* 当前用户的门户:用户从市场添加的应用,按个人格子位置排列。
* 当前用户的门户:用户从智能体广场添加的应用,按个人格子位置排列。
*/
public List<AppCard> listCardsForUser(Long userId) {
List<PortalApp> active = appRepository.findAllByStatusOrderBySortOrderAscIdAsc(1);
......@@ -122,7 +126,7 @@ public class AppService {
}
/**
* 应用市场:所有启用中的应用,以及当前用户是否已经添加。
* 智能体广场:所有启用中的应用,以及当前用户是否已经添加。
*/
public List<MarketApp> listMarket(Long userId) {
Set<Long> selected = new HashSet<>(jdbcTemplate.queryForList(
......@@ -175,14 +179,19 @@ public class AppService {
@Transactional
public void hideAppForUser(Long userId, Long appId) {
appRepository.findById(appId)
.filter(app -> app.getStatus() != null && app.getStatus() == 1)
PortalApp app = appRepository.findById(appId)
.filter(item -> item.getStatus() != null && item.getStatus() == 1)
.orElseThrow(() -> new BizException("应用不存在"));
jdbcTemplate.update("DELETE FROM user_app_selection WHERE user_id = ? AND app_id = ?",
userId, appId);
jdbcTemplate.update(
"DELETE FROM user_launcher_order WHERE user_id = ? AND item_type = 'APP' AND item_id = ?",
userId, appId);
String username = jdbcTemplate.query(
"SELECT username FROM sys_user WHERE id = ?",
(rs, rowNum) -> rs.getString(1), userId)
.stream().findFirst().orElse(null);
oauthAuthorizationRevocationService.revokeByPrincipalAndClientId(username, app.getClientId());
}
@Transactional
......@@ -241,6 +250,7 @@ public class AppService {
@Transactional
public AppResponse update(Long id, UpdateAppRequest request) {
PortalApp app = find(id);
Integer oldStatus = app.getStatus();
String appUrl = validateUri(request.appUrl(), "访问地址");
List<String> redirectUris = normalizeRedirectUris(request.redirectUris(), appUrl);
List<String> postLogoutUris = normalizePostLogoutUris(request.postLogoutRedirectUris(), appUrl);
......@@ -275,7 +285,11 @@ public class AppService {
}
app.setStatus(request.status());
}
return AppResponse.from(appRepository.save(app), updated);
AppResponse response = AppResponse.from(appRepository.save(app), updated);
if (oldStatus != null && oldStatus != 0 && response.status() != null && response.status() == 0) {
oauthAuthorizationRevocationService.revokeByRegisteredClientId(existing.getId());
}
return response;
}
@Transactional
......
......@@ -9,6 +9,8 @@ public interface PortalAppRepository extends JpaRepository<PortalApp, Long> {
Optional<PortalApp> findByAppCode(String appCode);
Optional<PortalApp> findByClientId(String clientId);
boolean existsByAppCode(String appCode);
List<PortalApp> findAllByStatusOrderBySortOrderAscIdAsc(Integer status);
......
......@@ -5,11 +5,13 @@ public final class AuditDtos {
private AuditDtos() {
}
public record AuditLogEntry(Long id, String eventType, Long userId, String username,
String clientIp, String userAgent, String targetType,
String targetId, String detail, boolean success, String createdAt) {
public record AuditLogEntry(Long id, String eventType, String loginType, Long userId,
String username, String clientIp, String userAgent,
String targetType, String targetId, String targetName,
String detail, boolean success, String createdAt) {
}
public record LoginRecord(Long id, String clientIp, String userAgent, String createdAt) {
public record LoginRecord(Long id, String clientIp, String userAgent, String loginType,
String createdAt) {
}
}
......@@ -5,10 +5,13 @@ public final class AuditEvents {
public static final String LOGIN_SUCCESS = "LOGIN_SUCCESS";
public static final String LOGIN_FAILURE = "LOGIN_FAILURE";
public static final String LOGIN_BLOCKED = "LOGIN_BLOCKED";
public static final String LOGIN_IP_CHANGED = "LOGIN_IP_CHANGED";
public static final String LOGOUT = "LOGOUT";
public static final String USER_CREATE = "USER_CREATE";
public static final String USER_UPDATE = "USER_UPDATE";
public static final String USER_PASSWORD_CHANGE = "USER_PASSWORD_CHANGE";
public static final String USER_AUTO_DISABLE = "USER_AUTO_DISABLE";
public static final String USER_PASSWORD_RESET = "USER_PASSWORD_RESET";
public static final String USER_DELETE = "USER_DELETE";
public static final String USER_IMPORT = "USER_IMPORT";
......
......@@ -52,7 +52,8 @@ public class DbUserDetailsService implements UserDetailsService {
user.getPhone(),
user.getEmail(),
user.getRole(),
user.getStatus() != null && user.getStatus() == 1
user.getStatus() != null && user.getStatus() == 1,
user.isMustChangePassword()
);
}
}
......@@ -32,10 +32,18 @@ public class SecurityUser implements UserDetails {
private final String email;
private final String role;
private final boolean enabled;
private final boolean mustChangePassword;
public SecurityUser(Long id, String username, String password, String displayName,
String policeNumber, String idCardNumber, String phoneNumber,
String email, String role, boolean enabled) {
this(id, username, password, displayName, policeNumber, idCardNumber, phoneNumber,
email, role, enabled, false);
}
public SecurityUser(Long id, String username, String password, String displayName,
String policeNumber, String idCardNumber, String phoneNumber,
String email, String role, boolean enabled, boolean mustChangePassword) {
this.id = id;
this.username = username;
this.password = password;
......@@ -46,6 +54,7 @@ public class SecurityUser implements UserDetails {
this.email = email;
this.role = role;
this.enabled = enabled;
this.mustChangePassword = mustChangePassword;
}
@JsonCreator
......@@ -57,9 +66,10 @@ public class SecurityUser implements UserDetails {
@JsonProperty("phoneNumber") String phoneNumber,
@JsonProperty("email") String email,
@JsonProperty("role") String role,
@JsonProperty("enabled") boolean enabled) {
@JsonProperty("enabled") boolean enabled,
@JsonProperty("mustChangePassword") Boolean mustChangePassword) {
this(id, username, null, displayName, policeNumber, idCardNumber, phoneNumber,
email, role, enabled);
email, role, enabled, Boolean.TRUE.equals(mustChangePassword));
}
public Long getId() {
......@@ -90,6 +100,11 @@ public class SecurityUser implements UserDetails {
return role;
}
@JsonProperty("mustChangePassword")
public boolean isMustChangePassword() {
return mustChangePassword;
}
@JsonIgnore
public List<String> getRoleList() {
return List.of(role);
......
......@@ -74,10 +74,12 @@ public class DataInitializer implements ApplicationRunner {
if (createdAdmin) {
log.warn("================================================================");
log.warn("已创建默认管理员:admin / {},请登录后立即修改密码!", properties.getAdminPassword());
log.warn("演示用户:demo / Demo@123");
log.warn("已创建默认管理员账号 admin,请使用配置的初始密码登录并立即修改密码!");
log.warn("================================================================");
}
if (createdDemo) {
log.warn("已创建演示用户账号 demo。");
}
log.info("统一认证发行方:{}", properties.getIssuer());
}
......
package com.zhihui.portal.cert;
import org.springframework.security.core.AuthenticationException;
import java.io.Serial;
public class CertificateAuthException extends AuthenticationException {
@Serial
private static final long serialVersionUID = 1L;
public enum Reason {
NOT_CONFIGURED,
CHALLENGE_INVALID,
GATEWAY_UNAVAILABLE,
GATEWAY_REJECTED,
CERT_INVALID,
NO_USER,
USER_DISABLED,
ACCOUNT_DENIED,
RATE_LIMITED
}
private final Reason reason;
private final String auditReason;
private final String auditIdentity;
private final boolean countFailure;
private final String errorCode;
private final long retryAfterSeconds;
public CertificateAuthException(Reason reason, String message, String auditReason,
String auditIdentity, boolean countFailure, String errorCode) {
this(reason, message, auditReason, auditIdentity, countFailure, errorCode, 0);
}
public CertificateAuthException(Reason reason, String message, String auditReason,
String auditIdentity, boolean countFailure, String errorCode,
long retryAfterSeconds) {
super(message);
this.reason = reason;
this.auditReason = auditReason;
this.auditIdentity = auditIdentity;
this.countFailure = countFailure;
this.errorCode = errorCode;
this.retryAfterSeconds = retryAfterSeconds;
}
public static CertificateAuthException rateLimited(long retryAfterSeconds) {
return new CertificateAuthException(Reason.RATE_LIMITED, "登录尝试过于频繁",
"证书登录触发限流", "证书用户", false, "locked", retryAfterSeconds);
}
public Reason getReason() {
return reason;
}
public String getAuditReason() {
return auditReason;
}
public String getAuditIdentity() {
return auditIdentity;
}
public boolean isCountFailure() {
return countFailure;
}
public String getErrorCode() {
return errorCode;
}
public long getRetryAfterSeconds() {
return retryAfterSeconds;
}
}
package com.zhihui.portal.cert;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
import java.time.Duration;
@ConfigurationProperties(prefix = "zhihui.cert-auth")
public class CertificateAuthProperties {
private boolean enabled = false;
private String gatewayUrl = "";
private String appId = "";
private String initParam = "";
private String initParamFile = "";
private boolean pinCode = false;
private boolean accessControl = false;
private boolean allowInsecureHttp = false;
private String attributesMode = "portion";
private String subjectDnIdAttribute = "CN";
private Duration connectTimeout = Duration.ofSeconds(5);
private Duration requestTimeout = Duration.ofSeconds(15);
private Duration challengeTtl = Duration.ofMinutes(2);
private int maxResponseBytes = 256 * 1024;
private int maxSignedDataBytes = 512 * 1024;
private String trustStore = "";
private String trustStorePassword = "";
private String trustStoreType = "PKCS12";
private String clientDownloadUrl = "";
public boolean isConfigured() {
return enabled && StringUtils.hasText(gatewayUrl) && StringUtils.hasText(appId);
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getGatewayUrl() {
return gatewayUrl;
}
public void setGatewayUrl(String gatewayUrl) {
this.gatewayUrl = gatewayUrl;
}
public String getAppId() {
return appId;
}
public void setAppId(String appId) {
this.appId = appId;
}
public String getInitParam() {
return initParam;
}
public void setInitParam(String initParam) {
this.initParam = initParam;
}
public String getInitParamFile() {
return initParamFile;
}
public void setInitParamFile(String initParamFile) {
this.initParamFile = initParamFile;
}
public boolean isPinCode() {
return pinCode;
}
public void setPinCode(boolean pinCode) {
this.pinCode = pinCode;
}
public boolean isAccessControl() {
return accessControl;
}
public void setAccessControl(boolean accessControl) {
this.accessControl = accessControl;
}
public boolean isAllowInsecureHttp() {
return allowInsecureHttp;
}
public void setAllowInsecureHttp(boolean allowInsecureHttp) {
this.allowInsecureHttp = allowInsecureHttp;
}
public String getAttributesMode() {
return attributesMode;
}
public void setAttributesMode(String attributesMode) {
this.attributesMode = attributesMode;
}
public String getSubjectDnIdAttribute() {
return subjectDnIdAttribute;
}
public void setSubjectDnIdAttribute(String subjectDnIdAttribute) {
this.subjectDnIdAttribute = subjectDnIdAttribute;
}
public Duration getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(Duration connectTimeout) {
this.connectTimeout = connectTimeout;
}
public Duration getRequestTimeout() {
return requestTimeout;
}
public void setRequestTimeout(Duration requestTimeout) {
this.requestTimeout = requestTimeout;
}
public Duration getChallengeTtl() {
return challengeTtl;
}
public void setChallengeTtl(Duration challengeTtl) {
this.challengeTtl = challengeTtl;
}
public int getMaxResponseBytes() {
return maxResponseBytes;
}
public void setMaxResponseBytes(int maxResponseBytes) {
this.maxResponseBytes = maxResponseBytes;
}
public int getMaxSignedDataBytes() {
return maxSignedDataBytes;
}
public void setMaxSignedDataBytes(int maxSignedDataBytes) {
this.maxSignedDataBytes = maxSignedDataBytes;
}
public String getTrustStore() {
return trustStore;
}
public void setTrustStore(String trustStore) {
this.trustStore = trustStore;
}
public String getTrustStorePassword() {
return trustStorePassword;
}
public void setTrustStorePassword(String trustStorePassword) {
this.trustStorePassword = trustStorePassword;
}
public String getTrustStoreType() {
return trustStoreType;
}
public void setTrustStoreType(String trustStoreType) {
this.trustStoreType = trustStoreType;
}
public String getClientDownloadUrl() {
return clientDownloadUrl;
}
public void setClientDownloadUrl(String clientDownloadUrl) {
this.clientDownloadUrl = clientDownloadUrl;
}
}
package com.zhihui.portal.cert;
import com.zhihui.portal.auth.PortalAccounts;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.security.LoginIdentifierResolver;
import com.zhihui.portal.user.SysUser;
import com.zhihui.portal.user.SysUserRepository;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
@Service
public class CertificateAuthService {
static final String SESSION_CHALLENGE = CertificateAuthService.class.getName() + ".challenge";
static final String SESSION_EXPIRES_AT = CertificateAuthService.class.getName() + ".expiresAt";
private static final int CHALLENGE_BYTES = 32;
private final CertificateAuthProperties properties;
private final GatewayMessageClient gatewayClient;
private final CertificateSubjectParser subjectParser;
private final SysUserRepository userRepository;
private final SecureRandom secureRandom = new SecureRandom();
public CertificateAuthService(CertificateAuthProperties properties,
GatewayMessageClient gatewayClient,
CertificateSubjectParser subjectParser,
SysUserRepository userRepository) {
this.properties = properties;
this.gatewayClient = gatewayClient;
this.subjectParser = subjectParser;
this.userRepository = userRepository;
}
public boolean isEnabled() {
return properties.isConfigured() && gatewayClient.isAvailable();
}
public ChallengeResponse createChallenge(HttpSession session) {
if (!isEnabled()) {
throw notConfigured();
}
byte[] random = new byte[CHALLENGE_BYTES];
secureRandom.nextBytes(random);
String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
Duration ttl = properties.getChallengeTtl() == null
? Duration.ofMinutes(2) : properties.getChallengeTtl();
session.setAttribute(SESSION_CHALLENGE, challenge);
session.setAttribute(SESSION_EXPIRES_AT,
Instant.now().plus(ttl).toEpochMilli());
return new ChallengeResponse(challenge, Math.max(1, ttl.toSeconds()));
}
public SecurityUser authenticate(HttpSession session, String submittedOriginal,
String signedData, String clientIp) {
if (!isEnabled()) {
throw notConfigured();
}
String challenge = consumeChallenge(session, submittedOriginal);
GatewayMessageClient.GatewayAuthResult result =
gatewayClient.authenticate(clientIp, challenge, signedData);
if (!result.success()) {
String code = StringUtils.hasText(result.errorCode())
? result.errorCode().trim() : "UNKNOWN";
throw new CertificateAuthException(
CertificateAuthException.Reason.GATEWAY_REJECTED,
"证书认证失败",
"证书认证失败(网关错误码: " + code + ")",
"证书用户",
true,
"cert_failed");
}
String idCardNumber = subjectParser.extractIdCardNumber(result.subjectDn())
.orElseThrow(() -> new CertificateAuthException(
CertificateAuthException.Reason.CERT_INVALID,
"证书主题中未找到身份证号",
"证书认证失败(证书主题缺少身份证号)",
"证书用户",
true,
"cert_failed"));
String maskedIdentity = LoginIdentifierResolver.maskForAudit(idCardNumber);
Optional<SysUser> found = userRepository.findByIdCardNumber(idCardNumber);
if (found.isEmpty()) {
throw new CertificateAuthException(
CertificateAuthException.Reason.NO_USER,
"该证书未关联门户账号",
"证书未关联门户账号",
maskedIdentity,
false,
"cert_no_user");
}
SysUser user = found.get();
if (user.getStatus() == null || user.getStatus() != 1) {
throw new CertificateAuthException(
CertificateAuthException.Reason.USER_DISABLED,
"账号已停用",
"账号已停用",
maskedIdentity,
false,
"cert_disabled");
}
if (PortalAccounts.isUsernameAccount(user.getUsername())) {
throw new CertificateAuthException(
CertificateAuthException.Reason.ACCOUNT_DENIED,
"该账号不支持数字证书登录",
"内置账号不支持数字证书登录",
maskedIdentity,
false,
"cert_failed");
}
return new SecurityUser(
user.getId(),
user.getUsername(),
null,
user.getDisplayName(),
user.getPoliceNumber(),
user.getIdCardNumber(),
user.getPhone(),
user.getEmail(),
user.getRole(),
true,
user.isMustChangePassword());
}
private String consumeChallenge(HttpSession session, String submittedOriginal) {
String expected = (String) session.getAttribute(SESSION_CHALLENGE);
Object expiresValue = session.getAttribute(SESSION_EXPIRES_AT);
session.removeAttribute(SESSION_CHALLENGE);
session.removeAttribute(SESSION_EXPIRES_AT);
if (!StringUtils.hasText(expected) || !(expiresValue instanceof Long expiresAt)) {
throw challengeInvalid();
}
if (Instant.now().toEpochMilli() > expiresAt) {
throw challengeInvalid();
}
String submitted = submittedOriginal == null ? "" : submittedOriginal.trim();
if (submitted.length() > 128 || !MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
submitted.getBytes(StandardCharsets.UTF_8))) {
throw challengeInvalid();
}
return expected;
}
private static CertificateAuthException notConfigured() {
return new CertificateAuthException(
CertificateAuthException.Reason.NOT_CONFIGURED,
"数字证书登录暂不可用",
"数字证书登录未配置",
"证书用户",
false,
"cert_unavailable");
}
private static CertificateAuthException challengeInvalid() {
return new CertificateAuthException(
CertificateAuthException.Reason.CHALLENGE_INVALID,
"认证原文无效或已过期",
"证书认证失败(认证原文无效或已过期)",
"证书用户",
true,
"cert_failed");
}
public record ChallengeResponse(String original, long expiresInSeconds) {
}
}
package com.zhihui.portal.cert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@Component
public class CertificateAuthSettings {
private static final Logger log = LoggerFactory.getLogger(CertificateAuthSettings.class);
private final CertificateAuthService authService;
private final CertificateAuthProperties properties;
private final String initParam;
public CertificateAuthSettings(CertificateAuthService authService,
CertificateAuthProperties properties) {
this.authService = authService;
this.properties = properties;
this.initParam = resolveInitParam(properties);
if (properties.isEnabled() && !StringUtils.hasText(initParam)) {
log.warn("数字证书登录缺少 PNXClient 初始化参数,登录页入口不会显示");
}
}
public boolean isClientReady() {
return authService.isEnabled() && StringUtils.hasText(initParam);
}
public boolean isPinCode() {
return properties.isPinCode();
}
public String getInitParam() {
return initParam;
}
public String getClientDownloadUrl() {
return properties.getClientDownloadUrl();
}
private static String resolveInitParam(CertificateAuthProperties properties) {
if (StringUtils.hasText(properties.getInitParam())) {
return properties.getInitParam().trim();
}
if (!StringUtils.hasText(properties.getInitParamFile())) {
return "";
}
try {
String file = properties.getInitParamFile().trim();
Path path = file.startsWith("file:") ? Path.of(URI.create(file)) : Path.of(file);
return Files.readString(path, StandardCharsets.UTF_8).trim();
} catch (IOException | IllegalArgumentException ex) {
log.warn("读取 PNXClient 初始化参数失败: {}", ex.getMessage());
return "";
}
}
}
package com.zhihui.portal.cert;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginAttemptService;
import com.zhihui.portal.security.LoginSuccessHandler;
import com.zhihui.portal.security.LoginType;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import java.io.IOException;
public class CertificateAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private final CertificateAuthService authService;
private final ClientInfoResolver clientInfoResolver;
private final LoginAttemptService loginAttemptService;
private final AuditLogService auditLogService;
public CertificateAuthenticationFilter(CertificateAuthService authService,
LoginSuccessHandler successHandler,
CertificateLoginFailureHandler failureHandler,
ClientInfoResolver clientInfoResolver,
LoginAttemptService loginAttemptService,
AuditLogService auditLogService) {
super(PathPatternRequestMatcher.withDefaults().matcher("/login/cert/authenticate"));
this.authService = authService;
this.clientInfoResolver = clientInfoResolver;
this.loginAttemptService = loginAttemptService;
this.auditLogService = auditLogService;
setAuthenticationSuccessHandler(successHandler);
setAuthenticationFailureHandler(failureHandler);
setSessionAuthenticationStrategy(new ChangeSessionIdAuthenticationStrategy());
setSecurityContextRepository(new HttpSessionSecurityContextRepository());
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
String clientIp = clientInfoResolver.resolveIp(request);
LoginAttemptService.Decision decision = loginAttemptService.check(null, clientIp);
if (decision.blocked()) {
auditLogService.recordLoginBlocked(null, clientIp,
clientInfoResolver.resolveUserAgent(request),
decision.scope(), decision.retryAfterSeconds(), LoginType.CERTIFICATE);
throw CertificateAuthException.rateLimited(decision.retryAfterSeconds());
}
HttpSession session = request.getSession(false);
if (session == null) {
throw new CertificateAuthException(
CertificateAuthException.Reason.CHALLENGE_INVALID,
"认证原文无效或已过期",
"证书认证失败(认证原文无效或已过期)",
"证书用户",
true,
"cert_failed");
}
SecurityUser user = authService.authenticate(
session,
request.getParameter("original"),
request.getParameter("signed_data"),
clientIp);
request.setAttribute(LoginSuccessHandler.LOGIN_DETAIL_ATTRIBUTE,
"登录成功(数字证书)");
request.setAttribute(LoginSuccessHandler.LOGIN_TYPE_ATTRIBUTE,
LoginType.CERTIFICATE.name());
return UsernamePasswordAuthenticationToken.authenticated(
user, null, user.getAuthorities());
}
@Override
protected boolean requiresAuthentication(HttpServletRequest request,
HttpServletResponse response) {
return "POST".equalsIgnoreCase(request.getMethod())
&& super.requiresAuthentication(request, response);
}
}
package com.zhihui.portal.cert;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/login/cert")
public class CertificateLoginController {
private final CertificateAuthService authService;
private final CertificateAuthSettings settings;
public CertificateLoginController(CertificateAuthService authService,
CertificateAuthSettings settings) {
this.authService = authService;
this.settings = settings;
}
@GetMapping("/config")
public ResponseEntity<CertificateLoginConfig> config() {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(new CertificateLoginConfig(settings.isClientReady(), settings.isPinCode(),
settings.getInitParam(), settings.getClientDownloadUrl()));
}
@PostMapping("/challenge")
public ResponseEntity<?> challenge(HttpSession session) {
if (!authService.isEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.cacheControl(CacheControl.noStore())
.body(Map.of("message", "数字证书登录暂不可用"));
}
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(authService.createChallenge(session));
}
public record CertificateLoginConfig(boolean enabled, boolean pinCode, String initParam,
String clientDownloadUrl) {
}
}
package com.zhihui.portal.cert;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginAttemptService;
import com.zhihui.portal.security.LoginType;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import java.io.IOException;
public class CertificateLoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private static final String DEFAULT_IDENTITY = "证书用户";
private final LoginAttemptService loginAttemptService;
private final ClientInfoResolver clientInfoResolver;
private final AuditLogService auditLogService;
public CertificateLoginFailureHandler(LoginAttemptService loginAttemptService,
ClientInfoResolver clientInfoResolver,
AuditLogService auditLogService) {
super("/login?error=cert_failed");
this.loginAttemptService = loginAttemptService;
this.clientInfoResolver = clientInfoResolver;
this.auditLogService = auditLogService;
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String clientIp = clientInfoResolver.resolveIp(request);
String userAgent = clientInfoResolver.resolveUserAgent(request);
if (!(exception instanceof CertificateAuthException certException)) {
auditLogService.recordLoginFailure(DEFAULT_IDENTITY, clientIp, userAgent,
"证书登录失败", false, LoginType.CERTIFICATE);
super.onAuthenticationFailure(request, response, exception);
return;
}
if (certException.getReason() == CertificateAuthException.Reason.RATE_LIMITED) {
response.setHeader("Retry-After", String.valueOf(certException.getRetryAfterSeconds()));
getRedirectStrategy().sendRedirect(request, response,
request.getContextPath() + "/login?error=locked");
return;
}
LoginAttemptService.Decision decision = null;
if (certException.isCountFailure()) {
decision = loginAttemptService.recordFailure(null, clientIp);
}
boolean blocked = decision != null && decision.blocked();
String identity = certException.getAuditIdentity() == null
? DEFAULT_IDENTITY : certException.getAuditIdentity();
auditLogService.recordLoginFailure(identity, clientIp, userAgent,
certException.getAuditReason(), blocked, LoginType.CERTIFICATE);
if (blocked) {
response.setHeader("Retry-After", String.valueOf(decision.retryAfterSeconds()));
getRedirectStrategy().sendRedirect(request, response,
request.getContextPath() + "/login?error=locked");
return;
}
getRedirectStrategy().sendRedirect(request, response,
request.getContextPath() + "/login?error=" + safeErrorCode(certException.getErrorCode()));
}
private static String safeErrorCode(String code) {
if (code == null || !code.matches("[a-z_]{1,32}")) {
return "cert_failed";
}
return code;
}
}
package com.zhihui.portal.cert;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class CertificateSubjectParser {
private static final Pattern ID_CARD = Pattern.compile("(?<!\\d)(\\d{17}[0-9Xx])(?!\\d)");
private final String idAttribute;
@Autowired
public CertificateSubjectParser(CertificateAuthProperties properties) {
this(properties.getSubjectDnIdAttribute());
}
CertificateSubjectParser(String idAttribute) {
this.idAttribute = StringUtils.hasText(idAttribute) ? idAttribute.trim() : "CN";
}
public Optional<String> extractIdCardNumber(String subjectDn) {
if (!StringUtils.hasText(subjectDn)) {
return Optional.empty();
}
final LdapName name;
try {
name = new LdapName(subjectDn.trim());
} catch (InvalidNameException ex) {
return Optional.empty();
}
Set<String> matches = new LinkedHashSet<>();
for (Rdn rdn : name.getRdns()) {
if (!idAttribute.equalsIgnoreCase(rdn.getType())) {
continue;
}
Object value = rdn.getValue();
if (value == null) {
continue;
}
Matcher matcher = ID_CARD.matcher(value.toString());
while (matcher.find()) {
matches.add(matcher.group(1).toUpperCase(Locale.ROOT));
}
}
return matches.size() == 1 ? Optional.of(matches.iterator().next()) : Optional.empty();
}
}
......@@ -3,8 +3,17 @@ package com.zhihui.portal.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.zhihui.portal.app.AppAuthorizationFilter;
import com.zhihui.portal.app.PortalAppRepository;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.PortalAccounts;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.security.ActiveUserFilter;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginIpBindingFilter;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.security.SessionRevocationService;
import com.zhihui.portal.user.SysUserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
......@@ -29,6 +38,7 @@ import org.springframework.security.oauth2.server.authorization.token.JwtEncodin
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import java.util.ArrayList;
......@@ -49,16 +59,34 @@ public class AuthorizationServerConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain authorizationServerSecurityFilterChain(
HttpSecurity http,
SysUserRepository userRepository,
PortalAppRepository appRepository,
JdbcTemplate jdbcTemplate,
AuditLogService auditLogService,
ClientInfoResolver clientInfoResolver,
SessionRevocationService sessionRevocationService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
OAuth2AuthorizationServerConfigurer.authorizationServer();
ActiveUserFilter activeUserFilter = new ActiveUserFilter(userRepository);
LoginIpBindingFilter loginIpBindingFilter = new LoginIpBindingFilter(
clientInfoResolver, auditLogService, sessionRevocationService,
oauthAuthorizationRevocationService);
AppAuthorizationFilter appAuthorizationFilter =
new AppAuthorizationFilter(appRepository, jdbcTemplate);
http
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
.with(authorizationServerConfigurer, authorizationServer ->
authorizationServer.oidc(oidc -> oidc.userInfoEndpoint(userInfo ->
userInfo.userInfoMapper(AuthorizationServerConfig::mapUserInfo))))
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.addFilterAfter(loginIpBindingFilter, SecurityContextHolderFilter.class)
.addFilterAfter(activeUserFilter, SecurityContextHolderFilter.class)
.addFilterAfter(appAuthorizationFilter, SecurityContextHolderFilter.class)
.exceptionHandling(exceptions -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
......
package com.zhihui.portal.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.filter.ForwardedHeaderFilter;
/**
* 只有显式信任反向代理时才解析 Forwarded / X-Forwarded-*,
* 避免直连部署时客户端伪造来源 IP 绕过登录 IP 绑定。
*/
@Configuration
public class ForwardedHeaderConfig {
@Bean
@ConditionalOnProperty(prefix = "zhihui.security", name = "trust-forwarded-headers",
havingValue = "true")
public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter() {
FilterRegistrationBean<ForwardedHeaderFilter> registration =
new FilterRegistrationBean<>(new ForwardedHeaderFilter());
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
return registration;
}
}
......@@ -5,12 +5,21 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.cert.CertificateAuthService;
import com.zhihui.portal.cert.CertificateAuthenticationFilter;
import com.zhihui.portal.cert.CertificateLoginFailureHandler;
import com.zhihui.portal.security.ActiveUserFilter;
import com.zhihui.portal.security.AuditLogoutSuccessHandler;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.LoginAttemptService;
import com.zhihui.portal.security.LoginFailureHandler;
import com.zhihui.portal.security.LoginIpBindingFilter;
import com.zhihui.portal.security.LoginRateLimitFilter;
import com.zhihui.portal.security.LoginSuccessHandler;
import com.zhihui.portal.security.MustChangePasswordFilter;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.security.SessionRevocationService;
import com.zhihui.portal.user.SysUserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
......@@ -21,6 +30,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.csrf.CsrfToken;
......@@ -51,25 +61,43 @@ public class SecurityConfig {
LoginAttemptService loginAttemptService,
ClientInfoResolver clientInfoResolver,
AuditLogService auditLogService,
PortalSecurityProperties securityProperties)
PortalSecurityProperties securityProperties,
SysUserRepository userRepository,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService,
SessionRevocationService sessionRevocationService,
CertificateAuthService certificateAuthService)
throws Exception {
RequestMatcher apiMatcher = PathPatternRequestMatcher.withDefaults().matcher("/api/**");
LoginRateLimitFilter rateLimitFilter = new LoginRateLimitFilter(
loginAttemptService, clientInfoResolver, auditLogService, securityProperties);
LoginSuccessHandler loginSuccessHandler = new LoginSuccessHandler(
loginAttemptService, clientInfoResolver, auditLogService);
loginAttemptService, clientInfoResolver, auditLogService, userRepository);
LoginFailureHandler loginFailureHandler = new LoginFailureHandler(
loginAttemptService, clientInfoResolver, auditLogService);
CertificateLoginFailureHandler certificateLoginFailureHandler =
new CertificateLoginFailureHandler(
loginAttemptService, clientInfoResolver, auditLogService);
CertificateAuthenticationFilter certificateAuthenticationFilter =
new CertificateAuthenticationFilter(
certificateAuthService, loginSuccessHandler, certificateLoginFailureHandler,
clientInfoResolver, loginAttemptService, auditLogService);
AuditLogoutSuccessHandler logoutSuccessHandler = new AuditLogoutSuccessHandler(
auditLogService, clientInfoResolver);
auditLogService, clientInfoResolver, oauthAuthorizationRevocationService);
LoginIpBindingFilter loginIpBindingFilter = new LoginIpBindingFilter(
clientInfoResolver, auditLogService, sessionRevocationService,
oauthAuthorizationRevocationService);
ActiveUserFilter activeUserFilter = new ActiveUserFilter(userRepository);
MustChangePasswordFilter mustChangePasswordFilter = new MustChangePasswordFilter();
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/login", "/css/**", "/img/**", "/spa/**", "/favicon.ico", "/error").permitAll()
.requestMatchers("/login", "/login/cert/**", "/css/**", "/img/**", "/spa/**",
"/favicon.ico", "/error").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/actuator/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.requestMatchers("/change-password").authenticated()
.anyRequest().permitAll())
.formLogin(form -> form
.loginPage("/login")
......@@ -86,7 +114,11 @@ public class SecurityConfig {
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class)
.addFilterAfter(loginIpBindingFilter, SecurityContextHolderFilter.class)
.addFilterAfter(activeUserFilter, SecurityContextHolderFilter.class)
.addFilterAfter(mustChangePasswordFilter, SecurityContextHolderFilter.class)
.addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(certificateAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.migrateSession()))
.exceptionHandling(exceptions -> exceptions
......
......@@ -11,6 +11,7 @@ import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.config.ZhihuiProperties;
import com.zhihui.portal.security.ClientInfoResolver;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.user.SysUser;
import com.zhihui.portal.user.UserService;
import com.zhihui.portal.user.dto.UserDtos.UpdateEmailRequest;
......@@ -40,15 +41,18 @@ public class PortalController {
private final AuditLogService auditLogService;
private final ClientInfoResolver clientInfoResolver;
private final UserService userService;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public PortalController(AppService appService, ZhihuiProperties properties,
AuditLogService auditLogService, ClientInfoResolver clientInfoResolver,
UserService userService) {
UserService userService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.appService = appService;
this.properties = properties;
this.auditLogService = auditLogService;
this.clientInfoResolver = clientInfoResolver;
this.userService = userService;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
}
@GetMapping("/me")
......@@ -113,6 +117,9 @@ public class PortalController {
auditLogService.recordLogout(user,
clientInfoResolver.resolveIp(request),
clientInfoResolver.resolveUserAgent(request));
if (user != null) {
oauthAuthorizationRevocationService.revokeByPrincipalName(user.getUsername());
}
new SecurityContextLogoutHandler().logout(request, response, authentication);
}
......
package com.zhihui.portal.security;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.user.SysUser;
import com.zhihui.portal.user.SysUserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Objects;
/**
* 每次受保护请求都从数据库刷新账号状态,避免降级、停用或删除后的旧会话继续生效。
*/
public class ActiveUserFilter extends OncePerRequestFilter {
private static final RequestMatcher API_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/api/**");
private static final RequestMatcher AUTHORIZE_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/oauth2/authorize");
private static final RequestMatcher LOGOUT_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/api/logout");
private final SysUserRepository userRepository;
public ActiveUserFilter(SysUserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
if (LOGOUT_MATCHER.matches(request)) {
return true;
}
return !API_MATCHER.matches(request) && !AUTHORIZE_MATCHER.matches(request);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !(authentication.getPrincipal() instanceof SecurityUser currentUser)) {
filterChain.doFilter(request, response);
return;
}
SysUser current = userRepository.findById(currentUser.getId()).orElse(null);
if (current == null || current.getStatus() == null || current.getStatus() != 1) {
SecurityContextHolder.clearContext();
HttpSession session = request.getSession(false);
if (session != null) {
try {
session.invalidate();
} catch (IllegalStateException ignored) {
// 会话已经失效时忽略
}
}
writeInactive(request, response);
return;
}
if (!sameIdentity(currentUser, current)) {
SecurityUser refreshed = new SecurityUser(
current.getId(),
current.getUsername(),
null,
current.getDisplayName(),
current.getPoliceNumber(),
current.getIdCardNumber(),
current.getPhone(),
current.getEmail(),
current.getRole(),
true,
current.isMustChangePassword());
UsernamePasswordAuthenticationToken replacement = new UsernamePasswordAuthenticationToken(
refreshed, null, refreshed.getAuthorities());
replacement.setDetails(authentication.getDetails());
SecurityContextHolder.getContext().setAuthentication(replacement);
}
filterChain.doFilter(request, response);
}
private static boolean sameIdentity(SecurityUser sessionUser, SysUser current) {
return Objects.equals(sessionUser.getUsername(), current.getUsername())
&& Objects.equals(sessionUser.getRole(), current.getRole())
&& Objects.equals(sessionUser.getDisplayName(), current.getDisplayName())
&& Objects.equals(sessionUser.getPoliceNumber(), current.getPoliceNumber())
&& Objects.equals(sessionUser.getIdCardNumber(), current.getIdCardNumber())
&& Objects.equals(sessionUser.getPhoneNumber(), current.getPhone())
&& Objects.equals(sessionUser.getEmail(), current.getEmail())
&& sessionUser.isMustChangePassword() == current.isMustChangePassword();
}
private static void writeInactive(HttpServletRequest request, HttpServletResponse response)
throws IOException {
if (API_MATCHER.matches(request)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\":\"账号已停用或不存在\"}");
return;
}
response.sendRedirect(request.getContextPath() + "/login?error=disabled");
}
}
......@@ -14,11 +14,14 @@ public class AuditLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private final AuditLogService auditLogService;
private final ClientInfoResolver clientInfoResolver;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public AuditLogoutSuccessHandler(AuditLogService auditLogService,
ClientInfoResolver clientInfoResolver) {
ClientInfoResolver clientInfoResolver,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.auditLogService = auditLogService;
this.clientInfoResolver = clientInfoResolver;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
setDefaultTargetUrl("/login?logout");
}
......@@ -27,6 +30,9 @@ public class AuditLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
Authentication authentication) throws IOException, ServletException {
SecurityUser user = authentication != null
&& authentication.getPrincipal() instanceof SecurityUser value ? value : null;
if (user != null) {
oauthAuthorizationRevocationService.revokeByPrincipalName(user.getUsername());
}
auditLogService.recordLogout(user,
clientInfoResolver.resolveIp(request),
clientInfoResolver.resolveUserAgent(request));
......
......@@ -36,7 +36,7 @@ public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
String userAgent = clientInfoResolver.resolveUserAgent(request);
LoginAttemptService.Decision decision = loginAttemptService.recordFailure(username, clientIp);
auditLogService.recordLoginFailure(username, clientIp, userAgent,
reason(exception), decision.blocked());
reason(exception), decision.blocked(), LoginType.PASSWORD);
if (decision.blocked()) {
response.setHeader("Retry-After", String.valueOf(decision.retryAfterSeconds()));
......
package com.zhihui.portal.security;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 登录会话与首次登录 IP 绑定。IP 变化视为认证信息可能被窃用,
* 立即撤销当前会话、该用户全部门户会话及全部子系统授权。
*/
public class LoginIpBindingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(LoginIpBindingFilter.class);
private static final RequestMatcher API_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/api/**");
private final ClientInfoResolver clientInfoResolver;
private final AuditLogService auditLogService;
private final SessionRevocationService sessionRevocationService;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public LoginIpBindingFilter(ClientInfoResolver clientInfoResolver,
AuditLogService auditLogService,
SessionRevocationService sessionRevocationService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.clientInfoResolver = clientInfoResolver;
this.auditLogService = auditLogService;
this.sessionRevocationService = sessionRevocationService;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !(authentication.getPrincipal() instanceof SecurityUser user)) {
filterChain.doFilter(request, response);
return;
}
HttpSession session = request.getSession(false);
String currentIp = clientInfoResolver.resolveIp(request);
if (!StringUtils.hasText(currentIp)) {
currentIp = "unknown";
}
if (session == null) {
SecurityContextHolder.clearContext();
deny(request, response);
return;
}
String boundIp = attribute(session, LoginSession.IP_ATTRIBUTE);
LoginType loginType = LoginType.from(attribute(session, LoginSession.TYPE_ATTRIBUTE));
if (!StringUtils.hasText(boundIp)) {
auditLogService.recordLoginIpChanged(user, null, currentIp,
clientInfoResolver.resolveUserAgent(request), loginType);
invalidate(session);
SecurityContextHolder.clearContext();
deny(request, response);
return;
}
if (boundIp.equals(currentIp)) {
filterChain.doFilter(request, response);
return;
}
log.warn("登录IP变更,撤销认证信息: username={}, boundIp={}, currentIp={}",
user.getUsername(), boundIp, currentIp);
auditLogService.recordLoginIpChanged(user, boundIp, currentIp,
clientInfoResolver.resolveUserAgent(request), loginType);
invalidate(session);
sessionRevocationService.revokeByUsername(user.getUsername());
oauthAuthorizationRevocationService.revokeByPrincipalName(user.getUsername());
SecurityContextHolder.clearContext();
deny(request, response);
}
private static String attribute(HttpSession session, String name) {
Object value = session.getAttribute(name);
return value instanceof String text ? text : null;
}
private static void invalidate(HttpSession session) {
try {
session.invalidate();
} catch (IllegalStateException ignored) {
// 会话已经失效时忽略
}
}
private static void deny(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setHeader("Cache-Control", "no-store");
if (API_MATCHER.matches(request)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":\"LOGIN_IP_CHANGED\",\"message\":\"登录IP已变更,请重新登录\"}");
return;
}
response.sendRedirect(request.getContextPath() + "/login?error=ip_changed");
}
}
......@@ -51,7 +51,7 @@ public class LoginRateLimitFilter extends OncePerRequestFilter {
auditLogService.recordLoginBlocked(username, clientIp,
clientInfoResolver.resolveUserAgent(request),
decision.scope(), decision.retryAfterSeconds());
decision.scope(), decision.retryAfterSeconds(), LoginType.PASSWORD);
response.setHeader("Retry-After", String.valueOf(decision.retryAfterSeconds()));
response.sendRedirect(request.getContextPath() + "/login?error=locked");
}
......
package com.zhihui.portal.security;
public final class LoginSession {
public static final String IP_ATTRIBUTE = "ZHIHUI_LOGIN_IP";
public static final String TYPE_ATTRIBUTE = "ZHIHUI_LOGIN_TYPE";
public static final String TIME_ATTRIBUTE = "ZHIHUI_LOGIN_TIME";
private LoginSession() {
}
}
......@@ -2,26 +2,37 @@ package com.zhihui.portal.security;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.user.SysUserRepository;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
public static final String LOGIN_DETAIL_ATTRIBUTE = "zhihui.login.detail";
public static final String LOGIN_TYPE_ATTRIBUTE = "zhihui.login.type";
private final LoginAttemptService loginAttemptService;
private final ClientInfoResolver clientInfoResolver;
private final AuditLogService auditLogService;
private final SysUserRepository userRepository;
public LoginSuccessHandler(LoginAttemptService loginAttemptService,
ClientInfoResolver clientInfoResolver,
AuditLogService auditLogService) {
AuditLogService auditLogService,
SysUserRepository userRepository) {
this.loginAttemptService = loginAttemptService;
this.clientInfoResolver = clientInfoResolver;
this.auditLogService = auditLogService;
this.userRepository = userRepository;
setDefaultTargetUrl("/");
setAlwaysUseDefaultTargetUrl(true);
}
......@@ -30,11 +41,30 @@ public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {
String clientIp = clientInfoResolver.resolveIp(request);
String boundIp = StringUtils.hasText(clientIp) ? clientIp : "unknown";
loginAttemptService.reset(request.getParameter("username"), clientIp);
loginAttemptService.reset(authentication.getName(), clientIp);
SecurityUser user = authentication.getPrincipal() instanceof SecurityUser value ? value : null;
LoginType loginType = LoginType.from(request.getAttribute(LOGIN_TYPE_ATTRIBUTE) instanceof String value
? value : null);
String detail = request.getAttribute(LOGIN_DETAIL_ATTRIBUTE) instanceof String value
&& !value.isBlank() ? value : "登录成功(" + loginType.getLabel() + ")";
HttpSession session = request.getSession(true);
session.setAttribute(LoginSession.IP_ATTRIBUTE, boundIp);
session.setAttribute(LoginSession.TYPE_ATTRIBUTE, loginType.name());
session.setAttribute(LoginSession.TIME_ATTRIBUTE, System.currentTimeMillis());
auditLogService.recordLoginSuccess(user, clientIp,
clientInfoResolver.resolveUserAgent(request));
clientInfoResolver.resolveUserAgent(request), detail, loginType);
if (user != null) {
userRepository.updateLastLoginAt(user.getId(), LocalDateTime.now(ZoneOffset.UTC));
if (user.isMustChangePassword()) {
getRedirectStrategy().sendRedirect(request, response,
request.getContextPath() + "/change-password");
return;
}
}
super.onAuthenticationSuccess(request, response, authentication);
}
}
package com.zhihui.portal.security;
import java.util.Locale;
public enum LoginType {
PASSWORD("账号密码"),
CERTIFICATE("数字证书");
private final String label;
LoginType(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
public static LoginType from(String value) {
if (value != null && "CERTIFICATE".equals(value.trim().toUpperCase(Locale.ROOT))) {
return CERTIFICATE;
}
return PASSWORD;
}
}
package com.zhihui.portal.security;
import com.zhihui.portal.auth.SecurityUser;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 导入账号首次登录后,在修改密码前只允许访问改密页和退出登录。
*/
public class MustChangePasswordFilter extends OncePerRequestFilter {
private static final RequestMatcher API_MATCHER =
PathPatternRequestMatcher.withDefaults().matcher("/api/**");
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !(authentication.getPrincipal() instanceof SecurityUser user)
|| !user.isMustChangePassword()) {
return true;
}
String path = request.getRequestURI().substring(request.getContextPath().length());
return path.equals("/change-password")
|| path.equals("/logout")
|| path.startsWith("/css/")
|| path.startsWith("/js/")
|| path.startsWith("/img/")
|| path.equals("/favicon.ico")
|| path.equals("/error");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (API_MATCHER.matches(request)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\":\"请先修改初始密码\"}");
return;
}
response.sendRedirect(request.getContextPath() + "/change-password");
}
}
package com.zhihui.portal.security;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class OAuthAuthorizationRevocationService {
private final JdbcTemplate jdbcTemplate;
public OAuthAuthorizationRevocationService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void revokeByPrincipalName(String username) {
if (!StringUtils.hasText(username)) {
return;
}
jdbcTemplate.update("DELETE FROM oauth2_authorization WHERE principal_name = ?", username);
jdbcTemplate.update("DELETE FROM oauth2_authorization_consent WHERE principal_name = ?", username);
}
public void revokeByRegisteredClientId(String registeredClientId) {
if (!StringUtils.hasText(registeredClientId)) {
return;
}
jdbcTemplate.update("DELETE FROM oauth2_authorization WHERE registered_client_id = ?", registeredClientId);
jdbcTemplate.update(
"DELETE FROM oauth2_authorization_consent WHERE registered_client_id = ?",
registeredClientId);
}
public void revokeByPrincipalAndClientId(String username, String clientId) {
if (!StringUtils.hasText(username) || !StringUtils.hasText(clientId)) {
return;
}
jdbcTemplate.update("""
DELETE FROM oauth2_authorization
WHERE principal_name = ?
AND registered_client_id = (
SELECT id FROM oauth2_registered_client WHERE client_id = ?
)
""", username, clientId);
jdbcTemplate.update("""
DELETE FROM oauth2_authorization_consent
WHERE principal_name = ?
AND registered_client_id = (
SELECT id FROM oauth2_registered_client WHERE client_id = ?
)
""", username, clientId);
}
}
package com.zhihui.portal.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class SessionRevocationService {
private static final Logger log = LoggerFactory.getLogger(SessionRevocationService.class);
private final ObjectProvider<FindByIndexNameSessionRepository<? extends Session>> repositoryProvider;
public SessionRevocationService(
ObjectProvider<FindByIndexNameSessionRepository<? extends Session>> repositoryProvider) {
this.repositoryProvider = repositoryProvider;
}
public void revokeByUsername(String username) {
if (!StringUtils.hasText(username)) {
return;
}
FindByIndexNameSessionRepository<? extends Session> repository = repositoryProvider.getIfAvailable();
if (repository == null) {
log.warn("未找到 Spring Session 索引仓库,无法撤销用户会话: username={}", username);
return;
}
try {
repository.findByPrincipalName(username).keySet().forEach(repository::deleteById);
} catch (RuntimeException ex) {
log.warn("撤销用户会话失败: username={}, error={}", username, ex.getMessage());
}
}
}
package com.zhihui.portal.user;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
@Component
public class AccountInactivityScheduler {
private final AccountInactivityService accountInactivityService;
public AccountInactivityScheduler(AccountInactivityService accountInactivityService) {
this.accountInactivityService = accountInactivityService;
}
@Scheduled(cron = "0 17 3 * * *", zone = "Asia/Shanghai")
public void disableInactiveAccounts() {
accountInactivityService.disableInactive(LocalDateTime.now(ZoneOffset.UTC));
}
}
package com.zhihui.portal.user;
import com.zhihui.portal.audit.AuditEvents;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.security.SessionRevocationService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
public class AccountInactivityService {
static final int IMPORTED_INACTIVE_DAYS = 30;
static final int ALL_INACTIVE_DAYS = 90;
private final SysUserRepository userRepository;
private final AuditLogService auditLogService;
private final SessionRevocationService sessionRevocationService;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public AccountInactivityService(SysUserRepository userRepository,
AuditLogService auditLogService,
SessionRevocationService sessionRevocationService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.userRepository = userRepository;
this.auditLogService = auditLogService;
this.sessionRevocationService = sessionRevocationService;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
}
@Transactional
public InactivityResult disableInactive(LocalDateTime now) {
List<SysUser> imported = userRepository.findInactiveImported(
now.minusDays(IMPORTED_INACTIVE_DAYS));
List<SysUser> all = userRepository.findInactiveExceptAdmin(
now.minusDays(ALL_INACTIVE_DAYS));
Set<Long> disabledIds = new HashSet<>();
int importedDisabled = disable(imported, "导入后30天未登录", disabledIds);
int generalDisabled = disable(all, "90天未使用", disabledIds);
return new InactivityResult(importedDisabled, generalDisabled);
}
private int disable(List<SysUser> users, String reason, Set<Long> disabledIds) {
int count = 0;
for (SysUser user : users) {
if (!disabledIds.add(user.getId())) {
continue;
}
user.setStatus(0);
userRepository.save(user);
sessionRevocationService.revokeByUsername(user.getUsername());
oauthAuthorizationRevocationService.revokeByPrincipalName(user.getUsername());
auditLogService.recordSystem(AuditEvents.USER_AUTO_DISABLE, "USER",
String.valueOf(user.getId()),
"username=" + user.getUsername() + "; reason=" + reason);
count++;
}
return count;
}
public record InactivityResult(int importedDisabled, int generalDisabled) {
}
}
......@@ -44,6 +44,15 @@ public class SysUser {
@Column(name = "status", nullable = false)
private Integer status = 1;
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
@Column(name = "must_change_password", nullable = false)
private boolean mustChangePassword = false;
@Column(name = "imported", nullable = false)
private boolean imported = false;
@Column(name = "created_at", insertable = false, updatable = false)
private LocalDateTime createdAt;
......@@ -130,6 +139,30 @@ public class SysUser {
this.status = status;
}
public LocalDateTime getLastLoginAt() {
return lastLoginAt;
}
public void setLastLoginAt(LocalDateTime lastLoginAt) {
this.lastLoginAt = lastLoginAt;
}
public boolean isMustChangePassword() {
return mustChangePassword;
}
public void setMustChangePassword(boolean mustChangePassword) {
this.mustChangePassword = mustChangePassword;
}
public boolean isImported() {
return imported;
}
public void setImported(boolean imported) {
this.imported = imported;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
......
......@@ -3,9 +3,13 @@ package com.zhihui.portal.user;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface SysUserRepository extends JpaRepository<SysUser, Long> {
......@@ -44,4 +48,27 @@ public interface SysUserRepository extends JpaRepository<SysUser, Long> {
or u.phone like concat('%', :kw, '%')
""")
Page<SysUser> search(@Param("kw") String keyword, Pageable pageable);
@Modifying
@Transactional
@Query("update SysUser u set u.lastLoginAt = :lastLoginAt where u.id = :id")
int updateLastLoginAt(@Param("id") Long id, @Param("lastLoginAt") LocalDateTime lastLoginAt);
@Query("""
select u from SysUser u
where u.status = 1
and u.imported = true
and u.lastLoginAt is null
and lower(u.username) <> 'admin'
and u.createdAt < :cutoff
""")
List<SysUser> findInactiveImported(@Param("cutoff") LocalDateTime cutoff);
@Query("""
select u from SysUser u
where u.status = 1
and lower(u.username) <> 'admin'
and coalesce(u.lastLoginAt, u.createdAt) < :cutoff
""")
List<SysUser> findInactiveExceptAdmin(@Param("cutoff") LocalDateTime cutoff);
}
......@@ -43,7 +43,7 @@ import java.util.stream.Collectors;
public class UserImportService {
private static final Logger log = LoggerFactory.getLogger(UserImportService.class);
private static final int MAX_ROWS = 200;
private static final int MAX_ROWS = 5000;
private static final long MAX_FILE_BYTES = 5L * 1024 * 1024;
private static final Map<String, String> HEADER_ALIASES = Map.ofEntries(
......@@ -121,6 +121,7 @@ public class UserImportService {
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
XSSFSheet sheet = workbook.createSheet("用户导入");
CellStyle headerStyle = headerStyle(workbook);
CellStyle textStyle = textStyle(workbook);
String[] headers = {"用户名", "密码", "姓名", "警号", "身份证号", "邮箱", "手机号", "角色", "状态"};
int[] widths = {18, 18, 16, 18, 22, 28, 18, 14, 14};
Row header = sheet.createRow(0);
......@@ -130,6 +131,10 @@ public class UserImportService {
cell.setCellStyle(headerStyle);
sheet.setColumnWidth(i, widths[i] * 256);
}
// 用户名、密码、警号、身份证号、手机号按文本处理,避免 Excel 转成科学计数法或丢失前导零
for (int column : new int[]{0, 1, 3, 4, 6}) {
sheet.setDefaultColumnStyle(column, textStyle);
}
sheet.createFreezePane(0, 1);
XSSFSheet help = workbook.createSheet("填写说明");
......@@ -138,12 +143,12 @@ public class UserImportService {
"2. 用户名:3-32 位字母、数字、下划线、点或中划线。",
"3. 密码:8-64 位;导入后请通知用户尽快修改初始密码。",
"4. 姓名:必填,最多 64 个字符。",
"5. 警号:普通用户必填,管理员可留空;填写时须为 1-32 位字母、数字或中划线,且不能重复。",
"6. 身份证号:可选,18 位,末位可为 X,且不能重复;请设置为文本格式。",
"7. 邮箱、手机号:可选;手机号须为 11 位中国大陆手机号,且不能重复。",
"5. 警号:普通用户必填,管理员可留空;填写时须为 1-32 位字母、数字或中划线,且不能重复;本列已预设为文本格式。",
"6. 身份证号:必填,18 位,末位可为 X,且不能重复;本列已预设为文本格式,请勿改成数值格式。",
"7. 邮箱:可选;手机号:必填,须为 11 位中国大陆手机号,且不能重复;手机号列已预设为文本格式。",
"8. 角色:ADMIN/USER 或 管理员/普通用户,留空默认 USER。",
"9. 状态:1/0 或 启用/停用,留空默认启用。",
"10. 单次最多导入 200 个用户;文件不超过 5MB。",
"10. 单次最多导入 5000 个用户;文件不超过 5MB。",
"11. 用户名、警号、身份证号或手机号重复/已存在时,该行会失败并返回原因,其他行继续导入。"
};
for (int i = 0; i < lines.length; i++) {
......@@ -194,6 +199,12 @@ public class UserImportService {
if (!columns.containsKey("policeNumber")) {
missing.add("警号");
}
if (!columns.containsKey("idCardNumber")) {
missing.add("身份证号");
}
if (!columns.containsKey("phone")) {
missing.add("手机号");
}
if (!missing.isEmpty()) {
throw new BizException("模板缺少必填列:" + String.join("、", missing));
}
......@@ -227,6 +238,9 @@ public class UserImportService {
try {
String username = readCell(row, columns.get("username"), formatter);
usernameForError = username;
requireTextIdentifier(row, columns.get("policeNumber"), "警号");
requireTextIdentifier(row, columns.get("idCardNumber"), "身份证号");
requireTextIdentifier(row, columns.get("phone"), "手机号");
String password = readCell(row, columns.get("password"), formatter);
String displayName = readCell(row, columns.get("displayName"), formatter);
String policeNumber = readCell(row, columns.get("policeNumber"), formatter);
......@@ -249,6 +263,12 @@ public class UserImportService {
if (!StringUtils.hasText(policeNumber) && !"ADMIN".equals(role)) {
throw new IllegalArgumentException("警号不能为空");
}
if (!StringUtils.hasText(idCardNumber)) {
throw new IllegalArgumentException("身份证号不能为空");
}
if (!StringUtils.hasText(phone)) {
throw new IllegalArgumentException("手机号不能为空");
}
String normalizedUsername = username.trim();
String usernameKey = normalizedUsername.toLowerCase(Locale.ROOT);
......@@ -288,7 +308,7 @@ public class UserImportService {
}
seenUsernames.add(usernameKey);
userService.create(request, status);
userService.createImported(request, status);
if (policeKey != null) {
seenPoliceNumbers.add(policeKey);
}
......@@ -316,8 +336,17 @@ public class UserImportService {
List.copyOf(errors), List.copyOf(importedUsernames));
}
private static String readCell(Row row, Integer column, DataFormatter formatter) {
private static void requireTextIdentifier(Row row, Integer column, String label) {
if (row == null || column == null) {
return;
}
Cell cell = row.getCell(column, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
if (cell != null && cell.getCellType() == CellType.NUMERIC) {
throw new IllegalArgumentException(label + "列必须为文本格式,请下载最新模板并重新填写");
}
}
private static String readCell(Row row, Integer column, DataFormatter formatter) { if (row == null || column == null) {
return null;
}
Cell cell = row.getCell(column, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
......@@ -382,6 +411,12 @@ public class UserImportService {
&& bytes[2] == 3 && bytes[3] == 4;
}
private static CellStyle textStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
style.setDataFormat(workbook.createDataFormat().getFormat("@"));
return style;
}
private static CellStyle headerStyle(Workbook workbook) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
......
......@@ -3,6 +3,8 @@ package com.zhihui.portal.user;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.common.BizException;
import com.zhihui.portal.common.PageResponse;
import com.zhihui.portal.security.OAuthAuthorizationRevocationService;
import com.zhihui.portal.security.SessionRevocationService;
import com.zhihui.portal.user.dto.UserDtos.CreateUserRequest;
import com.zhihui.portal.user.dto.UserDtos.ResetPasswordRequest;
import com.zhihui.portal.user.dto.UserDtos.UpdateUserRequest;
......@@ -20,6 +22,7 @@ import org.springframework.util.StringUtils;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
......@@ -36,12 +39,18 @@ public class UserService {
private final SysUserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JdbcTemplate jdbcTemplate;
private final SessionRevocationService sessionRevocationService;
private final OAuthAuthorizationRevocationService oauthAuthorizationRevocationService;
public UserService(SysUserRepository userRepository, PasswordEncoder passwordEncoder,
JdbcTemplate jdbcTemplate) {
JdbcTemplate jdbcTemplate,
SessionRevocationService sessionRevocationService,
OAuthAuthorizationRevocationService oauthAuthorizationRevocationService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jdbcTemplate = jdbcTemplate;
this.sessionRevocationService = sessionRevocationService;
this.oauthAuthorizationRevocationService = oauthAuthorizationRevocationService;
}
public PageResponse<UserResponse> list(String keyword, int page, int size) {
......@@ -64,6 +73,16 @@ public class UserService {
@Transactional
public UserResponse create(CreateUserRequest request, Integer status) {
return createInternal(request, status, false, false);
}
@Transactional
public UserResponse createImported(CreateUserRequest request, Integer status) {
return createInternal(request, status, true, true);
}
private UserResponse createInternal(CreateUserRequest request, Integer status,
boolean mustChangePassword, boolean imported) {
String username = request.username().trim();
if (userRepository.existsByUsername(username)) {
throw new BizException("用户名已存在");
......@@ -95,14 +114,17 @@ public class UserService {
user.setPhone(phone);
user.setRole(role);
user.setStatus(initialStatus);
user.setMustChangePassword(mustChangePassword);
user.setImported(imported);
return UserResponse.from(userRepository.save(user));
}
@Transactional
public UserResponse update(Long id, UpdateUserRequest request, SecurityUser currentUser) {
SysUser user = find(id);
boolean editingSelf = currentUser.getId().equals(id);
if (editingSelf && !currentUser.getRole().equals(request.role())) {
SysUser actor = find(currentUser.getId());
boolean editingSelf = actor.getId().equals(id);
if (editingSelf && !actor.getRole().equals(request.role())) {
throw new BizException("不能修改自己的角色");
}
if (editingSelf && request.status() != null && request.status() != 1) {
......@@ -111,6 +133,13 @@ public class UserService {
if (request.status() != null && request.status() != 0 && request.status() != 1) {
throw new BizException("状态值不合法");
}
String oldUsername = user.getUsername();
String oldRole = user.getRole();
Integer oldStatus = user.getStatus();
String oldPoliceNumber = user.getPoliceNumber();
String oldIdCardNumber = user.getIdCardNumber();
String oldPhone = user.getPhone();
String username = request.username() == null ? "" : request.username().trim();
if (!USERNAME_PATTERN.matcher(username).matches()) {
throw new BizException("用户名需为 3-32 位字母、数字、下划线、点或中划线");
......@@ -155,7 +184,21 @@ public class UserService {
if (request.status() != null) {
user.setStatus(request.status());
}
return UserResponse.from(userRepository.save(user));
UserResponse response = UserResponse.from(userRepository.save(user));
boolean securityChange = !Objects.equals(oldUsername, user.getUsername())
|| !Objects.equals(oldRole, user.getRole())
|| !Objects.equals(oldStatus, user.getStatus())
|| !Objects.equals(oldPoliceNumber, user.getPoliceNumber())
|| !Objects.equals(oldIdCardNumber, user.getIdCardNumber())
|| !Objects.equals(oldPhone, user.getPhone());
if (securityChange) {
revokeUserAccess(oldUsername);
if (!Objects.equals(oldUsername, user.getUsername())) {
revokeUserAccess(user.getUsername());
}
}
return response;
}
private static String normalizePoliceNumber(String value, String role) {
......@@ -200,6 +243,28 @@ public class UserService {
return user.getEmail();
}
@Transactional
public void changeOwnPassword(Long userId, String currentPassword, String newPassword) {
SysUser user = find(userId);
if (user.getStatus() == null || user.getStatus() != 1) {
throw new BizException("账号已停用");
}
if (!StringUtils.hasText(currentPassword)
|| !passwordEncoder.matches(currentPassword, user.getPassword())) {
throw new BizException("当前密码不正确");
}
if (!StringUtils.hasText(newPassword) || newPassword.length() < 8 || newPassword.length() > 64) {
throw new BizException("新密码长度需为 8-64 位");
}
if (passwordEncoder.matches(newPassword, user.getPassword())) {
throw new BizException("新密码不能与原密码相同");
}
user.setPassword(passwordEncoder.encode(newPassword));
user.setMustChangePassword(false);
userRepository.save(user);
revokeUserAccess(user.getUsername());
}
public void verifyAdminPassword(Long userId, String password) {
SysUser user = find(userId);
if (!"ADMIN".equals(user.getRole())
......@@ -215,6 +280,7 @@ public class UserService {
SysUser user = find(id);
user.setPassword(passwordEncoder.encode(request.password()));
userRepository.save(user);
revokeUserAccess(user.getUsername());
}
@Transactional
......@@ -223,6 +289,7 @@ public class UserService {
throw new BizException("不能删除自己");
}
SysUser user = find(id);
revokeUserAccess(user.getUsername());
jdbcTemplate.update("DELETE FROM user_app_selection WHERE user_id = ?", id);
jdbcTemplate.update("DELETE FROM user_launcher_order WHERE user_id = ?", id);
userRepository.delete(user);
......@@ -246,6 +313,7 @@ public class UserService {
if (users.size() != distinct.size()) {
throw new BizException("部分用户不存在,请刷新后重试");
}
users.forEach(user -> revokeUserAccess(user.getUsername()));
List<Object[]> rows = distinct.stream().map(id -> new Object[]{id}).toList();
jdbcTemplate.batchUpdate("DELETE FROM user_app_selection WHERE user_id = ?", rows);
jdbcTemplate.batchUpdate("DELETE FROM user_launcher_order WHERE user_id = ?", rows);
......@@ -253,6 +321,11 @@ public class UserService {
return users.size();
}
private void revokeUserAccess(String username) {
sessionRevocationService.revokeByUsername(username);
oauthAuthorizationRevocationService.revokeByPrincipalName(username);
}
public List<SysUser> findUsers(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
......
package com.zhihui.portal.web;
import com.zhihui.portal.cert.CertificateAuthSettings;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class PageController {
private final CertificateAuthSettings certificateAuthSettings;
public PageController(CertificateAuthSettings certificateAuthSettings) {
this.certificateAuthSettings = certificateAuthSettings;
}
@GetMapping("/")
public String index() {
return "redirect:/spa/";
}
@GetMapping("/login")
public String login(Authentication authentication) {
public String login(Authentication authentication, Model model) {
if (authentication != null
&& authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken)) {
return "redirect:/";
}
model.addAttribute("certAuthEnabled", certificateAuthSettings.isClientReady());
return "login";
}
......
package com.zhihui.portal.web;
import com.zhihui.portal.audit.AuditEvents;
import com.zhihui.portal.audit.AuditLogService;
import com.zhihui.portal.auth.SecurityUser;
import com.zhihui.portal.common.BizException;
import com.zhihui.portal.user.UserService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class PasswordChangeController {
private final UserService userService;
private final AuditLogService auditLogService;
public PasswordChangeController(UserService userService, AuditLogService auditLogService) {
this.userService = userService;
this.auditLogService = auditLogService;
}
@GetMapping("/change-password")
public String page(Authentication authentication, Model model) {
SecurityUser user = currentUser(authentication);
if (user == null) {
return "redirect:/login";
}
model.addAttribute("mustChange", user.isMustChangePassword());
return "change-password";
}
@PostMapping("/change-password")
public String change(@RequestParam String currentPassword,
@RequestParam String newPassword,
@RequestParam String confirmPassword,
Authentication authentication,
HttpServletRequest request,
Model model) {
SecurityUser user = currentUser(authentication);
if (user == null) {
return "redirect:/login";
}
model.addAttribute("mustChange", user.isMustChangePassword());
if (!newPassword.equals(confirmPassword)) {
model.addAttribute("error", "两次输入的新密码不一致");
return "change-password";
}
try {
userService.changeOwnPassword(user.getId(), currentPassword, newPassword);
auditLogService.recordCurrent(AuditEvents.USER_PASSWORD_CHANGE, "USER",
String.valueOf(user.getId()), "username=" + user.getUsername(), true);
} catch (BizException ex) {
model.addAttribute("error", ex.getMessage());
return "change-password";
}
SecurityContextHolder.clearContext();
HttpSession session = request.getSession(false);
if (session != null) {
try {
session.invalidate();
} catch (IllegalStateException ignored) {
// 会话已被访问撤销时忽略
}
}
return "redirect:/login?passwordChanged";
}
private static SecurityUser currentUser(Authentication authentication) {
if (authentication == null
|| authentication instanceof AnonymousAuthenticationToken
|| !(authentication.getPrincipal() instanceof SecurityUser user)) {
return null;
}
return user;
}
}
server:
port: ${SERVER_PORT:8080}
forward-headers-strategy: framework
# 默认不信任代理头;仅在 zhihui.security.trust-forwarded-headers=true 时注册 ForwardedHeaderFilter
forward-headers-strategy: none
servlet:
session:
timeout: 8h
......@@ -48,6 +49,7 @@ spring:
redis:
namespace: zhihui:session
flush-mode: on_save
repository-type: indexed
thymeleaf:
cache: false
jackson:
......@@ -73,6 +75,27 @@ zhihui:
max-failures-per-user-ip: ${ZHIHUI_LOGIN_MAX_FAILURES_PER_USER_IP:5}
max-failures-per-ip: ${ZHIHUI_LOGIN_MAX_FAILURES_PER_IP:20}
window: ${ZHIHUI_LOGIN_FAILURE_WINDOW:15m}
# 数字证书登录:默认关闭,网关地址和应用标识由现场配置,不写入代码
cert-auth:
enabled: ${ZHIHUI_CERT_AUTH_ENABLED:false}
gateway-url: ${ZHIHUI_CERT_GATEWAY_URL:}
app-id: ${ZHIHUI_CERT_APP_ID:}
init-param: ${ZHIHUI_CERT_INIT_PARAM:}
init-param-file: ${ZHIHUI_CERT_INIT_PARAM_FILE:}
pin-code: ${ZHIHUI_CERT_PIN_CODE:false}
access-control: ${ZHIHUI_CERT_ACCESS_CONTROL:false}
allow-insecure-http: ${ZHIHUI_CERT_ALLOW_INSECURE_HTTP:false}
attributes-mode: ${ZHIHUI_CERT_ATTRIBUTES_MODE:portion}
subject-dn-id-attribute: ${ZHIHUI_CERT_SUBJECT_DN_ID_ATTRIBUTE:CN}
connect-timeout: ${ZHIHUI_CERT_CONNECT_TIMEOUT:5s}
request-timeout: ${ZHIHUI_CERT_REQUEST_TIMEOUT:15s}
challenge-ttl: ${ZHIHUI_CERT_CHALLENGE_TTL:2m}
max-response-bytes: ${ZHIHUI_CERT_MAX_RESPONSE_BYTES:262144}
max-signed-data-bytes: ${ZHIHUI_CERT_MAX_SIGNED_DATA_BYTES:524288}
trust-store: ${ZHIHUI_CERT_TRUST_STORE:}
trust-store-password: ${ZHIHUI_CERT_TRUST_STORE_PASSWORD:}
trust-store-type: ${ZHIHUI_CERT_TRUST_STORE_TYPE:PKCS12}
client-download-url: ${ZHIHUI_CERT_CLIENT_DOWNLOAD_URL:}
demo:
enabled: ${ZHIHUI_DEMO_ENABLED:true}
app-url: ${ZHIHUI_DEMO_APP_URL:http://localhost:8081}
......
UPDATE portal_app
SET icon_url = '/img/app-icons/zhijing.png'
WHERE app_code = 'repeated-alarms';
ALTER TABLE audit_log
ADD COLUMN login_type VARCHAR(16) NULL AFTER event_type;
ALTER TABLE sys_user
ADD COLUMN last_login_at DATETIME(6) NULL AFTER status,
ADD COLUMN must_change_password TINYINT(1) NOT NULL DEFAULT 0 AFTER last_login_at,
ADD COLUMN imported TINYINT(1) NOT NULL DEFAULT 0 AFTER must_change_password;
......@@ -22,6 +22,10 @@
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html,
body {
margin: 0;
......@@ -52,9 +56,11 @@ body {
z-index: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 48px 56px 44px;
align-items: center;
justify-content: center;
padding: clamp(36px, 3.5vw, 64px) clamp(40px, 4.2vw, 80px) clamp(34px, 3vw, 56px);
color: #f2f7fc;
text-align: center;
overflow: hidden;
}
......@@ -70,44 +76,69 @@ body {
pointer-events: none;
}
.brand-top {
.brand-hero {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
z-index: 1;
max-width: 720px;
}
.brand-mark {
width: 60px;
height: 60px;
display: block;
width: clamp(150px, 16vw, 280px);
height: auto;
object-fit: contain;
filter: drop-shadow(0 22px 36px rgba(0, 0, 0, 0.42)) drop-shadow(0 0 26px rgba(120, 190, 240, 0.28));
}
.brand-name {
margin-top: clamp(20px, 3vh, 36px);
font-family: var(--display);
font-size: 28px;
letter-spacing: 0.12em;
line-height: 1.2;
font-size: clamp(38px, 4vw, 68px);
font-weight: 600;
letter-spacing: 0.14em;
line-height: 1.1;
color: #f2f7fc;
text-shadow: 0 0 28px rgba(120, 190, 240, 0.24);
}
.brand-sub {
margin-top: 5px;
font-size: 13px;
margin-top: clamp(10px, 1.2vh, 16px);
font-size: clamp(13px, 1vw, 18px);
letter-spacing: 0.3em;
color: rgba(220, 231, 243, 0.62);
}
.brand-slogan {
display: flex;
align-items: center;
gap: clamp(12px, 1.2vw, 20px);
margin-top: clamp(26px, 4vh, 44px);
font-size: clamp(15px, 1.3vw, 21px);
letter-spacing: 0.16em;
color: rgba(220, 231, 243, 0.64);
color: #9fc7e6;
}
.brand-slogan i {
width: clamp(24px, 3vw, 52px);
height: 1px;
background: linear-gradient(90deg, transparent, rgba(159, 199, 230, 0.7));
}
.brand-slogan i:last-child {
background: linear-gradient(90deg, rgba(159, 199, 230, 0.7), transparent);
}
.brand-nodes {
position: absolute;
top: 100px;
left: 50%;
inset: 0;
z-index: 0;
display: block;
width: min(760px, 92%, calc(100svh - 200px));
height: calc(50svh + 136px);
transform: translateX(calc(-50% + 50px));
opacity: 0.8;
width: 100%;
height: 100%;
opacity: 0.38;
pointer-events: none;
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.35));
}
......@@ -120,14 +151,14 @@ body {
display: flex;
flex-direction: column;
justify-content: center;
padding: 48px clamp(32px, 6vw, 96px);
padding: clamp(40px, 4.5vw, 80px) clamp(32px, 6vw, 96px);
}
.form-wrap {
width: 100%;
max-width: 430px;
max-width: clamp(430px, 30vw, 560px);
margin: 0 auto;
padding: 40px 36px;
padding: clamp(32px, 2.6vw, 46px) clamp(28px, 2.2vw, 40px);
background: linear-gradient(165deg, rgba(16, 54, 92, 0.82) 0%, rgba(8, 32, 60, 0.9) 100%);
border: 1px solid rgba(169, 201, 232, 0.24);
border-top: 2px solid rgba(169, 201, 232, 0.62);
......@@ -138,9 +169,9 @@ body {
}
.form-wrap h2 {
margin: 0 0 28px;
margin: 0 0 clamp(22px, 1.8vw, 32px);
font-family: var(--display);
font-size: 34px;
font-size: clamp(32px, 2.4vw, 42px);
font-weight: 600;
letter-spacing: 0.04em;
color: #f2f7fc;
......@@ -161,23 +192,53 @@ body {
border-color: #efd3d3;
}
.alert-success {
color: #86efac;
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.35);
}
.change-hint {
margin-bottom: 18px;
}
.change-logout {
margin-top: 18px;
}
.change-logout button {
width: 100%;
padding: 10px 14px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 4px;
background: transparent;
color: #cbd5e1;
font-size: 13px;
cursor: pointer;
}
.change-logout button:hover {
border-color: rgba(148, 163, 184, 0.6);
color: #f8fafc;
}
.login-form {
display: flex;
flex-direction: column;
}
.login-form label {
margin: 0 0 8px;
font-size: 13px;
margin: 0 0 clamp(7px, 0.7vw, 10px);
font-size: clamp(13px, 1vw, 15px);
color: rgba(220, 231, 243, 0.86);
}
.login-form input:not([type="hidden"]) {
height: 46px;
margin-bottom: 20px;
padding: 0 14px;
height: clamp(46px, 3.3vw, 58px);
margin-bottom: clamp(18px, 1.4vw, 26px);
padding: 0 clamp(12px, 1.1vw, 18px);
font: inherit;
font-size: 14px;
font-size: clamp(14px, 1.05vw, 17px);
color: var(--text);
background: #fff;
border: 1px solid var(--line);
......@@ -188,16 +249,16 @@ body {
.field-control {
position: relative;
margin-bottom: 20px;
margin-bottom: clamp(18px, 1.4vw, 26px);
}
.field-icon {
position: absolute;
top: 50%;
left: 14px;
left: clamp(12px, 1vw, 18px);
z-index: 1;
width: 18px;
height: 18px;
width: clamp(18px, 1.35vw, 22px);
height: clamp(18px, 1.35vw, 22px);
color: #5b7189;
transform: translateY(-50%);
pointer-events: none;
......@@ -206,7 +267,7 @@ body {
.login-form .field-control input:not([type="hidden"]) {
width: 100%;
margin-bottom: 0;
padding-left: 44px;
padding-left: clamp(40px, 3vw, 54px);
}
.field-control:focus-within .field-icon {
......@@ -260,11 +321,11 @@ body {
.login-form button {
align-self: center;
width: min(200px, 100%);
height: 48px;
margin-top: 10px;
width: min(clamp(200px, 14vw, 260px), 100%);
height: clamp(46px, 3.3vw, 56px);
margin-top: clamp(8px, 0.8vw, 14px);
font: inherit;
font-size: 15px;
font-size: clamp(15px, 1.1vw, 17px);
letter-spacing: 0.08em;
color: #fff;
background: var(--primary);
......@@ -287,14 +348,153 @@ body {
outline-offset: 3px;
}
.login-tabs {
display: flex;
align-items: center;
gap: clamp(22px, 2vw, 32px);
margin: 0 0 clamp(20px, 1.8vw, 28px);
padding: 0;
background: none;
border: 0;
border-bottom: 1px solid rgba(169, 201, 232, 0.16);
border-radius: 0;
}
.login-tab {
padding: 0 0 clamp(9px, 0.8vw, 12px);
font: inherit;
font-size: clamp(14px, 1.05vw, 17px);
letter-spacing: 0.06em;
color: rgba(220, 231, 243, 0.55);
background: none;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
cursor: pointer;
transition: color 0.16s ease, border-color 0.16s ease;
}
.login-tab:hover {
color: #e4eef8;
}
.login-tab.is-active {
color: #a9d8ff;
background: none;
border-bottom-color: #a9d8ff;
box-shadow: none;
}
.login-tab:focus-visible {
outline: 2px solid var(--accent-blue-soft);
outline-offset: 3px;
}
.cert-login {
margin-top: 0;
}
.cert-login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
height: clamp(46px, 3.3vw, 56px);
font: inherit;
font-size: clamp(14px, 1.05vw, 16px);
letter-spacing: 0.06em;
color: #dce7f3;
background: rgba(143, 184, 220, 0.06);
border: 1px solid rgba(169, 201, 232, 0.34);
border-radius: 4px;
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.cert-login-btn svg {
width: 20px;
height: 20px;
flex: none;
color: var(--accent-blue);
}
.cert-login-btn:hover {
background: rgba(143, 184, 220, 0.13);
border-color: rgba(191, 224, 255, 0.62);
}
.cert-login-btn:active {
transform: translateY(1px);
}
.cert-login-btn:focus-visible {
outline: 2px solid var(--accent-blue-soft);
outline-offset: 3px;
}
.cert-login-btn:disabled {
cursor: wait;
opacity: 0.62;
}
.cert-login-hint {
margin: 10px 0 0;
text-align: center;
font-size: 12px;
line-height: 1.6;
color: rgba(220, 231, 243, 0.5);
}
.cert-feedback {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
}
.cert-feedback.is-info {
color: #dce7f3;
background: rgba(143, 184, 220, 0.1);
border-color: rgba(169, 201, 232, 0.24);
}
.cert-feedback.is-error {
color: #ffdadd;
background: rgba(179, 38, 46, 0.2);
border-color: rgba(239, 211, 211, 0.26);
}
.cert-feedback.is-success {
color: #d9f5e5;
background: rgba(35, 108, 73, 0.22);
border-color: rgba(185, 230, 205, 0.24);
}
.cert-download {
display: block;
margin-top: 8px;
text-align: center;
font-size: 12px;
color: var(--accent-blue);
text-decoration: none;
}
.cert-download:hover {
color: #c4e3ff;
text-decoration: underline;
}
.form-foot {
position: absolute;
right: 0;
bottom: 22px;
bottom: clamp(18px, 1.6vh, 28px);
left: 0;
z-index: 2;
text-align: center;
font-size: 17px;
font-size: clamp(16px, 1.18vw, 20px);
letter-spacing: 0.08em;
color: rgba(220, 231, 243, 0.76);
}
......@@ -305,7 +505,7 @@ body {
.form-foot span + span {
margin-top: 4px;
font-size: 14px;
font-size: clamp(13px, 0.98vw, 17px);
letter-spacing: 0.12em;
color: rgba(220, 231, 243, 0.56);
}
......@@ -320,6 +520,32 @@ body {
padding: 24px 24px 20px;
}
.brand-hero {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 10px 18px;
}
.brand-mark {
width: 64px;
}
.brand-name {
margin-top: 0;
font-size: 30px;
}
.brand-sub {
margin-top: 0;
font-size: 12px;
letter-spacing: 0.2em;
}
.brand-slogan {
display: none;
}
.brand-nodes {
display: none;
}
......
(function () {
'use strict';
const tabs = Array.from(document.querySelectorAll('[data-login-tab]'));
function activateTab(tab) {
const target = tab.dataset.loginTab;
for (const item of tabs) {
const active = item === tab;
item.classList.toggle('is-active', active);
item.setAttribute('aria-selected', String(active));
}
for (const panel of document.querySelectorAll('[data-login-panel]')) {
panel.hidden = panel.dataset.loginPanel !== target;
}
if (target === 'cert') {
const certButton = document.getElementById('cert-login-button');
if (certButton) {
certButton.focus();
}
}
}
for (const tab of tabs) {
tab.addEventListener('click', () => activateTab(tab));
tab.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return;
}
event.preventDefault();
const index = tabs.indexOf(tab);
const next = tabs[(index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length];
next.focus();
next.click();
});
}
const container = document.getElementById('cert-login');
if (!container) {
return;
}
const button = document.getElementById('cert-login-button');
const status = document.getElementById('cert-login-feedback');
const download = document.getElementById('cert-download-link');
const form = document.getElementById('cert-auth-form');
const originalInput = document.getElementById('cert-original');
const signedInput = document.getElementById('cert-signed-data');
const csrfMeta = document.querySelector('meta[name="_csrf"]');
const csrfParameterMeta = document.querySelector('meta[name="_csrf_parameter"]');
const configUrl = container.dataset.configUrl;
const challengeUrl = container.dataset.challengeUrl;
let busy = false;
let config = null;
function setStatus(message, tone) {
status.textContent = message;
status.className = 'cert-feedback is-' + tone;
status.hidden = false;
}
function clearStatus() {
status.textContent = '';
status.className = 'cert-feedback';
status.hidden = true;
}
function setBusy(value) {
busy = value;
button.disabled = value;
button.setAttribute('aria-busy', String(value));
}
function showDownload(url) {
if (!url || !/^(https?:\/\/|\/)/.test(url)) {
download.hidden = true;
download.removeAttribute('href');
return;
}
download.href = url;
download.hidden = false;
}
async function requestJson(url, options) {
const response = await fetch(url, {
credentials: 'same-origin',
cache: 'no-store',
...options,
});
const text = await response.text();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (ignored) {
data = {};
}
}
if (!response.ok) {
const fallback = response.status === 403
? '页面已过期,请刷新后重试。'
: '请求失败,请稍后重试。';
throw new Error(data && data.message ? data.message : fallback);
}
return data;
}
function plugin() {
if (!window.JIT_GW_ExtInterface) {
throw new Error('未检测到证书控件,请安装控件后重试。');
}
try {
const version = window.JIT_GW_ExtInterface.GetVersion();
if (!version) {
throw new Error('empty version');
}
} catch (error) {
throw new Error('未检测到证书控件,请安装控件后重试。');
}
return window.JIT_GW_ExtInterface;
}
function pluginError(code) {
if (code === 3758096386 || code === 2148532334 || code === 3758096385) {
return '已取消证书选择。';
}
if (code === -536870815 || code === 3758096481) {
return '未找到有效证书,请插入 UKey 后重试。';
}
return '证书读取或签名失败,请重试。';
}
function release(iface) {
try {
iface.Finalize();
} catch (ignored) {
try {
iface.ClearFilter();
} catch (ignoredAgain) {
// 控件释放失败时不阻断页面提示
}
}
}
function sign(iface, original) {
try {
iface.ClearFilter();
const initResult = iface.Initialize('', config.initParam);
if (initResult !== undefined && initResult !== null && initResult !== ''
&& Number(initResult) === 0) {
throw new Error('证书控件初始化失败,请检查控件和 UKey 后重试。');
}
try {
iface.SetChooseSingleCert(0);
} catch (ignored) {
// 旧版本控件可能没有该接口
}
if (config.pinCode) {
try {
iface.SetForcePinDialog(1);
} catch (ignored) {
// 旧版本控件可能没有该接口
}
}
const signed = iface.P7SignString(original, true, true);
const code = Number(iface.GetLastError());
if (code !== 0 || !signed) {
throw new Error(pluginError(code));
}
return signed;
} finally {
release(iface);
}
}
async function start() {
if (busy) {
return;
}
clearStatus();
showDownload('');
setBusy(true);
try {
config = await requestJson(configUrl);
if (!config.enabled || !config.initParam) {
throw new Error('数字证书登录暂不可用,请使用账号密码登录。');
}
showDownload(config.clientDownloadUrl);
setStatus('正在读取证书控件…', 'info');
const iface = plugin();
const csrfToken = csrfMeta ? csrfMeta.content : '';
const csrfParameter = csrfParameterMeta ? csrfParameterMeta.content : '';
if (!csrfToken || !csrfParameter) {
throw new Error('页面已过期,请刷新后重试。');
}
const challenge = await requestJson(challengeUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
[csrfParameter]: csrfToken,
}).toString(),
});
if (!challenge.original) {
throw new Error('认证原文获取失败,请重试。');
}
setStatus('请在证书控件中选择证书…', 'info');
const signed = sign(iface, challenge.original);
originalInput.value = challenge.original;
signedInput.value = signed;
setStatus('证书认证中…', 'info');
form.submit();
} catch (error) {
setStatus(error && error.message ? error.message : '数字证书登录失败,请稍后重试。',
'error');
setBusy(false);
button.focus();
}
}
button.addEventListener('click', start);
})();
......@@ -7,6 +7,7 @@
const ctx = canvas.getContext('2d')
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const timeOrigin = performance.now()
const W = 1040
const H = 760
......@@ -18,32 +19,34 @@
{ weight: 0.5, x1: 20, x2: 1020, y1: 14, y2: 180 },
{ weight: 0.17, x1: 10, x2: 180, y1: 140, y2: 480 },
{ weight: 0.17, x1: 860, x2: 1030, y1: 140, y2: 480 },
{ weight: 0.08, x1: 60, x2: 260, y1: 420, y2: 560 },
{ weight: 0.08, x1: 780, x2: 980, y1: 420, y2: 560 }
{ weight: 0.08, x1: 60, x2: 260, y1: 380, y2: 500 },
{ weight: 0.08, x1: 780, x2: 980, y1: 380, y2: 500 }
]
function randomBetween(min, max) {
return min + Math.random() * (max - min)
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
function randomStart() {
let roll = Math.random()
const zone = ZONES.find((item) => (roll -= item.weight) <= 0) || ZONES[0]
return {
x: randomBetween(zone.x1, zone.x2),
y: randomBetween(zone.y1, zone.y2)
for (let attempt = 0; attempt < 12; attempt++) {
let roll = Math.random()
const zone = ZONES.find((item) => (roll -= item.weight) <= 0) || ZONES[0]
const start = {
x: randomBetween(zone.x1, zone.x2),
y: randomBetween(zone.y1, zone.y2)
}
if (Math.hypot(start.x - GATE.x, start.y - GATE.y) >= 260) {
return start
}
}
return { x: randomBetween(20, 1020), y: randomBetween(20, 160) }
}
function randomGeometry() {
const start = randomStart()
const end = {
x: GATE.x + clamp((start.x - GATE.x) * 0.35 + randomBetween(-60, 60), -240, 240),
y: GATE.y - 30 + randomBetween(-24, 24)
x: GATE.x + randomBetween(-100, 100),
y: GATE.y
}
const dx = start.x - GATE.x
const dy = start.y - GATE.y
......@@ -60,11 +63,11 @@
}
function makeParticle() {
const duration = randomBetween(7, 14)
const duration = randomBetween(8.75, 17.5)
return {
...randomGeometry(),
duration,
startedAt: -Math.random() * duration,
startedAt: -Math.random() * duration * 0.65,
active: true,
nextAt: 0
}
......@@ -159,7 +162,7 @@
function recycleParticle(particle, t) {
Object.assign(particle, randomGeometry())
particle.duration = randomBetween(7, 14)
particle.duration = randomBetween(8.75, 17.5)
particle.startedAt = t
particle.active = true
}
......@@ -168,7 +171,7 @@
const point = screenPoint(quadPoint(particle, progress))
const depth = 0.22 + progress * 0.58
const fadeIn = Math.min(1, progress / 0.05)
const fadeOut = Math.min(1, (1 - progress) / 0.06)
const fadeOut = Math.min(1, (1 - progress) / 0.3)
return {
x: point.x,
y: point.y,
......@@ -193,9 +196,7 @@
drawGate()
const t = time / 1000
const minDistance = 22 * (width / 700)
const dots = []
const t = Math.max(0, time - timeOrigin) / 1000
for (const particle of particles) {
if (!particle.active) {
......@@ -223,11 +224,6 @@
continue
}
if (dots.some((item) => Math.hypot(item.x - dot.x, item.y - dot.y) < minDistance)) {
continue
}
dots.push(dot)
drawDot(dot)
}
}
......
This diff is collapsed.
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>修改密码 · 智汇平台</title>
<link rel="stylesheet" th:href="@{/css/login.css}">
<link rel="icon" th:href="@{/img/badge.png}" type="image/png">
</head>
<body>
<main class="login-shell">
<div class="brand-grid" aria-hidden="true"></div>
<section class="brand-panel">
<div class="brand-top">
<img th:src="@{/img/badge.png}" alt="" class="brand-mark">
<div>
<div class="brand-name">智汇平台</div>
<div class="brand-sub">统一身份认证平台</div>
</div>
</div>
</section>
<section class="form-panel">
<div class="form-wrap">
<h2>修改密码</h2>
<div class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></div>
<p class="cert-login-hint change-hint" th:if="${mustChange}">
首次登录需要修改初始密码,新密码不能与初始密码相同。
</p>
<form class="login-form" th:action="@{/change-password}" method="post">
<label for="currentPassword">当前密码</label>
<div class="field-control">
<input id="currentPassword" name="currentPassword" type="password"
autocomplete="current-password" required autofocus>
</div>
<label for="newPassword">新密码</label>
<div class="field-control">
<input id="newPassword" name="newPassword" type="password"
minlength="8" maxlength="64" autocomplete="new-password" required>
</div>
<label for="confirmPassword">确认新密码</label>
<div class="field-control">
<input id="confirmPassword" name="confirmPassword" type="password"
minlength="8" maxlength="64" autocomplete="new-password" required>
</div>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button type="submit">确认修改</button>
</form>
<form class="change-logout" th:action="@{/logout}" method="post">
<button type="submit">退出登录</button>
</form>
</div>
</section>
<footer class="form-foot">
<span>防城港市公安局网安支队501工作室</span>
<span>技术支持 © 2026</span>
</footer>
</main>
</body>
</html>
package com.zhihui.portal.cert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpSession;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CertificateLoginControllerTest {
@Mock
private CertificateAuthService authService;
@Mock
private CertificateAuthSettings settings;
private CertificateLoginController controller;
@BeforeEach
void setUp() {
controller = new CertificateLoginController(authService, settings);
}
@Test
void exposesBrowserSettingsButNotGatewayAddress() {
when(settings.isClientReady()).thenReturn(true);
when(settings.getInitParam()).thenReturn("<authinfo><liblist /></authinfo>");
when(settings.getClientDownloadUrl()).thenReturn("/downloads/pnxclient");
CertificateLoginController.CertificateLoginConfig config =
controller.config().getBody();
assertTrue(config.enabled());
assertEquals("<authinfo><liblist /></authinfo>", config.initParam());
assertEquals("/downloads/pnxclient", config.clientDownloadUrl());
}
@Test
void hidesLoginWhenInitParamMissing() {
when(settings.isClientReady()).thenReturn(false);
CertificateLoginController.CertificateLoginConfig config =
controller.config().getBody();
assertFalse(config.enabled());
}
@Test
void returnsServiceUnavailableWhenDisabled() {
when(authService.isEnabled()).thenReturn(false);
ResponseEntity<?> response = controller.challenge(new MockHttpSession());
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.getStatusCode());
}
@Test
void returnsChallengeWhenEnabled() {
when(authService.isEnabled()).thenReturn(true);
when(authService.createChallenge(org.mockito.ArgumentMatchers.any()))
.thenReturn(new CertificateAuthService.ChallengeResponse("original", 120));
ResponseEntity<?> response = controller.challenge(new MockHttpSession());
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(new CertificateAuthService.ChallengeResponse("original", 120),
response.getBody());
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment