java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringSecurityOAuth2微信授权登录

SpringSecurityOAuth2实现微信授权登录

作者:Lucas小毛驴

微信的登录功能是用户注册和使用微信的必经之路之一,而微信授权登录更是方便了用户的登录操作,本文主要介绍了SpringSecurityOAuth2实现微信授权登录,感兴趣的可以了解一下

继上一篇走了下登录的流程后,已经熟悉了不少,这一篇就来尝试下微信的授权登录实现,之所以学下微信,是因为微信在当前的地位还是无人可及的,而且也是因为微信的OAuth2比较不同,挺多需要自定义的,因此来搞下微信授权登录,会了这个,相信别的第三方都可以轻松应对。

一. 准备工作

微信的不是叫ClientID,而是appid

你以为这样就OK啦?当然不是!看到了那个接口配置信息了没,微信需要我们配置一个接口,然后在提交时他会去请求我们的接口,做一次校验,我们需要在自己的服务器提供这样的接口,并且按微信的要求正确返回,他才认为我们的服务器是正常的。

具体的要求可以看他的文档:消息接口使用指南其中最关键的就是这个:

在这里插入图片描述

其实这个也好办,咱们写个程序就可以了,但是这里又会有另一问题需要解决,我们自己在电脑写的应用,电脑的网络大概率是内网(除非你在有公网的服务器开发),那微信的服务器要怎么请求到我们内网的电脑?

这就需要我们去搞一个内网穿透了。

要注意的是好像24h还是多长时间,这个域名会自动刷新的,所以也仅仅是适合我们测试用用

这里我配置了几个隧道,分别映射本地的80端口和8844端口

在这里插入图片描述

80端口是为了给微信服务器能用http请求我们接口
8844是应用程序开启的端口

token可以随便填,但需要和接口代码中的token保持一样。

这里点击提交显示配置失败,是因为我们的接口还没写,微信服务器请求不到正确响应导致。这里我用golang来快速的提供下这个接口:

package main
import (
	"crypto/sha1"
	"encoding/hex"
	"net/http"
	"sort"
	"github.com/gin-gonic/gin"
)
type ByAlphabet []string
func (a ByAlphabet) Len() int {
	return len(a)
}
func (a ByAlphabet) Swap(i, j int) {
	a[i], a[j] = a[j], a[i]
}
func (a ByAlphabet) Less(i, j int) bool {
	return a[i] < a[j]
}
func SHA1(s string) string {
	hash := sha1.New()
	hash.Write([]byte(s))
	return hex.EncodeToString(hash.Sum(nil))
}
func main() {
	engine := gin.Default()
	engine.GET("/", func(ctx *gin.Context) {
		signature := ctx.Query("signature")
		timestamp := ctx.Query("timestamp")
		nonce := ctx.Query("nonce")
		echostr := ctx.Query("echostr")
		token := "lucas"
		tmpSlice := []string{nonce, timestamp, token}
		// 1.按字典序排序
		sort.Sort(ByAlphabet(tmpSlice))
		// 2.三个字段拼接为str
		str := tmpSlice[0] + tmpSlice[1] + tmpSlice[2]
		// 3. 计算str的sha1加密的字符串
		sha1Str := SHA1(str)
		// 4.比较sha1Str和signature,相同则返回echostr
		if sha1Str == signature {
			ctx.String(http.StatusOK, echostr)
			return
		} else {
			ctx.String(http.StatusOK, "")
			return
		}
	})
	engine.Run(":80")
}

启动应用,然后再在网页上提交,就可以成功了。

在这里插入图片描述

点击修改,会展示如下:

在这里插入图片描述

在这里填入我们的域名,注意不需要协议头,只要域名即可,也就是内网穿透给我们的那个:7453dd4b.r15.cpolar.top

注意这里不需要配置端口,只需要域名即可

好了,到了这一步,环境准备就完成了。

