Spring Boot集成OAuth2授权码模式的三种登录策略

oauth2认证是相对成熟的系统用户认证登录方案,尤其是web系统常使用支持oidc的oauth2授权码模式和其他web系统进行身份认证。

本文tony以实战经验为基础,从登录实现方式出发,给大家介绍如下三种在现有基于spring boot 3.4.6开发的web系统上集成oauth2授权码认证功能实现策略:

  1. 使用OAuth2框架内置登录实现
  2. 自定义登录实现
  3. 使用现有Web系统登录实现

一、准备工作

参考官网文档:

https://docs.spring.io/spring-authorization-server/reference/getting-started.html

添加Spring OAuth2授权服务依赖包:

添加OAuth2基础配置类:

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
            throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
                OAuth2AuthorizationServerConfigurer.authorizationServer();
        http
                .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
                .with(authorizationServerConfigurer, (authorizationServer) ->
                        authorizationServer
                                .oidc(Customizer.withDefaults())    // Enable OpenID Connect 1.0
                )
                .authorizeHttpRequests((authorize) ->
                        authorize
                                .anyRequest().authenticated()
                )
                // Redirect to the login page when not authenticated from the
                // authorization endpoint
                .exceptionHandling((exceptions) -> exceptions
                        .defaultAuthenticationEntryPointFor(
                                new LoginUrlAuthenticationEntryPoint("/login"),
                                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                        )
                );
        return http.build();
    }
    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
            throws Exception {
        http
                .authorizeHttpRequests((authorize) -> authorize
                        .anyRequest().authenticated()
                )
                // Form login handles the redirect to the login page from the
                // authorization server filter chain
                .formLogin(Customizer.withDefaults());
        return http.build();
    }
    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails userDetails = User.withDefaultPasswordEncoder()
                .username("user")
                .password("password")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(userDetails);
    }
    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("oidc-client")
                .clientSecret("{noop}secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/oidc-client")
                .postLogoutRedirectUri("http://127.0.0.1:8080/")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .build();
        //如果是自己数据库维护,需要自己定义RegisteredClientRepository的实现;或者在手动添加对应表后,使用内置的JdbcRegisteredClientRepository
        return new InMemoryRegisteredClientRepository(oidcClient);
    }
    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }
    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 JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder().build();
    }
}

二、使用OAuth2框架内置登录实现

直接按上面配置就会有如下默认登录页面:

application.yml配置的项目访问路径server.servlet.context-path值为"/"时,默认登录地址为/login

application.yml配置的项目访问路径server.servlet.context-path值为"/xxx"其他时,默认登录地址为/xxx/login

上面配置方法"authorizationServerSecurityFilterChain"中的如下代码还设置了全局未授权异常重定向到登录页:

三、自定义登录实现

Spring Oauth2默认登录页只支持"用户名+密码"登录,而且默认是英文的,不满足我们使用要求,我们实际项目登录页面除了语言和样式要求不一样,还可能需要支持验证码、扫码登录等功能,所以我们需要自定义登录。

