feat(auth): 完善API网关JWT认证和权限控制功能

- 实现网关侧JWT工具类和权限规则匹配器
- 集成JWT认证流程,支持Bearer Token验证
- 添加基于路径和HTTP方法的权限控制机制
- 配置白名单路由规则,优化认证性能
- 更新前端受保护路由组件,实现权限验证
- 添加403禁止访问页面和权限检查逻辑
- 重构登录页面,集成实际认证API调用
- 实现用户信息获取和权限加载功能
- 优化全局异常处理器中的认证错误状态码
- 集成FastJSON2和JJWT依赖库支持
This commit is contained in:
2026-02-06 13:11:08 +08:00
parent 719f54bf2e
commit 056cee11cc
33 changed files with 1462 additions and 89 deletions

View File

@@ -17,6 +17,11 @@
<description>DDD领域通用组件</description>
<dependencies>
<dependency>
<groupId>com.datamate</groupId>
<artifactId>security-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>

View File

@@ -0,0 +1,203 @@
package com.datamate.common.auth.application;
import com.datamate.common.auth.domain.model.AuthPermissionInfo;
import com.datamate.common.auth.domain.model.AuthRoleInfo;
import com.datamate.common.auth.domain.model.AuthUserAccount;
import com.datamate.common.auth.domain.model.AuthUserSummary;
import com.datamate.common.auth.infrastructure.exception.AuthErrorCode;
import com.datamate.common.auth.infrastructure.persistence.mapper.AuthMapper;
import com.datamate.common.auth.interfaces.rest.dto.AuthCurrentUserResponse;
import com.datamate.common.auth.interfaces.rest.dto.AuthLoginResponse;
import com.datamate.common.auth.interfaces.rest.dto.AuthUserView;
import com.datamate.common.auth.interfaces.rest.dto.AuthUserWithRolesResponse;
import com.datamate.common.infrastructure.exception.BusinessAssert;
import com.datamate.common.security.JwtUtils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 认证授权应用服务
*/
@Service
@RequiredArgsConstructor
public class AuthApplicationService {
private static final String TOKEN_TYPE = "Bearer";
private final AuthMapper authMapper;
private final JwtUtils jwtUtils;
private final PasswordEncoder passwordEncoder;
public AuthLoginResponse login(String username, String password) {
AuthUserAccount user = authMapper.findUserByUsername(username);
BusinessAssert.notNull(user, AuthErrorCode.INVALID_CREDENTIALS);
BusinessAssert.isTrue(Boolean.TRUE.equals(user.getEnabled()), AuthErrorCode.ACCOUNT_DISABLED);
BusinessAssert.isTrue(passwordEncoder.matches(password, user.getPasswordHash()), AuthErrorCode.INVALID_CREDENTIALS);
AuthBundle authBundle = loadAuthBundle(user.getId());
String token = buildToken(authBundle);
authMapper.updateLastLoginAt(user.getId());
return new AuthLoginResponse(
token,
TOKEN_TYPE,
computeExpiresInSeconds(token),
toUserView(authBundle.user()),
authBundle.roleCodes(),
authBundle.permissionCodes()
);
}
public AuthCurrentUserResponse getCurrentUser(String token) {
Claims claims = parseClaims(token);
Long userId = parseUserId(claims);
AuthBundle authBundle = loadAuthBundle(userId);
return new AuthCurrentUserResponse(
toUserView(authBundle.user()),
authBundle.roleCodes(),
authBundle.permissionCodes()
);
}
public AuthLoginResponse refreshToken(String token) {
Claims claims = parseClaims(token);
Long userId = parseUserId(claims);
AuthBundle authBundle = loadAuthBundle(userId);
String refreshedToken = buildToken(authBundle);
return new AuthLoginResponse(
refreshedToken,
TOKEN_TYPE,
computeExpiresInSeconds(refreshedToken),
toUserView(authBundle.user()),
authBundle.roleCodes(),
authBundle.permissionCodes()
);
}
public List<AuthUserWithRolesResponse> listUsersWithRoles() {
List<AuthUserSummary> users = authMapper.listUsers();
List<AuthUserWithRolesResponse> responses = new ArrayList<>(users.size());
for (AuthUserSummary user : users) {
List<String> roleCodes = authMapper.findRolesByUserId(user.getId())
.stream()
.map(AuthRoleInfo::getRoleCode)
.filter(Objects::nonNull)
.toList();
responses.add(new AuthUserWithRolesResponse(
user.getId(),
user.getUsername(),
user.getFullName(),
user.getEmail(),
user.getEnabled(),
roleCodes
));
}
return responses;
}
public List<AuthRoleInfo> listRoles() {
return authMapper.listRoles();
}
public List<AuthPermissionInfo> listPermissions() {
return authMapper.listPermissions();
}
public void assignUserRoles(Long userId, List<String> roleIds) {
AuthUserAccount user = authMapper.findUserById(userId);
BusinessAssert.notNull(user, AuthErrorCode.USER_NOT_FOUND);
Set<String> distinctRoleIds = new LinkedHashSet<>(roleIds);
BusinessAssert.notEmpty(distinctRoleIds, AuthErrorCode.ROLE_NOT_FOUND);
int existingRoleCount = authMapper.countRolesByIds(new ArrayList<>(distinctRoleIds));
BusinessAssert.isTrue(existingRoleCount == distinctRoleIds.size(), AuthErrorCode.ROLE_NOT_FOUND);
authMapper.deleteUserRoles(userId);
authMapper.insertUserRoles(userId, new ArrayList<>(distinctRoleIds));
}
private String buildToken(AuthBundle authBundle) {
Map<String, Object> claims = Map.of(
"userId", authBundle.user().getId(),
"roles", authBundle.roleCodes(),
"permissions", authBundle.permissionCodes()
);
return jwtUtils.generateToken(authBundle.user().getUsername(), claims);
}
private AuthBundle loadAuthBundle(Long userId) {
AuthUserAccount user = authMapper.findUserById(userId);
BusinessAssert.notNull(user, AuthErrorCode.USER_NOT_FOUND);
BusinessAssert.isTrue(Boolean.TRUE.equals(user.getEnabled()), AuthErrorCode.ACCOUNT_DISABLED);
List<String> roleCodes = authMapper.findRolesByUserId(userId).stream()
.map(AuthRoleInfo::getRoleCode)
.filter(Objects::nonNull)
.toList();
List<String> permissionCodes = authMapper.findPermissionCodesByUserId(userId).stream()
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
return new AuthBundle(user, roleCodes, permissionCodes);
}
private Claims parseClaims(String token) {
try {
return jwtUtils.getClaimsFromToken(token);
} catch (JwtException | IllegalArgumentException e) {
throw com.datamate.common.infrastructure.exception.BusinessException.of(AuthErrorCode.TOKEN_INVALID);
}
}
private Long parseUserId(Claims claims) {
Object userIdObject = claims.get("userId");
if (userIdObject instanceof Number number) {
return number.longValue();
}
if (userIdObject instanceof String str) {
try {
return Long.parseLong(str);
} catch (NumberFormatException e) {
throw com.datamate.common.infrastructure.exception.BusinessException.of(AuthErrorCode.TOKEN_INVALID);
}
}
throw com.datamate.common.infrastructure.exception.BusinessException.of(AuthErrorCode.TOKEN_INVALID);
}
private long computeExpiresInSeconds(String token) {
Date expirationDate = jwtUtils.getExpirationDateFromToken(token);
long seconds = Duration.between(new Date().toInstant(), expirationDate.toInstant()).toSeconds();
return Math.max(seconds, 0L);
}
private AuthUserView toUserView(AuthUserAccount user) {
return new AuthUserView(
user.getId(),
user.getUsername(),
user.getFullName(),
user.getEmail(),
user.getAvatarUrl(),
user.getOrganization()
);
}
private record AuthBundle(
AuthUserAccount user,
List<String> roleCodes,
List<String> permissionCodes
) {
}
}

