Merge remote-tracking branch 'origin/dev' into test

This commit is contained in:
akun 2024-04-12 13:36:33 +08:00
commit efb8e44a14
27 changed files with 1513 additions and 12 deletions

16
pom.xml
View File

@ -161,6 +161,19 @@
<version>${sf.version}</version>
</dependency>
<!-- 三方授权模块-->
<dependency>
<groupId>com.smarterFramework</groupId>
<artifactId>sf-oauth</artifactId>
<version>${sf.version}</version>
</dependency>
<dependency>
<groupId>com.smarterFramework</groupId>
<artifactId>sf-order</artifactId>
<version>${sf.version}</version>
</dependency>
<!-- 通用工具-->
<dependency>
<groupId>com.smarterFramework</groupId>
@ -200,7 +213,8 @@
<module>sf-file</module>
<module>sf-common</module>
<module>sf-apijson</module>
</modules>
<module>sf-oauth</module>
</modules>
<packaging>pom</packaging>
<build>

View File

@ -60,11 +60,15 @@
<groupId>com.smarterFramework</groupId>
<artifactId>sf-file</artifactId>
</dependency>
<dependency>
<groupId>com.smarterFramework</groupId>
<artifactId>sf-oauth</artifactId>
</dependency>
<dependency>
<groupId>com.smarterFramework</groupId>
<artifactId>sf-order</artifactId>
<version>1.0.0</version>
<scope>compile</scope>
</dependency>
</dependencies>

View File

@ -48,9 +48,10 @@ public class SysLoginController
public AjaxResult login(@RequestBody LoginBody loginBody, HttpSession session)
{
AjaxResult ajax = AjaxResult.success();
loginService.validateCaptcha(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode());
// 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid(), session);
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(),
session);
ajax.put(Constants.TOKEN, token);
return ajax;
}

View File

@ -112,7 +112,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage").permitAll()
.antMatchers("/login", "/register", "/captchaImage","/oauth/**").permitAll()
// 静态资源可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()

View File

@ -1,6 +1,7 @@
package com.sf.framework.web.service;
import javax.annotation.Resource;
import javax.security.auth.message.AuthException;
import javax.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
@ -32,6 +33,7 @@ import com.sf.framework.manager.factory.AsyncFactory;
import com.sf.framework.security.context.AuthenticationContextHolder;
import com.sf.system.service.ISysConfigService;
import com.sf.system.service.ISysUserService;
import org.springframework.util.Assert;
/**
* 登录校验方法
@ -56,19 +58,18 @@ public class SysLoginService
@Autowired
private ISysConfigService configService;
@Autowired
private SysPermissionService permissionService;
/**
* 登录验证
*
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public String login(String username, String password, String code, String uuid, HttpSession session)
public String login(String username, String password, HttpSession session)
{
// 验证码校验
validateCaptcha(username, code, uuid);
// 登录前置校验
loginPreCheck(username, password);
// 用户验证
@ -105,6 +106,25 @@ public class SysLoginService
return tokenService.createToken(loginUser);
}
/**
* 无密码登录
* @param userName
* @return
* @author zoukun
*/
public String noPwdLogin(String userName, HttpSession session){
SysUser user = userService.selectUserByUserName(userName);
Assert.notNull(user,MessageUtils.message("user.not.exists"));
LoginUser loginUser = new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
// 记录登陆信息
AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGIN_SUCCESS,
MessageUtils.message("user.login.success")));
recordLoginInfo(loginUser.getUserId());
ApijsonUtils.buildFormSession(session, String.valueOf(loginUser.getUserId()));
return tokenService.createToken(loginUser);
}
/**
* 校验验证码
*

View File

@ -1,6 +1,8 @@
package com.sf.framework.web.service;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
@ -23,6 +25,7 @@ import com.sf.framework.security.context.AuthenticationContextHolder;
*
* @author ztzh
*/
@Slf4j
@Component
public class SysPasswordService
{

32
sf-oauth/pom.xml Normal file
View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>smarterFramework</artifactId>
<groupId>com.smarterFramework</groupId>
<version>1.0.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>sf-oauth</artifactId>
<description>
oauth三方登录认证模块
</description>
<dependencies>
<!-- 通用工具-->
<dependency>
<groupId>com.smarterFramework</groupId>
<artifactId>sf-common</artifactId>
</dependency>
<dependency>
<groupId>com.smarterFramework</groupId>
<artifactId>sf-framework</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,41 @@
package com.sf.oauth.config;
import lombok.*;
import java.util.List;
/**
* 配置类
*
* @author zoukun
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthConfig {
/**
* 客户端id对应各平台的appKey
*/
private String clientId;
/**
* 客户端Secret对应各平台的appSecret
*/
private String clientSecret;
/**
* 登录成功后的回调地址
*/
private String redirectUri;
/**
* 支持自定义授权平台的 scope 内容
*/
private List<String> scopes;
private boolean ignoreCheckState;
}

View File

