引言
在当今的互联网生态中,安全认证与授权机制对于保护用户数据和系统资源至关重要。OAuth2作为一种行业标准的授权框架,被广泛应用于各类Web应用、移动应用和API服务中。本文将带领读者从零开始,使用Java和Spring Security框架构建一个功能完整的OAuth2授权服务器,深入理解OAuth2的核心概念和实现细节。
OAuth2基础知识
OAuth2是什么?
OAuth2(Open Authorization 2.0)是一个开放标准的授权协议,允许第三方应用在不获取用户凭证的情况下,获得对用户资源的有限访问权限。它解决了传统认证方式中的安全隐患,如密码共享和过度授权等问题。
OAuth2的角色
OAuth2定义了四个关键角色:
- 资源所有者(Resource Owner):通常是用户,拥有受保护资源的实体。
- 客户端(Client):请求访问资源的应用程序。
- 授权服务器(Authorization Server):验证资源所有者身份并颁发访问令牌。
- 资源服务器(Resource Server):托管受保护资源的服务器,接受并验证访问令牌。
OAuth2的授权流程
OAuth2支持多种授权流程,适用于不同场景:
- 授权码模式(Authorization Code):最完整、最安全的流程,适用于有后端的Web应用。
- 简化模式(Implicit):适用于无后端的单页应用。
- 密码模式(Resource Owner Password Credentials):适用于高度可信的应用。
- 客户端凭证模式(Client Credentials):适用于服务器间通信。
项目准备
环境要求
- JDK 11+
- Maven 3.6+
- Spring Boot 2.6.x
- Spring Security 5.6.x
- Spring Authorization Server 0.3.x
项目结构
oauth2-server/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── oauth2server/
│ │ │ ├── config/
│ │ │ ├── controller/
│ │ │ ├── entity/
│ │ │ ├── repository/
│ │ │ ├── service/
│ │ │ └── OAuth2ServerApplication.java
│ │ └── resources/
│ │ ├── templates/
│ │ ├── static/
│ │ └── application.yml
│ └── test/
├── pom.xml
└── README.md
Maven依赖配置
<dependencies>
<!-- Spring Boot -->
<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>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Spring Authorization Server -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>0.3.1</version>
</dependency>
<!-- Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
实现授权服务器
步骤1:创建基础应用
首先,创建一个Spring Boot应用作为我们的起点:
package com.example.oauth2server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OAuth2ServerApplication {
public static void main(String[] args) {
SpringApplication.run(OAuth2ServerApplication.class, args);
}
}
步骤2:配置数据库
在application.yml
中配置数据库连接:
spring:
datasource:
url: jdbc:h2:mem:oauth2db
driver-class-name: org.h2.Driver
username: sa
password: password
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: update
show-sql: true
h2:
console:
enabled: true
path: /h2-console
步骤3:创建用户实体和存储
package com.example.oauth2server.entity;
import lombok.Data;
import javax.persistence.*;
import java.util.Set;
@Entity
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String username;
private String password;
@ElementCollection(fetch = FetchType.EAGER)
private Set<String> roles;
private boolean enabled = true;
}
创建用户存储库:
package com.example.oauth2server.repository;
import com.example.oauth2server.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);
}
步骤4:实现用户服务
package com.example.oauth2server.service;
import com.example.oauth2server.entity.User;
import com.example.oauth2server.repository.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.stream.Collectors;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.isEnabled(),
true,
true,
true,
user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet())
);
}
}
步骤5:配置安全设置
package com.example.oauth2server.config;
import com.example.oauth2server.service.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
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;
@EnableWebSecurity
@Configuration
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
public SecurityConfig(UserDetailsServiceImpl userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize ->
authorize
.antMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated()
)
.formLogin()
.and()
.csrf().ignoringAntMatchers("/h2-console/**")
.and()
.headers().frameOptions().sameOrigin();
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
步骤6:配置OAuth2授权服务器
package com.example.oauth2server.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.ProviderSettings;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.RequestMatcher;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
@Configuration
public class AuthorizationServerConfig {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer<>();
RequestMatcher endpointsMatcher = authorizationServerConfigurer
.getEndpointsMatcher();
http
.requestMatcher(endpointsMatcher)
.authorizeRequests(authorizeRequests ->
authorizeRequests.anyRequest().authenticated()
)
.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
.apply(authorizationServerConfigurer);
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("client")
.clientSecret("$2a$10$jdJGhzsiIqYFpjJiYWMl/eKDOd8vdyQis2aynmFN0dgJ53XvpzzwC") // "secret" encoded
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/client")
.scope(OidcScopes.OPENID)
.scope("read")
.scope("write")
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
private static RSAKey generateRsa() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
@Bean
public ProviderSettings providerSettings() {
return ProviderSettings.builder()
.issuer("http://localhost:9000")
.build();
}
}
步骤7:初始化测试数据
创建一个数据初始化器:
package com.example.oauth2server.config;
import com.example.oauth2server.entity.User;
import com.example.oauth2server.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Set;
@Configuration
public class DataInitializer {
@Bean
public CommandLineRunner initData(UserRepository userRepository, PasswordEncoder passwordEncoder) {
return args -> {
User admin = new User();
admin.setUsername("admin");
admin.setPassword(passwordEncoder.encode("admin"));
admin.setRoles(Set.of("ADMIN", "USER"));
User user = new User();
user.setUsername("user");
user.setPassword(passwordEncoder.encode("password"));
user.setRoles(Set.of("USER"));
userRepository.save(admin);
userRepository.save(user);
};
}
}
步骤8:配置应用属性
在application.yml
中添加服务器端口配置:
server:
port: 9000
测试授权服务器
授权码流程测试
请求授权码:
访问以下URL(可以在浏览器中打开):
http://localhost:9000/oauth2/authorize?response_type=code&client_id=client&scope=read&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/client
系统会要求登录(使用我们创建的用户凭据),然后请求授权。授权后,系统会重定向到指定的URI,并附带授权码。
使用授权码获取令牌:
使用curl或Postman发送POST请求:
curl -X POST \ http://localhost:9000/oauth2/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Authorization: Basic Y2xpZW50OnNlY3JldA==" \ -d "grant_type=authorization_code&code=YOUR_AUTHORIZATION_CODE&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/client"
注意:
YOUR_AUTHORIZATION_CODE
需要替换为上一步获取的授权码。使用访问令牌访问资源:
使用获取到的访问令牌访问受保护资源:
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" http://localhost:9000/api/resource
扩展功能
添加资源服务器
创建一个简单的资源API:
package com.example.oauth2server.controller;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class ResourceController {
@GetMapping("/resource")
public Map<String, Object> resource(@AuthenticationPrincipal Jwt jwt) {
return Collections.singletonMap("message",
"Protected resource accessed by: " + jwt.getSubject());
}
}
配置资源服务器安全设置:
package com.example.oauth2server.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
@Order(3)
public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception {
http
.requestMatchers()
.antMatchers("/api/**")
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.jwt();
return http.build();
}
}
实现令牌撤销
添加令牌撤销端点:
package com.example.oauth2server.controller;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TokenController {
private final OAuth2AuthorizationService authorizationService;
public TokenController(OAuth2AuthorizationService authorizationService) {
this.authorizationService = authorizationService;
}
@PostMapping("/oauth2/revoke")
public void revokeToken(@RequestParam("token") String token,
@RequestParam("token_type_hint") String tokenTypeHint) {
OAuth2TokenType tokenType = "access_token".equals(tokenTypeHint)
? OAuth2TokenType.ACCESS_TOKEN
: OAuth2TokenType.REFRESH_TOKEN;
authorizationService.findByToken(token, tokenType)
.ifPresent(authorization -> {
authorizationService.remove(authorization);
});
}
}
自定义授权同意页面
创建一个Thymeleaf模板用于授权同意页面:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>授权确认</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
border: 1px solid #ddd;
border-radius: 5px;
padding: 20px;
margin-top: 20px;
}
.btn {
display: inline-block;
padding: 10px 15px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
margin-right: 10px;
}
.btn-cancel {
background-color: #f44336;
}
.scopes {
margin: 15px 0;
}
.scope-item {
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>授权请求</h1>
<p>
客户端 <strong th:text="${clientId}"></strong> 请求访问您的账户
</p>
<div class="scopes">
<p>请求的权限范围:</p>
<div th:each="scope : ${scopes}" class="scope-item">
<input type="checkbox" th:id="${scope}" th:name="scope" th:value="${scope}" checked />
<label th:for="${scope}" th:text="${scope}"></label>
</div>
</div>
<form method="post" th:action="${authorizationUri}">
<input type="hidden" name="client_id" th:value="${clientId}">
<input type="hidden" name="state" th:value="${state}">
<div>
<button type="submit" name="consent" value="approve" class="btn">授权</button>
<button type="submit" name="consent" value="deny" class="btn btn-cancel">拒绝</button>
</div>
</form>
</div>
</body>
</html>
创建控制器处理授权同意请求:
package com.example.oauth2server.controller;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.security.Principal;
import java.util.Set;
@Controller
public class AuthorizationConsentController {
private final RegisteredClientRepository registeredClientRepository;
private final OAuth2AuthorizationConsentService authorizationConsentService;
public AuthorizationConsentController(
RegisteredClientRepository registeredClientRepository,
OAuth2AuthorizationConsentService authorizationConsentService) {
this.registeredClientRepository = registeredClientRepository;
this.authorizationConsentService = authorizationConsentService;
}
@GetMapping("/oauth2/consent")
public String consent(
Principal principal,
Model model,
@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
@RequestParam(OAuth2ParameterNames.SCOPE) String scope,
@RequestParam(OAuth2ParameterNames.STATE) String state) {
RegisteredClient client = this.registeredClientRepository.findByClientId(clientId);
OAuth2AuthorizationConsent consent = this.authorizationConsentService.findById(
clientId, principal.getName());
Set<String> scopesToApprove = Set.of(scope.split(" "));
model.addAttribute("clientId", clientId);
model.addAttribute("state", state);
model.addAttribute("scopes", scopesToApprove);
model.addAttribute("authorizationUri", "/oauth2/authorize");
return "consent";
}
}
安全最佳实践
在实现OAuth2授权服务器时,应遵循以下安全最佳实践:
使用HTTPS:在生产环境中,始终使用HTTPS保护所有通信。
安全存储客户端密钥:客户端密钥应该使用强密码哈希算法(如BCrypt)进行加密存储。
实施PKCE:对于公共客户端(如SPA和移动应用),使用PKCE(Proof Key for Code Exchange)增强安全性。
限制令牌范围和生命周期:根据实际需求限制访问令牌的范围和有效期。
实施令牌撤销:提供令牌撤销机制,允许用户或管理员在需要时撤销访问权限。
监控和审计:实施日志记录和监控,以便及时发现可疑活动。
结论
通过本文,我们从零开始构建了一个功能完整的OAuth2授权服务器。我们深入了解了OAuth2的核心概念,并使用Spring Security和Spring Authorization Server实现了各种授权流程和扩展功能。
这个授权服务器可以作为您实际项目的起点,根据具体需求进行定制和扩展。随着安全需求的不断演变,持续关注OAuth2和Spring Security的最新发展,及时更新您的实现,是确保系统安全的关键。