Spring Oauth2支持自定义登录页面和登录处理器(参考:https://docs.spring.io/spring-authorization-server/reference/guides/how-to-social-login.html)。

具体实现步骤如下:

1.设置自定义登录页面:SecurityConfig.java配置文件方法"authorizationServerSecurityFilterChain"里通过defaultAuthenticationEntryPointFor方法设置:

上图中通过"new LoginUrlAuthenticationEntryPoint(url)"设置登录页面。

2.设置自定义登录处理逻辑:如果oauth2默认登录接口不满足需求,比如我们还需要校验验证码,添加用户登录失败次数判断等逻辑,可通过设置AuthenticationManager重写定义登录逻辑,示例代码如下:

上图中DaoAuthenticationProvider是框架自带的一种实现,实现了类AbstractUserDetailsAuthenticationProvider,我们可以参考实现自己登录Provider来替换默认登录处理逻辑。

四、使用现有Web系统登录实现

很多时候我们现有Web系统已有一套完整的登录逻辑,集成OAuth2认证服务时,还是想沿用现有逻辑,有不想用上面将的"自定义登录"方式,如何实现呢?官网没找到好的示例,tony经过查看分析源码,找到了如下解决方案:

1.使用原有web系统的登录页面和接口设置cookie+session全局会话。

2.自定义授权码认证转换器(继承AuthenticationConverter)替换框架默认转换器:

"OAuth2AuthorizationCodeRequestAuthenticationConverter",该转换器参考OAuth2AuthorizationCodeRequestAuthenticationConverter实现,convert方法中从session获取用户信息,如果按OAuth2的方式获取到就放到OAuh2缓存里,否则返回匿名登录会走后面未登录重定向到登录页逻辑。

3.SecurityConfig配置类里面增加认证不通过重定向到原有web登录页配置。

4.SecurityConfig配置类里面增加认证不通过重定向到原有web登录页配置自定义授权码认证转换器。

自定义授权码认证转换器示例如下:

java 复制代码
public class CustomerOAuth2AuthorizationCodeRequestAuthenticationConverter implements AuthenticationConverter {
    private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1";
    private static final String PKCE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1";
    private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken("anonymous",
            "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
    private static final RequestMatcher OIDC_REQUEST_MATCHER = createOidcRequestMatcher();
    public static final String USER_INFO = "userInfo";
    protected AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
    private final HttpSecurity http;
    public CustomerOAuth2AuthorizationCodeRequestAuthenticationConverter(HttpSecurity http) {
        this.http = http;
    }
    @Override
    public Authentication convert(HttpServletRequest request) {
        if (!"GET".equals(request.getMethod()) && !OIDC_REQUEST_MATCHER.matches(request)) {
            return null;
        }
        MultiValueMap<String, String> parameters = "GET".equals(request.getMethod())
                ? getQueryParameters(request) : getFormParameters(request);
        // response_type (REQUIRED)
        String responseType = parameters.getFirst(OAuth2ParameterNames.RESPONSE_TYPE);
        if (!StringUtils.hasText(responseType) || parameters.get(OAuth2ParameterNames.RESPONSE_TYPE).size() != 1) {
            throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.RESPONSE_TYPE);
        } else if (!responseType.equals(OAuth2AuthorizationResponseType.CODE.getValue())) {
            throwError(OAuth2ErrorCodes.UNSUPPORTED_RESPONSE_TYPE, OAuth2ParameterNames.RESPONSE_TYPE);
        }
        String authorizationUri = request.getRequestURL().toString();
        // client_id (REQUIRED)
        String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID);
        if (!StringUtils.hasText(clientId) || parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) {
            throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID);
        }
        Authentication principal = SecurityContextHolder.getContext().getAuthentication();
        if (principal == null) {
            UserInfo userInfo = (UserInfo) request.getSession().getAttribute(USER_INFO);
            if (userInfo == null) {
                principal = ANONYMOUS_AUTHENTICATION;
            } else {
                // 用户登录后,缓存认证信息到OAuth2缓存中,后续直接从缓存中获取认证信息
                UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(userInfo.getUsername(), userInfo.getPassword());
                authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
                principal = http.getSharedObject(AuthenticationManager.class).authenticate(authRequest);
                SecurityContextHolder.getContext().setAuthentication(principal);
            }
        }
        // redirect_uri (OPTIONAL)
        String redirectUri = parameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
        if (StringUtils.hasText(redirectUri) && parameters.get(OAuth2ParameterNames.REDIRECT_URI).size() != 1) {
            throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI);
        }
        // scope (OPTIONAL)
        Set<String> scopes = null;
        String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
        if (StringUtils.hasText(scope) && parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
            throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.SCOPE);
        }
        if (StringUtils.hasText(scope)) {
            scopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
        }
        // state (RECOMMENDED)
        String state = parameters.getFirst(OAuth2ParameterNames.STATE);
        if (StringUtils.hasText(state) && parameters.get(OAuth2ParameterNames.STATE).size() != 1) {
            throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE);
        }
        // code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE)
        String codeChallenge = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE);
        if (StringUtils.hasText(codeChallenge) && parameters.get(PkceParameterNames.CODE_CHALLENGE).size() != 1) {
            throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI);
        }
        // code_challenge_method (OPTIONAL for public clients) - RFC 7636 (PKCE)
        String codeChallengeMethod = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE_METHOD);
        if (StringUtils.hasText(codeChallengeMethod)
                && parameters.get(PkceParameterNames.CODE_CHALLENGE_METHOD).size() != 1) {
            throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI);
        }
        // prompt (OPTIONAL for OpenID Connect 1.0 Authentication Request)
        if (!CollectionUtils.isEmpty(scopes) && scopes.contains(OidcScopes.OPENID)) {
            String prompt = parameters.getFirst("prompt");
            if (StringUtils.hasText(prompt) && parameters.get("prompt").size() != 1) {
                throwError(OAuth2ErrorCodes.INVALID_REQUEST, "prompt");
            }
        }
        Map<String, Object> additionalParameters = new HashMap<>();
        parameters.forEach((key, value) -> {
            if (!key.equals(OAuth2ParameterNames.RESPONSE_TYPE) && !key.equals(OAuth2ParameterNames.CLIENT_ID)
                    && !key.equals(OAuth2ParameterNames.REDIRECT_URI) && !key.equals(OAuth2ParameterNames.SCOPE)
                    && !key.equals(OAuth2ParameterNames.STATE)) {
                additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0]));
            }
        });
        return new OAuth2AuthorizationCodeRequestAuthenticationToken(authorizationUri, clientId, principal, redirectUri,
                state, scopes, additionalParameters);
    }
    private static RequestMatcher createOidcRequestMatcher() {
        RequestMatcher postMethodMatcher = (request) -> "POST".equals(request.getMethod());
        RequestMatcher responseTypeParameterMatcher = (
                request) -> request.getParameter(OAuth2ParameterNames.RESPONSE_TYPE) != null;
        RequestMatcher openidScopeMatcher = (request) -> {
            String scope = request.getParameter(OAuth2ParameterNames.SCOPE);
            return StringUtils.hasText(scope) && scope.contains(OidcScopes.OPENID);
        };
        return new AndRequestMatcher(postMethodMatcher, responseTypeParameterMatcher, openidScopeMatcher);
    }
    private static void throwError(String errorCode, String parameterName) {
        throwError(errorCode, parameterName, DEFAULT_ERROR_URI);
    }
    private static void throwError(String errorCode, String parameterName, String errorUri) {
        OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, errorUri);
        throw new OAuth2AuthorizationCodeRequestAuthenticationException(error, null);
    }
    static MultiValueMap<String, String> getFormParameters(HttpServletRequest request) {
        Map<String, String[]> parameterMap = request.getParameterMap();
        MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
        parameterMap.forEach((key, values) -> {
            String queryString = StringUtils.hasText(request.getQueryString()) ? request.getQueryString() : "";
            // If not query parameter then it's a form parameter
            if (!queryString.contains(key) && values.length > 0) {
                for (String value : values) {
                    parameters.add(key, value);
                }
            }
        });
        return parameters;
    }
    static MultiValueMap<String, String> getQueryParameters(HttpServletRequest request) {
        Map<String, String[]> parameterMap = request.getParameterMap();
        MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
        parameterMap.forEach((key, values) -> {
            String queryString = StringUtils.hasText(request.getQueryString()) ? request.getQueryString() : "";
            if (queryString.contains(key) && values.length > 0) {
                for (String value : values) {
                    parameters.add(key, value);
                }
            }
        });
        return parameters;
    }
}