@ -0,0 +1,76 @@
package com.sf.oauth.config;
import com.sf.oauth.exception.AuthException;
import com.sf.oauth.request.AuthDefaultRequest;
/**
* OAuth平台的API地址的统一接口提供以下方法
* 1) {@link AuthSource#authorize()}: 获取授权url. 必须实现
* 2) {@link AuthSource#accessToken()}: 获取accessToken的url. 必须实现
* 3) {@link AuthSource#userInfo()}: 获取用户信息的url. 必须实现
* 4) {@link AuthSource#revoke()}: 获取取消授权的url. 非必须实现接口部分平台不支持
* 5) {@link AuthSource#refresh()}: 获取刷新授权的url. 非必须实现接口部分平台不支持
* <p>
*
* @author zoukun
*/
public interface AuthSource {
/**
* 授权的api
*
* @return url
*/
String authorize();
/**
* 获取accessToken的api
*
* @return url
*/
String accessToken();
/**
* 获取用户信息的api
*
* @return url
*/
String userInfo();
/**
* 取消授权的api
*
* @return url
*/
default String revoke() {
throw new AuthException("UNSUPPORTED");
}
/**
* 刷新授权的api
*
* @return url
*/
default String refresh() {
throw new AuthException("UNSUPPORTED");
}
/**
* 获取Source的字符串名字
*
* @return name
*/
default String getName() {
if (this instanceof Enum) {
return String.valueOf(this);
}
return this.getClass().getSimpleName();
}
/**
* 平台对应的 AuthRequest 实现类必须继承自 {@link AuthDefaultRequest}
*
* @return class
*/
Class<? extends AuthDefaultRequest> getTargetClass();
}

View File

@ -0,0 +1,143 @@
package com.sf.oauth.controller;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSONObject;
import com.sf.common.constant.Constants;
import com.sf.common.constant.HttpStatus;
import com.sf.common.core.domain.AjaxResult;
import com.sf.oauth.config.AuthConfig;
import com.sf.oauth.domain.AuthCallback;
import com.sf.oauth.domain.AuthUser;
import com.sf.oauth.enums.scope.AuthHuaweiScope;
import com.sf.oauth.exception.AuthException;
import com.sf.oauth.request.AuthHuaweiRequest;
import com.sf.oauth.request.AuthRequest;
import com.sf.oauth.service.IAuthService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Arrays;
import static org.apache.commons.lang3.StringUtils.EMPTY;
/**
* @author zoukun
*/
@Slf4j
@RestController
@RequestMapping("/oauth")
public class RestAuthController {
@Autowired
private IAuthService authService;
@GetMapping("/authorize/{source}")
public void authorize(@PathVariable("source") String source, HttpServletResponse response) throws IOException {
log.info("进入render" + source);
AuthRequest authRequest = getAuthRequest(source, EMPTY, EMPTY);
String authorizeUrl = authRequest.authorize(UUID.fastUUID().toString());
log.info(authorizeUrl);
response.sendRedirect(authorizeUrl);
}
/**
* oauth平台中配置的授权回调地址
* 如在创建华为授权应用时的回调地址为http://127.0.0.1:8080/oauth/callback/huawei
*/
@RequestMapping("/callback/{source}")
public AjaxResult callback(@PathVariable("source") String source, @RequestBody AuthCallback callback, HttpSession session) {
log.info("进入callback" + source + " callback params" + JSONObject.toJSONString(callback));
AuthRequest authRequest = getAuthRequest(source, callback.getClientId(), callback.getClientSecret());
AuthUser authUser = authRequest.callback(callback);
log.info(JSONObject.toJSONString(authUser));
AjaxResult ajax = AjaxResult.success();
String token = authService.authLogin(authUser,session);
ajax.put(Constants.TOKEN, token);
return ajax;
}
/*
@RequestMapping("/revoke/{source}/{userId}")
@ResponseBody
public AjaxResult revokeAuth(@PathVariable("source") String source, @PathVariable("userId") String userId) throws IOException {
AuthRequest authRequest = getAuthRequest(source.toLowerCase());
AuthUser user = userService.getByUuid(uuid);
if (null == user) {
return Response.error("用户不存在");
}
AjaxResult<AuthToken> response = null;
try {
response = authRequest.revoke(user.getToken());
if (response.ok()) {
userService.remove(user.getUuid());
return Response.success("用户 [" + user.getUsername() + "] 的 授权状态 已收回!");
}
return Response.error("用户 [" + user.getUsername() + "] 的 授权状态 收回失败!" + response.getMsg());
} catch (AuthException e) {
return Response.error(e.getErrorMsg());
}
}
@RequestMapping("/refresh/{source}/{userId}")
@ResponseBody
public Object refreshAuth(@PathVariable("source") String source, @PathVariable("userId") String userId) {
AuthRequest authRequest = getAuthRequest(source.toLowerCase());
AuthUser user = userService.getByUuid(userId);
if (null == user) {
return Response.error("用户不存在");
}
AjaxResult<AuthToken> response = null;
try {
response = authRequest.refresh(user.getToken());
if (response.ok()) {
user.setToken(response.getData());
userService.save(user);
return Response.success("用户 [" + user.getUsername() + "] 的 access token 已刷新!新的 callback: " + response.getData().getAccessToken());
}
return Response.error("用户 [" + user.getUsername() + "] 的 access token 刷新失败!" + response.getMsg());
} catch (AuthException e) {
return Response.error(e.getErrorMsg());
}
}*/
/**
* 根据具体的授权来源获取授权请求工具类
*
* @param source
* @return
*/
private AuthRequest getAuthRequest(String source, String clientId, String clientSecret) {
AuthRequest authRequest = null;
switch (source.toLowerCase()) {
case "huawei":
authRequest = new AuthHuaweiRequest(AuthConfig.builder()
.clientId(StrUtil.isBlank(clientId) ? "110693217" : clientId)
.clientSecret(StrUtil.isBlank(clientSecret) ? "1410c01bc71c7ba587175ae79e500137c70945acc1416a38127cf98a09a6f8ba" : clientSecret)
.redirectUri("")
.ignoreCheckState(true)
.scopes(Arrays.asList(
AuthHuaweiScope.BASE_PROFILE.getScope(),
AuthHuaweiScope.MOBILE_NUMBER.getScope(),
AuthHuaweiScope.ACCOUNTLIST.getScope(),
AuthHuaweiScope.SCOPE_DRIVE_FILE.getScope(),
AuthHuaweiScope.SCOPE_DRIVE_APPDATA.getScope()
))
.build());
break;
default:
break;
}
if (null == authRequest) {
throw new AuthException(HttpStatus.BAD_REQUEST, "未获取到有效的Auth配置");
}
return authRequest;
}
}

