This commit is contained in:
2026-04-17 16:31:32 +08:00
parent adadb3bf1d
commit 2476655b28
116 changed files with 3875 additions and 583 deletions

View File

@@ -30,6 +30,18 @@
<artifactId>api-auth</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>

View File

@@ -14,6 +14,13 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @description 认证 HTTP 入口;统一登录、刷新、当前用户查询,响应体由 AuthService 组装后通过 ApiResponse 包装
* @filename AuthController.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@RestController
@RequestMapping("/auth")
public class AuthController {
@@ -39,7 +46,7 @@ public class AuthController {
@GetMapping("/users/current")
public ApiResponse<CurrentUserResponse> currentUser(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(authService.currentUser(authorizationHeader));
@RequestHeader(value = "Authorization", required = false) String authorization) {
return ApiResponse.success(authService.currentUser(authorization));
}
}

View File

@@ -3,64 +3,423 @@ package com.k12study.auth.service;
import com.k12study.api.auth.dto.CurrentUserResponse;
import com.k12study.api.auth.dto.LoginRequest;
import com.k12study.api.auth.dto.TokenResponse;
import com.k12study.common.security.config.AuthProperties;
import com.k12study.common.security.context.RequestUserContextHolder;
import com.k12study.common.security.jwt.JwtTokenProvider;
import com.k12study.common.security.jwt.JwtUserPrincipal;
import com.k12study.common.web.exception.BizException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final NamedParameterJdbcTemplate jdbcTemplate;
private final AuthProperties authProperties;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public AuthService(JwtTokenProvider jwtTokenProvider) {
private static final RowMapper<UserRecord> USER_ROW_MAPPER = (rs, rowNum) -> mapUser(rs);
public AuthService(
JwtTokenProvider jwtTokenProvider,
NamedParameterJdbcTemplate jdbcTemplate,
AuthProperties authProperties) {
this.jwtTokenProvider = jwtTokenProvider;
this.jdbcTemplate = jdbcTemplate;
this.authProperties = authProperties;
}
public TokenResponse login(LoginRequest request) {
String username = request.username() == null || request.username().isBlank() ? "admin" : request.username();
JwtUserPrincipal principal = new JwtUserPrincipal(
"U10001",
username,
"K12Study 管理员",
request.tenantId() == null || request.tenantId().isBlank() ? "SCH-HQ" : request.tenantId(),
"DEPT-HQ-ADMIN"
);
String clientType = normalizeClientType(request == null ? null : request.clientType());
String tenantId = request == null || request.tenantId() == null ? "" : request.tenantId().trim();
UserRecord user = resolveLoginUser(request, tenantId);
verifyCredential(request, user);
List<String> roleCodes = findRoleCodes(user.userId());
ensureRoleAssigned(roleCodes);
validateClientRole(clientType, roleCodes);
String sessionId = UUID.randomUUID().toString().replace("-", "");
JwtUserPrincipal principal = toPrincipal(user, roleCodes, clientType, sessionId);
String accessToken = jwtTokenProvider.createAccessToken(principal);
String refreshToken = jwtTokenProvider.createAccessToken(principal);
return new TokenResponse(accessToken, refreshToken, "Bearer", 12 * 60 * 60);
String refreshToken = jwtTokenProvider.createRefreshToken(principal);
saveRefreshToken(principal, refreshToken);
if ("MINI".equals(clientType)) {
enforceMiniSessionLimit(user.userId());
}
auditLogin(user, clientType, "SUCCESS", null);
return new TokenResponse(accessToken, refreshToken, "Bearer", authProperties.getAccessTokenTtl().toSeconds());
}
public TokenResponse refresh(String refreshToken) {
JwtUserPrincipal principal = jwtTokenProvider.parse(refreshToken);
TokenRecord tokenRecord = findTokenRecord(refreshToken)
.orElseThrow(() -> new BizException(401, "refreshToken 无效或已失效"));
if (tokenRecord.revoked() || tokenRecord.expireAt().isBefore(Instant.now())) {
throw new BizException(401, "refreshToken 已失效");
}
JwtUserPrincipal tokenPrincipal;
try {
tokenPrincipal = jwtTokenProvider.parse(refreshToken);
} catch (Exception exception) {
throw new BizException(401, "refreshToken 已失效");
}
if (!tokenRecord.userId().equals(tokenPrincipal.userId())
|| !tokenRecord.sessionId().equals(tokenPrincipal.sessionId())) {
throw new BizException(401, "refreshToken 校验失败");
}
UserRecord user = findUserById(tokenRecord.userId())
.orElseThrow(() -> new BizException(401, "用户不存在或已禁用"));
List<String> roleCodes = findRoleCodes(user.userId());
ensureRoleAssigned(roleCodes);
validateClientRole(tokenRecord.clientType(), roleCodes);
JwtUserPrincipal principal = toPrincipal(user, roleCodes, tokenRecord.clientType(), tokenRecord.sessionId());
String accessToken = jwtTokenProvider.createAccessToken(principal);
return new TokenResponse(accessToken, refreshToken, "Bearer", 12 * 60 * 60);
String nextRefreshToken = jwtTokenProvider.createRefreshToken(principal);
revokeToken(tokenRecord.tokenId());
saveRefreshToken(principal, nextRefreshToken);
auditLogin(user, tokenRecord.clientType(), "REFRESH_SUCCESS", null);
return new TokenResponse(accessToken, nextRefreshToken, "Bearer", authProperties.getAccessTokenTtl().toSeconds());
}
public CurrentUserResponse currentUser(String authorizationHeader) {
JwtUserPrincipal principal;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
JwtUserPrincipal principal = jwtTokenProvider.parse(authorizationHeader.substring("Bearer ".length()));
return new CurrentUserResponse(
principal.userId(),
principal.username(),
principal.displayName(),
"330000",
"330100",
principal.tenantId(),
principal.deptId(),
List.of("SUPER_ADMIN", "ORG_ADMIN")
principal = jwtTokenProvider.parse(authorizationHeader.substring("Bearer ".length()));
} else {
var context = RequestUserContextHolder.get();
if (context == null || !StringUtils.hasText(context.userId())) {
throw new BizException(401, "未登录或登录已失效");
}
principal = new JwtUserPrincipal(
context.userId(),
context.username(),
context.displayName(),
context.adcode(),
context.tenantId(),
context.tenantPath(),
context.deptId(),
context.deptPath(),
context.roleCodes(),
normalizeClientType(context.clientType()),
context.sessionId()
);
}
var context = RequestUserContextHolder.get();
UserRecord user = findUserById(principal.userId())
.orElseThrow(() -> new BizException(401, "用户不存在或已禁用"));
List<String> roleCodes = findRoleCodes(user.userId());
String areaCode = safeAdcode(user.adcode());
String provinceCode = areaCode.length() >= 2 ? areaCode.substring(0, 2) + "0000" : areaCode;
return new CurrentUserResponse(
context == null ? "U10001" : context.userId(),
context == null ? "admin" : context.username(),
context == null ? "K12Study 管理员" : context.displayName(),
"330000",
"330100",
context == null ? "SCH-HQ" : context.tenantId(),
context == null ? "DEPT-HQ-ADMIN" : context.deptId(),
List.of("SUPER_ADMIN", "ORG_ADMIN")
user.userId(),
user.username(),
user.displayName(),
provinceCode,
areaCode,
user.tenantId(),
user.tenantPath(),
user.deptId(),
user.deptPath(),
roleCodes,
principal.clientType()
);
}
private UserRecord resolveLoginUser(LoginRequest request, String tenantId) {
if (request == null) {
throw new BizException(400, "登录参数不能为空");
}
if (StringUtils.hasText(request.mobile())) {
return findUserByMobile(request.mobile().trim(), tenantId)
.orElseThrow(() -> {
auditLogin(null, normalizeClientType(request.clientType()), "FAILED", "MOBILE_NOT_FOUND");
return new BizException(401, "手机号或密码错误");
});
}
if (!StringUtils.hasText(request.username())) {
throw new BizException(400, "用户名不能为空");
}
return findUserByUsername(request.username().trim(), tenantId)
.orElseThrow(() -> {
auditLogin(null, normalizeClientType(request.clientType()), "FAILED", "USER_NOT_FOUND");
return new BizException(401, "用户名或密码错误");
});
}
private void verifyCredential(LoginRequest request, UserRecord user) {
boolean passwordPassed = StringUtils.hasText(request.password()) && passwordMatches(request.password(), user.passwordHash());
boolean smsPassed = StringUtils.hasText(request.mobile()) && StringUtils.hasText(request.smsCode()) && "123456".equals(request.smsCode());
if (!passwordPassed && !smsPassed) {
auditLogin(user, normalizeClientType(request.clientType()), "FAILED", "BAD_CREDENTIAL");
throw new BizException(401, "用户名/手机号或凭据错误");
}
if (!"ACTIVE".equalsIgnoreCase(user.status())) {
auditLogin(user, normalizeClientType(request.clientType()), "FAILED", "USER_DISABLED");
throw new BizException(403, "用户状态不可用");
}
}
private void validateClientRole(String clientType, List<String> roleCodes) {
if ("MINI".equals(clientType) && roleCodes.stream().noneMatch("STUDENT"::equalsIgnoreCase)) {
throw new BizException(403, "小程序端仅允许学生账号登录");
}
}
private void ensureRoleAssigned(List<String> roleCodes) {
if (roleCodes == null || roleCodes.isEmpty()) {
throw new BizException(403, "用户未分配角色");
}
}
private boolean passwordMatches(String rawPassword, String storedPassword) {
if (!StringUtils.hasText(storedPassword)) {
return false;
}
if (storedPassword.startsWith("$2a$") || storedPassword.startsWith("$2b$") || storedPassword.startsWith("$2y$")) {
return passwordEncoder.matches(rawPassword, storedPassword);
}
return storedPassword.equals(rawPassword);
}
private JwtUserPrincipal toPrincipal(UserRecord user, List<String> roleCodes, String clientType, String sessionId) {
return new JwtUserPrincipal(
user.userId(),
user.username(),
user.displayName(),
user.adcode(),
user.tenantId(),
user.tenantPath(),
user.deptId(),
user.deptPath(),
roleCodes,
clientType,
sessionId
);
}
private Optional<UserRecord> findUserByUsername(String username, String tenantId) {
String sql = """
SELECT user_id, username, display_name, password_hash, mobile_phone, adcode, tenant_id, tenant_path, dept_id, dept_path, status
FROM upms.tb_sys_user
WHERE username = :username
AND (:tenantId = '' OR tenant_id = :tenantId)
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("username", username, "tenantId", tenantId), USER_ROW_MAPPER).stream().findFirst();
}
private Optional<UserRecord> findUserByMobile(String mobile, String tenantId) {
String sql = """
SELECT user_id, username, display_name, password_hash, mobile_phone, adcode, tenant_id, tenant_path, dept_id, dept_path, status
FROM upms.tb_sys_user
WHERE mobile_phone = :mobile
AND (:tenantId = '' OR tenant_id = :tenantId)
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("mobile", mobile, "tenantId", tenantId), USER_ROW_MAPPER).stream().findFirst();
}
private Optional<UserRecord> findUserById(String userId) {
String sql = """
SELECT user_id, username, display_name, password_hash, mobile_phone, adcode, tenant_id, tenant_path, dept_id, dept_path, status
FROM upms.tb_sys_user
WHERE user_id = :userId
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("userId", userId), USER_ROW_MAPPER).stream().findFirst();
}
private List<String> findRoleCodes(String userId) {
String sql = """
SELECT r.role_code
FROM upms.tb_sys_user_role ur
JOIN upms.tb_sys_role r ON r.role_id = ur.role_id
WHERE ur.user_id = :userId
ORDER BY r.role_code
""";
return jdbcTemplate.queryForList(sql, Map.of("userId", userId), String.class);
}
private Optional<TokenRecord> findTokenRecord(String refreshToken) {
String sql = """
SELECT token_id, session_id, client_type, user_id, refresh_token, expire_at, revoked
FROM auth.tb_auth_refresh_token
WHERE refresh_token = :refreshToken
LIMIT 1
""";
RowMapper<TokenRecord> mapper = (rs, rowNum) -> new TokenRecord(
rs.getString("token_id"),
rs.getString("session_id"),
normalizeClientType(rs.getString("client_type")),
rs.getString("user_id"),
rs.getString("refresh_token"),
rs.getTimestamp("expire_at").toInstant(),
rs.getBoolean("revoked")
);
return jdbcTemplate.query(sql, Map.of("refreshToken", refreshToken), mapper).stream().findFirst();
}
private void saveRefreshToken(JwtUserPrincipal principal, String refreshToken) {
String sql = """
INSERT INTO auth.tb_auth_refresh_token (
token_id, session_id, client_type, user_id, username, adcode,
tenant_id, tenant_path, dept_id, dept_path,
refresh_token, expire_at, revoked, revoked_at, last_active_at, created_at
) VALUES (
:tokenId, :sessionId, :clientType, :userId, :username, :adcode,
:tenantId, :tenantPath, :deptId, :deptPath,
:refreshToken, :expireAt, false, null, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""";
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("tokenId", UUID.randomUUID().toString().replace("-", ""))
.addValue("sessionId", principal.sessionId())
.addValue("clientType", normalizeClientType(principal.clientType()))
.addValue("userId", principal.userId())
.addValue("username", principal.username())
.addValue("adcode", principal.adcode())
.addValue("tenantId", principal.tenantId())
.addValue("tenantPath", principal.tenantPath())
.addValue("deptId", principal.deptId())
.addValue("deptPath", principal.deptPath())
.addValue("refreshToken", refreshToken)
.addValue("expireAt", Instant.now().plus(authProperties.getRefreshTokenTtl()));
jdbcTemplate.update(sql, parameters);
}
private void revokeToken(String tokenId) {
String sql = """
UPDATE auth.tb_auth_refresh_token
SET revoked = true, revoked_at = CURRENT_TIMESTAMP
WHERE token_id = :tokenId
""";
jdbcTemplate.update(sql, Map.of("tokenId", tokenId));
}
private void enforceMiniSessionLimit(String userId) {
String sessionSql = """
SELECT session_id
FROM auth.tb_auth_refresh_token
WHERE user_id = :userId
AND client_type = 'MINI'
AND revoked = false
AND expire_at > CURRENT_TIMESTAMP
GROUP BY session_id
ORDER BY MAX(last_active_at) DESC
""";
List<String> sessions = jdbcTemplate.queryForList(sessionSql, Map.of("userId", userId), String.class);
if (sessions.size() <= 3) {
return;
}
List<String> needRevoke = sessions.subList(3, sessions.size());
String revokeSql = """
UPDATE auth.tb_auth_refresh_token
SET revoked = true, revoked_at = CURRENT_TIMESTAMP
WHERE user_id = :userId
AND session_id IN (:sessionIds)
AND revoked = false
""";
jdbcTemplate.update(revokeSql, new MapSqlParameterSource()
.addValue("userId", userId)
.addValue("sessionIds", needRevoke));
}
private void auditLogin(UserRecord user, String clientType, String loginStatus, String failureReason) {
String sql = """
INSERT INTO auth.tb_auth_login_audit (
audit_id, user_id, username, client_type, adcode, tenant_id, tenant_path,
dept_id, dept_path, login_ip, login_status, failure_reason, created_at
) VALUES (
:auditId, :userId, :username, :clientType, :adcode, :tenantId, :tenantPath,
:deptId, :deptPath, :loginIp, :loginStatus, :failureReason, CURRENT_TIMESTAMP
)
""";
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("auditId", UUID.randomUUID().toString().replace("-", ""))
.addValue("userId", user == null ? null : user.userId())
.addValue("username", user == null ? "UNKNOWN" : user.username())
.addValue("clientType", normalizeClientType(clientType))
.addValue("adcode", user == null ? null : user.adcode())
.addValue("tenantId", user == null ? null : user.tenantId())
.addValue("tenantPath", user == null ? null : user.tenantPath())
.addValue("deptId", user == null ? null : user.deptId())
.addValue("deptPath", user == null ? null : user.deptPath())
.addValue("loginIp", null)
.addValue("loginStatus", loginStatus)
.addValue("failureReason", failureReason);
jdbcTemplate.update(sql, parameters);
}
private String normalizeClientType(String clientType) {
if (!StringUtils.hasText(clientType)) {
return "WEB";
}
String normalized = clientType.trim().toUpperCase();
return "MINI".equals(normalized) ? "MINI" : "WEB";
}
private static UserRecord mapUser(ResultSet rs) throws SQLException {
return new UserRecord(
rs.getString("user_id"),
rs.getString("username"),
rs.getString("display_name"),
rs.getString("password_hash"),
rs.getString("mobile_phone"),
rs.getString("adcode"),
rs.getString("tenant_id"),
rs.getString("tenant_path"),
rs.getString("dept_id"),
rs.getString("dept_path"),
rs.getString("status")
);
}
private String safeAdcode(String adcode) {
return StringUtils.hasText(adcode) ? adcode : "";
}
private record UserRecord(
String userId,
String username,
String displayName,
String passwordHash,
String mobilePhone,
String adcode,
String tenantId,
String tenantPath,
String deptId,
String deptPath,
String status
) {
}
private record TokenRecord(
String tokenId,
String sessionId,
String clientType,
String userId,
String refreshToken,
Instant expireAt,
boolean revoked
) {
}
}

View File

@@ -4,6 +4,10 @@ server:
spring:
application:
name: k12study-auth
datasource:
url: jdbc:postgresql://${K12STUDY_DB_HOST:localhost}:${K12STUDY_DB_PORT:5432}/${K12STUDY_DB_NAME:k12study}
username: ${K12STUDY_DB_USER:k12study}
password: ${K12STUDY_DB_PASSWORD:k12study}
data:
redis:
host: ${K12STUDY_REDIS_HOST:localhost}