初始化配置示例如下:

配置完后,核心OAuth2认证登录访问步骤如下:

1.第三方应用未登录时,浏览器会带回调地址会重定向到获取授权码接口地址:

http://localhost:8080/oauth2/authorize?response_type=code\&client_id=xxxx\&scope=openid\&redirect_uri=http%3A%2F%2F127.0.0.1%3A9000%2Fcallback

2.获取授权码接口里的"自定义授权码认证转换器"未找到登录信息,我们可以在"自定义授权码认证转换器"里记录"带回调地址参数的获取授权码地址"并跳转现有web登录页。

3.用户通过现有web登录页登录,登录成功后用户信息放到session、设置set-cookie,从上传访问记录里拿到"带回调地址参数的获取授权码地址"并重定向到浏览器访问。

4.获取授权码接口里的"自定义授权码认证转换器"找到session里的用户信息,缓存登录信息到OAuth2缓存,然后会带授权码重定向到第三方应用的接受授权码的回调地址接口。

5.第三方应用获取到授权码后,获取token、获取用户信息,通过自有的cookie+session记录用户全局会话,实现自有免登录逻辑,跳转自己首页或其他页面。

详细流程图如下:

流程关键点说明:

  1. 第三方应用构造标准OAuth2授权码请求,redirect_uri指向自己的回调地址。

  2. 认证服务器的"自定义授权码认证转换器"拦截请求,发现无Session,将完整请求URL(含参数)临时保存(如存入缓存)。

  3. 转换器不直接处理授权,而是重定向到企业现有的Web登录页面。

  4. 用户登录成功后,登录页逻辑从临时存储中取出之前保存的授权请求URL,并重定向回去。这一步实现了"登录后回到授权流程"。

  5. 第二次访问授权接口时,Session已有用户信息,转换器校验通过,生成授权码,并将用户信息与授权码关联存入OAuth2缓存(供后续token接口使用)。

  6. 重定向到第三方应用的redirect_uri,并附上code。

  7. 第三方用授权码换取Token(服务端通信,不经过浏览器)。

  8. 第三方用Token获取用户信息。

  9. 第三方建立自己的Cookie+Session,实现自身系统的用户登录态。

  10. 最终跳转到第三方首页,用户无需再次登录。