View File

@ -0,0 +1,42 @@
package com.sf.oauth.domain;
import lombok.*;
import java.io.Serializable;
/**
* 授权回调时的参数类
*
* @author zk
*/
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthCallback implements Serializable {
/**
* 访问AuthorizeUrl后回调时带的参数code
*/
private String code;
/**
* 访问AuthorizeUrl后回调时带的参数state用于和请求AuthorizeUrl前的state比较防止CSRF攻击
*/
private String state;
/**
* 华为授权登录
*
* 客户端id对应各平台的appKey
*/
private String clientId;
/**
* 客户端Secret对应各平台的appSecret
*/
private String clientSecret;
}

View File

@ -0,0 +1,42 @@
package com.sf.oauth.domain;
import lombok.*;
import java.io.Serializable;
/**
* 授权所需的token
*
* @author zoukun
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthToken implements Serializable {
private String accessToken;
private int expireIn;
private String refreshToken;
private int refreshTokenExpireIn;
private String uid;
private String openId;
private String accessCode;
private String unionId;
/**
* 华为返回 生成的Access Token中包含的scope
*/
private String scope;
/**
* 华为返回 固定返回Bearer标识返回Access Token的类型
*/
private String tokenType;
/**
* 华为返回 返回JWT格式数据包含用户基本帐号用户邮箱等信息
* 参照https://developer.huawei.com/consumer/cn/doc/HMSCore-References/account-verify-id-token_hms_reference-0000001050050577#section3142132691914
*/
private String idToken;
}

View File

@ -0,0 +1,79 @@
package com.sf.oauth.domain;
import com.alibaba.fastjson2.JSONObject;
import com.sf.oauth.enums.AuthUserGender;
import lombok.*;
import java.io.Serializable;
/**
* 授权成功后的用户信息根据授权平台的不同获取的数据完整性也不同
*
* @author zoukun
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthUser implements Serializable {
/**
* 用户第三方系统的唯一id
*/
private String uuid;
/**
* 用户名
*/
private String username;
/**
* 用户昵称
*/
private String nickname;
/**
* 用户头像
*/
private String avatar;
/**
* 用户网址
*/
private String blog;
/**
* 所在公司
*/
private String company;
/**
* 位置
*/
private String location;
/**
* 用户邮箱
*/
private String email;
/**
* 用户手机号
*/
private String mobileNumber;
/**
* 用户备注各平台中的用户个人介绍
*/
private String remark;
/**
* 性别
*/
private AuthUserGender gender;
/**
* 用户来源
*/
private String source;
/**
* 用户授权的token信息
*/
private AuthToken token;
/**
* 第三方平台返回的原始用户信息
*/
private JSONObject rawUserInfo;
}

View File

@ -0,0 +1,47 @@
package com.sf.oauth.enums;
import com.sf.oauth.config.AuthSource;
import com.sf.oauth.request.AuthDefaultRequest;
import com.sf.oauth.request.AuthHuaweiRequest;
/**
* 内置的各api需要的url 用枚举类分平台类型管理
*
* @author zoukun
*/
public enum AuthDefaultSource implements AuthSource {
/**
* 华为oauth2/v3
*/
HUAWEI {
@Override
public String authorize() {
return "https://oauth-login.cloud.huawei.com/oauth2/v3/authorize";
}
@Override
public String accessToken() {
return "https://oauth-login.cloud.huawei.com/oauth2/v3/token";
}
@Override
public String userInfo() {
return "https://account.cloud.huawei.com/rest.php";
}
@Override
public String refresh() {
return "https://oauth-login.cloud.huawei.com/oauth2/v3/token";
}
@Override
public Class<? extends AuthDefaultRequest> getTargetClass() {
return AuthHuaweiRequest.class;
}
},
}

View File

@ -0,0 +1,51 @@
package com.sf.oauth.enums;
import java.util.*;
/**
* @author zoukun
*/
public enum AuthPlatformInfo {
/**
* 平台
*/
HUAWEI("华为", "huawei", "https://developer.huawei.com/consumer/cn/doc/HMSCore-Guides/open-platform-oauth-0000001053629189"),
;
/**
* 平台名
*/
private final String name;
/**
* 平台编码
*/
private final String code;
/**
* 官网api文档
*/
private final String apiDoc;
AuthPlatformInfo(String name, String code, String apiDoc) {
this.name = name;
this.code = code;
this.apiDoc = apiDoc;
}
public static List<AuthPlatformInfo> getPlatformInfos() {
return Arrays.asList(AuthPlatformInfo.values());
}
public String getName() {
return name;
}
public String getCode() {
return code;
}
public String getApiDoc() {
return apiDoc;
}
}