二. 开始编码

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            clientId: xxxx # 填入自己应用的clientId
            clientSecret: xxxxx # 填入自己应用的clientSecret
            redirectUri: http://localhost:8844/login/oauth2/code/github
          gitee:
            clientId: xxxx # 填入自己应用的clientId
            clientSecret: xxxx # 填入自己应用的clientSecret
            redirectUri: http://localhost:8844/login/oauth2/code/gitee
            authorizationGrantType: authorization_code
          wechat:
            clientId: xxxx # 填入自己应用的appID
            clientSecret: xxxx # 填入自己应用的appsecret
            redirectUri: http://347b2d93.r8.cpolar.top/login/oauth2/code/wechat
            authorizationGrantType: authorization_code
            scope:
            - snsapi_userinfo
            clientName: tencent-wechat
        provider:
          gitee:
            authorizationUri: https://gitee.com/oauth/authorize
            tokenUri: https://gitee.com/oauth/token
            userInfoUri: https://gitee.com/api/v5/user
            userNameAttribute: name
          wechat:
            authorizationUri: https://open.weixin.qq.com/connect/oauth2/authorize
            tokenUri: https://api.weixin.qq.com/sns/oauth2/access_token
            userInfoUri: https://api.weixin.qq.com/sns/userinfo
            userNameAttribute: nickname

可以看到这里就需要自定义了,因为参数变为了appid以及需要加一个锚点#wechat_redirect

private final static String WECHAT_APPID = "appid";
private final static String WECHAT_SECRET = "secret";
private final static String WECHAT_FRAGMENT = "wechat_redirect";
/**
 * 1. 自定义微信获取授权码的uri
 * https://open.weixin.qq.com/connect/oauth2/authorize?
 * appid=wx807d86fb6b3d4fd2
 * &redirect_uri=http%3A%2F%2Fdevelopers.weixin.qq.com
 * &response_type=code
 * &scope=snsapi_userinfo
 * &state=STATE  非必须
 * #wechat_redirect
 * 微信比较特殊,比如不是clientid,而是appid,还强制需要一个锚点#wechat+redirect
 * @return
 */
public OAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) {
    // 定义一个默认的oauth2请求解析器
    DefaultOAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);
    // 进行自定义
    Consumer<OAuth2AuthorizationRequest.Builder> authorizationRequestCustomizer = (builder) -> {
        builder.attributes(attributeConsumer -> {
            // 判断registrationId是否为wechat
            String registrationId = (String) attributeConsumer.get(OAuth2ParameterNames.REGISTRATION_ID);
            if ("wechat".equals(registrationId)) {
                // 替换参数名称
                builder.parameters(this::replaceWechatUriParamter);
                // 增加锚点,需要在uri构建中添加
                builder.authorizationRequestUri((uriBuilder) -> {
                    uriBuilder.fragment(WECHAT_FRAGMENT);
                    return uriBuilder.build();
                });
            }
        });
    };
    // 设置authorizationRequestCustomizer
    oAuth2AuthorizationRequestResolver.setAuthorizationRequestCustomizer(authorizationRequestCustomizer);
    return oAuth2AuthorizationRequestResolver;
}
/**
 * 替换Uri参数,parameterMap是保存的请求的各个参数
 * @param parameterMap
 */
private void replaceWechatUriParamter(Map<String, Object> parameterMap) {
    Map<String, Object> linkedHashMap = new LinkedHashMap<>();
    // 遍历所有参数,有序的,替换掉clientId为appid
    parameterMap.forEach((k, v) -> {
        if (OAuth2ParameterNames.CLIENT_ID.equals(k)) {
            linkedHashMap.put(WECHAT_APPID, v);
        } else {
            linkedHashMap.put(k, v);
        }
    });
    // 清空原始的paramterMap
    parameterMap.clear();
    // 将新的linkedHashMap存入paramterMap
    parameterMap.putAll(linkedHashMap);
}

而参数部分,在构建uri时已经getParameters()将参数全部拿出来,并且设置到了this.parametersConsumer:

在这里插入图片描述

调用builder.parameters的用途就是重新处理参数:

在这里插入图片描述

这一块可能比较乱,我只是想告诉你们怎么写出那个自定义的代码的,结合这些应该是可以理解的。

可以看到这里的请求参数也是需要做下变更的。

一眼看穿,实际就是在构造请求参数,那么我们只需要来实现自己的requestEntityConverter就可以在请求参数上为所欲为了。

5. 参考代码如下:

private final static String WECHAT_APPID = "appid";
private final static String WECHAT_SECRET = "secret";
private final static String WECHAT_FRAGMENT = "wechat_redirect";
/**
    * 2. 自定义请求access_token时的请求体转换器
    * 获取access_token
    * https://api.weixin.qq.com/sns/oauth2/access_token?
    * appid=APPID
    * &secret=SECRET
    * &code=CODE 从上一个请求响应中获取
    * &grant_type=authorization_code  框架帮忙填写了
    */
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> customOAuth2AccessTokenResponseClient() {
    // 定义默认的Token响应客户端
    DefaultAuthorizationCodeTokenResponseClient oAuth2AccessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
    // 定义默认的转换器
    OAuth2AuthorizationCodeGrantRequestEntityConverter oAuth2AuthorizationCodeGrantRequestEntityConverter = new OAuth2AuthorizationCodeGrantRequestEntityConverter();
    // 自定义参数转换器
    Converter<OAuth2AuthorizationCodeGrantRequest, MultiValueMap<String, String>> customParameterConverter = (authorizationCodeGrantRequest) -> {
        ClientRegistration clientRegistration = authorizationCodeGrantRequest.getClientRegistration();
        OAuth2AuthorizationExchange authorizationExchange = authorizationCodeGrantRequest.getAuthorizationExchange();
        MultiValueMap<String, String> parameters = new LinkedMultiValueMap();
        parameters.add("grant_type", authorizationCodeGrantRequest.getGrantType().getValue());
        parameters.add("code", authorizationExchange.getAuthorizationResponse().getCode());
        String redirectUri = authorizationExchange.getAuthorizationRequest().getRedirectUri();
        String codeVerifier = (String)authorizationExchange.getAuthorizationRequest().getAttribute("code_verifier");
        if (redirectUri != null) {
            parameters.add("redirect_uri", redirectUri);
        }
        parameters.add(WECHAT_APPID, clientRegistration.getClientId());
        parameters.add(WECHAT_SECRET, clientRegistration.getClientSecret());
        if (codeVerifier != null) {
            parameters.add("code_verifier", codeVerifier);
        }
        return parameters;
    };
    // 设置自定义参数转换器
    oAuth2AuthorizationCodeGrantRequestEntityConverter.setParametersConverter(customParameterConverter);
    // 自定义RestTemplate处理响应content-type为“text/plain”
    OAuth2AccessTokenResponseHttpMessageConverter oAuth2AccessTokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
    oAuth2AccessTokenResponseHttpMessageConverter.setSupportedMediaTypes(Arrays.asList(MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON));
    // 处理TOKEN_TYPE为null的问题,自定义accessTokenResponseParametersConverter,给TOKEN_TYPE赋值
    // 因为已经有默认的处理了,只是需要给token_type赋值
    Converter<Map<String, Object>, OAuth2AccessTokenResponse> setAccessTokenResponseConverter = (paramMap) -> {
        DefaultMapOAuth2AccessTokenResponseConverter defaultMapOAuth2AccessTokenResponseConverter = new DefaultMapOAuth2AccessTokenResponseConverter();
        paramMap.put(OAuth2ParameterNames.TOKEN_TYPE, OAuth2AccessToken.TokenType.BEARER.getValue());
        return defaultMapOAuth2AccessTokenResponseConverter.convert(paramMap);
    };
    // 设置这个转换器
    oAuth2AccessTokenResponseHttpMessageConverter.setAccessTokenResponseConverter(setAccessTokenResponseConverter);
    RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(), oAuth2AccessTokenResponseHttpMessageConverter));
    restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
    // 设置自定义转换器
    oAuth2AccessTokenResponseClient.setRequestEntityConverter(oAuth2AuthorizationCodeGrantRequestEntityConverter);
    // 设置自定义RestTemplate
    oAuth2AccessTokenResponseClient.setRestOperations(restTemplate);
    return oAuth2AccessTokenResponseClient;
}

在初始化RestTemplate(RestOperations的实现类)时传入了转换器OAuth2AccessTokenResponseHttpMessageConverter,进去看看:

在这里插入图片描述

这就是官方自己定义的一个转换器,用来处理请求access_token响应的消息转换器,其实我们自定义就可以照猫画瓢,照抄这个转换器,再改改适配我们需要的。
但是看到这个转换器也提供了一些自定义的接口:accessTokenResponseConverteraccessTokenResponseParametersConverter,那我们也可以直接就自定义这部分。

因此我们要想支持text/plain,那我们可以直接调用这个方法,进行设置,因此有了以下代码:

OAuth2AccessTokenResponseHttpMessageConverter oAuth2AccessTokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
oAuth2AccessTokenResponseHttpMessageConverter.setSupportedMediaTypes(Arrays.asList(MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON));

但是我们请求接口时响应数据里没有TokenType,因此我们这里需要再处理下,给他填个值,这里就要用到OAuth2AccessTokenResponseHttpMessageConverter提供的自定义接口accessTokenResponseConverter了,在将参数转为OAuth2AccessTokenResponse对象时给他的OAuth2AccessToken设置一个TokenType:

// 处理TOKEN_TYPE为null的问题,自定义accessTokenResponseParametersConverter,给TOKEN_TYPE赋值
// 因为已经有默认的处理了,只是需要给token_type赋值
Converter<Map<String, Object>, OAuth2AccessTokenResponse> setAccessTokenResponseConverter = (paramMap) -> {
    DefaultMapOAuth2AccessTokenResponseConverter defaultMapOAuth2AccessTokenResponseConverter = new DefaultMapOAuth2AccessTokenResponseConverter();
    paramMap.put(OAuth2ParameterNames.TOKEN_TYPE, OAuth2AccessToken.TokenType.BEARER.getValue());
    return defaultMapOAuth2AccessTokenResponseConverter.convert(paramMap);
};

在这里依然是需要自定义一些操作,首先就是请求了,然后响应也是需要处理,因为微信响应的用户信息的实体是不同的,自然也是需要自定义了。

这里调用了一个userService的loadUser方法,并且返回了一个OAuth2User,这个OAuth2User是一个接口,因此我们自定义的用户实体只要实现它即可作为返回值返回了,在这里先定义出来:

@Data
public class WeChatEntity implements OAuth2User {
    // 用户的唯一标识
    private String openid;
    // 用户昵称
    private String nickname;
    // 用户的性别,值为1表示男,值为2表示女,值为0表示未知
    private Integer sex;
    // 用户个人资料填写的省份
    private String province;
    // 普通用户个人资料填写的城市
    private String city;
    // 国家,如中国为CN
    private String country;
    // 用户头像,最后一个数值代表正方形头像大小(有0、46、64、96、132数值可选,0代表640*640正方形头像),
    // 用户没有头像时该项为空。若用户更换头像,原有头像URL将失效。
    private String headimgurl;
    // 用户特权信息
    private List<String> privilege;
    // 只有在用户将公众号绑定到微信开放平台帐号后,才会出现该字段。
    private String unionid;
    @Override
    public Map<String, Object> getAttributes() {
        return null;
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }
    /**
        不可以返回null,在构建实体时会有断言
    **/
    @Override
    public String getName() {
        return nickname;
    }
}

这里需要注意的就是getName()方法不返回null,因为在OAuth2AuthorizedClient构造中断言它不为空

虽然这里也是提供了自定义接口,但是微信获取用户信息的接口参数是query参数,需要拼接在请求url上,获取的类型也是我们自定义的实体,因此这里不采用直接实现提供的自定义接口的方式,而是直接实现一个我们自己的UserService

3. 实现代码

