从零到一:用Java和Spring Security构建OAuth2授权服务器

发布于:2025-04-08 ⋅ 阅读:(52) ⋅ 点赞:(0)

引言

在当今的互联网生态中,安全认证与授权机制对于保护用户数据和系统资源至关重要。OAuth2作为一种行业标准的授权框架,被广泛应用于各类Web应用、移动应用和API服务中。本文将带领读者从零开始,使用Java和Spring Security框架构建一个功能完整的OAuth2授权服务器,深入理解OAuth2的核心概念和实现细节。

OAuth2基础知识

OAuth2是什么?

OAuth2(Open Authorization 2.0)是一个开放标准的授权协议,允许第三方应用在不获取用户凭证的情况下,获得对用户资源的有限访问权限。它解决了传统认证方式中的安全隐患,如密码共享和过度授权等问题。

OAuth2的角色

OAuth2定义了四个关键角色:

  1. 资源所有者(Resource Owner):通常是用户,拥有受保护资源的实体。
  2. 客户端(Client):请求访问资源的应用程序。
  3. 授权服务器(Authorization Server):验证资源所有者身份并颁发访问令牌。
  4. 资源服务器(Resource Server):托管受保护资源的服务器,接受并验证访问令牌。

OAuth2的授权流程

OAuth2支持多种授权流程,适用于不同场景:

  1. 授权码模式(Authorization Code):最完整、最安全的流程,适用于有后端的Web应用。
  2. 简化模式(Implicit):适用于无后端的单页应用。
  3. 密码模式(Resource Owner Password Credentials):适用于高度可信的应用。
  4. 客户端凭证模式(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

测试授权服务器

授权码流程测试

  1. 请求授权码

    访问以下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,并附带授权码。

  2. 使用授权码获取令牌

    使用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需要替换为上一步获取的授权码。

  3. 使用访问令牌访问资源

    使用获取到的访问令牌访问受保护资源:

    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授权服务器时,应遵循以下安全最佳实践:

  1. 使用HTTPS:在生产环境中,始终使用HTTPS保护所有通信。

  2. 安全存储客户端密钥:客户端密钥应该使用强密码哈希算法(如BCrypt)进行加密存储。

  3. 实施PKCE:对于公共客户端(如SPA和移动应用),使用PKCE(Proof Key for Code Exchange)增强安全性。

  4. 限制令牌范围和生命周期:根据实际需求限制访问令牌的范围和有效期。

  5. 实施令牌撤销:提供令牌撤销机制,允许用户或管理员在需要时撤销访问权限。

  6. 监控和审计:实施日志记录和监控,以便及时发现可疑活动。

结论

通过本文,我们从零开始构建了一个功能完整的OAuth2授权服务器。我们深入了解了OAuth2的核心概念,并使用Spring Security和Spring Authorization Server实现了各种授权流程和扩展功能。

这个授权服务器可以作为您实际项目的起点,根据具体需求进行定制和扩展。随着安全需求的不断演变,持续关注OAuth2和Spring Security的最新发展,及时更新您的实现,是确保系统安全的关键。

参考资料