五、其他注意事项

  1. 要了解OAuth2路径权限拦截机制:OAuth2主要基于Filter实现认证路径拦截处理,优先级高于Controller,未登录时,默认会对所有访问进行是否登录验证,如果未登录会跳转登录页,如果我们项目有部分接口是白名单,不需要被OAuth2拦截,可参考如下配置:

    注意:上图中路径不需要加上content-path前缀。

  2. 初始化应用信息?:如果是接入多个应用信息需要改为通过配置文件或者数据库维护应用ID、应用密匙等应用信息。

  3. 如何获取用户详细信息?:用户详细信息通常需要通过数据库初始化和维护,可自定义实现UserDetailsService接口或使用内置JdbcUserDetailsManager(users等表需要自己创建),示例如下:

java 复制代码
@Bean
    public UserDetailsService userDetailsService(DataSource dataSource) {
        return new CustomerJdbcUserDetailsManager(dataSource);
    }

六、总结

本文基于Spring Boot 3.4.6,从实战角度系统梳理了在现有Web系统中集成OAuth2授权码模式的三种登录实现方案"框架内置登录、自定义登录页以及复用现有系统登录",能帮助大家根据自身业务场景灵活选择最适合的集成路径,快速实现安全、标准化的OAuth2认证能力。

相关推荐
小 黄 鸡1 小时前
JVM 核心总结
java
阿kun要赚马内1 小时前
工具在langchain agent中的调用
人工智能·后端·python
IT_陈寒1 小时前
Vite的HMR在我项目上突然失效,排查三天找到离谱原因
前端·人工智能·后端
Data_Journal1 小时前
掌握网页抓取中的分页:完整指南
java·服务器·前端
莫逸风1 小时前
【AgentScope 2.0】05-文件系统(Filesystem)详解
java·ai·agent·springai·agentscope
AINative软件工程1 小时前
LLM 应用的依赖注入工程实践:解耦 Client、Prompt 和 Tool Registry,让 AI 系统真正可测试可替换
后端·llm·前端工程化
腾渊信息科技公司2 小时前
Spring Boot集成TDengine实战:工业时序数据存储选型与迁移方案
spring boot·后端·tdengine
codeGoogle11 小时前
自研 IM 还是选择第三方 SDK?企业开发者应该如何权衡?
前端·后端·程序员