View File

@ -0,0 +1,45 @@
package com.sf.oauth.enums;
import cn.hutool.core.util.StrUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 用户性别
*
* @author zoukun
*/
@Getter
@AllArgsConstructor
public enum AuthUserGender {
/**
* MALE/FAMALE为正常值通过{@link AuthUserGender#getRealGender(String)}方法获取真实的性别
* UNKNOWN为容错值部分平台不会返回用户性别为了方便统一使用UNKNOWN标记所有未知或不可测的用户性别信息
*/
MALE("0", ""),
FEMALE("1", ""),
UNKNOWN("2", "未知");
private String code;
private String desc;
/**
* 获取用户的实际性别常规网站
*
* @param originalGender 用户第三方标注的原始性别
* @return 用户性别
*/
public static AuthUserGender getRealGender(String originalGender) {
if (null == originalGender || UNKNOWN.getCode().equals(originalGender)) {
return UNKNOWN;
}
String[] males = {"m", "", "0", "male"};
if (Arrays.asList(males).contains(originalGender.toLowerCase())) {
return MALE;
}
return FEMALE;
}
}

View File

@ -0,0 +1,44 @@
package com.sf.oauth.enums.scope;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 华为平台 OAuth 授权范围
*
* @author zoukun
*/
@Getter
@AllArgsConstructor
public enum AuthHuaweiScope implements AuthScope {
/**
* {@code scope} 含义{@code description} 为准
*/
BASE_PROFILE("https://www.huawei.com/auth/account/base.profile", "获取用户的基本信息", true),
MOBILE_NUMBER("https://www.huawei.com/auth/account/mobile.number", "获取用户的手机号", false),
ACCOUNTLIST("https://www.huawei.com/auth/account/accountlist", "获取用户的账单列表", false),
/**
* 以下两个 scope 不需要经过华为评估和验证
*/
SCOPE_DRIVE_FILE("https://www.huawei.com/auth/drive.file", "只允许访问由应用程序创建或打开的文件", false),
SCOPE_DRIVE_APPDATA("https://www.huawei.com/auth/drive.appdata", "只允许访问由应用程序创建或打开的文件", false),
/**
* 以下四个 scope 使用前需要向drivekit@huawei.com提交申请
* <p>
* 参考https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides-V5/server-dev-0000001050039664-V5#ZH-CN_TOPIC_0000001050039664__section1618418855716
*/
SCOPE_DRIVE("https://www.huawei.com/auth/drive", "只允许访问由应用程序创建或打开的文件", false),
SCOPE_DRIVE_READONLY("https://www.huawei.com/auth/drive.readonly", "只允许访问由应用程序创建或打开的文件", false),
SCOPE_DRIVE_METADATA("https://www.huawei.com/auth/drive.metadata", "只允许访问由应用程序创建或打开的文件", false),
SCOPE_DRIVE_METADATA_READONLY("https://www.huawei.com/auth/drive.metadata.readonly", "只允许访问由应用程序创建或打开的文件", false),
;
private final String scope;
private final String description;
private final boolean isDefault;
}

View File

@ -0,0 +1,23 @@
package com.sf.oauth.enums.scope;
/**
* 各个平台 scope 类的统一接口
*
* @author zoukun
*/
public interface AuthScope {
/**
* 获取字符串 {@code scope}对应为各平台实际使用的 {@code scope}
*
* @return String
*/
String getScope();
/**
* 判断当前 {@code scope} 是否为各平台默认启用的
*
* @return boolean
*/
boolean isDefault();
}

View File

@ -0,0 +1,46 @@
package com.sf.oauth.exception;
import com.sf.oauth.config.AuthSource;
/**
* 授权异常
*
* @author zoukun
*/
public class AuthException extends RuntimeException {
private int errorCode;
private String errorMsg;
public AuthException(String errorMsg) {
super(errorMsg);
this.errorMsg = errorMsg;
}
public AuthException(int errorCode, String errorMsg) {
super(errorMsg);
this.errorCode = errorCode;
this.errorMsg = errorMsg;
}
public AuthException(int errorCode, String errorMsg, AuthSource source) {
this(errorCode, String.format("%s [%s]", errorMsg, source.getName()));
}
public AuthException(String message, Throwable cause) {
super(message, cause);
}
public AuthException(Throwable cause) {
super(cause);
}
public int getErrorCode() {
return errorCode;
}
public String getErrorMsg() {
return errorMsg;
}
}

View File

