Java框架快速入门: Spring Security+OAuth2之实现UserDetails与GrantedAuthority深度定制

概述

在构建基于 Spring Security 与 OAuth2 的安全体系时,标准的 JDBC 用户与权限存储有时难以满足复杂业务需求。本文将从定制化原理出发,深入讲解如何使用 JPA 实现自定义的 UserDetailsGrantedAuthority 接口,从而完成数据层的深度定制。

你将看到如何用 Hibernate 自动建表替代手写 SQL,如何通过实体关联映射用户与角色,以及如何编写可直接运行的代码示例。

纲要

  • UserDetailsManagerJdbcUserDetailsManager 的定制原理
  • 移除 JDBC 依赖,引入 spring-boot-starter-data-jpa
  • 实体建模:实现 UserDetailsUser 实体
  • 实体建模:实现 GrantedAuthorityRole 实体
  • 多对多关联映射与中间表配置
  • 数据初始化脚本适配新字段
  • 启动验证:Hibernate 自动建表结果
  • 完整可运行示例代码

定制化原理回顾

在默认的 Spring Security 配置中,我们通常使用 withUser() 配合 withDefaultSchema() 来快速构建内存用户或基于内建表结构的 JDBC 用户。这背后是 JdbcUserDetailsManager 在起作用,它内部预置了建表 SQL 以及查询用户、角色的 SQL。当我们不使用 withDefaultSchema() 而采用自定义数据库结构时,需要通过 AuthenticationManagerBuilderjdbcAuthentication() 并调用 usersByUsernameQuery()authoritiesByUsernameQuery() 来替换查询语句,这是基于 JdbcDaoImpl 的实现方式。

进一步地,如果我们想完全掌控用户、角色的数据加载过程,甚至使用 JPA 替代 JDBC,就需要自己实现 UserDetailsUserDetailsService 接口。下面我们将展示如何利用 Spring Data JPA 完成这一深度定制。

切换至 JPA 并配置数据源

首先移除原有的 JDBC 依赖,在 pom.xml 中添加:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

application.yml 中增加 JPA 及数据源配置:

yaml 复制代码
spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    database: h2
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: update
    show-sql: true
  sql:
    init:
      mode: always
      data-locations: classpath:data.sql

因为我们依赖 Hibernate 的自动建表能力,所以前期可以删除手写的 schema.sql,仅保留数据初始化脚本 data.sql(后续会调整字段)。

实现 UserDetails:用户实体

创建 User 实体,实现 UserDetails 接口。该实体将包含用户基本信息以及账户状态字段。

java 复制代码
package com.example.security.entity;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.Collection;
import java.util.Set;

@Entity
@Table(name = "mock_users")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 50, unique = true, nullable = false)
    private String username;

    @Column(name = "password_hash", length = 80, nullable = false)
    private String password;

    @Column(length = 50)
    private String name;

    @Column(length = 255, unique = true, nullable = false)
    private String email;

    @Column(length = 11)
    private String mobile;

    private boolean enabled = true;

    @Column(name = "account_non_expired", nullable = false)
    private boolean accountNonExpired = true;

    @Column(name = "account_non_locked", nullable = false)
    private boolean accountNonLocked = true;

    @Column(name = "credentials_non_expired", nullable = false)
    private boolean credentialsNonExpired = true;

    // 角色关联
    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "mock_users_roles",
        joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
        inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"))
    private Set<Role> authorities;

    // 构造器
    public User() {}

    public User(String username, String password, String name, String email, String mobile,
                Set<Role> authorities) {
        this.username = username;
        this.password = password;
        this.name = name;
        this.email = email;
        this.mobile = mobile;
        this.authorities = authorities;
    }

    // Getter / Setter 方法(可使用 Lombok @Data,但为清晰此处手动编写)
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    @Override
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }

    @Override
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getMobile() { return mobile; }
    public void setMobile(String mobile) { this.mobile = mobile; }

    @Override
    public boolean isEnabled() { return enabled; }
    public void setEnabled(boolean enabled) { this.enabled = enabled; }

    @Override
    public boolean isAccountNonExpired() { return accountNonExpired; }
    public void setAccountNonExpired(boolean accountNonExpired) { this.accountNonExpired = accountNonExpired; }

    @Override
    public boolean isAccountNonLocked() { return accountNonLocked; }
    public void setAccountNonLocked(boolean accountNonLocked) { this.accountNonLocked = accountNonLocked; }

    @Override
    public boolean isCredentialsNonExpired() { return credentialsNonExpired; }
    public void setCredentialsNonExpired(boolean credentialsNonExpired) { this.credentialsNonExpired = credentialsNonExpired; }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
    public void setAuthorities(Set<Role> authorities) { this.authorities = authorities; }
}

注意:所有布尔类型字段均使用基础类型 boolean,这样 Lombok 或手动生成的 getter 会自动命名为 isXxx(),符合 UserDetails 接口规范。如果使用包装类型 Boolean,则需要显式定义 getter 方法以确保命名一致。

实现 GrantedAuthority:角色实体

创建 Role 实体并实现 GrantedAuthority 接口。

java 复制代码
package com.example.security.entity;

import org.springframework.security.core.GrantedAuthority;

