在上一篇中,我们介绍了如何自定义 token,这一篇我们来介绍一下如何自定义 oidc。oidc 是OpenID Connect 的缩写,是 OAuth 2.0 协议之上的一个简单的身份层。我们在之前的篇章中,经常会看到用户信息被写入 id_token 返回给客户端,如果不想一下子往 id_token 写入太多用户信息或者客户端根本就没有 id_token 权限,那么此时,客户端想要获取更多的用户信息,则只能通过“/userinfo”接口来获取了。但是,通过默认的“/userinfo”接口,也往往获取不到我们想要的用户信息,这时候就需要我们通过自定义 oidc 来实现 userInfo了。

1. 默认 userInfo 演示

我们通过下面的请求获取 token 信息。

Spring Authorization Server (八)自定义OIDC_Authorization Server

将获取到的 id_token 进行 jwt 解析如下。

Spring Authorization Server (八)自定义OIDC_Authorization Server_02

因为在下面的代码中,我们已经将 sys_user 表中的 username、name、description 字段写入到id_token 中,因此这几个字段也被一起返回给客户端。

Spring Authorization Server (八)自定义OIDC_Spring Security_03

此时,我们请求 http://spring-oauth-server:9000/userinfo 地址,带上刚刚获取的 access_token,则返回如下结果。

Spring Authorization Server (八)自定义OIDC_OAuth2.1_04

我们看到,只返回 sub 和 name 两个字段,而 username、description 这些字段并未返回。

那么,“/userinfo”为什么只返回 sub 和 name 这两个字段字段呢?因为我们获取 token 时, scope 传入的范围是 profile openid,其中 openid 要求认证服务器要返回 id_token,而 profile 是要求认证服务器要返回 profile 权限对应的字段。profile 权限默认的字段有 "name", "family_name", "given_name", "middle_name", "nickname", "preferred_username", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale", "updated_at",再加上处理过程中默认加上“sub”字段,这些字段默认都从 id_token 中的 claims 取值,那么“/userinfo”从 id_token 中能取到有值的,也就 sub 和 name 了。

在 Spring Authorization Server 中,scope 定义的权限范围有 openid、profile、email、address、phone。其权限对照关系如下。

权限名称

字段范围

openid

id_token、sub

profile

name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, updated_at

email

email、email_verified

address

address,是一个 json对象、包含 formatted、street_address、locality、region、postal_code、country等信息。此处慎用json类型,容易出现json解析异常。

phone

phone_number、phone_number_verified

2. 自定义 OIDC

我们之前在 AuthorizationServerConfig 类中开启 oidc 的时候,直接使用 Customizer.withDefaults() 启动默认配置,通过官网介绍,我们可以通过设置自定义的 userInfoRequestConverter() 和 authenticationProvider() 来实现自定义的 userInfo。

Spring Authorization Server (八)自定义OIDC_OAuth2.1_05

实现步骤如下。

Spring Authorization Server (八)自定义OIDC_OAuth2.1_06

首先在 MyOidcUserInfoAuthenticationConverter 中需要提供 OidcUserInfo 对象,而 OidcUserInfo 中的构造器提供的都是默认的字段,并无我们 sys_user 表中的 username、name、descriptio n字段,因此我们需要创建 OidcUserInfo 的子类 MyOidcUserInfo 来实现自己的构造器, MyOidcUserInfo 代码如下。

package org.oauth.server.authentication.oidc;

import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.util.Assert;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Consumer;

/**
 * @author Rommel
 * @version 1.0
 * @date 2023/7/28-10:20
 * @description TODO
 */
public class MyOidcUserInfo extends OidcUserInfo {
    private static final long serialVersionUID = 610L;
    private final Map<String, Object> claims;

    public MyOidcUserInfo(Map<String, Object> claims) {
        super(claims);
        Assert.notEmpty(claims, "claims cannot be empty");
        this.claims = Collections.unmodifiableMap(new LinkedHashMap(claims));
    }

