Spring Boot整合Spring Authorization Server(二)——客户端、设备码、PKCE模式

上篇文章是授权码模式。

客户端模式

这里记录客户端模式。客户端内部调用时可能会用到该模式客户端模式的参数有四个,grant_type、scope、client_id和client_secret,客户端认证方式不是client_secret_post的客户端发起请求时只用携带grant_type参数即可,其它方式按照各自特点携带客户端的认证信息。

1. 拿token

2. 用token访问接口

grant_type 在客户端模式下固定为client_credentials
client_id:客户端的id
client_secret: 客户端的秘钥
scope:本次请求授权的范围 这个不带的话生成的token也访问不了接口

授权码扩展流程PKCE(Proof Key for Code Exchange)

首先需要添加一个公共客户端并且设置proof key支持,为求方便直接修改AuthorizationConfig.java,之后重启服务,会添加一条适用pkce流程的客户端

    /**
     * 配置客户端Repository
     *
     * @param jdbcTemplate    db 数据源信息
     * @param passwordEncoder 密码解析器
     * @return 基于数据库的repository
     */
    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate
            , PasswordEncoder passwordEncoder) {
        System.out.println("password encode : " + passwordEncoder.encode("123456"));
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                // 客户端id
                .clientId("messaging-client")
                // 客户端秘钥,使用密码解析器加密
                .clientSecret(passwordEncoder.encode("123456"))
                // 客户端认证方式,基于请求头的认证
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                // 配置资源服务器使用该客户端获取授权时支持的方式
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                // 授权码模式回调地址,oauth2.1已改为精准匹配,不能只设置域名,并且屏蔽了localhost,本机使用127.0.0.1访问
                // 这里每次启动是不会该数据库的,所以改了这个链接要相应的改一下数据库
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
                .redirectUri("https://www.baidu.com/")
                .redirectUri("http://127.0.0.1:8080/notify/oauth2/code")
                // 该客户端的授权范围,OPENID与PROFILE是IdToken的scope,获取授权时请求OPENID的scope时认证服务会返回IdToken
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                // 自定scope
                .scope("message.read")
                .scope("message.write")
                // 客户端设置,设置用户需要确认授权
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .build();

        // 基于db存储客户端,还有一个基于内存的实现 InMemoryRegisteredClientRepository
        JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);

        // 初始化客户端
        RegisteredClient repositoryByClientId =
                registeredClientRepository.findByClientId(registeredClient.getClientId());
        if (repositoryByClientId == null) {
            registeredClientRepository.save(registeredClient);
        }
        // 设备码授权客户端
        RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("device-message-client")
                // 公共客户端
                .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
                // 设备码授权
                .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                // 自定scope
                .scope("message.read")
                .scope("message.write")
                .build();
        RegisteredClient byClientId = registeredClientRepository.findByClientId(deviceClient.getClientId());
        if (byClientId == null) {
            registeredClientRepository.save(deviceClient);
        }

        // PKCE客户端
        RegisteredClient pkceClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("pkce-message-client")
                // 公共客户端
                .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
                // 设备码授权
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                // 授权码模式回调地址,oauth2.1已改为精准匹配,不能只设置域名,并且屏蔽了localhost,本机使用127.0.0.1访问
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
                .clientSettings(ClientSettings.builder().requireProofKey(Boolean.TRUE).build())
                // 自定scope
                .scope("message.read")
                .scope("message.write")
                .build();
        RegisteredClient findPkceClient = registeredClientRepository.findByClientId(pkceClient.getClientId());
        if (findPkceClient == null) {
            registeredClientRepository.save(pkceClient);
        }
        return registeredClientRepository;
    }

在请求code之前需要生成Code Verifier和Code Challenge

在线生成网站

这里步骤1和步骤2要反过来,写错了!!!!!

请求地址获取code:

http://127.0.0.1:8080/oauth2/authorize?response_type=code&client_id=pkce-message-client&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc&scope=message.read&code_challenge=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU&code_challenge_method=S256

参数说明

response_type: 固定值为code
client_id: 客户端id
redirect_uri:获取授权的回调地址
scope:请求授权的范围
code_challenge:在CodeVerifier的SHA256值基础上,再用BASE64URL编码

