安全实战:用户中心 + 权限管理
概述
本文从 0 构建一个企业级用户中台,涵盖用户注册/登录、角色管理、权限分配、操作日志、登录日志等核心功能。
架构概览
text
用户中心(User Center)
├── 认证模块(Authentication)
│ ├── 用户名密码登录
│ ├── JWT Token 发放
│ ├── Refresh Token 轮换
│ ├── OAuth2 社交登录(微信/GitHub)
│ └── MFA 多因素认证
├── 权限模块(Authorization)
│ ├── RBAC 五级体系(用户→角色→菜单→按钮→数据)
│ ├── 动态角色分配
│ └── 数据权限(部门数据隔离)
├── 用户管理
│ ├── 注册/信息维护
│ ├── 密码策略(强度/过期/历史防重用)
│ └── 账号状态(锁定/启用/禁用)
└── 审计模块(Audit)
├── 登录日志
├── 操作日志
└── 安全事件一、数据模型
1.1 RBAC 五级表设计
sql
-- 用户表
CREATE TABLE sys_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
real_name VARCHAR(50),
phone VARCHAR(20),
email VARCHAR(100),
department_id BIGINT,
status TINYINT DEFAULT 1 COMMENT '1=启用 0=禁用',
account_non_locked BIT(1) DEFAULT 1,
credentials_non_expired BIT(1) DEFAULT 1,
last_login_ip VARCHAR(45),
last_login_time DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME,
INDEX idx_department (department_id)
);
-- 角色表
CREATE TABLE sys_role (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE, -- ROLE_ADMIN
label VARCHAR(50) NOT NULL, -- 管理员
parent_id BIGINT, -- 角色继承
status TINYINT DEFAULT 1,
sort_order INT DEFAULT 0,
created_at DATETIME NOT NULL
);
-- 用户-角色关联
CREATE TABLE sys_user_role (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id)
);
-- 菜单/权限表
CREATE TABLE sys_permission (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL, -- user:create
label VARCHAR(100), -- 创建用户
type TINYINT COMMENT '1=菜单 2=按钮 3=API',
parent_id BIGINT,
path VARCHAR(200), -- /api/users
method VARCHAR(10), -- POST
sort_order INT DEFAULT 0,
status TINYINT DEFAULT 1
);
-- 角色-权限关联
CREATE TABLE sys_role_permission (
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
PRIMARY KEY (role_id, permission_id)
);
-- 部门表(数据权限)
CREATE TABLE sys_department (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id BIGINT,
sort_order INT DEFAULT 0,
status TINYINT DEFAULT 1
);二、认证模块实现
2.1 JWT Token 服务
java
@Service
public class JwtTokenService {
private static final long ACCESS_TOKEN_EXPIRE = 30 * 60 * 1000; // 30 分钟
private static final long REFRESH_TOKEN_EXPIRE = 7 * 24 * 3600_000; // 7 天
private final SecretKey signingKey = Keys.hmacShaKeyFor(
"my-256-bit-secret-key-at-least-thirty-two-characters".getBytes()
);
@Autowired
private RedisTemplate<String, String> redisTemplate;
public TokenPair generateTokens(Long userId, String username, Set<String> roles) {
String accessToken = generateAccessToken(userId, username, roles);
String refreshToken = generateRefreshToken(userId);
return new TokenPair(accessToken, refreshToken, ACCESS_TOKEN_EXPIRE / 1000);
}
private String generateAccessToken(Long userId, String username, Set<String> roles) {
return Jwts.builder()
.subject(userId.toString())
.claim("username", username)
.claim("roles", roles)
.claim("type", "access")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE))
.signWith(signingKey)
.compact();
}
private String generateRefreshToken(Long userId) {
String jti = UUID.randomUUID().toString();
String refreshToken = Jwts.builder()
.subject(userId.toString())
.claim("type", "refresh")
.id(jti)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE))
.signWith(signingKey)
.compact();
// 存储 refresh token 到 Redis(用于撤销)
redisTemplate.opsForValue()
.set("refresh_token:" + jti, userId.toString(),
REFRESH_TOKEN_EXPIRE, TimeUnit.MILLISECONDS);
return refreshToken;
}
// Token 刷新
public TokenPair refreshAccessToken(String refreshToken) {
Claims claims = parseToken(refreshToken);
if (!"refresh".equals(claims.get("type"))) {
throw new BadCredentialsException("Invalid refresh token type");
}
// 检查是否已撤销
String jti = claims.getId();
if (Boolean.FALSE.equals(redisTemplate.hasKey("refresh_token:" + jti))) {
throw new BadCredentialsException("Refresh token revoked");
}
// 删除旧 refresh token(实现轮换)
redisTemplate.delete("refresh_token:" + jti);
Long userId = Long.parseLong(claims.getSubject());
User user = userRepository.findById(userId).orElseThrow();
return generateTokens(user.getId(), user.getUsername(), user.getRoleNames());
}
// 登出
public void logout(String accessToken, String refreshToken) {
// 将 access token 加入黑名单
Claims claims = parseToken(accessToken);
long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
if (ttl > 0) {
redisTemplate.opsForValue()
.set("blacklist:" + claims.getId(), "1", ttl, TimeUnit.MILLISECONDS);
}
// 删除 refresh token
if (refreshToken != null) {
Claims refreshClaims = parseToken(refreshToken);
redisTemplate.delete("refresh_token:" + refreshClaims.getId());
}
}
private Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}2.2 登录认证
java
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenService tokenService;
@Autowired
private LoginLogService loginLogService;
@PostMapping("/login")
public ResponseEntity<TokenPair> login(@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
try {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword());
Authentication auth = authenticationManager.authenticate(authToken);
UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
// 生成 Token
TokenPair tokens = tokenService.generateTokens(
principal.getId(),
principal.getUsername(),
principal.getRoles()
);
// 记录登录日志
loginLogService.recordLoginSuccess(
principal.getId(), httpRequest.getRemoteAddr());
return ResponseEntity.ok(tokens);
} catch (BadCredentialsException e) {
loginLogService.recordLoginFailure(
request.getUsername(), httpRequest.getRemoteAddr());
return ResponseEntity.status(401).build();
}
}
@PostMapping("/refresh")
public ResponseEntity<TokenPair> refresh(@RequestBody RefreshRequest request) {
TokenPair tokens = tokenService.refreshAccessToken(request.getRefreshToken());
return ResponseEntity.ok(tokens);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestHeader("Authorization") String authHeader,
@RequestBody(required = false) LogoutRequest request) {
String accessToken = authHeader.replace("Bearer ", "");
tokenService.logout(accessToken,
request != null ? request.getRefreshToken() : null);
return ResponseEntity.ok().build();
}
}2.3 密码策略
java
@Service
public class PasswordPolicyService {
private static final int MIN_LENGTH = 8;
private static final int MAX_HISTORY = 5; // 历史密码防重用
private static final int MAX_AGE_DAYS = 90; // 密码过期天数
@Autowired
private PasswordHistoryRepository historyRepository;
// 密码强度校验
public void validatePassword(String password) {
List<String> errors = new ArrayList<>();
if (password.length() < MIN_LENGTH) {
errors.add("密码长度不能少于" + MIN_LENGTH + "位");
}
if (!password.matches(".*[A-Z].*")) {
errors.add("密码必须包含大写字母");
}
if (!password.matches(".*[a-z].*")) {
errors.add("密码必须包含小写字母");
}
if (!password.matches(".*\\d.*")) {
errors.add("密码必须包含数字");
}
if (!password.matches(".*[!@#$%^&*()_+].*")) {
errors.add("密码必须包含特殊字符");
}
if (!errors.isEmpty()) {
throw new PasswordPolicyException(String.join(";", errors));
}
}
// 历史密码防重用
public void checkPasswordHistory(Long userId, String newPassword) {
List<PasswordHistory> history =
historyRepository.findTopNByUserIdOrderByCreatedAtDesc(userId, MAX_HISTORY);
for (PasswordHistory h : history) {
if (passwordEncoder.matches(newPassword, h.getPasswordHash())) {
throw new PasswordPolicyException(
"密码不能与最近" + MAX_HISTORY + "次使用的密码相同");
}
}
}
// 检查密码是否过期
public boolean isPasswordExpired(Long userId) {
return userRepository.findLastPasswordChangeTime(userId)
.map(time -> Duration.between(time, Instant.now()).toDays() > MAX_AGE_DAYS)
.orElse(true);
}
}三、RBAC 权限模块
3.1 权限数据模型
java
// 权限类型
public enum PermissionType {
MENU, // 菜单权限
BUTTON, // 按钮权限
API // 接口权限
}
// 数据权限范围
public enum DataScope {
ALL, // 全部数据
DEPARTMENT, // 本部门
DEPARTMENT_AND_SUB, // 本部门及子部门
SELF // 仅本人
}3.2 动态权限加载
java
@Component
public class DynamicPermissionService {
@Autowired
private RolePermissionRepository rolePermissionRepository;
// 加载用户的所有权限
public Set<String> loadUserPermissions(Long userId) {
// 1. 查用户角色
List<Role> roles = roleRepository.findByUserId(userId);
// 2. 查角色权限
List<Permission> permissions =
rolePermissionRepository.findPermissionsByRoleIds(
roles.stream().map(Role::getId).collect(Collectors.toList()));
// 3. 转换为权限标识
return permissions.stream()
.map(p -> p.getType() + ":" + p.getName())
.collect(Collectors.toSet());
}
// 加载用户数据权限范围
public DataScope loadUserDataScope(Long userId) {
// 取用户拥有的最大数据权限范围
return rolePermissionRepository.findMaxDataScopeByUserId(userId);
}
}3.3 数据权限过滤
java
@Service
public class OrderServiceWithDataScope {
@Autowired
private OrderRepository orderRepository;
@Autowired
private UserContextHolder userContext;
public Page<Order> findOrders(Pageable pageable) {
UserPrincipal currentUser = userContext.getCurrentUser();
DataScope dataScope = currentUser.getDataScope();
Long userId = currentUser.getId();
Long departmentId = currentUser.getDepartmentId();
switch (dataScope) {
case ALL:
return orderRepository.findAll(pageable);
case DEPARTMENT:
return orderRepository.findByDepartmentId(departmentId, pageable);
case DEPARTMENT_AND_SUB:
List<Long> deptIds = departmentService
.findDepartmentAndSubIds(departmentId);
return orderRepository.findByDepartmentIdIn(deptIds, pageable);
case SELF:
return orderRepository.findByUserId(userId, pageable);
default:
return orderRepository.findByUserId(userId, pageable);
}
}
}四、MFA 多因素认证
java
@Service
public class MfaService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 生成 TOTP 密钥
public String generateSecretKey() {
return new DefaultGenerator().generate();
}
// 验证 TOTP 码
public boolean validateTotp(String secretKey, String code) {
Totp totp = new Totp(secretKey);
return totp.verify(code);
}
// 发送短信验证码
public void sendSmsCode(String phone) {
String code = String.format("%06d",
new Random().nextInt(1000000));
redisTemplate.opsForValue()
.set("sms:mfa:" + phone, code, 5, TimeUnit.MINUTES);
smsService.send(phone, "您的验证码是:" + code);
}
// 验证短信验证码
public boolean validateSmsCode(String phone, String code) {
String expected = redisTemplate.opsForValue().get("sms:mfa:" + phone);
return code.equals(expected);
}
// MFA 认证流程
public Authentication authenticateWithMfa(String username,
String password,
String mfaCode) {
// 步骤 1:验证用户名密码
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(username, password);
Authentication result = authenticationManager.authenticate(auth);
// 步骤 2:验证 MFA
UserPrincipal principal = (UserPrincipal) result.getPrincipal();
if (principal.isMfaEnabled()) {
boolean mfaValid = principal.getMfaType() == MfaType.TOTP
? validateTotp(principal.getMfaSecret(), mfaCode)
: validateSmsCode(principal.getPhone(), mfaCode);
if (!mfaValid) {
throw new BadCredentialsException("MFA 验证失败");
}
}
return result;
}
}五、审计日志
java
@Service
public class AuditLogService {
@Async
public void recordLogin(Long userId, String username,
String ip, boolean success, String detail) {
LoginLog log = new LoginLog();
log.setUserId(userId);
log.setUsername(username);
log.setIpAddress(ip);
log.setResult(success ? "SUCCESS" : "FAILURE");
log.setDetail(detail);
log.setCreatedAt(Instant.now());
loginLogRepository.save(log);
}
@Async
public void recordOperation(Long userId, String username,
String action, String resource,
String detail, boolean success) {
OperationLog log = new OperationLog();
log.setUserId(userId);
log.setUsername(username);
log.setAction(action); // CREATE_USER / DELETE_ROLE / ASSIGN_PERMISSION
log.setResource(resource); // USER / ROLE / PERMISSION
log.setDetail(detail);
log.setResult(success ? "SUCCESS" : "FAILURE");
log.setCreatedAt(Instant.now());
operationLogRepository.save(log);
}
}六、总结
| 模块 | 要点 |
|---|---|
| 数据模型 | RBAC 五级:用户→角色→菜单→按钮→数据 |
| JWT Token | 双 Token 模型 + Redis 黑名单 + Token 轮换 |
| 密码策略 | 强度校验 + 历史防重用 + 90 天过期 |
| 数据权限 | ALL / DEPARTMENT / SELF 四种范围 |
| MFA | TOTP + 短信验证码双因素 |
| 审计日志 | 登录日志 + 操作日志 + 安全事件 |
参考链接: