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

概述

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

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

纲要

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

定制化原理回顾

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

进一步地,如果我们想完全掌控用户、角色的数据加载过程,甚至使用 JPA 替代 JDBC,就需要自己实现 UserDetails 和 UserDetailsService 接口。下面我们将展示如何利用 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_id 和 role_id 两个外键字段。为了减少查询次数,我们使用了 FetchType.EAGER,在加载用户的同时通过 JOIN 查询直接获取所有角色。

Hibernate 会根据这些注解自动生成数据库表结构,无需手动编写任何 DDL。启动应用后,控制台将打印建表语句,并可在 H2 控制台观察到 MOCK_USERS、MOCK_ROLES 和 MOCK_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_USERS、MOCK_ROLES 以及 MOCK_USERS_ROLES 的结构及数据。随后通过登录表单使用 admin / 123456 进行登录,Spring Security 将通过我们自定义的 CustomUserDetailsService 加载用户及角色。

总结

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

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

相关推荐
Cc.Y1 小时前
Java零基础入门:字符串深度掌握——从基础API到StringBuilder性能优化
java·开发语言·性能优化
shuoshuohaohao6 小时前
MyBatis 小知识点
java·spring·mybatis
海上小飞龙6 小时前
【KMP算法-下篇】同一道题:Java 库函数 2600 微秒,手写 KMP 16 微秒
java·开发语言·算法
谢亮_vipxieliang7 小时前
泛型擦除、通配符与 PECS 一次讲透
java·面试
八解毒剂7 小时前
【计组】中央处理器CPU
java·开发语言·计算机组成原理
打工仔折腾 AI8 小时前
工业场景下时序库与实时计算一体化架构选型实践对比
java·开发语言·后端·python·性能优化·架构·ai agent 实战
ZealSinger8 小时前
Java虚拟线程上线后瓶颈转移到哪看三层
java·开发语言·firefox
2601_961133288 小时前
盘立方软件指标文华期货公式下载
java
蜗牛互联网8 小时前
长任务多Agent共享文件系统的Manifest交接模式
java·人工智能·后端
Sirens.9 小时前
Java并发锁详解:六类锁策略与 synchronized 底层原理
java·前端·算法