自定义设备码模式

代码

1. DeviceClientAuthenticationConverter 

/**
 * 获取请求中参数转化为DeviceClientAuthenticationToken
 *
 * @author lxq
 * @since 1.1
 */
public final class DeviceClientAuthenticationConverter implements AuthenticationConverter {
    private final RequestMatcher deviceAuthorizationRequestMatcher;
    private final RequestMatcher deviceAccessTokenRequestMatcher;

    public DeviceClientAuthenticationConverter(String deviceAuthorizationEndpointUri) {
        RequestMatcher clientIdParameterMatcher = request ->
                request.getParameter(OAuth2ParameterNames.CLIENT_ID) != null;
        this.deviceAuthorizationRequestMatcher = new AndRequestMatcher(
                new AntPathRequestMatcher(
                        deviceAuthorizationEndpointUri, HttpMethod.POST.name()),
                clientIdParameterMatcher);
        this.deviceAccessTokenRequestMatcher = request ->
                AuthorizationGrantType.DEVICE_CODE.getValue().equals(request.getParameter(OAuth2ParameterNames.GRANT_TYPE)) &&
                        request.getParameter(OAuth2ParameterNames.DEVICE_CODE) != null &&
                        request.getParameter(OAuth2ParameterNames.CLIENT_ID) != null;
    }

    @Nullable
    @Override
    public Authentication convert(HttpServletRequest request) {
        if (!this.deviceAuthorizationRequestMatcher.matches(request) &&
                !this.deviceAccessTokenRequestMatcher.matches(request)) {
            return null;
        }

        // client_id (REQUIRED)
        String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID);
        if (!StringUtils.hasText(clientId) ||
                request.getParameterValues(OAuth2ParameterNames.CLIENT_ID).length != 1) {
            throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
        }

        return new DeviceClientAuthenticationToken(clientId, ClientAuthenticationMethod.NONE, null, null);
    }

}

2. DeviceClientAuthenticationProvider

/**
 * 设备码认证提供者
 *
 * @author lxq
 * @since 1.1
 * @see DeviceClientAuthenticationToken
 * @see DeviceClientAuthenticationConverter
 * @see OAuth2ClientAuthenticationFilter
 */
@Slf4j
@RequiredArgsConstructor
public final class DeviceClientAuthenticationProvider implements AuthenticationProvider {

    private final RegisteredClientRepository registeredClientRepository;

    /**
     * 异常说明地址
     */
    private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1";


    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 执行时肯定是设备码流程
        DeviceClientAuthenticationToken deviceClientAuthentication =
                (DeviceClientAuthenticationToken) authentication;

        // 只支持公共客户端
        if (!ClientAuthenticationMethod.NONE.equals(deviceClientAuthentication.getClientAuthenticationMethod())) {
            return null;
        }

        // 获取客户端id并查询
        String clientId = deviceClientAuthentication.getPrincipal().toString();
        RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
        if (registeredClient == null) {
            throwInvalidClient(OAuth2ParameterNames.CLIENT_ID);
        }

        if (log.isTraceEnabled()) {
            log.trace("Retrieved registered client");
        }

        // 校验客户端
        if (!registeredClient.getClientAuthenticationMethods().contains(
                deviceClientAuthentication.getClientAuthenticationMethod())) {
            throwInvalidClient("authentication_method");
        }

        if (log.isTraceEnabled()) {
            log.trace("Validated device client authentication parameters");
        }

        if (log.isTraceEnabled()) {
            log.trace("Authenticated device client");
        }

        return new DeviceClientAuthenticationToken(registeredClient,
                deviceClientAuthentication.getClientAuthenticationMethod(), null);
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // 只处理设备码请求
        return DeviceClientAuthenticationToken.class.isAssignableFrom(authentication);
    }

    private static void throwInvalidClient(String parameterName) {
        OAuth2Error error = new OAuth2Error(
                OAuth2ErrorCodes.INVALID_CLIENT,
                "Device client authentication failed: " + parameterName,
                ERROR_URI
        );
        throw new OAuth2AuthenticationException(error);
    }

}

3. DeviceClientAuthenticationToken

/**
 * 设备码模式token
 *
 * @author lxq
 * @since 1.1
 */