@ -0,0 +1,177 @@
package com.sf.oauth.request;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import cn.hutool.http.HttpUtil;
import com.sf.common.constant.HttpStatus;
import com.sf.oauth.config.AuthConfig;
import com.sf.oauth.config.AuthSource;
import com.sf.oauth.exception.AuthException;
import com.sf.oauth.domain.AuthCallback;
import com.sf.oauth.domain.AuthToken;
import com.sf.oauth.domain.AuthUser;
import com.sf.oauth.utils.AuthChecker;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 默认的request处理类
*
* @author zoukun
*/
@Slf4j
public abstract class AuthDefaultRequest implements AuthRequest {
protected AuthConfig config;
protected AuthSource source;
public AuthDefaultRequest(AuthConfig config, AuthSource source) {
this.config = config;
this.source = source;
if (!AuthChecker.isSupportedAuth(config, source)) {
throw new AuthException(HttpStatus.ERROR,"Parameter incomplete", source);
}
// 校验配置合法性
// AuthChecker.checkConfig(config, source);
}
/**
* 获取access token
*
* @param authCallback 授权成功后的回调参数
* @return token
* @see AuthDefaultRequest#authorize()
* @see AuthDefaultRequest#authorize(String)
*/
protected abstract AuthToken getAccessToken(AuthCallback authCallback);
/**
* 使用token换取用户信息
*
* @param authToken token信息
* @return 用户信息
* @see AuthDefaultRequest#getAccessToken(AuthCallback)
*/
protected abstract AuthUser getUserInfo(AuthToken authToken);
/**
* 统一的登录入口当通过{@link AuthDefaultRequest#authorize(String)}授权成功后会跳转到调用方的相关回调方法中
* 方法的入参可以使用{@code AuthCallback}{@code AuthCallback}类中封装好了OAuth2授权回调所需要的参数
*
* @param authCallback 用于接收回调参数的实体
* @return AuthResponse
*/
@Override
public AuthUser callback(AuthCallback authCallback) {
try {
checkCode(authCallback);
if (!config.isIgnoreCheckState()) {
AuthChecker.checkState(authCallback.getState(), source);
}
AuthToken authToken = this.getAccessToken(authCallback);
return this.getUserInfo(authToken);
} catch (Exception e) {
log.error("Failed to callback with oauth authorization", e);
throw new AuthException(HttpStatus.UNAUTHORIZED,"Failed to callback with oauth authorization");
}
}
protected void checkCode(AuthCallback authCallback) {
AuthChecker.checkCode(source, authCallback);
}
/**
* 返回授权url可自行跳转页面
* <p>
* 不建议使用该方式获取授权地址不带{@code state}的授权地址容易受到csrf攻击
* 建议使用{@link AuthDefaultRequest#authorize(String)}方法生成授权地址在回调方法中对{@code state}进行校验
*
* @return 返回授权地址
* @see AuthDefaultRequest#authorize(String)
*/
@Override
public String authorize() {
return this.authorize(null);
}
/**
* 返回带{@code state}参数的授权url授权回调时会带上这个{@code state}
*
* @param state state 验证授权流程的参数可以防止csrf
* @return 返回授权地址
*/
@Override
public String authorize(String state) {
Map<String, Object> form = new HashMap<>(8);
form.put("client_id", config.getClientId());
form.put("client_secret", config.getClientSecret());
form.put("redirect_uri", config.getRedirectUri());
form.put("state", getRealState(state));
form.put("response_type","code");
return HttpUtil.get(source.authorize(), form);
}
/**
* 返回获取accessToken的url
*
* @param code 授权码
* @return 返回获取accessToken的url
*/
protected String accessTokenUrl(String code) {
Map<String, Object> form = new HashMap<>(8);
form.put("code", code);
form.put("client_id", config.getClientId());
form.put("client_secret", config.getClientSecret());
form.put("grant_type", "authorization_code");
form.put("redirect_uri", config.getRedirectUri());
return HttpUtil.get(source.accessToken(),form);
}
/**
* 获取state如果为空 则默认取当前日期的时间戳
*
* @param state 原始的state
* @return 返回不为null的state
*/
protected String getRealState(String state) {
if (StrUtil.isEmpty(state)) {
state = UUID.fastUUID().toString();
}
// todo 需要缓存state
return state;
}
/**
* 获取以 {@code separator}分割过后的 scope 信息
*
* @param separator 多个 {@code scope} 间的分隔符
* @param encode 是否 encode 编码
* @param defaultScopes 默认的 scope 当客户端没有配置 {@code scopes} 时启用
* @return String
*/
protected String getScopes(String separator, boolean encode, List<String> defaultScopes) {
List<String> scopes = config.getScopes();
if (null == scopes || scopes.isEmpty()) {
if (null == defaultScopes || defaultScopes.isEmpty()) {
return "";
}
scopes = defaultScopes;
}
if (null == separator) {
// 默认为空格
separator = " ";
}
String scopeStr = String.join(separator, scopes);
return encode ? URLUtil.encode(scopeStr) : scopeStr;
}
}

View File

