引言
在当今的互联网生态中,安全认证与授权机制对于保护用户数据和系统资源至关重要。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:oauth2dbdriver-class-name: org.h2.Driverusername: sapassword: passwordjpa:database-platform: org.hibernate.dialect.H2Dialecthibernate:ddl-auto: updateshow-sql: trueh2:console:enabled: truepath: /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;}@Overridepublic 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();}@Beanpublic 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();}@Beanpublic 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);}@Beanpublic 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;}@Beanpublic 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 {@Beanpublic 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的最新发展,及时更新您的实现,是确保系统安全的关键。
参考资料
- OAuth 2.0 规范
- Spring Authorization Server 文档
- Spring Security 文档
- RFC 6749 - OAuth 2.0 授权框架
- RFC 7636 - PKCE