From 5ed7e1563b5d721ce97a7b55ba313f371cc7ea57 Mon Sep 17 00:00:00 2001 From: Hongwei Peng Date: Mon, 30 Sep 2019 08:11:21 +0800 Subject: [PATCH] integrate twitter login --- .../zhyd/oauth/config/AuthDefaultSource.java | 22 +++ .../me/zhyd/oauth/model/AuthCallback.java | 16 ++ .../java/me/zhyd/oauth/model/AuthToken.java | 11 ++ .../oauth/request/AuthTwitterRequest.java | 156 ++++++++++++++++++ .../me/zhyd/oauth/utils/GlobalAuthUtil.java | 77 ++++++++- .../oauth/request/AuthExtendRequestTest.java | 7 +- .../zhyd/oauth/utils/GlobalAuthUtilTest.java | 115 +++++++++++-- 7 files changed, 379 insertions(+), 25 deletions(-) create mode 100644 src/main/java/me/zhyd/oauth/request/AuthTwitterRequest.java diff --git a/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java b/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java index 9b239e1..77b9eae 100644 --- a/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java +++ b/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java @@ -672,6 +672,28 @@ public enum AuthDefaultSource implements AuthSource { public String refresh() { return "https://open-api.shop.ele.me/token"; } + }, + + /** + * Twitter + * + * @since 1.12.0 + */ + TWITTER { + @Override + public String authorize() { + return "https://api.twitter.com/oauth/authenticate"; + } + + @Override + public String accessToken() { + return "https://api.twitter.com/oauth/access_token"; + } + + @Override + public String userInfo() { + return "https://api.twitter.com/1.1/users/show.json"; + } } } diff --git a/src/main/java/me/zhyd/oauth/model/AuthCallback.java b/src/main/java/me/zhyd/oauth/model/AuthCallback.java index 4e268f0..c145392 100644 --- a/src/main/java/me/zhyd/oauth/model/AuthCallback.java +++ b/src/main/java/me/zhyd/oauth/model/AuthCallback.java @@ -1,5 +1,6 @@ package me.zhyd.oauth.model; +import lombok.Builder; import lombok.Getter; import lombok.Setter; @@ -11,6 +12,7 @@ import lombok.Setter; */ @Getter @Setter +@Builder public class AuthCallback { /** @@ -34,4 +36,18 @@ public class AuthCallback { * @since 1.10.0 */ private String authorization_code; + + /** + * Twitter回调后返回的oauth_token + * + * @since 1.12.0 + */ + private String oauthToken; + + /** + * Twitter回调后返回的oauth_verifier + * + * @since 1.12.0 + */ + private String oauthVerifier; } diff --git a/src/main/java/me/zhyd/oauth/model/AuthToken.java b/src/main/java/me/zhyd/oauth/model/AuthToken.java index 5ae5fbd..97e9fe1 100644 --- a/src/main/java/me/zhyd/oauth/model/AuthToken.java +++ b/src/main/java/me/zhyd/oauth/model/AuthToken.java @@ -42,4 +42,15 @@ public class AuthToken { */ private String code; + /** + * Twitter附带属性 + * + * @since 1.12.0 + */ + private String oauthToken; + private String oauthTokenSecret; + private String userId; + private String screenName; + private Boolean oauthCallbackConfirmed; + } diff --git a/src/main/java/me/zhyd/oauth/request/AuthTwitterRequest.java b/src/main/java/me/zhyd/oauth/request/AuthTwitterRequest.java new file mode 100644 index 0000000..08f3fcc --- /dev/null +++ b/src/main/java/me/zhyd/oauth/request/AuthTwitterRequest.java @@ -0,0 +1,156 @@ +package me.zhyd.oauth.request; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.alibaba.fastjson.JSONObject; +import me.zhyd.oauth.cache.AuthStateCache; +import me.zhyd.oauth.config.AuthConfig; +import me.zhyd.oauth.exception.AuthException; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthToken; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.utils.GlobalAuthUtil; +import me.zhyd.oauth.utils.UrlBuilder; + +import java.util.HashMap; +import java.util.Map; + +import static me.zhyd.oauth.config.AuthDefaultSource.TWITTER; +import static me.zhyd.oauth.utils.GlobalAuthUtil.generateTwitterSignature; +import static me.zhyd.oauth.utils.GlobalAuthUtil.urlEncode; + +/** + * Twitter登录 + * + * @author hongwei.peng (pengisgood(at)gmail(dot)com) + * @since 1.12.0 + */ +public class AuthTwitterRequest extends AuthDefaultRequest { + + public AuthTwitterRequest(AuthConfig config) { + super(config, TWITTER); + } + + public AuthTwitterRequest(AuthConfig config, AuthStateCache authStateCache) { + super(config, TWITTER, authStateCache); + } + + /** + * Obtaining a request token + * https://developer.twitter.com/en/docs/twitter-for-websites/log-in-with-twitter/guides/implementing-sign-in-with-twitter + * + * @return request token + */ + public AuthToken getRequestToken() { + String baseUrl = "https://api.twitter.com/oauth/request_token"; + + Map oauthParams = buildOauthParams(); + oauthParams.put("oauth_callback", config.getRedirectUri()); + oauthParams.put("oauth_signature", generateTwitterSignature(oauthParams, "POST", baseUrl, config.getClientSecret(), null)); + oauthParams.forEach((k, v) -> oauthParams.put(k, "\"" + urlEncode(v.toString()) + "\"")); + + HttpResponse requestToken = HttpRequest.post(baseUrl) + .header("Authorization", "OAuth " + GlobalAuthUtil.parseMapToString(oauthParams, false).replaceAll("&", ", ")) + .execute(); + checkResponse(requestToken); + + Map res = GlobalAuthUtil.parseQueryToMap(requestToken.body()); + + return AuthToken.builder() + .oauthToken(res.get("oauth_token").toString()) + .oauthTokenSecret(res.get("oauth_token_secret").toString()) + .oauthCallbackConfirmed(Boolean.valueOf(res.get("oauth_callback_confirmed").toString())) + .build(); + } + + /** + * Convert request token to access token + * https://developer.twitter.com/en/docs/twitter-for-websites/log-in-with-twitter/guides/implementing-sign-in-with-twitter + * + * @return access token + */ + @Override + protected AuthToken getAccessToken(AuthCallback authCallback) { + Map oauthParams = buildOauthParams(); + oauthParams.put("oauth_token", authCallback.getOauthToken()); + oauthParams.put("oauth_verifier", authCallback.getOauthVerifier()); + oauthParams.put("oauth_signature", generateTwitterSignature(oauthParams, "POST", source.accessToken(), config.getClientSecret(), authCallback.getOauthToken())); + oauthParams.forEach((k, v) -> oauthParams.put(k, "\"" + urlEncode(v.toString()) + "\"")); + + HttpResponse response = HttpRequest.post(source.accessToken()) + .header("Authorization", "OAuth " + GlobalAuthUtil.parseMapToString(oauthParams, false).replaceAll("&", ", ")) + .header("Content-Type", "application/x-www-form-urlencoded") + .form("oauth_verifier", authCallback.getOauthVerifier()) + .execute(); + checkResponse(response); + + Map requestToken = GlobalAuthUtil.parseQueryToMap(response.body()); + + return AuthToken.builder() + .oauthToken(requestToken.get("oauth_token").toString()) + .oauthTokenSecret(requestToken.get("oauth_token_secret").toString()) + .userId(requestToken.get("user_id").toString()) + .screenName(requestToken.get("screen_name").toString()) + .build(); + } + + @Override + protected AuthUser getUserInfo(AuthToken authToken) { + + Map queryParams = new HashMap<>(); + queryParams.put("user_id", authToken.getUserId()); + queryParams.put("screen_name", authToken.getScreenName()); + queryParams.put("include_entities", true); + + Map oauthParams = buildOauthParams(); + oauthParams.put("oauth_token", authToken.getOauthToken()); + + Map params = new HashMap<>(oauthParams); + params.putAll(queryParams); + oauthParams.put("oauth_signature", generateTwitterSignature(params, "GET", source.userInfo(), config.getClientSecret(), authToken.getOauthTokenSecret())); + oauthParams.forEach((k, v) -> oauthParams.put(k, "\"" + urlEncode(v.toString()) + "\"")); + + HttpResponse response = HttpRequest.get(userInfoUrl(authToken)) + .header("Authorization", "OAuth " + GlobalAuthUtil.parseMapToString(oauthParams, false).replaceAll("&", ", ")) + .execute(); + checkResponse(response); + JSONObject userInfo = JSONObject.parseObject(response.body()); + + return AuthUser.builder() + .uuid(userInfo.getString("id_str")) + .username(userInfo.getString("screen_name")) + .nickname(userInfo.getString("name")) + .remark(userInfo.getString("description")) + .avatar(userInfo.getString("profile_image_url_https")) + .blog(userInfo.getString("url")) + .location(userInfo.getString("location")) + .source(source.toString()) + .token(authToken) + .build(); + } + + @Override + protected String userInfoUrl(AuthToken authToken) { + return UrlBuilder.fromBaseUrl(source.userInfo()) + .queryParam("user_id", authToken.getUserId()) + .queryParam("screen_name", authToken.getScreenName()) + .queryParam("include_entities", true) + .build(); + } + + private Map buildOauthParams() { + Map params = new HashMap<>(); + params.put("oauth_consumer_key", config.getClientId()); + params.put("oauth_nonce", GlobalAuthUtil.generateNonce(32)); + params.put("oauth_signature_method", "HMAC-SHA1"); + params.put("oauth_timestamp", GlobalAuthUtil.getTimestamp()); + params.put("oauth_version", "1.0"); + return params; + } + + private void checkResponse(HttpResponse response) { + if (!response.isOk()) { + throw new AuthException(response.body()); + } + } +} diff --git a/src/main/java/me/zhyd/oauth/utils/GlobalAuthUtil.java b/src/main/java/me/zhyd/oauth/utils/GlobalAuthUtil.java index 5c248f0..c6f0db4 100644 --- a/src/main/java/me/zhyd/oauth/utils/GlobalAuthUtil.java +++ b/src/main/java/me/zhyd/oauth/utils/GlobalAuthUtil.java @@ -17,7 +17,13 @@ import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.TreeMap; /** * 全局的工具类 @@ -27,7 +33,8 @@ import java.util.*; */ public class GlobalAuthUtil { private static final Charset DEFAULT_ENCODING = StandardCharsets.UTF_8; - private static final String ALGORITHM = "HmacSHA256"; + private static final String HMAC_SHA1 = "HmacSHA1"; + private static final String HMAC_SHA_256 = "HmacSHA256"; /** * 生成钉钉请求的Signature @@ -37,24 +44,25 @@ public class GlobalAuthUtil { * @return Signature */ public static String generateDingTalkSignature(String secretKey, String timestamp) { - byte[] signData = sign(secretKey.getBytes(DEFAULT_ENCODING), timestamp.getBytes(DEFAULT_ENCODING)); + byte[] signData = sign(secretKey.getBytes(DEFAULT_ENCODING), timestamp.getBytes(DEFAULT_ENCODING), HMAC_SHA_256); return urlEncode(new String(Base64.encode(signData, false))); } /** * 签名 * - * @param key key - * @param data data + * @param key key + * @param data data + * @param algorithm algorithm * @return byte[] */ - private static byte[] sign(byte[] key, byte[] data) { + private static byte[] sign(byte[] key, byte[] data, String algorithm) { try { - Mac mac = Mac.getInstance(ALGORITHM); - mac.init(new SecretKeySpec(key, ALGORITHM)); + Mac mac = Mac.getInstance(algorithm); + mac.init(new SecretKeySpec(key, algorithm)); return mac.doFinal(data); } catch (NoSuchAlgorithmException ex) { - throw new AuthException("Unsupported algorithm: " + ALGORITHM, ex); + throw new AuthException("Unsupported algorithm: " + algorithm, ex); } catch (InvalidKeyException ex) { throw new AuthException("Invalid key: " + Arrays.toString(key), ex); } @@ -184,6 +192,57 @@ public class GlobalAuthUtil { return StringUtils.isEmpty(url) || url.contains("127.0.0.1") || url.contains("localhost"); } + + /** + * Generate nonce with given length + * + * @param len length + * @return nonce string + */ + public static String generateNonce(int len) { + String s = "0123456789QWERTYUIOPLKJHGFDSAZXCVBNMqwertyuioplkjhgfdsazxcvbnm"; + Random rng = new Random(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < len; i++) { + int index = rng.nextInt(62); + sb.append(s, index, index + 1); + } + return sb.toString(); + } + + /** + * Get current timestamp + * + * @return timestamp string + */ + public static String getTimestamp() { + return String.valueOf(System.currentTimeMillis() / 1000); + } + + /** + * Generate Twitter signature + * https://developer.twitter.com/en/docs/basics/authentication/guides/creating-a-signature + * + * @param params parameters including: oauth headers, query params, body params + * @param method HTTP method + * @param baseUrl base url + * @param apiSecret api key secret can be found in the developer portal by viewing the app details page + * @param tokenSecret oauth token secret + * @return BASE64 encoded signature string + */ + public static String generateTwitterSignature(Map params, String method, String baseUrl, String apiSecret, String tokenSecret) { + TreeMap map = new TreeMap<>(); + for (Map.Entry e : params.entrySet()) { + map.put(urlEncode(e.getKey()), e.getValue()); + } + String str = parseMapToString(map, true); + String baseStr = method.toUpperCase() + "&" + urlEncode(baseUrl) + "&" + urlEncode(str); + String signKey = apiSecret + "&" + (StringUtils.isEmpty(tokenSecret) ? "" : tokenSecret); + byte[] signature = sign(signKey.getBytes(DEFAULT_ENCODING), baseStr.getBytes(DEFAULT_ENCODING), HMAC_SHA1); + + return new String(Base64.encode(signature, false)); + } + /** * 生成饿了么请求的Signature *

diff --git a/src/test/java/me/zhyd/oauth/request/AuthExtendRequestTest.java b/src/test/java/me/zhyd/oauth/request/AuthExtendRequestTest.java index c1184c2..765881d 100644 --- a/src/test/java/me/zhyd/oauth/request/AuthExtendRequestTest.java +++ b/src/test/java/me/zhyd/oauth/request/AuthExtendRequestTest.java @@ -41,9 +41,10 @@ public class AuthExtendRequestTest { String state = AuthStateUtils.createState(); request.authorize(state); - AuthCallback callback = new AuthCallback(); - callback.setCode("code"); - callback.setState(state); + AuthCallback callback = AuthCallback.builder() + .code("code") + .state(state) + .build(); AuthResponse response = request.login(callback); Assert.assertNotNull(response); diff --git a/src/test/java/me/zhyd/oauth/utils/GlobalAuthUtilTest.java b/src/test/java/me/zhyd/oauth/utils/GlobalAuthUtilTest.java index 65f0c15..ce2a476 100644 --- a/src/test/java/me/zhyd/oauth/utils/GlobalAuthUtilTest.java +++ b/src/test/java/me/zhyd/oauth/utils/GlobalAuthUtilTest.java @@ -1,36 +1,40 @@ package me.zhyd.oauth.utils; +import me.zhyd.oauth.config.AuthConfig; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthToken; import org.junit.Assert; import org.junit.Test; import java.util.HashMap; import java.util.Map; +import static me.zhyd.oauth.config.AuthDefaultSource.TWITTER; +import static me.zhyd.oauth.utils.GlobalAuthUtil.generateTwitterSignature; +import static me.zhyd.oauth.utils.GlobalAuthUtil.urlEncode; +import static org.junit.Assert.assertEquals; + public class GlobalAuthUtilTest { @Test public void testGenerateDingTalkSignature() { - Assert.assertEquals("mLTZEMqIlpAA3xtJ43KcRT0EDLwgSamFe%2FNis5lq9ik%3D", - GlobalAuthUtil.generateDingTalkSignature( - "SHA-256", "1562325753000 ")); + assertEquals("mLTZEMqIlpAA3xtJ43KcRT0EDLwgSamFe%2FNis5lq9ik%3D", + GlobalAuthUtil.generateDingTalkSignature("SHA-256", "1562325753000 ")); } @Test public void testUrlDecode() { - Assert.assertEquals("", GlobalAuthUtil.urlDecode(null)); - Assert.assertEquals("https://www.foo.bar", - GlobalAuthUtil.urlDecode("https://www.foo.bar")); - Assert.assertEquals("mLTZEMqIlpAA3xtJ43KcRT0EDLwgSamFe/Nis5lq9ik=", - GlobalAuthUtil.urlDecode( - "mLTZEMqIlpAA3xtJ43KcRT0EDLwgSamFe%2FNis5lq9ik%3D")); + assertEquals("", GlobalAuthUtil.urlDecode(null)); + assertEquals("https://www.foo.bar", GlobalAuthUtil.urlDecode("https://www.foo.bar")); + assertEquals("mLTZEMqIlpAA3xtJ43KcRT0EDLwgSamFe/Nis5lq9ik=", + GlobalAuthUtil.urlDecode("mLTZEMqIlpAA3xtJ43KcRT0EDLwgSamFe%2FNis5lq9ik%3D")); } @Test public void testParseStringToMap() { Map expected = new HashMap(); expected.put("bar", "baz"); - Assert.assertEquals(expected, - GlobalAuthUtil.parseStringToMap("foo&bar=baz")); + assertEquals(expected, GlobalAuthUtil.parseStringToMap("foo&bar=baz")); } @Test @@ -46,8 +50,7 @@ public class GlobalAuthUtilTest { Assert.assertFalse(GlobalAuthUtil.isHttpsProtocol("")); Assert.assertFalse(GlobalAuthUtil.isHttpsProtocol("foo")); - Assert.assertTrue( - GlobalAuthUtil.isHttpsProtocol("https://www.foo.bar")); + Assert.assertTrue(GlobalAuthUtil.isHttpsProtocol("https://www.foo.bar")); } @Test @@ -58,4 +61,90 @@ public class GlobalAuthUtilTest { Assert.assertTrue(GlobalAuthUtil.isLocalHost("127.0.0.1")); Assert.assertTrue(GlobalAuthUtil.isLocalHost("localhost")); } + + @Test + public void testGenerateTwitterSignatureForRequestToken() { + AuthConfig config = AuthConfig.builder() + .clientId("HD0XLqzi5Wz0G08rh45Cg8mgh") + .clientSecret("0YX3RH2DnPiT77pgzLzFdfpMKX8ENLIWQKYQ7lG5TERuZNgXN5") + .redirectUri("https://codinglife.tech") + .build(); + Map params = new HashMap<>(); + params.put("oauth_consumer_key", config.getClientId()); + params.put("oauth_nonce", "sTj7Ivg73u052eXstpoS1AWQCynuDEPN"); + params.put("oauth_signature_method", "HMAC-SHA1"); + params.put("oauth_timestamp", "1569750981"); + params.put("oauth_callback", config.getRedirectUri()); + params.put("oauth_version", "1.0"); + + String baseUrl = "https://api.twitter.com/oauth/request_token"; + params.put("oauth_signature", generateTwitterSignature(params, "POST", baseUrl, config.getClientSecret(), null)); + + params.forEach((k, v) -> params.put(k, "\"" + urlEncode(v.toString()) + "\"")); + String actual = "OAuth " + GlobalAuthUtil.parseMapToString(params, false).replaceAll("&", ", "); + + assertEquals("OAuth oauth_nonce=\"sTj7Ivg73u052eXstpoS1AWQCynuDEPN\", oauth_signature=\"%2BL5Jq%2FTaKubge04cWw%2B4yfjFlaU%3D\", oauth_callback=\"https%3A%2F%2Fcodinglife.tech\", oauth_consumer_key=\"HD0XLqzi5Wz0G08rh45Cg8mgh\", oauth_signature_method=\"HMAC-SHA1\", oauth_timestamp=\"1569750981\", oauth_version=\"1.0\"", actual); + } + + @Test + public void testGenerateTwitterSignatureForAccessToken() { + AuthConfig config = AuthConfig.builder() + .clientId("HD0XLqzi5Wz0G08rh45Cg8mgh") + .clientSecret("0YX3RH2DnPiT77pgzLzFdfpMKX8ENLIWQKYQ7lG5TERuZNgXN5") + .build(); + AuthCallback authCallback = AuthCallback.builder() + .oauthToken("W_KLmAAAAAAAxq5LAAABbXxJeD0") + .oauthVerifier("lYou4gxfA6S5KioUa8VF8HCShzA2nSxp") + .build(); + Map params = new HashMap<>(); + params.put("oauth_consumer_key", config.getClientId()); + params.put("oauth_nonce", "sTj7Ivg73u052eXstpoS1AWQCynuDEPN"); + params.put("oauth_signature_method", "HMAC-SHA1"); + params.put("oauth_timestamp", "1569751082"); + params.put("oauth_token", authCallback.getOauthToken()); + params.put("oauth_verifier", authCallback.getOauthVerifier()); + params.put("oauth_version", "1.0"); + + params.put("oauth_signature", generateTwitterSignature(params, "POST", TWITTER.accessToken(), config.getClientSecret(), authCallback.getOauthToken())); + + params.forEach((k, v) -> params.put(k, "\"" + urlEncode(v.toString()) + "\"")); + String actual = "OAuth " + GlobalAuthUtil.parseMapToString(params, false).replaceAll("&", ", "); + + assertEquals("OAuth oauth_verifier=\"lYou4gxfA6S5KioUa8VF8HCShzA2nSxp\", oauth_nonce=\"sTj7Ivg73u052eXstpoS1AWQCynuDEPN\", oauth_signature=\"9i0lmWgvphtkl2KcCO9VyZ3K2%2F0%3D\", oauth_token=\"W_KLmAAAAAAAxq5LAAABbXxJeD0\", oauth_consumer_key=\"HD0XLqzi5Wz0G08rh45Cg8mgh\", oauth_signature_method=\"HMAC-SHA1\", oauth_timestamp=\"1569751082\", oauth_version=\"1.0\"", actual); + } + + @Test + public void testGenerateTwitterSignatureForUserInfo() { + AuthConfig config = AuthConfig.builder() + .clientId("HD0XLqzi5Wz0G08rh45Cg8mgh") + .clientSecret("0YX3RH2DnPiT77pgzLzFdfpMKX8ENLIWQKYQ7lG5TERuZNgXN5") + .build(); + AuthToken authToken = AuthToken.builder() + .oauthToken("1961977975-PcFQaCnpN9h9xqtqHwHlpGBXFrHJ9bOLy7OtGAL") + .oauthTokenSecret("ffyKe39GYYf8tAyhliSe3QmazpO65kZp5b49xOFX6wHho") + .userId("1961977975") + .screenName("pengisgood") + .build(); + + Map oauthParams = new HashMap<>(); + oauthParams.put("oauth_consumer_key", config.getClientId()); + oauthParams.put("oauth_nonce", "sTj7Ivg73u052eXstpoS1AWQCynuDEPN"); + oauthParams.put("oauth_signature_method", "HMAC-SHA1"); + oauthParams.put("oauth_timestamp", "1569751082"); + oauthParams.put("oauth_token", authToken.getOauthToken()); + oauthParams.put("oauth_version", "1.0"); + + Map queryParams = new HashMap<>(); + queryParams.put("user_id", authToken.getUserId()); + queryParams.put("screen_name", authToken.getScreenName()); + queryParams.put("include_entities", true); + + Map params = new HashMap<>(oauthParams); + params.putAll(queryParams); + oauthParams.put("oauth_signature", generateTwitterSignature(params, "GET", TWITTER.userInfo(), config.getClientSecret(), authToken.getOauthTokenSecret())); + oauthParams.forEach((k, v) -> oauthParams.put(k, "\"" + urlEncode(v.toString()) + "\"")); + + String actual = "OAuth "+ GlobalAuthUtil.parseMapToString(oauthParams, false).replaceAll("&", ", "); + assertEquals("OAuth oauth_nonce=\"sTj7Ivg73u052eXstpoS1AWQCynuDEPN\", oauth_signature=\"elV04U%2FiLm%2Ff3ue1dSrZeChFkEM%3D\", oauth_token=\"1961977975-PcFQaCnpN9h9xqtqHwHlpGBXFrHJ9bOLy7OtGAL\", oauth_consumer_key=\"HD0XLqzi5Wz0G08rh45Cg8mgh\", oauth_signature_method=\"HMAC-SHA1\", oauth_timestamp=\"1569751082\", oauth_version=\"1.0\"", actual); + } } -- GitLab