1. 概述

本文将演示如何通过 Spring Security OAuth 与 Reddit API 集成,实现用户认证功能。整个流程基于 OAuth2 协议,适用于希望接入 Reddit 登录的 Web 应用。

我们不会止步于“能跑就行”,而是会深入关键环节,比如踩坑点、非标准实现的处理方式,帮助你在实际项目中少走弯路。


2. Maven 依赖配置

要使用 Spring Security 的 OAuth2 客户端功能,首先需要引入核心依赖:

<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
    <version>2.0.6.RELEASE</version>
</dependency>

⚠️ 注意:该库已进入维护模式,官方推荐迁移到 Spring Security 5+ 的原生 OAuth2 支持。但如果你维护的是老项目,或追求快速集成,这套方案依然稳定可用。


3. OAuth2 客户端配置

我们需要配置一个 OAuth2RestTemplate 来管理令牌获取和资源访问。同时通过 reddit.properties 管理敏感信息。

配置类示例

@Configuration
@EnableOAuth2Client
@PropertySource("classpath:reddit.properties")
protected static class ResourceConfiguration {

    @Value("${accessTokenUri}")
    private String accessTokenUri;

    @Value("${userAuthorizationUri}")
    private String userAuthorizationUri;

    @Value("${clientID}")
    private String clientID;

    @Value("${clientSecret}")
    private String clientSecret;

    @Bean
    public OAuth2ProtectedResourceDetails reddit() {
        AuthorizationCodeResourceDetails details = new AuthorizationCodeResourceDetails();
        details.setId("reddit");
        details.setClientId(clientID);
        details.setClientSecret(clientSecret);
        details.setAccessTokenUri(accessTokenUri);
        details.setUserAuthorizationUri(userAuthorizationUri);
        details.setTokenName("oauth_token");
        details.setScope(Arrays.asList("identity"));
        details.setPreEstablishedRedirectUri("http://localhost/login");
        details.setUseCurrentUri(false);
        return details;
    }

    @Bean
    public OAuth2RestTemplate redditRestTemplate(OAuth2ClientContext clientContext) {
        OAuth2RestTemplate template = new OAuth2RestTemplate(reddit(), clientContext);
        AccessTokenProvider accessTokenProvider = new AccessTokenProviderChain(
          Arrays.<AccessTokenProvider> asList(
            new MyAuthorizationCodeAccessTokenProvider(), 
            new ImplicitAccessTokenProvider(), 
            new ResourceOwnerPasswordAccessTokenProvider(),
            new ClientCredentialsAccessTokenProvider())
        );
        template.setAccessTokenProvider(accessTokenProvider);
        return template;
    }
}

reddit.properties

clientID=your_client_id_here
clientSecret=your_client_secret_here
accessTokenUri=https://www.reddit.com/api/v1/access_token
userAuthorizationUri=https://www.reddit.com/api/v1/authorize

✅ 提示:前往 https://www.reddit.com/prefs/apps 创建应用,获取 clientIDclientSecret

📌 关键点说明:

  • scope="identity":这是访问用户基本信息(如用户名)所必需的权限。
  • preEstablishedRedirectUri 必须与你在 Reddit 后台注册的回调地址完全一致,否则会报 redirect_uri_mismatch 错误。

4. 自定义 AuthorizationCodeAccessTokenProvider

Reddit 的 OAuth2 实现有 非标准行为 —— 如果你想获取长期有效的 token(即“永久 token”),必须在授权请求中显式添加 duration=permanent 参数。

而标准的 AuthorizationCodeAccessTokenProvider 并不支持这个参数,所以我们必须自定义实现。

解决方案:继承并覆盖关键方法

public class MyAuthorizationCodeAccessTokenProvider extends AuthorizationCodeAccessTokenProvider {

    @Override
    protected String getRedirectForAuthorization(OAuth2ProtectedResourceDetails resource, 
                                               MultiValueMap<String, String> requestParams) {
        requestParams.add("duration", "permanent"); // ⚠️ Reddit 特有参数
        return super.getRedirectForAuthorization(resource, requestParams);
    }
}

✅ 踩坑提醒:

  • 忘记加 duration=permanent?那拿到的 token 默认只有 1 小时有效期。
  • 这个参数只能在 授权阶段 添加,不能在获取 token 阶段补。

完整源码可参考:GitHub - Baeldung Reddit App


5. ServerInitializer 配置

为了让 OAuth2 上下文在整个请求链中可用,我们需要注册 oauth2ClientContextFilter 这个关键过滤器。

public class ServletInitializer extends AbstractDispatcherServletInitializer {

    @Override
    protected WebApplicationContext createServletApplicationContext() {
        AnnotationConfigWebApplicationContext context = 
          new AnnotationConfigWebApplicationContext();
        context.register(WebConfig.class, SecurityConfig.class);
        return context;
    }

    @Override
    protected String[] getServletMappings() {
        return new String[] { "/" };
    }

