Spring Security 深度学习(二): 自定义认证机制与用户管理
目录
- 1. 引言:告别内存用户,拥抱数据库
- 2. 核心接口深度解析
- 2.1 UserDetailsService:用户详情加载的核心
- 2.2 UserDetails:用户信息的承载体
- 2.3 PasswordEncoder:安全密码的守护者
- 3. 数据库准备:用户表设计
- 4. 实战演练:自定义数据库用户认证
- 4.1 创建Spring Boot项目(沿用第一阶段项目)
- 4.2 引入数据库依赖
- 4.3 配置数据源
- 4.4 定义实体类与Mapper接口
- 4.5 实现 CustomUserDetailsService
- 4.6 更新 SecurityFilterChain 配置
- 4.7 自定义登录页面与控制器
- 4.8 测试自定义认证
- 5. 深入理解:AuthenticationProvider
- 6. HTTP Basic 认证的场景与局限
- 7. 常见陷阱与注意事项
- 8. 阶段总结与进阶展望
1. 引言:告别内存用户,拥抱数据库
在第一阶段,我们为了快速入门,使用了Spring Security提供的内存用户管理方式。这在开发原型或学习时非常方便,但在实际的企业级应用中,用户数据通常需要持久化存储在数据库中,例如MySQL、PostgreSQL等。
本阶段的核心任务就是实现:
- 自定义
UserDetailsService
: 从数据库中加载用户数据。 - 配置
PasswordEncoder
: 对用户注册时的密码进行加密存储,并在登录时进行匹配验证。 - 创建自定义登录表单: 提供一个更加符合应用UI风格的登录界面,而不是Spring Security默认的简陋页面。
通过这些定制,我们将把Spring Security的认证机制与我们的业务数据层无缝集成。
2. 核心接口深度解析
2.1 UserDetailsService:用户详情加载的核心
UserDetailsService
是Spring Security中用于加载用户认证信息的核心接口。它只有一个方法:
package org.springframework.security.core.userdetails;import org.springframework.dao.DataAccessException; // Spring 6.x+ Changed from AuthenticationExceptionpublic interface UserDetailsService {/*** 根据用户名加载用户详情。** @param username 用户的用户名。* @return 包含用户认证信息的 UserDetails 对象。* @throws UsernameNotFoundException 如果找不到该用户。* @throws DataAccessException 如果在数据访问过程中发生错误。*/UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException;
}
当你实现这个接口时,Spring Security会在用户尝试登录时调用你的loadUserByUsername
方法。你需要在这个方法中:
- 根据传入的
username
从你的数据源(如数据库)中查询用户记录。 - 如果用户不存在,抛出
UsernameNotFoundException
。 - 如果用户存在,将用户数据封装成
UserDetails
对象并返回。这个UserDetails
对象包含了Spring Security进行认证所需的所有信息(用户名、密码、权限等)。
2.2 UserDetails:用户信息的承载体
UserDetails
接口代表了一个已认证用户的所有核心信息。它提供了Spring Security进行认证和授权决策所需的一切。你在UserDetailsService
中返回的必须是UserDetails
的实现类。
主要方法:
package org.springframework.security.core.userdetails;import org.springframework.security.core.GrantedAuthority;
import java.io.Serializable;
import java.util.Collection;public interface UserDetails extends Serializable {Collection<? extends GrantedAuthority> getAuthorities(); // 获取用户拥有的权限集合String getPassword(); // 获取用户的密码(通常是加密后的)String getUsername(); // 获取用户名boolean isAccountNonExpired(); // 账户是否未过期boolean isAccountNonLocked(); // 账户是否未锁定boolean isCredentialsNonExpired(); // 凭证(密码)是否未过期boolean isEnabled(); // 账户是否启用// Spring Security 提供了一个默认的实现类 `org.springframework.security.core.userdetails.User`// 在大多数情况下,直接使用 User.builder()...build() 即可
}
getAuthorities()
:返回一个GrantedAuthority
集合。GrantedAuthority
通常表示用户的角色(如ROLE_ADMIN
)或更细粒度的权限(如PRODUCT_READ
)。- 其他
is...
方法:允许你实现更复杂的账户状态管理,例如:isAccountNonExpired()
:用户账户是否过期(例如,订阅到期)。isAccountNonLocked()
:用户账户是否被锁定(例如,多次登录失败导致)。isCredentialsNonExpired()
:用户密码是否过期(例如,强制定期修改密码)。isEnabled()
:用户账户是否禁用。
2.3 PasswordEncoder:安全密码的守护者
正如第一阶段所强调的,PasswordEncoder
是用于密码加密和验证的关键组件。在本阶段,我们将再次确认其重要性,并在用户注册和登录时实际使用它。
推荐:BCryptPasswordEncoder
它将“盐”(Salt)值与密码结合,并通过慢哈希算法进行加密。每次对相同的密码进行加密,都会生成不同的哈希值,大大增强了安全性,防止彩虹表攻击和暴力破解。
配置方式(重温):
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 PasswordEncoderConfig { // 可以放在CustomSecurityConfig中,也可以单独一个类@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}
}
3. 数据库准备:用户表设计
为了从数据库加载用户,我们需要一张用户表。这里我们设计一个简单的sys_user
表:
CREATE TABLE `sys_user` (`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',`password` VARCHAR(255) NOT NULL COMMENT '加密后的密码',`enabled` TINYINT(1) NOT NULL DEFAULT '1' COMMENT '是否启用,1为启用,0为禁用',`account_non_expired` TINYINT(1) NOT NULL DEFAULT '1' COMMENT '账户是否未过期',`account_non_locked` TINYINT(1) NOT NULL DEFAULT '1' COMMENT '账户是否未锁定',`credentials_non_expired` TINYINT(1) NOT NULL DEFAULT '1' COMMENT '凭证是否未过期',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统用户表';CREATE TABLE `sys_role` (`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',`role_name` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称,如ROLE_ADMIN,ROLE_USER',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统角色表';CREATE TABLE `sys_user_role` (`user_id` BIGINT NOT NULL COMMENT '用户ID',`role_id` BIGINT NOT NULL COMMENT '角色ID',PRIMARY KEY (`user_id`,`role_id`),CONSTRAINT `fk_user_role_user` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`) ON DELETE CASCADE,CONSTRAINT `fk_user_role_role` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户角色关联表';-- 插入一些测试数据
-- 密码均为 'password',使用BCryptPasswordEncoder.encode("password")预先加密生成
-- 演示如何生成加密密码:
-- String encodedPassword = new BCryptPasswordEncoder().encode("password");
-- System.out.println(encodedPassword); // 每次运行结果不同,示例如下-- 插入角色
INSERT INTO `sys_role` (`id`, `role_name`) VALUES (1, 'ROLE_USER');
INSERT INTO `sys_role` (`id`, `role_name`) VALUES (2, 'ROLE_ADMIN');-- 插入用户 (密码都为'password', 预先通过BCryptPasswordEncoder加密后的结果)
-- userpass_encoded = $2a$10$wTf2P/jZ.d29mGvVd.vR4O.k9zT4QfG8QzH.y9lW.c.f6jB4X.5N
-- adminpass_encoded = $2a$10$4jK/tGzO2.e7p.u2V.c.m.z0A.j.3M.6M.8J.V.l.E.p5.R4X.8K
INSERT INTO `sys_user` (`id`, `username`, `password`, `enabled`, `account_non_expired`, `account_non_locked`, `credentials_non_expired`)
VALUES (1, 'user', '$2a$10$pLwGBKqE2hL8B.y9lW.c.f6jB4X.5N.5N.5N.5N.5N.5N.5N.5N.5N.5N.5N', 1, 1, 1, 1); -- 替换为实际加密后的密码
INSERT INTO `sys_user` (`id`, `username`, `password`, `enabled`, `account_non_expired`, `account_non_locked`, `credentials_non_expired`)
VALUES (2, 'admin', '$2a$10$iGgQfG8QzH.y9lW.c.f6jB4X.5N.5N.5N.5N.5N.5N.5N.5N.5N.5N.5N', 1, 1, 1, 1); -- 替换为实际加密后的密码-- 关联用户和角色
INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES (1, 1); -- user -> ROLE_USER
INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES (2, 1); -- admin -> ROLE_USER
INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES (2, 2); -- admin -> ROLE_ADMIN
注意: 上面的加密密码示例$2a$10$...
是占位符,每次BCryptPasswordEncoder.encode("password")
都会生成不同的结果,请您在本地运行Java代码生成实际的加密密码,然后插入数据库。
4. 实战演练:自定义数据库用户认证
我们将基于第一阶段的项目进行改造。
4.1 创建Spring Boot项目(沿用第一阶段项目)
确保你的项目已经配置了Spring Web和Spring Security依赖。
4.2 引入数据库依赖
以MySQL和MyBatis-Plus为例:
<!-- MySQL Driver --><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.33</version> <!-- 根据你的MySQL版本选择 --></dependency><!-- MyBatis-Plus Starter --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId><version>3.5.5</version> <!-- 适配Spring Boot 3 --></dependency><!-- Lombok (可选,代码更简洁) --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
注意: 如果你使用的是Spring Boot 3.0+,mysql-connector-java
的group id 可能会导致警告,建议使用 com.mysql
group id。这里为了兼容性,依然使用mysql
。更高版本推荐 com.mysql:mysql-connector-j
。
4.3 配置数据源
在application.yml
中添加数据库连接信息,并配置MyBatis-Plus:
spring:datasource:url: jdbc:mysql://localhost:3306/security_db?useSSL=false&serverTimezone=UTCusername: rootpassword: your_db_password # 替换为你的数据库密码driver-class-name: com.mysql.cj.jdbc.Drivermybatis-plus:mapper-locations: classpath*:/mapper/**/*.xml # Mybatis-Plus Mapper XML文件位置configuration:log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印SQL日志,方便调试
在你的main
类上添加@MapperScan
注解:
package com.example.springsecuritystage1;import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication
@MapperScan("com.example.springsecuritystage1.mapper") // 扫描MyBatis Mapper接口
public class SpringSecurityStage1Application {public static void main(String[] args) {SpringApplication.run(SpringSecurityStage1Application.class, args);}}
4.4 定义实体类与Mapper接口
SysUser.java
(用户实体)
package com.example.springsecuritystage1.entity;import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.List;@Data
@TableName("sys_user")
public class SysUser implements Serializable {private Long id;private String username;private String password;private Boolean enabled;private Boolean accountNonExpired;private Boolean accountNonLocked;private Boolean credentialsNonExpired;@TableField(exist = false) // 排除非数据库字段private List<SysRole> roles; // 用户拥有的角色列表
}
SysRole.java
(角色实体)
package com.example.springsecuritystage1.entity;import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;@Data
@TableName("sys_role")
public class SysRole implements Serializable {private Long id;private String roleName; // 角色名称,如ROLE_ADMIN
}
SysUserMapper.java
(MyBatis-Plus Mapper)
package com.example.springsecuritystage1.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.springsecuritystage1.entity.SysUser;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;import java.util.List;public interface SysUserMapper extends BaseMapper<SysUser> {// 自定义方法,根据用户名查询用户及其角色// 注意:这里的SQL需要根据你的数据库方言和表名进行调整@Select("SELECT su.id, su.username, su.password, su.enabled, su.account_non_expired as accountNonExpired, " +"su.account_non_locked as accountNonLocked, su.credentials_non_expired as credentialsNonExpired, " +"sr.id as role_id, sr.role_name as role_name " +"FROM sys_user su " +"LEFT JOIN sys_user_role sur ON su.id = sur.user_id " +"LEFT JOIN sys_role sr ON sur.role_id = sr.id " +"WHERE su.username = #{username}")List<SysUser> selectUserWithRolesByUsername(@Param("username") String username);
}
由于MyBatis-Plus的BaseMapper
只支持单表操作,对于这种多表关联查询用户及角色的场景,我们通常会自定义@Select
注解或在XML中定义。这里直接使用@Select
。
4.5 实现 CustomUserDetailsService
这是本阶段的核心!
package com.example.springsecuritystage1.service;import com.example.springsecuritystage1.entity.SysRole;
import com.example.springsecuritystage1.entity.SysUser;
import com.example.springsecuritystage1.mapper.SysUserMapper;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User; // 注意这里是Spring Security的User类
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;@Service
public class CustomUserDetailsService implements UserDetailsService {private final SysUserMapper sysUserMapper;public CustomUserDetailsService(SysUserMapper sysUserMapper) {this.sysUserMapper = sysUserMapper;}@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 1. 从数据库查询用户及其角色信息List<SysUser> userWithRoles = sysUserMapper.selectUserWithRolesByUsername(username);if (userWithRoles == null || userWithRoles.isEmpty()) {throw new UsernameNotFoundException("User not found with username: " + username);}// MyBatis-Plus的联表查询可能返回多条记录(如果用户有多个角色),但用户本身信息是重复的// 获取唯一的 SysUser 对象SysUser sysUser = userWithRoles.get(0);// 2. 构造 GrantedAuthority 列表 (角色信息)List<GrantedAuthority> authorities = userWithRoles.stream().filter(u -> u.getRoles() != null && !u.getRoles().isEmpty()) // 过滤掉没有角色的查询结果.flatMap(u -> u.getRoles().stream()) // 扁平化角色列表.map(role -> new SimpleGrantedAuthority(role.getRoleName())) // 将角色名转换为GrantedAuthority.distinct() // 去重,防止同一角色多次添加.collect(Collectors.toList());// 如果用户在数据库中没有关联任何角色,上面的流式操作会得到空列表。// 为了确保至少有一个ROLE,可以添加一个默认角色或检查并抛出异常if(authorities.isEmpty()){// 考虑添加一个默认角色,或者根据业务需求抛出异常// 例如:authorities.add(new SimpleGrantedAuthority("ROLE_GUEST"));System.out.println("User " + username + " has no assigned roles.");// 或者抛出异常:throw new RuntimeException("User has no assigned roles!");}// 3. 构造 Spring Security 的 UserDetails 对象// 注意:这里使用的是 Spring Security 提供的 User 类实现 UserDetails 接口return new User(sysUser.getUsername(),sysUser.getPassword(), // 数据库中是加密后的密码sysUser.getEnabled(),sysUser.getAccountNonExpired(),sysUser.getCredentialsNonExpired(),sysUser.getAccountNonLocked(),authorities);}
}
在这个实现中,我们:
- 注入了
SysUserMapper
来访问数据库。 - 在
loadUserByUsername
方法中,根据用户名从数据库查询用户及其关联的角色。 - 将查询到的
SysRole
对象转换为Spring Security所需的GrantedAuthority
列表。这里我们假设角色名称以ROLE_
开头(这是Spring Security的惯例),并使用SimpleGrantedAuthority
。 - 最终,使用Spring Security提供的
org.springframework.security.core.userdetails.User
类来构建UserDetails
对象并返回。注意这个User
类不是我们自定义的SysUser
实体类。
4.6 更新 SecurityFilterChain 配置
在CustomSecurityConfig
中移除InMemoryUserDetailsManager
的Bean,并确保PasswordEncoder
已配置。由于我们已经定义了@Service
注解的CustomUserDetailsService
,Spring Security会自动找到它并使用它进行认证,无需在SecurityFilterChain
中额外配置。
package com.example.springsecuritystage1.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;@Configuration
@EnableWebSecurity
public class CustomSecurityConfig {// 只需要保留 PasswordEncoder 的配置@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}// 移除了 InMemroyUserDetailsManager 的 Bean,让 CustomUserDetailsService 接管@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/public/**", "/register", "/login").permitAll() // 允许 /register 接口公共访问.requestMatchers("/admin/**").hasRole("ADMIN").anyRequest().authenticated()).formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/user/profile", true).permitAll()).logout(logout -> logout // 配置登出功能.logoutUrl("/logout") // 登出请求的URL.logoutSuccessUrl("/login?logout") // 登出成功后跳转URL.invalidateHttpSession(true) // 使HttpSession失效.deleteCookies("JSESSIONID") // 删除与会话相关的Cookie.permitAll()).httpBasic(Customizer.withDefaults());return http.build();}
}
- 我们添加了
/register
路径的permitAll()
,因为我们可能需要一个用户注册功能。 - 同时,添加了
logout
的配置,这是生产环境中必备的功能。
4.7 自定义登录页面与控制器
沿用第一阶段的LoginController
和login.html
文件,它们已经配置好了form action="/login"
和CSRF令牌,可以直接使用。
我们再添加一个用户注册的Controller:
AuthController.java
package com.example.springsecuritystage1.controller;import com.example.springsecuritystage1.entity.SysUser;
import com.example.springsecuritystage1.mapper.SysUserMapper;
import com.example.springsecuritystage1.entity.SysRole; // 需要引入SysRole
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder;
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;import java.util.Arrays;
import java.util.List;@Controller
public class AuthController {private final SysUserMapper sysUserMapper;private final PasswordEncoder passwordEncoder;public AuthController(SysUserMapper sysUserMapper, PasswordEncoder passwordEncoder) {this.sysUserMapper = sysUserMapper;this.passwordEncoder = passwordEncoder;}@GetMapping("/register")public String showRegistrationForm(Model model) {model.addAttribute("message", "");return "register"; // 假设你有一个register.html模板}@PostMapping("/register")public String registerUser(@RequestParam String username,@RequestParam String password,Model model) {try {// 检查用户是否已存在(虽然数据库有UNIQUE约束,但在插入前检查可以给更友好的提示)List<SysUser> existingUsers = sysUserMapper.selectUserWithRolesByUsername(username);if (!existingUsers.isEmpty()) {model.addAttribute("message", "Username already exists!");return "register";}// 1. 加密密码String encodedPassword = passwordEncoder.encode(password);// 2. 构建 SysUser 对象SysUser newUser = new SysUser();newUser.setUsername(username);newUser.setPassword(encodedPassword);newUser.setEnabled(true);newUser.setAccountNonExpired(true);newUser.setAccountNonLocked(true);newUser.setCredentialsNonExpired(true);// 3. 保存用户到数据库sysUserMapper.insert(newUser);// 4. 为新用户分配默认角色 (例如:ROLE_USER)// 这是一个简化的处理,实际中可能需要查询数据库中 ROLE_USER 的ID// 这里假设 ROLE_USER 的ID是1SysUser registeredUser = sysUserMapper.selectUserWithRolesByUsername(username).get(0); // 重新查询以获取用户IDsysUserMapper.insertUserRole(registeredUser.getId(), 1L); // 假设1L是ROLE_USER的IDmodel.addAttribute("message", "Registration successful! Please login.");return "redirect:/login"; // 注册成功后跳转到登录页面} catch (DuplicateKeyException e) {model.addAttribute("message", "Username already exists. Please choose another username.");return "register";} catch (Exception e) {model.addAttribute("message", "Registration failed: " + e.getMessage());e.printStackTrace();return "register";}}
}
更新SysUserMapper.java
,添加用户和角色关联的方法:
package com.example.springsecuritystage1.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.springsecuritystage1.entity.SysUser;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;import java.util.List;public interface SysUserMapper extends BaseMapper<SysUser> {@Select("SELECT su.id, su.username, su.password, su.enabled, su.account_non_expired as accountNonExpired, " +"su.account_non_locked as accountNonLocked, su.credentials_non_expired as credentialsNonExpired, " +"sr.id as role_id, sr.role_name as role_name " + // 添加角色信息到结果集"FROM sys_user su " +"LEFT JOIN sys_user_role sur ON su.id = sur.user_id " +"LEFT JOIN sys_role sr ON sur.role_id = sr.id " +"WHERE su.username = #{username}")List<SysUser> selectUserWithRolesByUsername(@Param("username") String username);// 自定义插入用户角色关联表的方法@Insert("INSERT INTO sys_user_role (user_id, role_id) VALUES (#{userId}, #{roleId})")void insertUserRole(@Param("userId") Long userId, @Param("roleId") Long roleId);
}
创建register.html
(在 src/main/resources/templates
目录下)
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>Register</title><style>body { font-family: Arial, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; }.register-container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); }h2 { text-align: center; color: #333; }label { display: block; margin-bottom: 8px; color: #555; }input[type="text"], input[type="password"] { width: calc(100% - 20px); padding: 10px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 4px; }input[type="submit"] { width: 100%; padding: 10px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }input[type="submit"]:hover { background-color: #218838; }.message { color: red; text-align: center; margin-bottom: 15px; }.message.success { color: green; }.login-link { text-align: center; margin-top: 15px; }</style>
</head>
<body><div class="register-container"><h2>Register New Account</h2><div th:if="${message}" class="message" th:classappend="${message.contains('successful')} ? 'success' : ''"><p th:text="${message}"></p></div><form th:action="@{/register}" method="post"><div><label for="username">Username:</label><input type="text" id="username" name="username" required/></div><div><label for="password">Password:</label><input type="password" id="password" name="password" required/></div><input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" /><div><input type="submit" value="Register"/></div></form><div class="login-link">Already have an account? <a th:href="@{/login}">Login here</a></div></div>
</body>
</html>
4.8 测试自定义认证
- 确保你的MySQL数据库已启动,并创建了
security_db
数据库,执行了上述SQL脚本。 - 更新
application.yml
中的数据库密码。 - 运行Spring Boot应用。
- 打开浏览器访问
http://localhost:8080/register
(现在这是一个可公共访问的页面)。 - 尝试注册一个新用户,例如
newuser/newpass
。注册成功后应该跳转到登录页。 - 使用
newuser/newpass
(或user/password
、admin/adminpass
)登录,你应该能够访问用户页面/user/profile
。 - 尝试以
user
或newuser
身份访问/admin/dashboard
,应该收到403 Forbidden
。 - 以
admin/adminpass
身份登录,可以访问/admin/dashboard
。 - 访问
/logout
,成功登出。
5. 深入理解:AuthenticationProvider
虽然我们通过 UserDetailsService
实现了自定义认证,但 Spring Security 内部实际上是通过 AuthenticationProvider
来完成认证逻辑的。UserDetailsService
是 DaoAuthenticationProvider
的一个重要组成部分。
AuthenticationManager
: 认证管理器,负责接收认证请求(Authentication
对象),并委托一个或多个AuthenticationProvider
进行处理。ProviderManager
:AuthenticationManager
的默认实现,内部维护了一个List<AuthenticationProvider>
。DaoAuthenticationProvider
: 这是最常用的AuthenticationProvider
,它结合了UserDetailsService
(加载用户详情)和PasswordEncoder
(验证密码)来完成基于用户名和密码的认证。
当你提供了自定义的UserDetailsService
和PasswordEncoder
Bean时,Spring Security的自动配置会默认创建一个DaoAuthenticationProvider
并将其注入到ProviderManager
中。这就是为什么我们无需手动配置AuthenticationProvider
,只提供UserDetailsService
和PasswordEncoder
就可以正常工作的原因。
如果你需要实现更复杂的认证流程(例如自定义令牌认证、第三方认证等),你可能需要实现自己的AuthenticationProvider
。
6. HTTP Basic 认证的场景与局限
在CustomSecurityConfig
中我们保留了 httpBasic(Customizer.withDefaults())
。
- HTTP Basic 认证 是一种简单的认证机制,浏览器会将用户名和密码(用冒号分隔后进行Base64编码)放在HTTP请求头的
Authorization
字段中发送。例如:Authorization: Basic dXNlcjpwYXNzd29yZA==
。 - 优点: 简单易用,无需会话管理,对于简单的API或内部服务调用比较方便。
- 缺点: 每次请求都要发送凭证,安全性较低(虽然是Base64编码,但并非加密,容易被截获),且无法实现真正的登出(因为浏览器通常会缓存凭证)。它不适用于防止CSRF攻击,因为用户凭据可以被恶意站点利用。
- 生产建议: 仅在HTTPS环境下,并对于对安全性要求不那么高、或需要简单无状态认证(如内部API之间)的场景使用。对于面向用户的Web应用,应优先使用表单登录或JWT等更安全的认证方式。
7. 常见陷阱与注意事项
- 密码明文存储: 再次强调,数据库中绝不能存储明文密码!必须使用
PasswordEncoder
加密存储。 UserDetails
和SysUser
的混淆: 记住,你的实体类SysUser
用于数据库ORM,而org.springframework.security.core.userdetails.User
(或其自定义实现)是Spring Security内部使用的接口表示。二者不要混淆。- 角色前缀: Spring Security默认要求角色名称以
ROLE_
开头(例如ROLE_ADMIN
,ROLE_USER
)。如果你的数据库中存储的角色名没有此前缀,在创建SimpleGrantedAuthority
时需要手动添加。 DataAccessException
: 在loadUserByUsername
中,除了UsernameNotFoundException
,你还需要考虑处理数据库访问异常。- Spring Security自动注入: 当你定义了
UserDetailsService
和PasswordEncoder
的@Bean
或@Service
,Spring Security的自动配置就会使用它们,无需额外复杂配置。 - SQL注入风险: 确保你的数据库查询代码能够防止SQL注入。MyBatis-Plus或JPA通常能处理好这一点。
- 事务管理: 在用户注册等操作中,确保整个操作(插入用户、分配角色)在一个事务中完成。
8. 阶段总结与进阶展望
至此,你已经成功地将Spring Security的认证机制与应用的数据层结合起来,实现了基于数据库的自定义用户认证。你学会了:
- 实现
UserDetailsService
和PasswordEncoder
来加载和验证用户凭证。 - 设计用户和角色数据库表结构。
- 通过
SecurityFilterChain
配置,将自定义认证服务与Spring Security集成。 - 自定义登录页面和实现简单的用户注册功能。
- 理解了
AuthenticationProvider
的内部工作原理以及HTTP Basic认证的特性。
在第三阶段,我们将进一步提升Spring Security的能力,聚焦于授权管理与访问控制。我们将学习如何为不同的URL路径和方法设置细粒度的权限,利用Spring表达式语言(SpEL)实现复杂的访问逻辑,并深入理解角色与权限的差异。这将是你构建安全应用体系中不可或缺的一环。