    public Map<String, Object> getClaims() {
        return this.claims;
    }

    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        } else if (obj != null && this.getClass() == obj.getClass()) {
            MyOidcUserInfo that = (MyOidcUserInfo)obj;
            return this.getClaims().equals(that.getClaims());
        } else {
            return false;
        }
    }

    public int hashCode() {
        return this.getClaims().hashCode();
    }

    public static MyOidcUserInfo.Builder myBuilder() {
        return new MyOidcUserInfo.Builder();
    }

    public static final class Builder {
        private final Map<String, Object> claims = new LinkedHashMap();

        private Builder() {
        }

        public MyOidcUserInfo.Builder claim(String name, Object value) {
            this.claims.put(name, value);
            return this;
        }

        public MyOidcUserInfo.Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
            claimsConsumer.accept(this.claims);
            return this;
        }

        public MyOidcUserInfo.Builder username(String username) {
            return this.claim("username", username);
        }

        public MyOidcUserInfo.Builder name(String name) {
            return this.claim("name", name);
        }

        public MyOidcUserInfo.Builder description(String description) {
            return this.claim("description", description);
        }

        public MyOidcUserInfo.Builder status(Integer status) {
            return this.claim("status", status);
        }

        public MyOidcUserInfo.Builder phoneNumber(String phoneNumber) {
            return this.claim("phone_number", phoneNumber);
        }

        public MyOidcUserInfo.Builder email(String email) {
            return this.claim("email", email);
        }

        public MyOidcUserInfo.Builder profile(String profile) {
            return this.claim("profile", profile);
        }

        public MyOidcUserInfo.Builder address(String address) {
            return this.claim("address", address);
        }

        public MyOidcUserInfo build() {
            return new MyOidcUserInfo(this.claims);
        }

    }
}

MyOidcUserInfoAuthenticationConverter 代码如下。

package org.oauth.server.authentication.oidc;

import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationToken;
import org.oauth.server.constant.OAuth2Constant;
import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationToken;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;

import java.util.HashMap;
import java.util.Map;

/**
 * @author Rommel
 * @version 1.0
 * @date 2023/7/20-14:37
 * @description TODO
 */
public class MyOidcUserInfoAuthenticationConverter implements AuthenticationConverter {

    private MyOidcUserInfoService myOidcUserInfoService;


    public MyOidcUserInfoAuthenticationConverter(MyOidcUserInfoService myOidcUserInfoService){
        this.myOidcUserInfoService = myOidcUserInfoService;
    }

    @Nullable
    @Override
    public Authentication convert(HttpServletRequest request) {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        //查询用户信息
        MyOidcUserInfo myOidcUserInfo = myOidcUserInfoService.loadUser(authentication.getName());

        //返回自定义的OidcUserInfoAuthenticationToken
        return new OidcUserInfoAuthenticationToken(authentication, myOidcUserInfo);
    }
    
}

MyOidcUserInfoService 代码如下。

package org.oauth.server.authentication.oidc;

/**
 * @author Rommel
 * @version 1.0
 * @date 2023/7/25-16:50
 * @description TODO
 */

import jakarta.annotation.Resource;
import org.oauth.server.authentication.oidc.MyOidcUserInfo;
import org.oauth.server.model.SysUserEntity;
import org.oauth.server.service.SysUserService;
import org.springframework.stereotype.Service;

import java.util.Map;

/**
 * Example service to perform lookup of user info for customizing an {@code id_token}.
 */
@Service
public class MyOidcUserInfoService {

    @Resource
    private SysUserService sysUserService;

    public MyOidcUserInfo loadUser(String username) {
        SysUserEntity sysUserEntity = sysUserService.selectByUsername(username);
        return new MyOidcUserInfo(this.createUser(sysUserEntity));
    }

