1. 为什么需要自己实现登录注册?
在Spring Boot项目中,用户认证是绝大多数系统的基础功能。虽然Spring Security提供了开箱即用的解决方案,但很多开发者发现直接使用它会有几个痛点:
首先,Spring Security的默认配置过于重量级,会引入大量可能用不到的功能(比如Remember-Me、CSRF防护等),导致项目启动变慢。其次,它的学习曲线陡峭,想要自定义登录逻辑时常常需要深入理解过滤器链、Provider等复杂机制。最重要的是,现成的方案往往无法满足业务的特殊需求,比如:
- 需要同时支持密码登录和短信验证码登录
- 要求登录后返回自定义的Token格式
- 用户状态需要与第三方系统实时同步
我在电商项目中就遇到过这样的场景:用户登录时需要检查账户是否被风控系统锁定,这个检查必须调用风控团队的HTTP接口。标准的Spring Security流程根本无法满足这种定制化需求。
2. 基础环境搭建
2.1 项目初始化
使用Spring Initializr创建项目时,除了基础的Web依赖,建议明确添加以下依赖:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
这里特别说明几个关键选择:
- 没有选用Hibernate Validator而是手动校验,因为实际项目中验证规则经常变化
- 使用Java-JWT而非JJWT,因为前者对JWT标准支持更全面
- 引入Lombok是为了减少样板代码,但要注意在团队项目中需要统一注解使用规范
2.2 数据库设计
用户表的核心字段设计如下(MySQL语法):
sql复制CREATE TABLE `sys_user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '登录账号',
`password` varchar(100) NOT NULL COMMENT '加密后的密码',
`salt` varchar(20) NOT NULL COMMENT '加密盐值',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0-禁用,1-正常',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`),
KEY `idx_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
几个关键设计点:
- 密码字段长度预留100位,为后续可能更换加密算法留余地
- 单独存储salt值而非硬编码,提高安全性
- 对username建立唯一索引,phone建立普通索引(根据业务决定是否唯一)
- 使用utf8mb4字符集支持emoji等特殊字符
3. 核心功能实现
3.1 密码加密策略
绝对不要直接存储明文密码!推荐使用PBKDF2WithHmacSHA256算法:
java复制public class PasswordUtil {
private static final int ITERATIONS = 10000;
private static final int KEY_LENGTH = 256;
public static String encrypt(String password, String salt) {
try {
PBEKeySpec spec = new PBEKeySpec(
password.toCharArray(),
salt.getBytes(),
ITERATIONS,
KEY_LENGTH
);
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] hash = skf.generateSecret(spec).getEncoded();
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("密码加密失败", e);
}
}
public static boolean verify(String inputPwd, String salt, String storedPwd) {
String encrypted = encrypt(inputPwd, salt);
return encrypted.equals(storedPwd);
}
public static String generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[16];
random.nextBytes(salt);
return Base64.getEncoder().encodeToString(salt);
}
}
为什么选择这个方案:
- PBKDF2是NIST推荐的密码哈希算法
- 可配置迭代次数对抗暴力破解
- 每个用户独立salt防止彩虹表攻击
- 相比BCrypt更轻量,适合中小型项目
3.2 注册逻辑实现
Controller层代码示例:
java复制@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private UserService userService;
@PostMapping("/register")
public Result register(@Valid @RequestBody RegisterDTO dto) {
if (userService.existsByUsername(dto.getUsername())) {
throw new BusinessException("用户名已存在");
}
User user = userService.createUser(dto);
return Result.success(user.getId());
}
}
Service层关键逻辑:
java复制@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepository;
public User createUser(RegisterDTO dto) {
String salt = PasswordUtil.generateSalt();
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(PasswordUtil.encrypt(dto.getPassword(), salt));
user.setSalt(salt);
user.setPhone(dto.getPhone());
return userRepository.save(user);
}
}
几个重要细节:
- 使用@Valid进行基础参数校验
- 用户名唯一性检查要放在事务外,避免锁表
- 密码加密和salt生成在Service层完成
- 返回结果只包含必要信息,不要暴露敏感字段
3.3 登录与Token签发
JWT工具类封装:
java复制public class JwtUtil {
private static final String SECRET = "your-256-bit-secret"; // 实际项目应从配置读取
private static final long EXPIRATION = 86400L; // 24小时
public static String generateToken(UserDetails user) {
return JWT.create()
.withSubject(user.getUsername())
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION * 1000))
.sign(Algorithm.HMAC256(SECRET));
}
public static String getUsernameFromToken(String token) {
return JWT.require(Algorithm.HMAC256(SECRET))
.build()
.verify(token)
.getSubject();
}
}
登录接口实现:
java复制@PostMapping("/login")
public Result login(@RequestBody LoginDTO dto) {
User user = userService.findByUsername(dto.getUsername());
if (user == null || !PasswordUtil.verify(dto.getPassword(), user.getSalt(), user.getPassword())) {
throw new BusinessException("用户名或密码错误");
}
if (user.getStatus() == 0) {
throw new BusinessException("账号已被禁用");
}
String token = JwtUtil.generateToken(user);
return Result.success(Map.of(
"token", token,
"expire", JwtUtil.EXPIRATION
));
}
关键安全措施:
- 登录失败不提示具体是用户名还是密码错误
- 检查账号状态是否可用
- Token设置合理的过期时间
- 使用HTTPS传输敏感信息
4. 进阶功能实现
4.1 接口权限控制
自定义注解实现方法级权限控制:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermission {
String[] value();
}
@Aspect
@Component
public class PermissionAspect {
@Before("@annotation(requiresPermission)")
public void checkPermission(JoinPoint joinPoint, RequiresPermission requiresPermission) {
String[] permissions = requiresPermission.value();
// 从Token解析当前用户权限并校验
if (!hasPermissions(permissions)) {
throw new BusinessException("无权限访问");
}
}
private boolean hasPermissions(String[] required) {
// 实现权限校验逻辑
return true;
}
}
使用示例:
java复制@RequiresPermission("user:delete")
@DeleteMapping("/users/{id}")
public Result deleteUser(@PathVariable Long id) {
// 删除逻辑
}
4.2 防重复提交处理
利用Redis实现Token防重:
java复制@PostMapping("/update-password")
public Result updatePassword(@RequestBody PasswordDTO dto,
@RequestHeader("X-Request-Token") String clientToken) {
if (StringUtils.isEmpty(clientToken) || !redisTemplate.delete("pwd_token:" + clientToken)) {
throw new BusinessException("请勿重复提交");
}
// 处理密码修改逻辑
}
前端需要在提交时生成唯一Token:
javascript复制function changePassword() {
const token = uuidv4(); // 生成唯一标识
axios.post('/update-password', data, {
headers: { 'X-Request-Token': token }
});
}
4.3 登录日志审计
使用Spring事件机制记录登录日志:
java复制public class LoginEvent extends ApplicationEvent {
private final String username;
private final String ip;
private final boolean success;
// 构造方法、getter省略
}
@Component
public class LoginListener {
@Async
@EventListener
public void handleLoginEvent(LoginEvent event) {
// 异步记录登录日志到数据库
logService.saveLoginLog(
event.getUsername(),
event.getIp(),
event.isSuccess()
);
}
}
// 在登录逻辑中发布事件
if (loginSuccess) {
eventPublisher.publishEvent(new LoginEvent(
this, username, getClientIp(), true
));
}
5. 生产环境注意事项
5.1 安全加固措施
-
密码策略:
- 前端做初步复杂度校验
- 后端限制尝试频率(如5分钟内最多5次)
- 定期提醒修改密码
-
Token安全:
- 设置合理的过期时间(建议2-4小时)
- 使用Refresh Token机制
- 实现Token黑名单
-
其他防护:
- 关键接口添加图形验证码
- 敏感操作需要二次认证
- 定期审计登录日志
5.2 性能优化建议
- 用户查询缓存:
java复制@Cacheable(value = "user", key = "#username")
public User findByUsername(String username) {
return userRepository.findByUsername(username);
}
-
JWT验签优化:
- 使用本地缓存存储已验签的Token
- 对于过期Token直接拒绝
-
数据库优化:
- 对status字段加索引
- 大表考虑分库分表
5.3 常见问题排查
-
登录缓慢:
- 检查密码加密算法性能
- 监控数据库查询时间
- 确认没有不必要的权限检查
-
Token失效异常:
- 检查服务器时间是否同步
- 验证Secret的一致性
- 排查缓存清除逻辑
-
并发问题:
- 使用@Transactional保证原子操作
- 对关键资源加分布式锁
- 避免长事务
我在实际项目中遇到过Redis连接池耗尽导致登录超时的问题,后来通过以下配置解决:
yaml复制spring:
redis:
lettuce:
pool:
max-active: 50
max-wait: 1000ms
max-idle: 20
min-idle: 5
这个案例告诉我们,登录功能看似简单,但在高并发场景下需要全面考虑各种中间件的配置。建议在项目初期就进行压力测试,模拟真实用户登录场景,提前发现性能瓶颈。