@Transient
public class DeviceClientAuthenticationToken extends OAuth2ClientAuthenticationToken {

    public DeviceClientAuthenticationToken(String clientId, ClientAuthenticationMethod clientAuthenticationMethod,
                                           @Nullable Object credentials, @Nullable Map<String, Object> additionalParameters) {
        super(clientId, clientAuthenticationMethod, credentials, additionalParameters);
    }

    public DeviceClientAuthenticationToken(RegisteredClient registeredClient, ClientAuthenticationMethod clientAuthenticationMethod,
                                           @Nullable Object credentials) {
        super(registeredClient, clientAuthenticationMethod, credentials);
    }

}

4. AuthorizationConfig

/**
     * 配置端点的过滤器链
     *
     * @param http spring security核心配置类
     * @return 过滤器链
     * @throws Exception 抛出
     */
    @Bean
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http
            , RegisteredClientRepository registeredClientRepository
            , AuthorizationServerSettings authorizationServerSettings) throws Exception {
        // OAuth2 security配置 默认的设置,忽略认证端点的csrf校验
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        // 新建设备码converter和provider
        DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
                new DeviceClientAuthenticationConverter(
                        authorizationServerSettings.getDeviceAuthorizationEndpoint());
        DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
                new DeviceClientAuthenticationProvider(registeredClientRepository);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                // 开启OpenID Connect 1.0协议相关端点
                .oidc(Customizer.withDefaults())
                // 设置自定义用户确认授权页
                .authorizationEndpoint(authorizationEndpoint -> {
                    System.out.println("process trace | authorizationEndpoint.consentPage(" + CUSTOM_CONSENT_PAGE_URI + ")");
                    authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI);
                })
                // 设置设备码用户验证url(自定义用户验证页)
                .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint -> {
                    deviceAuthorizationEndpoint.verificationUri("/activate");
                })
                // 设置验证设备码用户确认页面
                .deviceVerificationEndpoint(deviceVerificationEndpoint -> {
                    deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI);
                })
                // 客户端认证添加设备码的converter和provider
                .clientAuthentication(clientAuthentication -> {
                    clientAuthentication
                            .authenticationConverter(deviceClientAuthenticationConverter)
                            .authenticationProvider(deviceClientAuthenticationProvider);
                });
        http
                // 当未登录时访问认证端点时重定向至login页面
                .exceptionHandling((exceptions) -> {
                    System.out.println("process trace | exceptions.defaultAuthenticationEntryPointFor(/login)");
                    exceptions.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"),
                            new MediaTypeRequestMatcher(MediaType.TEXT_HTML));
                })
                // 处理使用access token访问用户信息端点和客户端注册端点
                .oauth2ResourceServer((resourceServer) -> {
                    System.out.println("process trace | resourceServer.jwt(Customizer.withDefaults())");
                    resourceServer.jwt(Customizer.withDefaults());
                });

        return http.build();
    }

流程说明

首先,用户请求/oauth2/device_authorization接口,获取user_code、设备码和给用户在浏览器访问的地址,用户在浏览器打开地址,输入user_code,如果用户尚未登录则需要进行登录;输入user_code之后如果该客户端当前用户尚未授权则重定向至授权确认页面;授权完成后设备通过设备码换取token,设备一般是在给出用户验证地址后轮训携带设备码访问/oauth2/token接口,如果用户尚未验证时访问则会响应"authorization_pending"。

详见:rfc8628#section-3.5

测试步骤

1. 先去拿设备码

请求参数:

client_id: 客户端id
scope: 设备请求授权的范围

响应参数:

user_code: 用户在浏览器打开验证地址时输入的内容
device_code:设备码,用该值换取token
verification_uri_complete:用户在浏览器打开的验证地址,页面会自动获取参数并提交表单
verification_uri:验证地址,需要用户输入user_code
expires_in:过期时间,单位(秒)

2. 访问verification_uri或者verification_uri_complete

3. 授权成功后拿设备码去拿token

client_id:客户端id
device_code:请求/oauth2/device_authorization接口返回的设备码(device_code)
grant_type:在设备码模式固定是urn:ietf:params:oauth:grant-type:device_code

去访问接口,成功访问!