@ -0,0 +1,168 @@
package com.sf.oauth.request;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSONObject;
import com.sf.common.constant.HttpStatus;
import com.sf.common.core.domain.AjaxResult;
import com.sf.oauth.config.AuthConfig;
import com.sf.oauth.enums.AuthDefaultSource;
import com.sf.oauth.enums.AuthUserGender;
import com.sf.oauth.enums.scope.AuthHuaweiScope;
import com.sf.oauth.exception.AuthException;
import com.sf.oauth.domain.AuthCallback;
import com.sf.oauth.domain.AuthToken;
import com.sf.oauth.domain.AuthUser;
import com.sf.oauth.utils.AuthScopeUtils;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.Map;
/**
* 华为授权登录
*
* @author zkun
*/
@Slf4j
public class AuthHuaweiRequest extends AuthDefaultRequest {
public AuthHuaweiRequest(AuthConfig config) {
super(config, AuthDefaultSource.HUAWEI);
}
/**
* 获取access token
*
* @param authCallback 授权成功后的回调参数
* @return token
* @see AuthDefaultRequest#authorize()
* @see AuthDefaultRequest#authorize(String)
*/
@Override
protected AuthToken getAccessToken(AuthCallback authCallback) {
Map<String, Object> form = new HashMap<>(8);
form.put("grant_type", "authorization_code");
form.put("code", authCallback.getCode());
form.put("client_id", config.getClientId());
form.put("client_secret", config.getClientSecret());
form.put("redirect_uri", config.getRedirectUri());
String response = HttpUtil.post(source.accessToken(), form);
return getAuthToken(response);
}
/**
* 使用token换取用户信息
*
* @param authToken token信息
* @return 用户信息
* @see AuthDefaultRequest#getAccessToken(AuthCallback)
*/
@Override
protected AuthUser getUserInfo(AuthToken authToken) {
Map<String, Object> form = new HashMap<>(8);
form.put("nsp_ts", System.currentTimeMillis() + "");
form.put("access_token", authToken.getAccessToken());
form.put("nsp_svc", "GOpen.User.getInfo");
form.put("getNickName", "1");
String response = HttpUtil.post(source.userInfo(), form);
log.info(response);
JSONObject object = JSONObject.parseObject(response);
this.checkResponse(object);
AuthUserGender gender = getRealGender(object);
return AuthUser.builder()
.rawUserInfo(object)
.uuid(object.getString("unionID"))
.username(object.getString("displayName"))
.nickname(object.getString("displayName"))
.gender(gender)
.avatar(object.getString("headPictureURL"))
.mobileNumber(object.getString("mobileNumber"))
.email(object.getString("email"))
.token(authToken)
.source(source.toString())
.build();
}
/**
* 刷新access token 续期
*
* @param authToken 登录成功后返回的Token信息
* @return AuthResponse
*/
@Override
public AjaxResult refresh(AuthToken authToken) {
Map<String, Object> form = new HashMap<>(8);
form.put("client_id", config.getClientId());
form.put("client_secret", config.getClientSecret());
form.put("refresh_token", authToken.getRefreshToken());
form.put("grant_type", "refresh_token");
String response = HttpUtil.post(source.refresh(), form);
return AjaxResult.success(getAuthToken(response));
}
private AuthToken getAuthToken(String response) {
JSONObject object = JSONObject.parseObject(response);
this.checkResponse(object);
return AuthToken.builder()
.accessToken(object.getString("access_token"))
.expireIn(object.getIntValue("expires_in"))
.refreshToken(object.getString("refresh_token"))
.scope(object.getString("scope"))
.tokenType(object.getString("token_type"))
.idToken(object.getString("id_token"))
.build();
}
/**
* 返回带{@code state}参数的授权url授权回调时会带上这个{@code state}
*
* @param state state 验证授权流程的参数可以防止csrf
* @return 返回授权地址
*/
@Override
public String authorize(String state) {
Map<String, Object> form = new HashMap<>(8);
form.put("client_id", config.getClientId());
form.put("client_secret", config.getClientSecret());
form.put("redirect_uri", config.getRedirectUri());
form.put("state", getRealState(state));
form.put("response_type", "code");
form.put("access_type", "offline");
form.put("scope", this.getScopes(" ", true, AuthScopeUtils.getDefaultScopes(AuthHuaweiScope.values())));
return HttpUtil.get(source.authorize(), form);
}
/**
* 获取用户的实际性别华为系统中用户的性别1表示女0表示男
*
* @param object obj
* @return AuthUserGender
*/
private AuthUserGender getRealGender(JSONObject object) {
int genderCodeInt = object.getIntValue("gender");
String genderCode = genderCodeInt == 0 ? "0" : (genderCodeInt == 1) ? "1" : genderCodeInt + "";
return AuthUserGender.getRealGender(genderCode);
}
/**
* 校验响应结果
*
* @param object 接口返回的结果
*/
private void checkResponse(JSONObject object) {
if (object.containsKey("NSP_STATUS")) {
throw new AuthException(object.getString("error"));
}
if (object.containsKey("error")) {
throw new AuthException(object.getString("sub_error") + ":" + object.getString("error_description"));
}
}
}

View File

