feat: [auth] design ThirdPartyLoginService

This commit is contained in:
tjq 2024-02-10 14:11:14 +08:00
parent 4793c19af6
commit cda55c918b
18 changed files with 681 additions and 0 deletions

View File

@ -22,6 +22,7 @@
<module>powerjob-server-migrate</module>
<module>powerjob-server-core</module>
<module>powerjob-server-monitor</module>
<module>powerjob-server-auth</module>
</modules>

View File

@ -0,0 +1,58 @@
<?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>
<groupId>tech.powerjob</groupId>
<artifactId>powerjob-server</artifactId>
<version>4.3.7</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>powerjob-server-auth</artifactId>
<version>${project.parent.version}</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jjwt.version>0.11.5</jjwt.version>
<dingtalk.version>1.1.86</dingtalk.version>
</properties>
<dependencies>
<dependency>
<groupId>tech.powerjob</groupId>
<artifactId>powerjob-server-persistence</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dingtalk</artifactId>
<version>${dingtalk.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,40 @@
package tech.powerjob.server.auth;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 权限
*
* @author tjq
* @since 2023/3/20
*/
@Getter
@AllArgsConstructor
public enum Permission {
/**
* 不需要权限
*/
NONE(1),
/**
* 读权限查看控制台数据
*/
READ(10),
/**
* 写权限新增/修改任务等
*/
WRITE(20),
/**
* 运维权限比如任务的执行
*/
OPS(30),
/**
* 超级权限
*/
SU(100)
;
private int v;
}

View File

@ -0,0 +1,53 @@
package tech.powerjob.server.auth;
import com.google.common.collect.Sets;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Set;
import static tech.powerjob.server.auth.Permission.*;
/**
* 角色
* PowerJob 采用 RBAC 实现权限出于实际需求的考虑不决定采用动态权限模型因此 RBAC 中的角色和权限均在此处定义
* 如果有自定义诉求可以修改 Role 的定义
*
* @author tjq
* @since 2023/3/20
*/
@Getter
@AllArgsConstructor
public enum Role {
/**
* 观察者默认只读权限
*/
OBSERVER(10, Sets.newHashSet(READ)),
/**
* 技术质量 + 操作权限
*/
QA(20, Sets.newHashSet(READ, OPS)),
/**
* 开发者 + 编辑 + 操作权限
*/
DEVELOPER(30, Sets.newHashSet(READ, WRITE, OPS)),
/**
* 管理员
*/
ADMIN(40, Sets.newHashSet(READ, WRITE, OPS, SU))
;
private final int v;
private final Set<Permission> permissions;
public static Role of(int vv) {
for (Role role : values()) {
if (vv == role.v) {
return role;
}
}
throw new IllegalArgumentException("unknown role: " + vv);
}
}

View File

@ -0,0 +1,31 @@
package tech.powerjob.server.auth;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 权限范围
*
* @author tjq
* @since 2023/9/3
*/
@Getter
@AllArgsConstructor
public enum RoleScope {
/**
* NAMESPACE 权限
*/
NAMESPACE(1),
/**
* APP 级别权限
*/
APP(10),
/**
* 全局权限
*/
GLOBAL(666)
;
private final int v;
}

View File

@ -0,0 +1,25 @@
package tech.powerjob.server.auth.login;
import lombok.Data;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 登录上下文
*
* @author tjq
* @since 2024/2/10
*/
@Data
public class LoginContext {
/**
* 原始参数给第三方登录方式一个服务端和前端交互的数据通道PowerJob 本身不感知其中的内容
*/
private String originParams;
private transient HttpServletRequest httpServletRequest;
private transient HttpServletResponse httpServletResponse;
}

View File

@ -0,0 +1,30 @@
package tech.powerjob.server.auth.login;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* 登录类型描述
*
* @author tjq
* @since 2024/2/10
*/
@Data
@Accessors(chain = true)
public class LoginTypeInfo implements Serializable {
/**
* 登录类型唯一标识
*/
private String type;
/**
* 描述名称前端展示用
*/
private String name;
/**
* 展示用的 ICON
*/
private String iconUrl;
}

View File

@ -0,0 +1,31 @@
package tech.powerjob.server.auth.login;
/**
* 第三方登录服务
*
* @author tjq
* @since 2024/2/10
*/
public interface ThirdPartyLoginService {
/**
* 登陆服务的类型
* @return 登陆服务类型比如 PowerJob / DingTalk
*/
LoginTypeInfo loginType();
/**
* 生成登陆的重定向 URL
* @param loginContext 上下文
* @return 重定向地址
*/
String generateLoginUrl(LoginContext loginContext);
/**
* 执行第三方登录
* @param loginContext 上下文
* @return 登录地址
*/
ThirdPartyUser login(LoginContext loginContext);
}

View File

@ -0,0 +1,38 @@
package tech.powerjob.server.auth.login;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 第三方用户
*
* @author tjq
* @since 2024/2/10
*/
@Data
@Accessors(chain = true)
public class ThirdPartyUser {
/**
* 用户的唯一标识用于关联到 PowerJob username
*/
private String username;
/* ******** 以下全部选填即可,只是方便数据同步,后续都可以去 PowerJob 控制台更改 ******** */
/**
* 用户昵称
*/
private String nick;
/**
* 手机号
*/
private String phone;
/**
* 邮箱地址
*/
private String email;
/**
* 扩展字段
*/
private String extra;
}

View File

@ -0,0 +1,143 @@
package tech.powerjob.server.auth.login.impl;
import com.aliyun.dingtalkcontact_1_0.models.GetUserHeaders;
import com.aliyun.dingtalkcontact_1_0.models.GetUserResponseBody;
import com.aliyun.dingtalkoauth2_1_0.models.GetUserTokenRequest;
import com.aliyun.dingtalkoauth2_1_0.models.GetUserTokenResponse;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.models.RuntimeOptions;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import tech.powerjob.common.exception.PowerJobException;
import tech.powerjob.server.auth.login.LoginContext;
import tech.powerjob.server.auth.login.LoginTypeInfo;
import tech.powerjob.server.auth.login.ThirdPartyLoginService;
import tech.powerjob.server.auth.login.ThirdPartyUser;
import tech.powerjob.server.common.Loggers;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* <a href="https://open.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information">钉钉账号体系登录第三方网站</a>
* PowerJob 官方支持钉钉账号体系登录原因
* 1. 钉钉作为当下用户体量最大的企业级办公软件覆盖率足够高提供钉钉支持能让更多开发者开箱即用
* 2. 钉钉的 API 设计和 PowerJob 设想一致算是个最佳实践其他企业内部的账号体系可参考这套流程进行接入
* - PowerJob 重定向到第三方账号体系登陆页 -> 第三方完成登陆 -> 跳转回调 PowerJob auth 接口 -> PowerJob 解析回调登陆信息完整用户关联
*
* @author tjq
* @since 2023/3/26
*/
public class DingTalkLoginService implements ThirdPartyLoginService {
/*
配置示例
oms.auth.dingtalk.appkey=dinggzqqzqqzqqzqq
oms.auth.dingtalk.appSecret=iY-FS8mzqqzqq_xEizqqzqqzqqzqqzqqzqqYEbkZOal
oms.auth.dingtalk.callbackUrl=http://localhost:7700/auth/loginCallback
*/
/**
* 钉钉应用 AppKey
*/
@Value("${oms.auth.dingtalk.appkey:#{null}}")
private String dingTalkAppKey;
/**
* 钉钉应用 AppSecret
*/
@Value("${oms.auth.dingtalk.appSecret:#{null}}")
private String dingTalkAppSecret;
/**
* 回调地址powerjob-server 地址 + /user/auth
* 比如本地调试时为 <a href="http://localhost:7700/auth/loginCallback">LocalDemoCallbackUrl</a>
* 部署后则为 <a href="http://try.powerjob.tech/auth/loginCallback">demoCallBackUrl</a>
*/
@Value("${oms.auth.dingtalk.callbackUrl:#{null}}")
private String dingTalkCallbackUrl;
private static final String DING_TALK = "DingTalk";
@Override
public LoginTypeInfo loginType() {
return new LoginTypeInfo()
.setType(DING_TALK)
.setName("钉钉登录")
;
}
@Override
@SneakyThrows
public String generateLoginUrl(LoginContext loginContext) {
if (StringUtils.isAnyEmpty(dingTalkAppKey, dingTalkAppSecret, dingTalkCallbackUrl)) {
throw new IllegalArgumentException("please config 'oms.auth.dingtalk.appkey', 'oms.auth.dingtalk.appSecret' and 'oms.auth.dingtalk.callbackUrl' in properties!");
}
String urlString = URLEncoder.encode(dingTalkCallbackUrl, StandardCharsets.UTF_8.name());
String url = "https://login.dingtalk.com/oauth2/auth?" +
"redirect_uri=" + urlString +
"&response_type=code" +
"&client_id=" + dingTalkAppKey +
"&scope=openid" +
"&state=" + DING_TALK +
"&prompt=consent";
Loggers.WEB.info("[DingTalkBizLoginService] login url: {}", url);
return url;
}
@Override
@SneakyThrows
public ThirdPartyUser login(LoginContext loginContext) {
try {
com.aliyun.dingtalkoauth2_1_0.Client client = authClient();
GetUserTokenRequest getUserTokenRequest = new GetUserTokenRequest()
//应用基础信息-应用信息的AppKey,请务必替换为开发的应用AppKey
.setClientId(dingTalkAppKey)
//应用基础信息-应用信息的AppSecret,请务必替换为开发的应用AppSecret
.setClientSecret(dingTalkAppSecret)
.setCode(loginContext.getHttpServletRequest().getParameter("authCode"))
.setGrantType("authorization_code");
GetUserTokenResponse getUserTokenResponse = client.getUserToken(getUserTokenRequest);
//获取用户个人 token
String accessToken = getUserTokenResponse.getBody().getAccessToken();
// 查询钉钉用户
final GetUserResponseBody dingUser = getUserinfo(accessToken);
// 将钉钉用户的唯一ID PowerJob 账户体系的唯一键 username 关联
if (dingUser != null) {
ThirdPartyUser bizUser = new ThirdPartyUser();
bizUser.setUsername(dingUser.getUnionId());
bizUser.setNick(dingUser.getNick());
bizUser.setPhone(dingUser.getMobile());
bizUser.setEmail(dingUser.getEmail());
return bizUser;
}
} catch (Exception e) {
Loggers.WEB.error("[DingTalkBizLoginService] login by dingTalk failed!", e);
throw e;
}
throw new PowerJobException("login from dingTalk failed!");
}
/* 以下代码均拷自钉钉官网示例 */
private static com.aliyun.dingtalkoauth2_1_0.Client authClient() throws Exception {
Config config = new Config();
config.protocol = "https";
config.regionId = "central";
return new com.aliyun.dingtalkoauth2_1_0.Client(config);
}
private static com.aliyun.dingtalkcontact_1_0.Client contactClient() throws Exception {
Config config = new Config();
config.protocol = "https";
config.regionId = "central";
return new com.aliyun.dingtalkcontact_1_0.Client(config);
}
private GetUserResponseBody getUserinfo(String accessToken) throws Exception {
com.aliyun.dingtalkcontact_1_0.Client client = contactClient();
GetUserHeaders getUserHeaders = new GetUserHeaders();
getUserHeaders.xAcsDingtalkAccessToken = accessToken;
//获取用户个人信息如需获取当前授权人的信息unionId参数必须传me
return client.getUserWithOptions("me", getUserHeaders, new RuntimeOptions()).getBody();
}
}

View File

@ -0,0 +1,85 @@
package tech.powerjob.server.auth.login.impl;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import tech.powerjob.common.exception.PowerJobException;
import tech.powerjob.server.auth.login.LoginContext;
import tech.powerjob.server.auth.login.LoginTypeInfo;
import tech.powerjob.server.auth.login.ThirdPartyLoginService;
import tech.powerjob.server.auth.login.ThirdPartyUser;
import tech.powerjob.server.common.Loggers;
import tech.powerjob.server.common.SJ;
import tech.powerjob.server.common.utils.DigestUtils;
import tech.powerjob.server.persistence.remote.model.UserInfoDO;
import tech.powerjob.server.persistence.remote.repository.UserInfoRepository;
import javax.annotation.Resource;
import java.util.Map;
import java.util.Optional;
/**
* PowerJob 自带的登陆服务
* 和应用主框架无关依然属于第三方登录体系
*
* @author tjq
* @since 2023/3/20
*/
@Service
public class PowerJobLoginService implements ThirdPartyLoginService {
@Resource
private UserInfoRepository userInfoRepository;
private static final String POWER_JOB_LOGIN_SERVICE = "PowerJob";
private static final String KEY_USERNAME = "username";
private static final String KEY_PASSWORD = "password";
@Override
public LoginTypeInfo loginType() {
return new LoginTypeInfo()
.setType(POWER_JOB_LOGIN_SERVICE)
.setName("PowerJob's built-in login system")
;
}
@Override
public String generateLoginUrl(LoginContext loginContext) {
// 前端实现跳转服务端返回特殊指令
return "FE-REDIRECT:PowerJob";
}
@Override
public ThirdPartyUser login(LoginContext loginContext) {
final String loginInfo = loginContext.getOriginParams();
if (StringUtils.isEmpty(loginInfo)) {
throw new IllegalArgumentException("can't find login Info");
}
final Map<String, String> loginInfoMap = SJ.splitKvString(loginInfo);
final String username = loginInfoMap.get(KEY_USERNAME);
final String password = loginInfoMap.get(KEY_PASSWORD);
if (StringUtils.isAnyEmpty(username, password)) {
Loggers.WEB.debug("[PowerJobLoginService] username or password is empty, login failed!");
throw new IllegalArgumentException("username or password is empty!");
}
final Optional<UserInfoDO> userInfoOpt = userInfoRepository.findByUsername(username);
if (!userInfoOpt.isPresent()) {
Loggers.WEB.debug("[PowerJobLoginService] can't find user by username: {}", username);
throw new PowerJobException("can't find user by username: " + username);
}
final UserInfoDO dbUser = userInfoOpt.get();
if (DigestUtils.rePassword(password, username).equals(dbUser.getPassword())) {
ThirdPartyUser bizUser = new ThirdPartyUser();
bizUser.setUsername(username);
return bizUser;
}
Loggers.WEB.debug("[PowerJobLoginService] user[{}]'s password is incorrect, login failed!", username);
throw new PowerJobException("password is incorrect");
}
}

View File

@ -0,0 +1,18 @@
package tech.powerjob.server.common;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 统一定义日志
*
* @author tjq
* @since 2023/3/25
*/
public class Loggers {
/**
* Web 层统一日志
*/
public static final Logger WEB = LoggerFactory.getLogger("P_SERVER_LOGGER_WEB");
}

View File

@ -3,6 +3,8 @@ package tech.powerjob.server.common;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import java.util.Map;
/**
* Splitter & Joiner
*
@ -16,4 +18,9 @@ public class SJ {
public static final Joiner MONITOR_JOINER = Joiner.on("|").useForNull("-");
private static final Splitter.MapSplitter MAP_SPLITTER = Splitter.onPattern(";").withKeyValueSeparator(":");
public static Map<String, String> splitKvString(String kvString) {
return MAP_SPLITTER.split(kvString);
}
}

View File

@ -0,0 +1,41 @@
package tech.powerjob.server.common.utils;
import lombok.SneakyThrows;
import java.math.BigInteger;
import java.security.MessageDigest;
/**
* 加密工具
*
* @author tjq
* @since 2023/3/25
*/
public class DigestUtils {
/**
* 32位小写 md5
* @param input 输入
* @return md5
*/
@SneakyThrows
public static String md5(String input) {
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update(input.getBytes());
byte[] byteArray = md5.digest();
BigInteger bigInt = new BigInteger(1, byteArray);
// 参数16表示16进制
StringBuilder result = new StringBuilder(bigInt.toString(16));
// 不足32位高位补零
while(result.length() < 32) {
result.insert(0, "0");
}
return result.toString();
}
public static String rePassword(String password, String salt) {
String f1 = String.format("%s_%s_z", salt, password);
return String.format("%s_%s_b", salt, md5(f1));
}
}

View File

@ -26,6 +26,11 @@ public class UserInfoDO {
private Long id;
private String username;
/**
* since 5.0.0
* 昵称第三方登陆的 username 很难识别方便后续展示引入 nick
*/
private String nick;
private String password;
/**

View File

@ -0,0 +1,53 @@
package tech.powerjob.server.persistence.remote.model;
import lombok.Data;
import org.hibernate.annotations.GenericGenerator;
import javax.persistence.*;
import java.util.Date;
/**
* 用户角色表
*
* @author tjq
* @since 2023/3/20
*/
@Data
@Entity
@Table(indexes = {
@Index(name = "uidx01_user_id", columnList = "userId")
})
public class UserRoleDO {
@Id
@GeneratedValue(strategy = GenerationType.AUTO, generator = "native")
@GenericGenerator(name = "native", strategy = "native")
private Long id;
/**
* 授予角色的用户ID
*/
private Long userId;
/**
* 权限范围namespace 还是 app
*/
private Integer scope;
/**
* scope 一起组成授权目标比如某个 app 某个 namespace
*/
private Long target;
/**
* 角色比如 Observer
*/
private Integer role;
/**
* 扩展字段
*/
private String extra;
private Date gmtCreate;
private Date gmtModified;
}

View File

@ -4,6 +4,7 @@ import tech.powerjob.server.persistence.remote.model.UserInfoDO;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 用户信息表数据库访问层
@ -13,6 +14,8 @@ import java.util.List;
*/
public interface UserInfoRepository extends JpaRepository<UserInfoDO, Long> {
Optional<UserInfoDO> findByUsername(String username);
List<UserInfoDO> findByUsernameLike(String username);
List<UserInfoDO> findByIdIn(List<Long> userIds);

View File

@ -0,0 +1,19 @@
package tech.powerjob.server.persistence.remote.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import tech.powerjob.server.persistence.remote.model.UserRoleDO;
import java.util.List;
/**
* UserRoleRepository
*
* @author tjq
* @since 2023/3/20
*/
public interface UserRoleRepository extends JpaRepository<UserRoleDO, Long> {
List<UserRoleDO> findAllByUserId(Long userId);
List<UserRoleDO> findAllByScopeAndTarget(Integer scope, Long target);
}