    private Map<String, Object> createUser(SysUserEntity sysUserEntity) {
        return MyOidcUserInfo.myBuilder()
                .name(sysUserEntity.getName())
                .username(sysUserEntity.getUsername())
                .description(sysUserEntity.getDescription())
                .status(sysUserEntity.getStatus())
                .phoneNumber(sysUserEntity.getUsername())
                .email(sysUserEntity.getUsername() + "@example.com")
                .profile("https://example.com/" + sysUserEntity.getName())
                .address("XXX共和国XX省XX市XX区XXX街XXX号")
                .build()
                .getClaims();
    }

}

MyOidcUserInfoAuthenticationProvider 代码如下(参考框架中的 OidcUserInfoAuthenticationProvider)。

package org.oauth.server.authentication.oidc;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken;
import org.springframework.util.Assert;

import java.util.*;
import java.util.function.Function;

/**
 * @author Rommel
 * @version 1.0
 * @date 2023/7/28-18:35
 * @description TODO
 */
public class MyOidcUserInfoAuthenticationProvider implements AuthenticationProvider {
    private final Log logger = LogFactory.getLog(this.getClass());
    private final OAuth2AuthorizationService authorizationService;
    private Function<OidcUserInfoAuthenticationContext, MyOidcUserInfo> userInfoMapper = new MyOidcUserInfoAuthenticationProvider.DefaultOidcUserInfoMapper();

    public MyOidcUserInfoAuthenticationProvider(OAuth2AuthorizationService authorizationService) {
        Assert.notNull(authorizationService, "authorizationService cannot be null");
        this.authorizationService = authorizationService;
    }

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        OidcUserInfoAuthenticationToken userInfoAuthentication = (OidcUserInfoAuthenticationToken)authentication;
        AbstractOAuth2TokenAuthenticationToken<?> accessTokenAuthentication = null;
        if (AbstractOAuth2TokenAuthenticationToken.class.isAssignableFrom(userInfoAuthentication.getPrincipal().getClass())) {
            accessTokenAuthentication = (AbstractOAuth2TokenAuthenticationToken)userInfoAuthentication.getPrincipal();
        }