@ -0,0 +1,76 @@
package com.sf.oauth.request;
import com.sf.common.constant.HttpStatus;
import com.sf.common.core.domain.AjaxResult;
import com.sf.oauth.domain.AuthUser;
import com.sf.oauth.exception.AuthException;
import com.sf.oauth.domain.AuthCallback;
import com.sf.oauth.domain.AuthToken;
/**
* {@code Request}公共接口所有平台的{@code Request}都需要实现该接口
* <p>
* {@link AuthRequest#authorize()}
* {@link AuthRequest#authorize(String)}
* {@link AuthRequest#callback(AuthCallback)}
* {@link AuthRequest#revoke(AuthToken)}
* {@link AuthRequest#refresh(AuthToken)}
*
* @author zoukun
*/
public interface AuthRequest {
/**
* 返回授权url可自行跳转页面
* <p>
* 不建议使用该方式获取授权地址不带{@code state}的授权地址容易受到csrf攻击
* 建议使用{@link AuthDefaultRequest#authorize(String)}方法生成授权地址在回调方法中对{@code state}进行校验
*
* @return 返回授权地址
*/
@Deprecated
default String authorize() {
throw new AuthException(HttpStatus.UNAUTHORIZED,"Not Implemented");
}
/**
* 返回带{@code state}参数的授权url授权回调时会带上这个{@code state}
*
* @param state state 验证授权流程的参数可以防止csrf
* @return 返回授权地址
*/
default String authorize(String state) {
throw new AuthException(HttpStatus.UNAUTHORIZED,"Not Implemented");
}
/**
* 第三方登录
*
* @param authCallback 用于接收回调参数的实体
* @return 返回登录成功后的用户信息
*/
default AuthUser callback(AuthCallback authCallback) {
throw new AuthException(HttpStatus.UNAUTHORIZED,"Not Implemented");
}
/**
* 撤销授权
*
* @param authToken 登录成功后返回的Token信息
* @return AjaxResult
*/
default AjaxResult revoke(AuthToken authToken) {
throw new AuthException(HttpStatus.UNAUTHORIZED,"Not Implemented");
}
/**
* 刷新access token 续期
*
* @param authToken 登录成功后返回的Token信息
* @return AjaxResult
*/
default AjaxResult refresh(AuthToken authToken) {
throw new AuthException(HttpStatus.UNAUTHORIZED,"Not Implemented");
}
}

View File

@ -0,0 +1,15 @@
package com.sf.oauth.service;
import com.sf.oauth.domain.AuthUser;
import javax.servlet.http.HttpSession;
/**
* 功能描述:
*
* @author a_kun
* @date 2024/4/12 10:21
*/
public interface IAuthService {
String authLogin(AuthUser authUser, HttpSession session);
}

View File

@ -0,0 +1,62 @@
package com.sf.oauth.service.impl;
import com.sf.common.core.domain.AjaxResult;
import com.sf.common.core.domain.entity.SysUser;
import com.sf.common.enums.UserStatus;
import com.sf.common.utils.SecurityUtils;
import com.sf.common.utils.ip.IpUtils;
import com.sf.framework.web.service.SysLoginService;
import com.sf.oauth.domain.AuthUser;
import com.sf.oauth.exception.AuthException;
import com.sf.oauth.service.IAuthService;
import com.sf.system.service.ISysUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpSession;
import java.util.Date;
/**
* 功能描述:
*
* @author a_kun
* @date 2024/4/12 10:21
*/
@Slf4j
@Service
public class IAuthServiceImpl implements IAuthService {
@Autowired
private SysLoginService loginService;
@Autowired
private ISysUserService userService;
@Override
public String authLogin(AuthUser authUser, HttpSession session) {
// TODO 应该要登录后绑定一个平台账号没有平台账号要创建保存用户的三方来源及用户的三方唯一标识和绑定的用户
if (authUser != null) {
SysUser sysUser = new SysUser();
sysUser.setUserName(authUser.getUsername());
if (userService.checkUserNameUnique(sysUser)) {
// 注册用户后登录
sysUser.setNickName(authUser.getNickname());
sysUser.setEmail(authUser.getEmail());
sysUser.setPhonenumber(authUser.getMobileNumber());
sysUser.setSex(authUser.getGender().getCode());
sysUser.setAvatar(authUser.getAvatar());
sysUser.setPassword(SecurityUtils.encryptPassword("ztzh@sac123"));
sysUser.setStatus(UserStatus.OK.getCode());
sysUser.setDelFlag("0");
sysUser.setLoginIp(IpUtils.getHostIp());
sysUser.setLoginDate(new Date());
userService.insertUser(sysUser);
}
// 生成令牌
return loginService.noPwdLogin(authUser.getUsername(), session);
}
throw new AuthException("login error");
}
}

View File

@ -0,0 +1,79 @@
package com.sf.oauth.utils;
import cn.hutool.core.util.StrUtil;
import com.sf.common.constant.HttpStatus;
import com.sf.oauth.config.AuthConfig;
import com.sf.oauth.enums.AuthDefaultSource;
import com.sf.oauth.config.AuthSource;
import com.sf.oauth.exception.AuthException;
import com.sf.oauth.domain.AuthCallback;
/**
* 授权配置类的校验器
*
* @author ZK
*/
public class AuthChecker {
/**
* 是否支持第三方登录
*
* @param config config
* @param source source
* @return true or false
*/
public static boolean isSupportedAuth(AuthConfig config, AuthSource source) {
return StrUtil.isNotEmpty(config.getClientId())
&& StrUtil.isNotEmpty(config.getClientSecret())
&& null != source;
}
/**
* 检查配置合法性针对部分平台 对redirect uri有特定要求一般来说redirect uri都是http://
* 而对于部分平台 redirect uri 必须是https的链接
*
* @param config config
* @param source source
*/
public static void checkConfig(AuthConfig config, AuthSource source) {
String redirectUri = config.getRedirectUri();
if (StrUtil.isEmpty(redirectUri)) {
throw new AuthException(HttpStatus.BAD_REQUEST, "Illegal redirect uri", source);
}
if (!AuthUtils.isHttpProtocol(redirectUri) && !AuthUtils.isHttpsProtocol(redirectUri)) {
throw new AuthException(HttpStatus.BAD_REQUEST, "Illegal redirect uri", source);
}
}
/**
* 校验回调传回的code
* <p>
* {@code v1.10.0}版本中改为传入{@code source}{@code callback}对于不同平台使用不同参数接受code的情况统一做处理
*
* @param source 当前授权平台
* @param callback 从第三方授权回调回来时传入的参数集合
*/
public static void checkCode(AuthSource source, AuthCallback callback) {
if (StrUtil.isEmpty(callback.getCode())) {
throw new AuthException(HttpStatus.UNAUTHORIZED,"Illegal code", source);
}
}
/**
* 校验回调传回的{@code state}为空或者不存在
* <p>
* {@code state}不存在的情况只有两种
* 1. {@code state}已使用被正常清除
* 2. {@code state}为前端伪造本身就不存在
*
* @param state {@code state}一定不为空
* @param source {@code source}当前授权平台
*/
public static void checkState(String state, AuthSource source) {
if (StrUtil.isEmpty(state)) {
throw new AuthException(HttpStatus.UNAUTHORIZED,"Illegal state", source);
}
}
}