View File

@@ -0,0 +1,21 @@
package com.datamate.common.auth.domain.model;
import lombok.Getter;
import lombok.Setter;
/**
* 权限信息
*/
@Getter
@Setter
public class AuthPermissionInfo {
private String id;
private String permissionCode;
private String permissionName;
private String module;
private String action;
private String pathPattern;
private String method;
private Boolean enabled;
}

View File

@@ -0,0 +1,18 @@
package com.datamate.common.auth.domain.model;
import lombok.Getter;
import lombok.Setter;
/**
* 角色信息
*/
@Getter
@Setter
public class AuthRoleInfo {
private String id;
private String roleCode;
private String roleName;
private String description;
private Boolean enabled;
}

View File

@@ -0,0 +1,24 @@
package com.datamate.common.auth.domain.model;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 认证用户账户
*/
@Getter
@Setter
public class AuthUserAccount {
private Long id;
private String username;
private String email;
private String passwordHash;
private String fullName;
private String avatarUrl;
private String organization;
private Boolean enabled;
private LocalDateTime lastLoginAt;
}

View File

@@ -0,0 +1,18 @@
package com.datamate.common.auth.domain.model;
import lombok.Getter;
import lombok.Setter;
/**
* 用户摘要
*/
@Getter
@Setter
public class AuthUserSummary {
private Long id;
private String username;
private String email;
private String fullName;
private Boolean enabled;
}