        if (accessTokenAuthentication != null && accessTokenAuthentication.isAuthenticated()) {
            String accessTokenValue = accessTokenAuthentication.getToken().getTokenValue();
            OAuth2Authorization authorization = this.authorizationService.findByToken(accessTokenValue, OAuth2TokenType.ACCESS_TOKEN);
            if (authorization == null) {
                throw new OAuth2AuthenticationException("invalid_token");
            } else {
                if (this.logger.isTraceEnabled()) {
                    this.logger.trace("Retrieved authorization with access token");
                }

                OAuth2Authorization.Token<OAuth2AccessToken> authorizedAccessToken = authorization.getAccessToken();
                if (!authorizedAccessToken.isActive()) {
                    throw new OAuth2AuthenticationException("invalid_token");
                } else {
                    //从认证结果中获取userInfo
                    MyOidcUserInfo myOidcUserInfo = (MyOidcUserInfo)userInfoAuthentication.getUserInfo();
                    //从authorizedAccessToken中获取授权范围
                    Set<String> scopeSet = (HashSet<String>)authorizedAccessToken.getClaims().get("scope") ;
                    //获取授权范围对应userInfo的字段信息
                    Map<String, Object> claims = DefaultOidcUserInfoMapper.getClaimsRequestedByScope(myOidcUserInfo.getClaims(),scopeSet);
                    if (this.logger.isTraceEnabled()) {
                        this.logger.trace("Authenticated user info request");
                    }
                    //构造新的OidcUserInfoAuthenticationToken
                    return new OidcUserInfoAuthenticationToken(accessTokenAuthentication, new MyOidcUserInfo(claims));
                }
            }
        } else {
            throw new OAuth2AuthenticationException("invalid_token");
        }
    }

    public boolean supports(Class<?> authentication) {
        return OidcUserInfoAuthenticationToken.class.isAssignableFrom(authentication);
    }

    public void setUserInfoMapper(Function<OidcUserInfoAuthenticationContext, MyOidcUserInfo> userInfoMapper) {
        Assert.notNull(userInfoMapper, "userInfoMapper cannot be null");
        this.userInfoMapper = userInfoMapper;
    }

    private static final class DefaultOidcUserInfoMapper implements Function<OidcUserInfoAuthenticationContext, MyOidcUserInfo> {
        private static final List<String> EMAIL_CLAIMS = Arrays.asList("email", "email_verified");
        private static final List<String> PHONE_CLAIMS = Arrays.asList("phone_number", "phone_number_verified");
        private static final List<String> PROFILE_CLAIMS = Arrays.asList("name", "username", "description", "status", "profile");

        private DefaultOidcUserInfoMapper() {
        }

        public MyOidcUserInfo apply(OidcUserInfoAuthenticationContext authenticationContext) {
            OAuth2Authorization authorization = authenticationContext.getAuthorization();
            OidcIdToken idToken = (OidcIdToken)authorization.getToken(OidcIdToken.class).getToken();
            OAuth2AccessToken accessToken = authenticationContext.getAccessToken();
            Map<String, Object> scopeRequestedClaims = getClaimsRequestedByScope(idToken.getClaims(), accessToken.getScopes());
            return new MyOidcUserInfo(scopeRequestedClaims);
        }

        private static Map<String, Object> getClaimsRequestedByScope(Map<String, Object> claims, Set<String> requestedScopes) {
            Set<String> scopeRequestedClaimNames = new HashSet(32);
            scopeRequestedClaimNames.add("sub");
            if (requestedScopes.contains("address")) {
                scopeRequestedClaimNames.add("address");
            }

            if (requestedScopes.contains("email")) {
                scopeRequestedClaimNames.addAll(EMAIL_CLAIMS);
            }

            if (requestedScopes.contains("phone")) {
                scopeRequestedClaimNames.addAll(PHONE_CLAIMS);
            }

            if (requestedScopes.contains("profile")) {
                scopeRequestedClaimNames.addAll(PROFILE_CLAIMS);
            }

            Map<String, Object> requestedClaims = new HashMap(claims);
            requestedClaims.keySet().removeIf((claimName) -> {
                return !scopeRequestedClaimNames.contains(claimName);
            });
            return requestedClaims;
        }
    }
}

将 MyOidcUserInfoAuthenticationConverter、MyOidcUserInfoAuthenticationProvider 设置到AuthorizationServerConfig中,AuthorizationServerConfig 代码如下。

package org.oauth.server.config;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import jakarta.annotation.Resource;
import org.apache.catalina.util.StandardSessionIdGenerator;
import org.oauth.server.authentication.device.DeviceClientAuthenticationConverter;
import org.oauth.server.authentication.device.DeviceClientAuthenticationProvider;
import org.oauth.server.authentication.mobile.MobileGrantAuthenticationConverter;
import org.oauth.server.authentication.mobile.MobileGrantAuthenticationProvider;
import org.oauth.server.authentication.oidc.MyOidcUserInfoAuthenticationConverter;
import org.oauth.server.authentication.oidc.MyOidcUserInfoAuthenticationProvider;
import org.oauth.server.authentication.oidc.MyOidcUserInfoService;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationConverter;
import org.oauth.server.authentication.password.PasswordGrantAuthenticationProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.authorization.*;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.token.*;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Collection;
import java.util.Date;
import java.util.Set;
import java.util.UUID;

/**
 * @author Rommel
 * @version 1.0
 * @date 2023/7/10-16:34
 * @description TODO
 */
@Configuration
public class AuthorizationServerConfig {

    private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private MyOidcUserInfoService myOidcUserInfoService;


