基于Spring Security OAuth2的单点登录系统实现
下面是一个完整的基于Spring Security OAuth2的单点登录系统实现,满足您提出的要求:
- 使用数据库存储用户账号密码等信息
- 使用Redis存储会话信息提高性能
- 采用JWT实现令牌(访问令牌+刷新令牌)
一、系统架构
graph TD
A[客户端应用] --> B[认证服务器]
C[客户端应用] --> B
D[资源服务器] --> B
B --> E[(MySQL数据库)]
B --> F[(Redis缓存)]
D --> F
二、实现代码
1. 数据库设计 (MySQL)
-- 用户表
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
mobile VARCHAR(20) UNIQUE,
email VARCHAR(100),
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 角色表
CREATE TABLE roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE
);
-- 用户角色关联表
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (role_id) REFERENCES roles(id)
);
-- OAuth2客户端表
CREATE TABLE oauth_client_details (
client_id VARCHAR(255) PRIMARY KEY,
resource_ids VARCHAR(255),
client_secret VARCHAR(255) NOT NULL,
scope VARCHAR(255),
authorized_grant_types VARCHAR(255),
web_server_redirect_uri VARCHAR(255),
authorities VARCHAR(255),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additional_information TEXT,
autoapprove VARCHAR(255)
);
2. 认证服务器实现
主应用类
@SpringBootApplication
@EnableAuthorizationServer
@EnableDiscoveryClient
public class AuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServerApplication.class, args);
}
}
安全配置类
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/oauth/**", "/login**", "/logout**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
}
用户详情服务实现
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 从数据库加载用户信息
String sql = "SELECT u.id, u.username, u.password, u.enabled, r.name as role " +
"FROM users u " +
"LEFT JOIN user_roles ur ON u.id = ur.user_id " +
"LEFT JOIN roles r ON ur.role_id = r.id " +
"WHERE u.username = ? OR u.mobile = ?";
List<UserDetails> users = jdbcTemplate.query(sql, new Object[]{username, username},
(rs, rowNum) -> {
boolean enabled = rs.getBoolean("enabled");
String password = rs.getString("password");
String userAuthorities = rs.getString("role");
return User.builder()
.username(rs.getString("username"))
.password(password)
.disabled(!enabled)
.authorities(getAuthorities(userAuthorities))
.build();
});
if (users.isEmpty()) {
throw new UsernameNotFoundException("用户不存在");
}
return users.get(0);
}
private Collection<? extends GrantedAuthority> getAuthorities(String role) {
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role));
}
}
授权服务器配置
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private DataSource dataSource;
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Value("${security.oauth2.jwt.signing-key}")
private String signingKey;
@Bean
public TokenStore tokenStore() {
// 使用Redis存储令牌
return new RedisTokenStore(redisConnectionFactory);
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(signingKey);
return converter;
}
@Bean
public TokenEnhancer tokenEnhancer() {
return new CustomTokenEnhancer();
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(tokenEnhancer(), accessTokenConverter()));
endpoints
.tokenStore(tokenStore())
.tokenEnhancer(tokenEnhancerChain)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
.reuseRefreshTokens(false); // 每次刷新都生成新的刷新令牌
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()")
.allowFormAuthenticationForClients();
}
}
自定义令牌增强器
public class CustomTokenEnhancer extends JwtAccessTokenConverter {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
// 添加额外信息到JWT负载中
Map<String, Object> additionalInfo = new HashMap<>();
additionalInfo.put("organization", "MyCompany");
if (authentication.getUserAuthentication() != null) {
Object principal = authentication.getUserAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
UserDetails userDetails = (UserDetails) principal;
additionalInfo.put("user_id", userDetails.getUsername());
additionalInfo.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
}
}
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return super.enhance(accessToken, authentication);
}
}
3. 资源服务器实现
资源服务器配置
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private TokenStore tokenStore;
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.tokenStore(tokenStore).resourceId("resource_id");
}
@Override
public void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/api/private/**").authenticated()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.and()
.requestMatchers()
.antMatchers("/api/**");
}
}
控制器示例
@RestController
@RequestMapping("/api")
public class ApiController {
@GetMapping("/public/hello")
public ResponseEntity<?> publicHello() {
return ResponseEntity.ok("Public Hello World!");
}
@GetMapping("/private/hello")
public ResponseEntity<?> privateHello(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> claims = jwt.getClaims();
return ResponseEntity.ok("Private Hello, " + claims.get("user_id"));
}
@GetMapping("/admin/dashboard")
public ResponseEntity<?> adminDashboard() {
return ResponseEntity.ok("Admin Dashboard");
}
}
4. 客户端应用实现
安全配置
@Configuration
@EnableWebSecurity
public class ClientSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/login**").permitAll()
.anyRequest().authenticated()
.and()
.oauth2Login()
.defaultSuccessUrl("/dashboard")
.failureUrl("/login?error=true")
.and()
.logout()
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID");
}
}
控制器示例
@Controller
public class ClientController {
@GetMapping("/")
public String home() {
return "index";
}
@GetMapping("/dashboard")
public String dashboard(Model model,
@RegisteredOAuth2AuthorizedClient("my-auth")
OAuth2AuthorizedClient authorizedClient,
@AuthenticationPrincipal OidcUser principal) {
model.addAttribute("username", principal.getName());
model.addAttribute("email", principal.getEmail());
// 获取访问令牌
String accessToken = authorizedClient.getAccessToken().getTokenValue();
model.addAttribute("accessToken", accessToken);
return "dashboard";
}
}
5. Redis配置
@Configuration
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory(
@Value("${spring.redis.host}") String host,
@Value("${spring.redis.port}") int port) {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
return new LettuceConnectionFactory(config);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
6. 配置文件
认证服务器配置 (application.yml)
server:
port: 8080
spring:
application:
name: auth-server
datasource:
url: jdbc:mysql://localhost:3306/oauth2_db?useSSL=false&characterEncoding=utf8
username: root
password: root123
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
host: localhost
port: 6379
jpa:
hibernate:
ddl-auto: update
show-sql: true
security:
oauth2:
jwt:
signing-key: mySecretKeyForJWTSigning # 生产环境应使用强密钥
authorization:
client:
client-id: my-client
client-secret: my-secret
scope: read,write
authorized-grant-types: authorization_code,refresh_token
redirect-uris: http://localhost:8081/login/oauth2/code/my-auth,http://localhost:8082/login/oauth2/code/my-auth
资源服务器配置 (application.yml)
server:
port: 9000
spring:
application:
name: resource-server
redis:
host: localhost
port: 6379
security:
oauth2:
resource:
jwt:
key-value: mySecretKeyForJWTSigning # 必须与认证服务器相同
id: resource_id
客户端应用配置 (application.yml)
server:
port: 8081
spring:
application:
name: client-app
security:
oauth2:
client:
registration:
my-auth:
client-id: my-client
client-secret: my-secret
scope: read,write
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8081/login/oauth2/code/my-auth
provider:
my-auth:
authorization-uri: http://localhost:8080/oauth/authorize
token-uri: http://localhost:8080/oauth/token
user-info-uri: http://localhost:8080/userinfo
user-name-attribute: sub
三、工作流程
sequenceDiagram
participant User as 用户
participant Client as 客户端应用
participant AuthServer as 认证服务器
participant ResourceServer as 资源服务器
participant DB as MySQL数据库
participant Redis as Redis缓存
User->>Client: 访问受保护资源
Client->>User: 重定向到登录页
User->>AuthServer: 提交凭证(用户名/密码)
AuthServer->>DB: 验证用户凭证
DB-->>AuthServer: 返回用户信息
AuthServer->>Redis: 存储会话信息
AuthServer->>AuthServer: 生成JWT令牌(访问+刷新)
AuthServer->>Client: 重定向回客户端(带授权码)
Client->>AuthServer: 使用授权码交换令牌
AuthServer-->>Client: 返回访问令牌和刷新令牌
Client->>ResourceServer: 请求资源(带访问令牌)
ResourceServer->>ResourceServer: 验证JWT令牌
ResourceServer-->>Client: 返回受保护资源
Client->>User: 显示资源内容
Note over Client,ResourceServer: 访问令牌过期后
Client->>AuthServer: 使用刷新令牌获取新访问令牌
AuthServer->>Redis: 验证刷新令牌
AuthServer-->>Client: 返回新的访问令牌
四、关键技术
- 数据库存储:
- 使用MySQL存储用户账号、密码、手机号等信息
- 使用JDBC存储OAuth2客户端配置
- Redis缓存:
- 存储会话信息提高性能
- 存储OAuth2令牌(访问令牌和刷新令牌)
- 配置Redis连接工厂和模板
- JWT令牌实现:
- 访问令牌:短期有效(如30分钟),包含用户信息和权限
- 刷新令牌:长期有效(如7天),用于获取新的访问令牌
- 使用RSA非对称加密保证安全性(生产环境推荐)
- 令牌增强:
- 在JWT中添加自定义声明(如组织信息、用户ID等)
- 包含用户角色和权限信息
- 单点登录流程:
- 用户在一个客户端登录后,认证服务器创建会话
- 用户访问其他客户端时,认证服务器检测到已有会话
- 直接发放授权码,无需重新登录
五、安全建议
- 密钥管理:
- 使用非对称密钥对(RSA)代替对称密钥
- 将私钥存储在安全的密钥管理系统(如Vault)
- 令牌生命周期:
- 访问令牌:短时间有效(15-30分钟)
- 刷新令牌:较长时间有效(7天),可撤销
- 令牌撤销:
- 实现令牌撤销端点
- 将撤销的令牌存储在Redis黑名单中
- 限流与防护:
- 在认证服务器添加速率限制
- 防止暴力破解攻击
- 审计日志:
- 记录所有认证和授权事件
- 监控异常登录行为
六、部署架构
+-----------------+ +-----------------+ +-----------------+
| 客户端应用1 | | 客户端应用2 | | 资源服务器 |
| (Port: 8081) | | (Port: 8082) | | (Port: 9000) |
+--------+--------+ +--------+--------+ +--------+--------+
| | |
| | |
| +-----------v-----------+ |
| | 认证服务器 | |
| | (Authorization Server)| |
| | (Port: 8080) | |
| +-----------+-----------+ |
| | |
| | |
+--------v----------------------v-----------------------v--------+
| 共享存储 |
| +----------------+ +----------------+ +-----------+ |
| | MySQL数据库 | | Redis缓存 | | 日志系统 | |
| | (用户数据/OAuth2 | | (会话/令牌存储) | | (ELK) | |
| | 客户端配置) | +----------------+ +-----------+ |
| +----------------+ |
+-------------------------------------------------------------+
这个实现提供了一个完整的基于Spring Security OAuth2的单点登录系统,满足您的所有要求,并包含了生产环境所需的安全性和可扩展性考虑。
支持作者