View File

@@ -0,0 +1,18 @@
package com.datamate.common.auth.infrastructure.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 认证模块配置
*/
@Configuration
public class AuthConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,23 @@
package com.datamate.common.auth.infrastructure.exception;
import com.datamate.common.infrastructure.exception.ErrorCode;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 认证授权错误码
*/
@Getter
@AllArgsConstructor
public enum AuthErrorCode implements ErrorCode {
INVALID_CREDENTIALS("auth.0001", "用户名或密码错误"),
ACCOUNT_DISABLED("auth.0002", "账号已被禁用"),
TOKEN_INVALID("auth.0003", "登录状态已失效"),
USER_NOT_FOUND("auth.0004", "用户不存在"),
ROLE_NOT_FOUND("auth.0005", "角色不存在"),
AUTHORIZATION_DENIED("auth.0006", "无权限执行该操作");
private final String code;
private final String message;
}

View File

@@ -0,0 +1,39 @@
package com.datamate.common.auth.infrastructure.persistence.mapper;
import com.datamate.common.auth.domain.model.AuthPermissionInfo;
import com.datamate.common.auth.domain.model.AuthRoleInfo;
import com.datamate.common.auth.domain.model.AuthUserAccount;
import com.datamate.common.auth.domain.model.AuthUserSummary;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 认证授权数据访问
*/
@Mapper
public interface AuthMapper {
AuthUserAccount findUserByUsername(@Param("username") String username);
AuthUserAccount findUserById(@Param("userId") Long userId);
int updateLastLoginAt(@Param("userId") Long userId);
List<AuthRoleInfo> findRolesByUserId(@Param("userId") Long userId);
List<String> findPermissionCodesByUserId(@Param("userId") Long userId);
List<AuthUserSummary> listUsers();
List<AuthRoleInfo> listRoles();
List<AuthPermissionInfo> listPermissions();
int countRolesByIds(@Param("roleIds") List<String> roleIds);
int deleteUserRoles(@Param("userId") Long userId);
int insertUserRoles(@Param("userId") Long userId, @Param("roleIds") List<String> roleIds);
}

View File

@@ -0,0 +1,82 @@
package com.datamate.common.auth.interfaces.rest;
import com.datamate.common.auth.application.AuthApplicationService;
import com.datamate.common.auth.domain.model.AuthPermissionInfo;
import com.datamate.common.auth.domain.model.AuthRoleInfo;
import com.datamate.common.auth.interfaces.rest.dto.AssignUserRolesRequest;
import com.datamate.common.auth.interfaces.rest.dto.AuthCurrentUserResponse;
import com.datamate.common.auth.interfaces.rest.dto.AuthLoginResponse;
import com.datamate.common.auth.interfaces.rest.dto.AuthUserWithRolesResponse;
import com.datamate.common.auth.interfaces.rest.dto.LoginRequest;
import com.datamate.common.infrastructure.exception.BusinessAssert;
import com.datamate.common.auth.infrastructure.exception.AuthErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 认证授权控制器
*/
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthApplicationService authApplicationService;
@PostMapping("/login")
public AuthLoginResponse login(@RequestBody @Valid LoginRequest loginRequest) {
return authApplicationService.login(loginRequest.username(), loginRequest.password());
}
@GetMapping("/me")
public AuthCurrentUserResponse me(HttpServletRequest request) {
return authApplicationService.getCurrentUser(extractBearerToken(request.getHeader("Authorization")));
}
@PostMapping("/refresh")
public AuthLoginResponse refresh(@RequestHeader("Authorization") String authorization) {
return authApplicationService.refreshToken(extractBearerToken(authorization));
}
@GetMapping("/users")
public List<AuthUserWithRolesResponse> listUsers() {
return authApplicationService.listUsersWithRoles();
}
@PutMapping("/users/{userId}/roles")
public void assignRoles(@PathVariable("userId") Long userId,
@RequestBody @Valid AssignUserRolesRequest request) {
authApplicationService.assignUserRoles(userId, request.roleIds());
}
@GetMapping("/roles")
public List<AuthRoleInfo> listRoles() {
return authApplicationService.listRoles();
}
@GetMapping("/permissions")
public List<AuthPermissionInfo> listPermissions() {
return authApplicationService.listPermissions();
}
private String extractBearerToken(String authorizationHeader) {
BusinessAssert.isTrue(
authorizationHeader != null && authorizationHeader.startsWith("Bearer "),
AuthErrorCode.TOKEN_INVALID
);
String token = authorizationHeader.substring("Bearer ".length()).trim();
BusinessAssert.isTrue(!token.isEmpty(), AuthErrorCode.TOKEN_INVALID);
return token;
}
}