import javax.persistence.*;
import java.io.Serializable;

@Entity
@Table(name = "mock_roles")
public class Role implements GrantedAuthority, Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "role_name", length = 50, unique = true, nullable = false)
    private String authority;

    public Role() {}

    public Role(String authority) {
        this.authority = authority;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    @Override
    public String getAuthority() {
        return authority;
    }

    public void setAuthority(String authority) {
        this.authority = authority;
    }
}

关联映射详解

User 实体中,通过 @ManyToMany@JoinTable 定义了用户与角色的多对多关系,中间表名为 mock_users_roles,包含 user_idrole_id 两个外键字段。为了减少查询次数,我们使用了 FetchType.EAGER,在加载用户的同时通过 JOIN 查询直接获取所有角色。

Hibernate 会根据这些注解自动生成数据库表结构,无需手动编写任何 DDL。启动应用后,控制台将打印建表语句,并可在 H2 控制台观察到 MOCK_USERSMOCK_ROLESMOCK_USERS_ROLES 三张表。

数据初始化脚本

src/main/resources/data.sql 中插入测试用户及角色数据。注意字段名需与实体映射一致。

sql 复制代码
-- 插入角色
INSERT INTO mock_roles (id, role_name) VALUES (1, 'ROLE_USER');
INSERT INTO mock_roles (id, role_name) VALUES (2, 'ROLE_ADMIN');

-- 插入用户,密码是 BCrypt 加密后的 "123456"
INSERT INTO mock_users (id, username, password_hash, name, email, mobile, enabled,
                        account_non_expired, account_non_locked, credentials_non_expired)
VALUES (1, 'admin', '$2a$10$...', '管理员', 'admin@example.com', '13000000001', true, true, true, true);

-- 建立用户-角色关联
INSERT INTO mock_users_roles (user_id, role_id) VALUES (1, 1);
INSERT INTO mock_users_roles (user_id, role_id) VALUES (1, 2);

请将 password_hash 替换为实际 BCrypt 密文,可通过 new BCryptPasswordEncoder().encode("123456") 生成。

自定义 UserDetailsService

为了整合上述实体,我们需要实现 UserDetailsService 接口,利用 Spring Data JPA 的 Repository 进行查询。

java 复制代码
package com.example.security.service;

import com.example.security.entity.User;
import com.example.security.repository.UserRepository;
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;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
    }
}

对应的 Repository 接口:

java 复制代码
package com.example.security.repository;

import com.example.security.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

安全配置类

最后,在 Spring Security 配置中注入自定义的 UserDetailsService

java 复制代码
package com.example.security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final UserDetailsService userDetailsService;

    public SecurityConfig(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .permitAll();
        return http.build();
    }

    @Autowired
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
}

项目结构概览

dir 复制代码
src/main/java
└── com.example.security
    ├── config
    │   └── SecurityConfig.java
    ├── entity
    │   ├── User.java
    │   └── Role.java
    ├── repository
    │   └── UserRepository.java
    └── service
        └── CustomUserDetailsService.java
src/main/resources
├── application.yml
└── data.sql

验证运行结果

启动应用后,Hibernate 会自动创建三张表,执行 data.sql 插入初始数据。访问 H2 控制台(默认 /h2-console)可以看到 MOCK_USERSMOCK_ROLES 以及 MOCK_USERS_ROLES 的结构及数据。随后通过登录表单使用 admin / 123456 进行登录,Spring Security 将通过我们自定义的 CustomUserDetailsService 加载用户及角色。

总结

本文从 JdbcUserDetailsManager 的定制化原理切入,展示了如何彻底抛弃默认 JDBC 依赖,使用 JPA 与 Hibernate 实现完全自定义的 UserDetailsGrantedAuthority

通过实体映射、多对多关联以及 EAGER 抓取策略,我们实现了用户与角色的高效加载,并为后续集成 OAuth2 或 Rest API 鉴权奠定了坚实基础。

相关推荐
Amberish2 小时前
26Java细节知识点总结
java
钱栈up2 小时前
qoder CLI 1.0.45 本地小说生成工作流搭建:Feature Gate 配置与长文本断连排查从死锁到全流程跑通:Python德州扑克项目的优化
java·前端·数据库
代码调试师2 小时前
【毕设分享】springboot攀枝花生鲜电商平台58990
java·vue.js·spring boot·后端·架构·eclipse·课程设计
xcl09252 小时前
全民健身解决方案小程序系统开发实战:从架构设计到上线指南
java·spring boot
洛阳泰山2 小时前
别卷 Python 了:我用 Java 21 + Spring Boot 3 打造了一个企业级 RAG + 智能体工作流引擎(附架构与源码解析)
java·spring boot·后端
杨运交2 小时前
[070][Web模块]Spring MVC TOTP 二次认证拦截器:设计与源码深度解析
前端·spring·mvc
MetaLite2 小时前
Spring Cache只留注解-MetaLite如何二开缓存运行时
java·spring·缓存
Wang's Blog2 小时前
Java框架快速入门: Spring Security+OAuth2之自定义数据库表结构实现认证
java·数据库·spring
白远山2 小时前
家政服务派单平台搭建实战指南:从需求分析到系统设计全流程解析
java·开发语言·架构·uni-app·需求分析