private ResponseEntity<WeChatEntity> getResponse(OAuth2UserRequest userRequest) {
    OAuth2Error oauth2Error;
    try {
        // 发起Get请求,请求参数是query参数,需要自己拼接
        MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
        queryParams.add("access_token", userRequest.getAccessToken().getTokenValue());
        // 获取access token时,其他参数被存储在了userRequest中,从里面把openid拿出来
        queryParams.add("openid", (String) userRequest.getAdditionalParameters().get("openid"));
        queryParams.add("lang", "zh_CN");
        URI uri = UriComponentsBuilder.fromUriString(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri()).queryParams(queryParams).build().toUri();
        ResponseEntity<WeChatEntity> retData = this.restOperations.exchange(uri, HttpMethod.GET, null, PARAMETERIZED_RESPONSE_TYPE);
        return retData;
    } catch (OAuth2AuthorizationException var6) {
        oauth2Error = var6.getError();
        StringBuilder errorDetails = new StringBuilder();
        errorDetails.append("Error details: [");
        errorDetails.append("UserInfo Uri: ").append(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri());
        errorDetails.append(", Error Code: ").append(oauth2Error.getErrorCode());
        if (oauth2Error.getDescription() != null) {
            errorDetails.append(", Error Description: ").append(oauth2Error.getDescription());
        }
        errorDetails.append("]");
        oauth2Error = new OAuth2Error("invalid_user_info_response", "An error occurred while attempting to retrieve the UserInfo Resource: " + errorDetails.toString(), (String)null);
        throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), var6);
    } catch (UnknownContentTypeException var7) {
        String errorMessage = "An error occurred while attempting to retrieve the UserInfo Resource from '" + userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri() + "': response contains invalid content type '" + var7.getContentType().toString() + "'. The UserInfo Response should return a JSON object (content type 'application/json') that contains a collection of name and value pairs of the claims about the authenticated End-User. Please ensure the UserInfo Uri in UserInfoEndpoint for Client Registration '" + userRequest.getClientRegistration().getRegistrationId() + "' conforms to the UserInfo Endpoint, as defined in OpenID Connect 1.0: 'https://openid.net/specs/openid-connect-core-1_0.html#UserInfo'";
        oauth2Error = new OAuth2Error("invalid_user_info_response", errorMessage, (String)null);
        throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), var7);
    } catch (RestClientException var8) {
        oauth2Error = new OAuth2Error("invalid_user_info_response", "An error occurred while attempting to retrieve the UserInfo Resource: " + var8.getMessage(), (String)null);
        throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), var8);
    }
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    Assert.notNull(userRequest, "userRequest cannot be null");
    if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
        OAuth2Error oauth2Error = new OAuth2Error("missing_user_info_uri", "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
        throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
    } else {
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        if (!StringUtils.hasText(userNameAttributeName)) {
            OAuth2Error oauth2Error = new OAuth2Error("missing_user_name_attribute", "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        } else {
            ResponseEntity<WeChatEntity> response = this.getResponse(userRequest);
            // 直接返回最终的实体
            WeChatEntity userAttributes = (WeChatEntity)response.getBody();
            return userAttributes;
        }
    }
}
public class WeChatUserHttpMessageConverter extends AbstractHttpMessageConverter<WeChatEntity> {
    private static final ParameterizedTypeReference<WeChatEntity> STRING_OBJECT_MAP;
    private static final Charset DEFAULT_CHARSET;
    private GenericHttpMessageConverter<Object> jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter();
    static {
        DEFAULT_CHARSET = StandardCharsets.UTF_8;
        STRING_OBJECT_MAP = new ParameterizedTypeReference<WeChatEntity>() {
        };
    }
    public WeChatUserHttpMessageConverter() {
        super(DEFAULT_CHARSET, MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON, new MediaType("application", "*+json"));
    }
    @Override
    protected boolean supports(Class<?> clazz) {
        return  WeChatEntity.class.isAssignableFrom(clazz);
    }
    @Override
    protected WeChatEntity readInternal(Class<? extends WeChatEntity> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
        try {
            WeChatEntity weChatEntity = (WeChatEntity)this.jsonMessageConverter.read(STRING_OBJECT_MAP.getType(), (Class)null, inputMessage);
            return weChatEntity;
        } catch (Exception var5) {
            throw new HttpMessageNotReadableException("An error occurred reading the OAuth 2.0 Access Token Response: " + var5.getMessage(), var5, inputMessage);
        }
    }
    @Override
    protected void writeInternal(WeChatEntity weChatEntity, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
    }
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,  ClientRegistrationRepository clientRegistrationRepository) throws Exception {
    http
            .authorizeHttpRequests()
            .anyRequest()
            .authenticated().and()
            .oauth2Login(oauth2LoginCustomizer -> {
                // 授权端点配置
                oauth2LoginCustomizer.authorizationEndpoint().authorizationRequestResolver(customOAuth2AuthorizationRequestResolver(clientRegistrationRepository));
                // 获取token端点配置
                oauth2LoginCustomizer.tokenEndpoint().accessTokenResponseClient(customOAuth2AccessTokenResponseClient());
                // 获取用户信息端点配置
                oauth2LoginCustomizer.userInfoEndpoint().userService(new WeChatUserService());
            });
    return http.build();
}

到了这里就真的大功告成…
接着准备测试…

三. 测试验证

有了以上的自定义改造后,剩下的就是测试验证了,对于微信,因为我们只是测试,没有接入网站应用,因此我们也没法使用那种二维码扫码登录的方式来测试了。。
但我们可以使用微信开发者工具来发起请求,微信开发者工具需要先使用微信账号登录,这样你发起请求就相当于是用这个账号来申请微信的权限。

四. 总结

这一篇主要是介绍了对于微信的第三方登录自定义,讲的可能比较乱,还是得结合源码理解理解,我只想把思路和为什么尽量都分享清楚,当然这只是测试,真正的支持微信第三方还得需要在微信登记公众号等操作,那些是需要认证啥的,我们当前学习的话目前的已经足够了。

到此这篇关于SpringSecurityOAuth2实现微信授权登录的文章就介绍到这了,更多相关SpringSecurityOAuth2微信授权登录内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文