diff --git a/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java b/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java index a4f5a85..8317ea7 100644 --- a/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java +++ b/src/main/java/me/zhyd/oauth/config/AuthDefaultSource.java @@ -1493,6 +1493,42 @@ public String userInfo() { public Class getTargetClass() { return null; } + }, + VK { + @Override + public String authorize() { + return "https://id.vk.com/authorize"; + } + + @Override + public String accessToken() { + return "https://id.vk.com/oauth2/auth"; + } + + @Override + public String userInfo() { + return "https://id.vk.com/oauth2/user_info"; + } + + @Override + public String revoke() { + return "https://id.vk.com/oauth2/revoke"; + } + + @Override + public String refresh() { + return "https://id.vk.com/oauth2/auth"; + } + + @Override + public String getName() { + return "VK"; + } + + @Override + public Class getTargetClass() { + return AuthVKRequest.class; + } } } diff --git a/src/main/java/me/zhyd/oauth/enums/scope/AuthVKScope.java b/src/main/java/me/zhyd/oauth/enums/scope/AuthVKScope.java new file mode 100644 index 0000000..020a5f8 --- /dev/null +++ b/src/main/java/me/zhyd/oauth/enums/scope/AuthVKScope.java @@ -0,0 +1,34 @@ +package me.zhyd.oauth.enums.scope; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AuthVKScope implements AuthScope { + + /** + * {@code scope} 含义,以{@code description} 为准 + */ + PERSONAL("vkid.personal_info", "Last name, first name, gender, profile photo and date of birth. The basic permission used by default for all apps", true), + EMAIL("email", "Access to the user's email", true), + PHONE("phone", "Access to the user's phone number", false), + FRIENDS("friends", "Access to friends", false), + WALL("wall", "Access to standard and advanced wall methods", false), + GROUPS("groups", "Access to the user's groups", false), + STORIES("stories", "Access to stories", false), + DOCS("docs", "Access to documents", false), + PHOTOS("photos", "Access to photos", false), + ADS("ads", "Access to advanced methods of the advertising API", false), + VIDEO("video", "Access to videos", false), + STATUS("status", "Access to the user's status", false), + MARKET("market", "Access to products", false), + PAGES("pages", "Access to wiki pages", false), + NOTIFICATIONS("notifications", "Access to notifications about responses to the user", false), + STATS("stats", "Access to statistics of the user's groups and apps for which they are an administrator", false), + NOTES("notes", "Access to notes", false); + + private final String scope; + private final String description; + private final boolean isDefault; + +} diff --git a/src/main/java/me/zhyd/oauth/model/AuthCallback.java b/src/main/java/me/zhyd/oauth/model/AuthCallback.java index b6558e2..fbc7e3b 100644 --- a/src/main/java/me/zhyd/oauth/model/AuthCallback.java +++ b/src/main/java/me/zhyd/oauth/model/AuthCallback.java @@ -21,7 +21,10 @@ @AllArgsConstructor @NoArgsConstructor public class AuthCallback implements Serializable { - + /** + * 设备id + */ + private String device_id; /** * 访问AuthorizeUrl后回调时带的参数code */ diff --git a/src/main/java/me/zhyd/oauth/model/AuthToken.java b/src/main/java/me/zhyd/oauth/model/AuthToken.java index 706f95c..4d82b3d 100644 --- a/src/main/java/me/zhyd/oauth/model/AuthToken.java +++ b/src/main/java/me/zhyd/oauth/model/AuthToken.java @@ -16,6 +16,7 @@ @NoArgsConstructor @AllArgsConstructor public class AuthToken implements Serializable { + private String deviceId; private String accessToken; private int expireIn; private String refreshToken; diff --git a/src/main/java/me/zhyd/oauth/request/AuthVKRequest.java b/src/main/java/me/zhyd/oauth/request/AuthVKRequest.java new file mode 100644 index 0000000..28cd0d8 --- /dev/null +++ b/src/main/java/me/zhyd/oauth/request/AuthVKRequest.java @@ -0,0 +1,212 @@ +package me.zhyd.oauth.request; + +import com.alibaba.fastjson.JSONObject; +import com.xkcoding.http.support.HttpHeader; +import me.zhyd.oauth.cache.AuthStateCache; +import me.zhyd.oauth.config.AuthConfig; +import me.zhyd.oauth.config.AuthDefaultSource; +import me.zhyd.oauth.enums.AuthResponseStatus; +import me.zhyd.oauth.enums.scope.AuthVKScope; +import me.zhyd.oauth.exception.AuthException; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthToken; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.utils.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * VK 登录请求 + * https://id.vk.com/about/business/go/docs/en/vkid/latest/vk-id/connection/api-integration/api-description + */ +public class AuthVKRequest extends AuthDefaultRequest { + + public AuthVKRequest(AuthConfig config) { + super(config, AuthDefaultSource.VK); + } + + public AuthVKRequest(AuthConfig config, AuthStateCache authStateCache) { + super(config, AuthDefaultSource.VK, authStateCache); + } + + /** + * 获取授权 URL,附带 state 参数,防止 CSRF 攻击 + * + * @param state 用于验证授权流程的参数 + * @return 授权 URL + */ + @Override + public String authorize(String state) { + String realState = getRealState(state); + + UrlBuilder builder = UrlBuilder.fromBaseUrl(super.authorize(state)) + .queryParam("scope", this.getScopes(" ", false, AuthScopeUtils.getDefaultScopes(AuthVKScope.values()))); + if (config.isPkce()) { + String cacheKey = this.source.getName().concat(":code_verifier:").concat(realState); + String codeVerifier = PkceUtil.generateCodeVerifier(); + String codeChallengeMethod = "S256"; + String codeChallenge = PkceUtil.generateCodeChallenge(codeChallengeMethod, codeVerifier); + builder.queryParam("code_challenge", codeChallenge) + .queryParam("code_challenge_method", codeChallengeMethod); + // 缓存 codeVerifier 十分钟 + this.authStateCache.cache(cacheKey, codeVerifier, TimeUnit.MINUTES.toMillis(10)); + } + + return builder.build(); + } + + /** + * 获取授权后的 access token + */ + @Override + public AuthToken getAccessToken(AuthCallback authCallback) { + // 使用授权码获取access_token + String response = doPostAuthorizationCode(authCallback); + JSONObject object = JSONObject.parseObject(response); + // 验证响应结果 + this.checkResponse(object); + + // 返回 token + return AuthToken.builder() + .idToken(object.getString("id_token")) + .accessToken(object.getString("access_token")) + .refreshToken(object.getString("refresh_token")) + .tokenType(object.getString("token_type")) + .scope(object.getString("scope")) + .deviceId(authCallback.getDevice_id()) + .userId(object.getString("user_id")).build(); + } + + /** + * 使用授权码获取 access_token 的 POST 请求 + * + * @return 获取的响应体 + */ + protected String doPostAuthorizationCode(AuthCallback authCallback) { + Map form = new HashMap<>(7); + form.put("grant_type", "authorization_code"); + form.put("redirect_uri", config.getRedirectUri()); + form.put("client_id", config.getClientId()); + form.put("code", authCallback.getCode()); + form.put("state", authCallback.getState()); + form.put("device_id", authCallback.getDevice_id()); + + if (config.isPkce()) { + String cacheKey = this.source.getName().concat(":code_verifier:").concat(authCallback.getState()); + String codeVerifier = this.authStateCache.get(cacheKey); + form.put("code_verifier", codeVerifier); + } + + return new HttpUtils(config.getHttpConfig()).post(this.source.accessToken(), form, this.buildHeader(), false).getBody(); + } + + @Override + public AuthResponse refresh(AuthToken authToken) { + Map form = new HashMap<>(7); + form.put("grant_type", "refresh_token"); + form.put("refresh_token", authToken.getRefreshToken()); + form.put("state", AuthStateUtils.createState()); + form.put("device_id", authToken.getDeviceId()); + form.put("client_id", config.getClientId()); + form.put("ip", "10.10.10.10"); + return AuthResponse.builder() + .code(AuthResponseStatus.SUCCESS.getCode()) + .data(getToken(form, this.source.refresh())) + .build(); + + } + + private AuthToken getToken(Map param, String url) { + String response = new HttpUtils(config.getHttpConfig()).post(url, param, this.buildHeader(), false).getBody(); + JSONObject jsonObject = JSONObject.parseObject(response); + this.checkResponse(jsonObject); + return AuthToken.builder() + .accessToken(jsonObject.getString("access_token")) + .tokenType(jsonObject.getString("token_type")) + .expireIn(jsonObject.getIntValue("expires_in")) + .refreshToken(jsonObject.getString("refresh_token")) + .deviceId(param.get("device_id")) + .build(); + } + + @Override + public AuthResponse revoke(AuthToken authToken) { + String response = doPostRevoke(authToken); + JSONObject object = JSONObject.parseObject(response); + this.checkResponse(object); + // 返回1表示取消授权成功,否则失败 + AuthResponseStatus status = object.getIntValue("response") == 1 ? AuthResponseStatus.SUCCESS : AuthResponseStatus.FAILURE; + return AuthResponse.builder().code(status.getCode()).msg(status.getMsg()).build(); + } + + protected String doPostRevoke(AuthToken authToken) { + Map form = new HashMap<>(7); + form.put("access_token", authToken.getAccessToken()); + form.put("client_id", config.getClientId()); + + return new HttpUtils(config.getHttpConfig()).post(this.source.revoke(), form, this.buildHeader(), false).getBody(); + + } + + /** + * 获取用户信息 + */ + @Override + public AuthUser getUserInfo(AuthToken authToken) { + String body = doGetUserInfo(authToken); + JSONObject object = JSONObject.parseObject(body); + + // 验证响应结果 + this.checkResponse(object); + + // 提取嵌套的user对象 + JSONObject userObj = object.getJSONObject("user"); + + // 提取用户信息 + return AuthUser.builder() + .uuid(userObj.getString("user_id")) + .username(userObj.getString("first_name")) + .nickname(userObj.getString("first_name") + " " + userObj.getString("last_name")) + .avatar(userObj.getString("avatar")) + .email(userObj.getString("email")) + .token(authToken) + .rawUserInfo(userObj) + .source(source.toString()) + .build(); + } + + + /** + * 获取用户信息的 POST 请求 + * + * @param authToken access token + * @return 获取的响应体 + */ + protected String doGetUserInfo(AuthToken authToken) { + Map form = new HashMap<>(7); + form.put("access_token", authToken.getAccessToken()); + form.put("client_id", config.getClientId()); + return new HttpUtils(config.getHttpConfig()).post(this.source.userInfo(), form, this.buildHeader(), false).getBody(); + } + + private void checkResponse(JSONObject object) { + // 如果响应包含 error,说明出现问题 + if (object.containsKey("error")) { + throw new AuthException(object.getString("error_description")); + } + // 如果响应包含 message,说明用户信息获取失败 + if (object.containsKey("message")) { + throw new AuthException(object.getString("message")); + } + } + + private HttpHeader buildHeader() { + HttpHeader httpHeader = new HttpHeader(); + httpHeader.add("Content-Type", "application/x-www-form-urlencoded"); + return httpHeader; + } + +}