自定义jwt中包含的内容与资源服务jwt解析器

资源服务器解析access token时会将用户通过客户端请求的scope当做权限放入authorities属性中,当使用注解@PreAuthorize的hasAuthority校验用户权限时,实际上校验的是access token中拥有的scope权限;框架也提供了对应的定制内容,可以使开发者自定jwt(access token)中的claims,同时对应的resource server也提供了对应的自定义解析配置。

OAuth2TokenCustomizer

文档地址

文档中对于OAuth2TokenCustomizer有这样一段描述:

An OAuth2TokenCustomizer<JwtEncodingContext> declared with a generic type of JwtEncodingContext (implements OAuth2TokenContext) provides the ability to customize the headers and claims of a Jwt. JwtEncodingContext.getHeaders() provides access to the JwsHeader.Builder, allowing the ability to add, replace, and remove headers. JwtEncodingContext.getClaims() provides access to the JwtClaimsSet.Builder, allowing the ability to add, replace, and remove claims.

大概意思就是可以通过OAuth2TokenContext的实现类对jwt的header和claims部分进行修改。所以在认证服务器中实现OAuth2TokenCustomizer并将用户的权限信息放入jwt的claims中,并将实例注入IOC中。

代码如下:

/**
 * 自定义jwt,将权限信息放至jwt中
 *
 * @return OAuth2TokenCustomizer的实例
 */
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> oAuth2TokenCustomizer() {
    return context -> {
        // 检查登录用户信息是不是UserDetails,排除掉没有用户参与的流程
        if (context.getPrincipal().getPrincipal() instanceof UserDetails user) {
            // 获取申请的scopes
            Set<String> scopes = context.getAuthorizedScopes();
            // 获取用户的权限
            Collection<? extends GrantedAuthority> authorities = user.getAuthorities();
            // 提取权限并转为字符串
            Set<String> authoritySet = Optional.ofNullable(authorities).orElse(Collections.emptyList()).stream()
                    // 获取权限字符串
                    .map(GrantedAuthority::getAuthority)
                    // 去重
                    .collect(Collectors.toSet());

            // 合并scope与用户信息
            authoritySet.addAll(scopes);

            JwtClaimsSet.Builder claims = context.getClaims();
            // 将权限信息放入jwt的claims中(也可以生成一个以指定字符分割的字符串放入)
            claims.claim("authorities", authoritySet);
            // 放入其它自定内容
            // 角色、头像...
        }
    };
}

这段代码将申请的scope与用户本身自带的权限合并后放入jwt中。

JwtAuthenticationConverter


自定义token部分就完成了,那么接下来就到resource server部分,早在最开始就添加了resource server的配置,将认证服务器也当做一个资源服务器,所以接下就在资源服务器文档中找到关于
JwtAuthenticationConverter的说明文档。文档中有如下一段说明:
However, there are a number of circumstances where this default is insufficient. For example, some authorization servers don’t use the scope attribute, but instead have their own custom attribute. Or, at other times, the resource server may need to adapt the attribute or a composition of attributes into internalized authorities.
正好对应了上文说的自定义token,所以按照示例添加自己的jwt解析器

/**
 * 自定义jwt解析器,设置解析出来的权限信息的前缀与在jwt中的key
 *
 * @return jwt解析器 JwtAuthenticationConverter
 */
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
    // 设置解析权限信息的前缀,设置为空是去掉前缀
    grantedAuthoritiesConverter.setAuthorityPrefix("");
    // 设置权限信息在jwt claims中的key
    grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");

    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    return jwtAuthenticationConverter;
}

这里设置解析jwt时将权限key设置为上文中存入时的key,去除SCOPE_ 前缀

测试:

总结:

设备码流程一般使用在不便输入的设备上,设备提供一个链接给用户验证,用户在其它设备的浏览器中认证;其它的三方服务需要接入时就比较适合授权码模式,桌面客户端、移动app和前端应用就比较适合pkce流程,pkce靠随机生成的Code Verifier和Code Challenge来保证流程的安全,无法让他人拆包获取clientId和clientSecret来伪造登录信息;至于用户登录时输入的账号和密码只能通过升级https来防止拦截请求获取用户密码。

注意:代码已经加上了json处理未认证,响应为:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值