    /**
     * Spring Authorization Server 相关配置
     * 此处方法与下面defaultSecurityFilterChain都是SecurityFilterChain配置,配置的内容有点区别,
     * 因为Spring Authorization Server是建立在Spring Security 基础上的,defaultSecurityFilterChain方法主要
     * 配置Spring Security相关的东西,而此处authorizationServerSecurityFilterChain方法主要配置OAuth 2.1和OpenID Connect 1.0相关的东西
     */
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,RegisteredClientRepository registeredClientRepository,
                                                                      AuthorizationServerSettings authorizationServerSettings,
                                                                      OAuth2AuthorizationService authorizationService,
                                                                      OAuth2TokenGenerator<?> tokenGenerator)
            throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        //AuthenticationConverter(预处理器),尝试从HttpServletRequest提取客户端凭据,用以构建OAuth2ClientAuthenticationToken实例。
        DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
                new DeviceClientAuthenticationConverter(
                        authorizationServerSettings.getDeviceAuthorizationEndpoint());
        //AuthenticationProvider(主处理器),用于验证OAuth2ClientAuthenticationToken。
        DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
                new DeviceClientAuthenticationProvider(registeredClientRepository);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
                        //设置用户码校验地址
                        deviceAuthorizationEndpoint.verificationUri("/activate")
                )
                .deviceVerificationEndpoint(deviceVerificationEndpoint ->
                        //设置授权页地址
                        deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
                )
                .clientAuthentication(clientAuthentication ->
                        //设置AuthenticationConverter(预处理器)和AuthenticationProvider(主处理器)
                        clientAuthentication
                                .authenticationConverter(deviceClientAuthenticationConverter)
                                .authenticationProvider(deviceClientAuthenticationProvider)
                )
                .authorizationEndpoint(authorizationEndpoint ->
                        authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
                //设置自定义密码模式
                .tokenEndpoint(tokenEndpoint ->
                        tokenEndpoint
                                .accessTokenRequestConverter(
                                        new PasswordGrantAuthenticationConverter())
                                .authenticationProvider(
                                        new PasswordGrantAuthenticationProvider(
                                                authorizationService, tokenGenerator)))
                //设置自定义手机验证码模式
                .tokenEndpoint(tokenEndpoint ->
                        tokenEndpoint
                                .accessTokenRequestConverter(
                                        new MobileGrantAuthenticationConverter())
                                .authenticationProvider(
                                        new MobileGrantAuthenticationProvider(
                                                authorizationService, tokenGenerator)))
                //开启OpenID Connect 1.0(其中oidc为OpenID Connect的缩写)。
                //.oidc(Customizer.withDefaults());
                //自定义oidc
                .oidc(oidcCustomizer->{
                    oidcCustomizer.userInfoEndpoint(userInfoEndpointCustomizer->{
                        userInfoEndpointCustomizer.userInfoRequestConverter(new MyOidcUserInfoAuthenticationConverter(myOidcUserInfoService));
                        userInfoEndpointCustomizer.authenticationProvider(new MyOidcUserInfoAuthenticationProvider(authorizationService));
                    });
                });

        //设置登录地址,需要进行认证的请求被重定向到该地址
        http
                .exceptionHandling((exceptions) -> exceptions
                        .defaultAuthenticationEntryPointFor(
                                new LoginUrlAuthenticationEntryPoint("/login"),
                                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                        )
                )
                .oauth2ResourceServer(oauth2ResourceServer ->
                        oauth2ResourceServer.jwt(Customizer.withDefaults()));

        return  http.build();
    }


    /**
     * 客户端信息
     * 对应表:oauth2_registered_client
     */
    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        return new JdbcRegisteredClientRepository(jdbcTemplate);
    }


    /**
     * 授权信息
     * 对应表:oauth2_authorization
     */
    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }

    /**
     * 授权确认
     *对应表:oauth2_authorization_consent
     */
    @Bean
    public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    /**
     *配置 JWK,为JWT(id_token)提供加密密钥,用于加密/解密或签名/验签
     * JWK详细见:https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-key-41
     */
    @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);
    }

    /**
     *生成RSA密钥对,给上面jwkSource() 方法的提供密钥对
     */
    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;
    }

    /**
     * 配置jwt解析器
     */
    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    /**
     *配置认证服务器请求地址
     */
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        //什么都不配置,则使用默认地址
        return AuthorizationServerSettings.builder().build();
    }

    /**
     *配置token生成器
     */
    @Bean
    OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {
        JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));
        jwtGenerator.setJwtCustomizer(jwtCustomizer(myOidcUserInfoService));
        OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
        OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
        return new DelegatingOAuth2TokenGenerator(
                jwtGenerator, accessTokenGenerator, refreshTokenGenerator);
    }

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer(MyOidcUserInfoService myOidcUserInfoService) {

        return context -> {
            JwsHeader.Builder headers = context.getJwsHeader();
            JwtClaimsSet.Builder claims = context.getClaims();
            if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
                // Customize headers/claims for access_token
                claims.claims(claimsConsumer->{
                    UserDetails userDetails = userDetailsService.loadUserByUsername(context.getPrincipal().getName());
                    claimsConsumer.merge("scope",userDetails.getAuthorities(),(scope,authorities)->{
                        Set<String> scopeSet = (Set<String>)scope;
                        Collection<SimpleGrantedAuthority> simpleGrantedAuthorities = ( Collection<SimpleGrantedAuthority>)authorities;
                        simpleGrantedAuthorities.stream().forEach(simpleGrantedAuthority -> {
                            if(!scopeSet.contains(simpleGrantedAuthority.getAuthority())){
                                scopeSet.add(simpleGrantedAuthority.getAuthority());
                            }
                        });
                        return scopeSet;
                    });
                });

            } else if (context.getTokenType().getValue().equals(OidcParameterNames.ID_TOKEN)) {
                // Customize headers/claims for id_token
                claims.claim(IdTokenClaimNames.AUTH_TIME, Date.from(Instant.now()));
                StandardSessionIdGenerator standardSessionIdGenerator = new StandardSessionIdGenerator();
                claims.claim("sid", standardSessionIdGenerator.generateSessionId());
            }
        };
    }

}