    @Override
    protected WebApplicationContext createRootApplicationContext() {
        return null;
    }

    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        super.onStartup(servletContext);
        registerProxyFilter(servletContext, "oauth2ClientContextFilter");
        registerProxyFilter(servletContext, "springSecurityFilterChain");
    }

    private void registerProxyFilter(ServletContext servletContext, String name) {
        DelegatingFilterProxy filter = new DelegatingFilterProxy(name);
        filter.setContextAttribute(
          "org.springframework.web.servlet.FrameworkServlet.CONTEXT.dispatcher");
        servletContext.addFilter(name, filter).addMappingForUrlPatterns(null, false, "/*");
    }
}

📌 为什么需要这个?

  • oauth2ClientContextFilter 负责维护 OAuth2 的临时状态(如 code、state)。
  • 缺少它会导致 NoSuchBeanDefinitionException 或认证流程中断。

6. MVC 配置

基础 MVC 配置,用于支持 JSP 视图和静态资源:

@Configuration
@EnableWebMvc
@ComponentScan(basePackages = { "org.baeldung.web" })
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public static PropertySourcesPlaceholderConfigurer 
      propertySourcesPlaceholderConfigurer() {
        return new PropertySourcesPlaceholderConfigurer();
    }

    @Bean
    public ViewResolver viewResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setPrefix("/WEB-INF/jsp/");
        viewResolver.setSuffix(".jsp");
        return viewResolver;
    }

    @Override
    public void configureDefaultServletHandling(
      DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/resources/**").addResourceLocations("/resources/");
    }

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/home.html");
    }
}

📌 简单粗暴但够用,适合演示场景。生产环境建议结合 Thymeleaf 或前后端分离。


7. 安全配置(SecurityConfig)

核心安全逻辑:禁用匿名访问和 CSRF,所有受保护接口需登录。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) 
      throws Exception {
        auth.inMemoryAuthentication();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .anonymous().disable()
            .csrf().disable()
            .authorizeRequests()
            .antMatchers("/home.html").hasRole("USER")
            .and()
            .httpBasic()
            .authenticationEntryPoint(oauth2AuthenticationEntryPoint());
    }

    private LoginUrlAuthenticationEntryPoint oauth2AuthenticationEntryPoint() {
        return new LoginUrlAuthenticationEntryPoint("/login");
    }
}

关键点:

  • .authenticationEntryPoint() 指定未认证时跳转到 /login,触发 OAuth2 流程。
  • hasRole("USER") 表示访问 /home.html 必须拥有 ROLE_USER 角色。

8. RedditController:处理登录与认证

这是整个流程的“发动机”——用户访问 /login 时,自动完成 OAuth2 授权码流程,并建立本地认证。

@Controller
public class RedditController {

    @Autowired
    private OAuth2RestTemplate redditRestTemplate;

    @RequestMapping("/login")
    public String redditLogin() {
        JsonNode node = redditRestTemplate.getForObject(
          "https://oauth.reddit.com/api/v1/me", JsonNode.class);
        UsernamePasswordAuthenticationToken auth = 
          new UsernamePasswordAuthenticationToken(
              node.get("name").asText(), 
              redditRestTemplate.getAccessToken().getValue(), 
              Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))
          );
        
        SecurityContextHolder.getContext().setAuthentication(auth);
        return "redirect:home.html";
    }
}

✅ 核心机制说明:

  • redditRestTemplate.getForObject() 会自动:
    1. 检查是否有有效 access token
    2. 若无,则跳转 Reddit 授权页获取 code
    3. 用 code 换取 token
    4. 再发起原始请求
  • https://oauth.reddit.com/api/v1/me 是 Reddit 提供的获取当前用户信息的接口。
  • 我们将 Reddit 用户名作为 principal 存入 Spring Security 上下文。

⚠️ 注意事项:

  • 使用 oauth.reddit.com 而非 www.reddit.com,否则会返回 403。
  • 需要 Jackson 支持 JsonNode 反序列化。

9. 前端页面:home.jsp

最简单的视图,展示已登录用户:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags"%>
<html>
<body>
    <h1>Welcome, <small><sec:authentication property="principal.username" /></small></h1>
</body>
</html>

📌 使用 Spring Security Taglib 直接读取当前认证用户的用户名。


10. 总结

本文完整实现了通过 Reddit OAuth2 登录 Spring Web 应用的流程,重点包括:

✅ 成功集成 spring-security-oauth2
✅ 处理 Reddit 非标准参数 duration=permanent
✅ 自动获取 token 并建立本地认证上下文
✅ 展示用户信息

虽然这套方案基于较老的技术栈(Spring Security OAuth),但在维护旧项目或快速原型开发中依然非常实用。

下一步可以扩展的功能:

  • 实现自动刷新 token
  • 绑定本地用户账户
  • 访问更多 Reddit API(如发帖、点赞)

原文系列后续将深入这些高级主题,值得持续关注。


原始标题:Authentication with Reddit OAuth2 and Spring | Baeldung