View File

@ -0,0 +1,44 @@
package com.sf.oauth.utils;
import com.sf.oauth.enums.scope.AuthScope;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* Scope 工具类提供对 scope 类的统一操作
*
* @author zoukun
*/
public class AuthScopeUtils {
/**
* 获取 {@link com.sf.oauth.enums.scope.AuthScope} 数组中所有的被标记为 {@code default} scope
*
* @param scopes scopes
* @return List
*/
public static List<String> getDefaultScopes(AuthScope[] scopes) {
if (null == scopes || scopes.length == 0) {
return null;
}
return Arrays.stream(scopes)
.filter((AuthScope::isDefault))
.map(AuthScope::getScope)
.collect(Collectors.toList());
}
/**
* {@link com.sf.oauth.enums.scope.AuthScope} 数组中获取实际的 scope 字符串
*
* @param scopes 可变参数支持传任意 {@link com.sf.oauth.enums.scope.AuthScope}
* @return List
*/
public static List<String> getScopes(AuthScope... scopes) {
if (null == scopes || scopes.length == 0) {
return null;
}
return Arrays.stream(scopes).map(AuthScope::getScope).collect(Collectors.toList());
}
}

View File

@ -0,0 +1,127 @@
package com.sf.oauth.utils;
import cn.hutool.core.util.StrUtil;
import com.sf.oauth.exception.AuthException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class AuthUtils {
private static final Charset DEFAULT_ENCODING;
private static final String HMAC_SHA1 = "HmacSHA1";
private static final String HMAC_SHA_256 = "HmacSHA256";
public AuthUtils() {
}
public static String urlEncode(String value) {
if (value == null) {
return "";
} else {
try {
String encoded = URLEncoder.encode(value, DEFAULT_ENCODING.displayName());
return encoded.replace("+", "%20").replace("*", "%2A").replace("~", "%7E").replace("/", "%2F");
} catch (UnsupportedEncodingException var2) {
throw new AuthException("Failed To Encode Uri", var2);
}
}
}
public static String urlDecode(String value) {
if (value == null) {
return "";
} else {
try {
return URLDecoder.decode(value, DEFAULT_ENCODING.displayName());
} catch (UnsupportedEncodingException var2) {
throw new AuthException("Failed To Decode Uri", var2);
}
}
}
public static String parseMapToString(Map<String, String> params, boolean encode) {
if (null != params && !params.isEmpty()) {
List<String> paramList = new ArrayList();
params.forEach((k, v) -> {
if (null == v) {
paramList.add(k + "=");
} else {
paramList.add(k + "=" + (encode ? urlEncode(v) : v));
}
});
return String.join("&", paramList);
} else {
return "";
}
}
public static boolean isHttpProtocol(String url) {
if (StrUtil.isEmpty(url)) {
return false;
} else {
return url.startsWith("http://") || url.startsWith("http%3A%2F%2F");
}
}
public static boolean isHttpsProtocol(String url) {
if (StrUtil.isEmpty(url)) {
return false;
} else {
return url.startsWith("https://") || url.startsWith("https%3A%2F%2F");
}
}
public static boolean isLocalHost(String url) {
return StrUtil.isEmpty(url) || url.contains("127.0.0.1") || url.contains("localhost");
}
public static boolean isHttpsProtocolOrLocalHost(String url) {
if (StrUtil.isEmpty(url)) {
return false;
} else {
return isHttpsProtocol(url) || isLocalHost(url);
}
}
public static String getTimestamp() {
return String.valueOf(System.currentTimeMillis() / 1000L);
}
public static String md5(String str) {
MessageDigest md = null;
StringBuilder buffer = null;
try {
md = MessageDigest.getInstance("MD5");
md.update(str.getBytes(StandardCharsets.UTF_8));
byte[] byteData = md.digest();
buffer = new StringBuilder();
byte[] var4 = byteData;
int var5 = byteData.length;
for(int var6 = 0; var6 < var5; ++var6) {
byte byteDatum = var4[var6];
buffer.append(Integer.toString((byteDatum & 255) + 256, 16).substring(1));
}
} catch (Exception var8) {
}
return null == buffer ? "" : buffer.toString();
}
static {
DEFAULT_ENCODING = StandardCharsets.UTF_8;
}
}