3. 自定义 OIDC 测试

oauth2_registered_client 表增加一条手机号码的记录如下。

INSERT INTO `oauth-server`.`oauth2_registered_client` (`id`, `client_id`, `client_id_issued_at`, `client_secret`, `client_secret_expires_at`, `client_name`, `client_authentication_methods`, `authorization_grant_types`, `redirect_uris`, `post_logout_redirect_uris`, `scopes`, `client_settings`, `token_settings`) VALUES ('d84e9e7c-abb1-46f7-bb0f-4511af362ca8', 'custom-oidc-id', '2023-07-12 07:33:42', '$2a$10$.J0Rfg7y2Mu8AN8Dk2vL.eBFa9NGbOYCPOAFEw.QhgGLVXjO7eFDC', NULL, '自定义oidc测试', 'client_secret_basic', 'refresh_token,authorization_password,authorization_mobile', '', 'http://127.0.0.1:9000/', 'openid,profile,email,address,phone', '{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.client.require-proof-key\":false,\"settings.client.require-authorization-consent\":true}', '{\"@class\":\"java.util.Collections$UnmodifiableMap\",\"settings.token.reuse-refresh-tokens\":true,\"settings.token.id-token-signature-algorithm\":[\"org.springframework.security.oauth2.jose.jws.SignatureAlgorithm\",\"RS256\"],\"settings.token.access-token-time-to-live\":[\"java.time.Duration\",3600.000000000],\"settings.token.access-token-format\":{\"@class\":\"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat\",\"value\":\"self-contained\"},\"settings.token.refresh-token-time-to-live\":[\"java.time.Duration\",7200.000000000],\"settings.token.authorization-code-time-to-live\":[\"java.time.Duration\",3600.000000000],\"settings.token.device-code-time-to-live\":[\"java.time.Duration\",3600.000000000]}');