View File

@@ -0,0 +1,14 @@
package com.datamate.common.auth.interfaces.rest.dto;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* 用户角色分配请求
*/
public record AssignUserRolesRequest(
@NotEmpty(message = "角色列表不能为空") List<String> roleIds
) {
}

View File

@@ -0,0 +1,14 @@
package com.datamate.common.auth.interfaces.rest.dto;
import java.util.List;
/**
* 当前用户信息响应
*/
public record AuthCurrentUserResponse(
AuthUserView user,
List<String> roles,
List<String> permissions
) {
}

View File

@@ -0,0 +1,17 @@
package com.datamate.common.auth.interfaces.rest.dto;
import java.util.List;
/**
* 登录响应
*/
public record AuthLoginResponse(
String token,
String tokenType,
long expiresInSeconds,
AuthUserView user,
List<String> roles,
List<String> permissions
) {
}

View File

@@ -0,0 +1,15 @@
package com.datamate.common.auth.interfaces.rest.dto;
/**
* 登录用户信息
*/
public record AuthUserView(
Long id,
String username,
String fullName,
String email,
String avatarUrl,
String organization
) {
}

View File

@@ -0,0 +1,17 @@
package com.datamate.common.auth.interfaces.rest.dto;
import java.util.List;
/**
* 用户与角色响应
*/
public record AuthUserWithRolesResponse(
Long id,
String username,
String fullName,
String email,
Boolean enabled,
List<String> roleCodes
) {
}

View File

@@ -0,0 +1,13 @@
package com.datamate.common.auth.interfaces.rest.dto;
import jakarta.validation.constraints.NotBlank;
/**
* 登录请求
*/
public record LoginRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "密码不能为空") String password
) {
}

View File

