小 明 的 摄 影 网 站 已 经 用 Spring Boot 搭 建 完 成 , 访 客 越 来 越 多 。 他 决 定 给 网 站 加 上 完 整 的 登 录 认 证 鉴 权 功 能 , 让 不 同 角 色 的 用 户 ( 游 客 、 编 辑 、 管 理 员 ) 有 不 同 的 操 作 权 限 。 今 天 , 他 就 带 着 这 个 需 求 , 开 启 Spring Security 的 学 习 之 旅 。
** 第 一 步 : 搭 建 Spring Boot 项 目 , 引 入 Spring Security **
** 小 明 的 需 求 **
“ 先 让 网 站 有 个 最 基 础 的 登 录 功 能 , 能 拦 住 未 登 录 的 用 户 。 ”
** 实 操 步 骤 **
- ** 创 建 Spring Boot 项 目 ** : 用 https://start.spring.io/ , 选
Spring Web( 搭 Web 服 务 ) 、Spring Security( 安 全 框 架 ) 、Spring Data JPA( 操 作 数 据 库 ) 、MySQL Driver( 数 据 库 驱 动 ) 。 - ** 添 加 依 赖 ** ( pom.xml 核 心 部 分 ) :
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <!-- Spring Security 依 赖 --> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency> </dependencies> - ** 运 行 项 目 ** : 启 动 Spring Boot , 访 问
http://localhost:8080, 会 自 动 跳 转 到 Spring Security 的 默 认 登 录 页 。
** 现 象 与 原 理 **
- ** 默 认 用 户 ** : 控 制 台 会 打 印 一 行 密 码 , 格 式 如
Using generated security password: abcdef12-3456-7890-abcd-ef1234567890, 用 户 名 固 定 为user。 - ** 原 理 ** : Spring Security 自 动 配 置 了 默 认 的
InMemoryUserDetailsManager( 内 存 用 户 存 储 ) , 生 成 随 机 密 码 。 所 有 接 口 默 认 需 要USER角 色 才 能 访 问 。
💡 小 明 的 疑 问 : “ 这 密 码 每 次 启 动 都 变 , 而 且 用 户 不 能 自 己 注 册 , 得 改 ! ”
** 第 二 步 : 自 定 义 用 户 存 储 —— 从 内 存 到 数 据 库 **
** 小 明 的 需 求 **
“ 用 户 信 息 存 在 自 己 的 数 据 库 里 , 支 持 注 册 、 登 录 , 密 码 用 BCrypt 加 密 。 ”
** 核 心 概 念 **
- ** UserDetailsService ** : Spring Security 的 “ 用 户 查 询 接 口 ” , 开 发 者 实 现 它 , 告 诉 框 架 “ 怎 么 从 数 据 库 查 用 户 ” 。
- ** UserDetails ** : “ 用 户 信 息 封 装 类 ” , 包 含 用 户 名 、 密 码 、 角 色 、 账 户 是 否 过 期 等 信 息 。
- ** PasswordEncoder ** : “ 密 码 加 密 器 ” , 用 BCrypt 算 法 把 明 文 密 码 变 成 哈 希 值 存 储 。
** 实 操 步 骤 **
- ** 建 用 户 表 ** ( MySQL ) :
CREATE TABLE users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, -- 用 户 名 password VARCHAR(100) NOT NULL, -- BCrypt 哈 希 后 的 密 码 role VARCHAR(20) NOT NULL -- 角 色 : ROLE_VISITOR / ROLE_EDITOR / ROLE_ADMIN ); - ** 实 现 UserDetailsService ** ( 查 数 据 库 用 户 ) :
@Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepo; // JPA 操 作 users 表 @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 1. 查 数 据 库 用 户 User user = userRepo.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("用户不存在")); // 2. 封 装 成 UserDetails 对 象 ( Spring Security 认 识 的 格 式 ) return org.springframework.security.core.userdetails.User.builder() .username(user.getUsername()) .password(user.getPassword()) // 存 的 是 BCrypt 哈 希 值 .roles(user.getRole().replace("ROLE_", "")) // 角 色 去 掉 ROLE_ 前 缀 .build(); } } - ** 配 置 PasswordEncoder ** ( 用 BCrypt 加 密 ) :
@Configuration public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); // BCrypt 加 密 器 , 自 带 随 机 盐 } } - ** 注 册 用 户 ** ( 比 如 小 明 自 己 注 册 为 管 理 员 ) :
@RestController public class RegisterController { @Autowired private UserRepository userRepo; @Autowired private PasswordEncoder passwordEncoder; @PostMapping("/register") public String register(String username, String rawPassword, String role) { User user = new User(); user.setUsername(username); user.setPassword(passwordEncoder.encode(rawPassword)); // 明 文 密 码 → BCrypt 哈 希 user.setRole("ROLE_" + role.toUpperCase()); // 角 色 加 ROLE_ 前 缀 ( Spring Security 约 定 ) userRepo.save(user); return "注册成功!"; } }
** 效 果 **
小 明 用 POST /register?username=xiaoming&rawPassword=123456&role=admin 注 册 后 , 数 据 库 users 表 会 存 一 条 记 录 , password 字 段 是 BCrypt 哈 希 值 ( 如 $2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx )。 登 录 时 , 框 架 会 用 BCrypt 验 证 输 入 密 码 是 否 匹 配 。
💡 小 明 的 体 会 : “ UserDetailsService 就 像 图 书 馆 的 ‘ 查 阅 员 ’ , 框 架 告 诉 他 ‘ 找 小 明 ’ , 他 就 去 数 据 库 把 小 明 的 书 ( 用 户 信 息 ) 拿 回 来 。 ”
** 第 三 步 : 配 置 权 限 控 制 —— 谁 能 干 啥 ? **
** 小 明 的 需 求 **
“ 游 客 只 能 看 照 片 , 编 辑 能 上 传 , 管 理 员 能 删 除 。 ”
** 核 心 概 念 **
- ** HttpSecurity ** : Spring Security 的 “ Web 安 全 配 置 类 ” , 用 来 设 置 URL 访 问 权 限 、 登 录 / 注 销 页 、 CSRF 等 。
- ** @PreAuthorize ** : “ 方 法 级 权 限 注 解 ” , 直 接 在 Controller 方 法 上 标 注 需 要 的 角 色 。
** 实 操 步 骤 **
- ** 配 置 URL 访 问 权 限 ** ( 在 SecurityConfig 中 ) :
@Configuration @EnableWebSecurity // 开 启 Web 安 全 配 置 public class SecurityConfig { @Autowired private CustomUserDetailsService userDetailsService; @Autowired private PasswordEncoder passwordEncoder; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/photos/**").permitAll() // 公 开 : 所 有 人 能 看 照 片 .requestMatchers("/upload/**").hasRole("EDITOR") // 上 传 : 需 EDITOR 角 色 .requestMatchers("/delete/**").hasRole("ADMIN") // 删 除 : 需 ADMIN 角 色 .anyRequest().authenticated() // 其 他 接 口 需 登 录 ) .formLogin(form -> form // 配 置 登 录 页 .loginPage("/my-login") // 自 定 义 登 录 页 ( 下 节 讲 ) .defaultSuccessUrl("/home") // 登 录 成 功 跳 转 ) .logout(logout -> logout // 配 置 注 销 .logoutUrl("/logout") .logoutSuccessUrl("/photos") ); return http.build(); } // 配 置 认 证 管 理 器 ( 用 自 定 义 UserDetailsService 和 PasswordEncoder ) @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } } - ** 用 @PreAuthorize 做 方 法 级 权 限 ** ( 更 精 细 控 制 ) :
@RestController public class PhotoController { // 游 客 能 看 @GetMapping("/photos") public List<Photo> listPhotos() { ... } // 编 辑 能 上 传 ( @PreAuthorize 需 开 启 @EnableMethodSecurity ) @PostMapping("/upload") @PreAuthorize("hasRole('EDITOR')") // 直 接 标 注 需 EDITOR 角 色 public String uploadPhoto(MultipartFile file) { ... } // 管 理 员 能 删 除 @DeleteMapping("/delete/{id}") @PreAuthorize("hasRole('ADMIN') or hasRole('EDITOR')") // 管 理 员 或 编 辑 都 能 删 public String deletePhoto(@PathVariable Long id) { ... } } - ** 开 启 @PreAuthorize 支 持 ** : 在 SecurityConfig 上 加
@EnableMethodSecurity。
** 效 果 **
- 访 问
/upload时 , 若 用 户 是ROLE_VISITOR, 会 被 拦 截 并 返 回 403 错 误 ( 权 限 不 足 ) ; - 用
@PreAuthorize的 方 法 , 框 架 会 在 执 行 前 检 查 用 户 角 色 , 不 符 合 则 抛 异 常 。
💡 小 明 的 体 会 : “ HttpSecurity 像 ‘ 门 卫 ’ , 管 URL 级 的 大 门 ; @PreAuthorize 像 ‘ 房 间 锁 ’ , 管 方 法 级 的 小 门 , 双 保 险 ! ”
** 第 四 步 : 自 定 义 登 录 页 、 注 销 与 记 住 我 **
** 小 明 的 需 求 **
“ 默 认 登 录 页 太 丑 , 想 用 自 己 的 ; 加 个 ‘ 记 住 我 ’ 功 能 , 关 闭 浏 览 器 再 打 开 还 是 登 录 状 态 。 ”
** 实 操 步 骤 **
- ** 自 定 义 登 录 页 ** :
- 写 一 个 HTML 页 面
my-login.html( 放resources/templates目 录 ) , 包 含username、password输 入 框 和remember-me复 选 框 ; - 在 SecurityConfig 中 配 置
.loginPage("/my-login"), 并 指 定 处 理 登 录 的 URL :
.formLogin(form -> form .loginPage("/my-login") // 自 定 义 登 录 页 路 径 .loginProcessingUrl("/do-login") // 处 理 登 录 的 URL ( 框 架 自 动 处 理 ) .defaultSuccessUrl("/home") .failureUrl("/my-login?error") // 登 录 失 败 跳 转 ) - 写 一 个 HTML 页 面
- ** 开 启 “ 记 住 我 ” 功 能 ** :
- 在 SecurityConfig 中 配 置
.rememberMe():
.rememberMe(remember -> remember .key("xiaoming-secret-key") // 自 定 义 密 钥 ( 防 篡 改 ) .tokenValiditySeconds(7 * 24 * 3600) // 记 住 我 有 效 期 : 7 天 )- 在 自 定 义 登 录 页 加 “ 记 住 我 ” 复 选 框 :
<input type="checkbox" name="remember-me"> 记住我 - 在 SecurityConfig 中 配 置
- ** 自 定 义 注 销 页 ** :
- 在 SecurityConfig 中 配 置
.logout(), 默 认 注 销 URL 是/logout, 可 自 定 义 :
.logout(logout -> logout .logoutUrl("/my-logout") // 自 定 义 注 销 URL .logoutSuccessUrl("/photos") // 注 销 成 功 跳 转 .invalidateHttpSession(true) // 注 销 时 清 空 Session .deleteCookies("JSESSIONID") // 删 除 Cookie ) - 在 SecurityConfig 中 配 置
** 效 果 **
- 访 问 需 登 录 的 页 面 时 , 跳 转 到 小 明 自 己 写 的
my-login.html; - 勾 选 “ 记 住 我 ” 后 , 关 闭 浏 览 器 , 7 天 内 再 访 问 网 站 仍 是 登 录 状 态 ;
- 点 击 注 销 链 接 , 清 空 Session 和 Cookie , 跳 回 公 开 照 片 页 。
💡 小 明 的 体 会 : “ 自 定 义 登 录 页 让 网 站 更 好 看 , ‘ 记 住 我 ’ 像 ‘ 长 期 饭 卡 ’ , 不 用 每 次 都 刷 临 时 卡 。 ”
** 第 五 步 : 集 成 JWT —— 支 持 APP 与 微 服 务 **
** 小 明 的 需 求 **
“ 我 做 了 个 手 机 APP , 用 Session - Cookie 不 方 便 , 想 用 JWT Token 做 认 证 。 ”
** 核 心 概 念 **
- ** JWT ** : “ 自 带 信 息 的 身 份 证 ” , 服 务 器 不 存 Session , 用 户 登 录 后 拿 Token , 后 续 请 求 带 Token 即 可 。
- ** JwtAuthenticationFilter ** : 自 定 义 过 滤 器 , 从
Authorization头 取 JWT , 验 证 后 设 置 Security Context 。
** 实 操 步 骤 **
- ** 添 加 JWT 依 赖 ** :
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> - ** 写 JWT 工 具 类 ** ( 生 成 / 验 证 Token ) :
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; // 配 置 在 application.properties 中 @Value("${jwt.expiration}") private long expiration; // Token 有 效 期 ( 如 3600000 毫 秒 ) // 生 成 Token public String generateToken(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(); claims.put("roles", userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority).collect(Collectors.toList())); return Jwts.builder() .setClaims(claims) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration)) .signWith(SignatureAlgorithm.HS512, secret) // 用 HS512 算 法 签 名 .compact(); } // 验 证 Token 并 取 用 户 名 public String getUsernameFromToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody().getSubject(); } } - ** 写 JWT 认 证 过 滤 器 ** :
public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtil jwtUtil; @Autowired private CustomUserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 1. 从 Header 取 Token String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); // 2. 验 证 Token , 取 用 户 名 String username = jwtUtil.getUsernameFromToken(token); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { // 3. 查 用 户 信 息 , 设 置 Security Context UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); } } chain.doFilter(request, response); // 继 续 处 理 请 求 } } - ** 修 改 SecurityConfig , 用 JWT 替 换 Session ** :
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) // JWT 无 状 态 , 关 闭 CSRF .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无 Session .authorizeHttpRequests(auth -> auth .requestMatchers("/api/photos/**").permitAll() .requestMatchers("/api/upload/**").hasRole("EDITOR") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // 加 JWT 过 滤 器 .formLogin(form -> form.disable()) // 禁 用 默 认 表 单 登 录 ( APP 用 JWT ) .logout(logout -> logout.disable()); // 禁 用 默 认 注 销 return http.build(); }
** 效 果 **
- APP 用 户 登 录 时 , 调
POST /api/login( 自 定 义 接 口 , 验 证 用 户 名 密 码 后 返 JWT ) ; - 后 续 APP 请 求 API 时 , 在
Authorization头 带Bearer <JWT>, 框 架 自 动 认 证 。
💡 小 明 的 体 会 : “ JWT 像 ‘ 电 子 通 行 证 ’ , APP 拿 着 它 就 能 畅 通 无 阻 , 服 务 器 不 用 记 住 谁 来 过 , 轻 松 支 持 多 端 ! ”
** 第 六 步 : 深 入 : OAuth2 第 三 方 登 录 与 安 全 防 护 **
** 小 明 的 需 求 **
“ 想 让 用 户 用 GitHub 账 号 直 接 登 录 , 还 要 防 CSRF 、 XSS 攻 击 。 ”
** 核 心 概 念 **
- ** OAuth2 ** : “ 第 三 方 登 录 协 议 ” , 用 户 授 权 GitHub 告 诉 小 明 的 网 站 “ 这 是 我 ” , 无 需 注 册 。
- ** Spring Security OAuth2 Client ** : 框 架 内 置 OAuth2 客 户 端 , 支 持 GitHub 、 Google 等 登 录 。
** 实 操 步 骤 **
- ** 配 置 GitHub OAuth2 登 录 ** :
- 在 GitHub 开 发 者 设 置 中 创 建 OAuth App , 获 取
client-id和client-secret; - 在
application.properties中 配 置 :
spring.security.oauth2.client.registration.github.client-id=你的client-id spring.security.oauth2.client.registration.github.client-secret=你的client-secret spring.security.oauth2.client.registration.github.scope=user:email # 申 请 邮 箱 权 限- 在 SecurityConfig 中 开 启 OAuth2 登 录 :
.oauth2Login(oauth2 -> oauth2 .loginPage("/my-login") // 自 定 义 登 录 页 加 “ GitHub 登 录 ” 按 钮 .defaultSuccessUrl("/home") ) - 在 GitHub 开 发 者 设 置 中 创 建 OAuth App , 获 取
- ** 安 全 防 护 ** ( 框 架 默 认 开 启 , 无 需 额 外 配 置 ) :
- ** CSRF 防 护 ** : 默 认 开 启 , 框 架 自 动 生 成 CSRF 令 牌 , 表 单 提 交 时 验 证 ;
- ** XSS 防 护 ** : Spring Boot 默 认 开 启 HTML 转 义 , 防 注 入 脚 本 ;
- ** 会 话 固 定 攻 击 防 护 ** : 登 录 后 自 动 更 换 SessionID 。
** 效 果 **
- 小 明 的 登 录 页 多 了 个 “ 用 GitHub 登 录 ” 按 钮 , 点 击 后 跳 转 GitHub 授 权 , 授 权 成 功 后 自 动 登 录 网 站 ;
- 框 架 自 动 挡 住 CSRF 攻 击 , 即 使 黑 客 诱 骗 小 明 点 链 接 , 也 无 法 操 作 。
💡 小 明 的 体 会 : “ OAuth2 像 ‘ 找 朋 友 作 证 ’ , 让 GitHub 帮 忙 证 明 ‘ 这 是 小 明 ’ ; 框 架 的 安 全 防 护 像 ‘ 隐 形 铠 甲 ’ , 平 时 感 觉 不 到 , 关 键 时 刻 能 挡 刀 。 ”
** 小 明 的 学 习 总 结 **
通 过 这 几 步 , 小 明 彻 底 掌 握 了 Spring Security 配 Spring Boot 的 核 心 用 法 :
- ** 从 默 认 配 置 入 门 ** , 观 察 框 架 自 动 生 成 的 登 录 / 权 限 逻 辑 ;
- ** 自 定 义 UserDetailsService 和 PasswordEncoder ** , 连 接 自 己 的 数 据 库 , 用 BCrypt 加 密 密 码 ;
- ** 用 HttpSecurity 和 @PreAuthorize 配 置 权 限 ** , 实 现 URL 级 和 方 法 级 控 制 ;
- ** 自 定 义 登 录 / 注 销 页 、 加 “ 记 住 我 ” ** , 优 化 用 户 体 验 ;
- ** 集 成 JWT ** , 支 持 APP 和 微 服 务 无 状 态 认 证 ;
- ** 用 OAuth2 实 现 第 三 方 登 录 ** , 开 启 框 架 默 认 安 全 防 护 。
✨ 小 明 的 最 终 感 悟 :
Spring Security 不 是 一 堆 复 杂 的 配 置 , 而 是 一 个 “ 安 全 积 木 盒 ” 。 小 明 用 它 搭 建 了 摄 影 网 站 的 安 全 堡 垒 , 从 默 认 配 置 到 自 定 义 扩 展 , 从 Web 到 APP , 从 账 号 密 码 到 第 三 方 登 录 , 每 一 步 都 踩 在 “ 需 求 ” 和 “ 技 术 ” 的 结 合 点 上 。 现 在 , 他 可 以 专 心 拍 摄 更 美 的 照 片 , 而 网 站 的 安 全 , 交 给 Spring Security 就 够 了 !