3.1. 测试所有权限

启动认证服务器,postman 请求 http://spring-oauth-server:9000/oauth2/token 地址,参数如下。

Spring Authorization Server (八)自定义OIDC_Authorization Server_07

Spring Authorization Server (八)自定义OIDC_Spring Boot3_08

返回结果如下。

Spring Authorization Server (八)自定义OIDC_Authorization Server_09

将 id_token 进行 jwt 解析如下。

Spring Authorization Server (八)自定义OIDC_Spring Boot3_10

此时 id_token 并未返回过多用户信息。

postman 请求 http://spring-oauth-server:9000/userinfo 地址,带上刚刚获取的 access_token,则返回如下结果。

Spring Authorization Server (八)自定义OIDC_Spring Security_11

从上面结果中可以看到,userinfo 已经返回我们自定义的 username、description、status 字段了。

3.2. 测试 profile 权限

postman 请求 http://spring-oauth-server:9000/oauth2/token 地址,scope 只传 profile 值,则结果如下。

Spring Authorization Server (八)自定义OIDC_自定义OIDC_12

可以看到,id_token 已经不返回了,因为当前 scope 只有 profile。

postman 请求 http://spring-oauth-server:9000/userinfo 地址,带上刚刚获取的 access_token,则返回如下结果。

Spring Authorization Server (八)自定义OIDC_OAuth2.1_13

可以看到,address、email、phone_number 等字段已经不返回了,而返回的字段正是我们在 DefaultOidcUserInfoMapper 定义的 PROFILE_CLAIMS 值。

Spring Authorization Server (八)自定义OIDC_Spring Boot3_14

3.3. 测试 address 权限

postman 请求 http://spring-oauth-server:9000/oauth2/token 地址,scope 只传 address 值,则结果如下。

Spring Authorization Server (八)自定义OIDC_Spring Security_15

postman 请求 http://spring-oauth-server:9000/userinfo 地址,带上刚刚获取的 access_token,则返回如下结果。

Spring Authorization Server (八)自定义OIDC_Spring Boot3_16

可以看到,只返回了 address 字段。

3.4. 测试 email 权限

postman 请求 http://spring-oauth-server:9000/oauth2/token 地址,scope 只传 email 值,则结果如下。

Spring Authorization Server (八)自定义OIDC_Spring Security_17

postman 请求 http://spring-oauth-server:9000/userinfo 地址,带上刚刚获取的 access_token,则返回如下结果。

Spring Authorization Server (八)自定义OIDC_OAuth2.1_18

可以看到,只返回了 email 字段。

3.5. 测试 phone 权限

postman 请求 http://spring-oauth-server:9000/oauth2/token 地址,scope 只传 phone 值,则结果如下。

Spring Authorization Server (八)自定义OIDC_Spring Boot3_19

postman 请求 http://spring-oauth-server:9000/userinfo 地址,带上刚刚获取的 access_token,则返回如下结果。

Spring Authorization Server (八)自定义OIDC_OAuth2.1_20

可以看到,只返回了 phone_number 字段。

3.6. 测试组合权限

postman 请求 http://spring-oauth-server:9000/oauth2/token 地址,scope 传 address email phone 值,则结果如下。

Spring Authorization Server (八)自定义OIDC_Spring Security_21

postman 请求 http://spring-oauth-server:9000/userinfo 地址,带上刚刚获取的 access_token,则返回如下结果。

Spring Authorization Server (八)自定义OIDC_OAuth2.1_22

可以看到,返回 address、email、phone 权限组合字段。

4. 总结

本篇先是演示了默认 userinfo 接口的返回结果,并介绍了默认 userinfo 返回哪些字段信息,然后讲解并实现如何自定义 oidc,最后对自定义 oidc 的各种权限场景进行了测试演示。

本篇代码在 spring-oauth-oidc-server 目录下:链接地址