@@ -3,6 +3,7 @@ package com.datamate.common.infrastructure.config;
import com.datamate.common.infrastructure.common.Response;
import com.datamate.common.infrastructure.exception.BusinessException;
import com.datamate.common.infrastructure.exception.SystemErrorCode;
import org.springframework.http.HttpStatus;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
@@ -28,7 +29,8 @@ public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Response<?>> handleBusinessException(BusinessException e) {
log.warn("BusinessException: code={}, message={}", e.getCode(), e.getMessage(), e);
return ResponseEntity.internalServerError().body(Response.error(e.getErrorCodeEnum()));
HttpStatus status = resolveBusinessStatus(e.getCode());
return ResponseEntity.status(status).body(Response.error(e.getErrorCodeEnum()));
}
/**
@@ -51,4 +53,17 @@ public class GlobalExceptionHandler {
log.error("SystemException: ", e);
return ResponseEntity.internalServerError().body(Response.error(SystemErrorCode.SYSTEM_BUSY));
}
private HttpStatus resolveBusinessStatus(String code) {
if (code == null) {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
if (!code.startsWith("auth.")) {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
if ("auth.0006".equals(code)) {
return HttpStatus.FORBIDDEN;
}
return HttpStatus.UNAUTHORIZED;
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.datamate.common.auth.infrastructure.persistence.mapper.AuthMapper">
<select id="findUserByUsername" resultType="com.datamate.common.auth.domain.model.AuthUserAccount">
SELECT id,
username,
email,
password_hash AS passwordHash,
full_name AS fullName,
avatar_url AS avatarUrl,
organization,
enabled,
last_login_at AS lastLoginAt
FROM users
WHERE username = #{username}
LIMIT 1
</select>
<select id="findUserById" resultType="com.datamate.common.auth.domain.model.AuthUserAccount">
SELECT id,
username,
email,
password_hash AS passwordHash,
full_name AS fullName,
avatar_url AS avatarUrl,
organization,
enabled,
last_login_at AS lastLoginAt
FROM users
WHERE id = #{userId}
LIMIT 1
</select>
<update id="updateLastLoginAt">
UPDATE users
SET last_login_at = NOW()
WHERE id = #{userId}
</update>
<select id="findRolesByUserId" resultType="com.datamate.common.auth.domain.model.AuthRoleInfo">
SELECT r.id,
r.role_code AS roleCode,
r.role_name AS roleName,
r.description,
r.enabled
FROM t_auth_roles r
INNER JOIN t_auth_user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = #{userId}
ORDER BY r.role_code
</select>
<select id="findPermissionCodesByUserId" resultType="string">
SELECT DISTINCT p.permission_code
FROM t_auth_permissions p
INNER JOIN t_auth_role_permissions rp ON rp.permission_id = p.id
INNER JOIN t_auth_user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = #{userId}
AND p.enabled = 1
ORDER BY p.permission_code
</select>
<select id="listUsers" resultType="com.datamate.common.auth.domain.model.AuthUserSummary">
SELECT id,
username,
email,
full_name AS fullName,
enabled
FROM users
ORDER BY id ASC
</select>
<select id="listRoles" resultType="com.datamate.common.auth.domain.model.AuthRoleInfo">
SELECT id,
role_code AS roleCode,
role_name AS roleName,
description,
enabled
FROM t_auth_roles
ORDER BY role_code ASC
</select>
<select id="listPermissions" resultType="com.datamate.common.auth.domain.model.AuthPermissionInfo">
SELECT id,
permission_code AS permissionCode,
permission_name AS permissionName,
module,
action,
path_pattern AS pathPattern,
method,
enabled
FROM t_auth_permissions
ORDER BY module ASC, action ASC
</select>
<select id="countRolesByIds" resultType="int">
SELECT COUNT(1)
FROM t_auth_roles
WHERE id IN
<foreach collection="roleIds" item="roleId" open="(" separator="," close=")">
#{roleId}
</foreach>
</select>
<delete id="deleteUserRoles">
DELETE
FROM t_auth_user_roles
WHERE user_id = #{userId}
</delete>
<insert id="insertUserRoles">
INSERT INTO t_auth_user_roles (user_id, role_id)
VALUES
<foreach collection="roleIds" item="roleId" separator=",">
(#{userId}, #{roleId})
</foreach>
</insert>
</mapper>

View File

@@ -3,9 +3,13 @@ package com.datamate.common.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StringUtils;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@@ -15,15 +19,23 @@ import java.util.Map;
*/
@Component
public class JwtUtils {
private static final String DEFAULT_SECRET = "datamate-secret-key-for-jwt-token-generation";
@Value("${jwt.secret:datamate-secret-key-for-jwt-token-generation}")
@Value("${jwt.secret:" + DEFAULT_SECRET + "}")
private String secret;
@Value("${jwt.expiration:86400}") // 24小时
private Long expiration;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
String secretValue = StringUtils.hasText(secret) ? secret : DEFAULT_SECRET;
try {
MessageDigest digest = MessageDigest.getInstance("SHA-512");
byte[] keyBytes = digest.digest(secretValue.getBytes(StandardCharsets.UTF_8));
return Keys.hmacShaKeyFor(keyBytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Cannot initialize JWT signing key", e);
}
}
/**
@@ -84,7 +96,18 @@ public class JwtUtils {
public Boolean validateToken(String token, String username) {
try {
String tokenUsername = getUsernameFromToken(token);
return (username.equals(tokenUsername) && !isTokenExpired(token));
return (username.equals(tokenUsername) && validateToken(token));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
/**
* 仅校验令牌格式与有效期
*/
public Boolean validateToken(String token) {
try {
return !isTokenExpired(token);
} catch (JwtException | IllegalArgumentException e) {
return false;
}