feat: [auth] PowerJobLoginService

This commit is contained in:
tjq 2024-02-11 10:14:47 +08:00
parent cda55c918b
commit 0caa854409
19 changed files with 558 additions and 23 deletions

View File

@ -97,6 +97,11 @@
<artifactId>powerjob-server-migrate</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>tech.powerjob</groupId>
<artifactId>powerjob-server-auth</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>tech.powerjob</groupId>
<artifactId>powerjob-server-starter</artifactId>

View File

@ -0,0 +1,24 @@
package tech.powerjob.server.auth;
/**
* LoginUserHolder
*
* @author tjq
* @since 2023/4/16
*/
public class LoginUserHolder {
private static final ThreadLocal<PowerJobUser> TL = new ThreadLocal<>();
public static PowerJobUser get() {
return TL.get();
}
public static void set(PowerJobUser powerJobUser) {
TL.set(powerJobUser);
}
public static void clean() {
TL.remove();
}
}

View File

@ -0,0 +1,44 @@
package tech.powerjob.server.auth;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
/**
* PowerJob 登陆用户
*
* @author tjq
* @since 2023/3/20
*/
@Getter
@Setter
@ToString
public class PowerJobUser implements Serializable {
private Long id;
private String username;
/**
* 手机号
*/
private String phone;
/**
* 邮箱地址
*/
private String email;
/**
* webHook
*/
private String webHook;
/**
* 扩展字段
*/
private String extra;
/* ************** 以上为数据库字段 ************** */
private String jwtToken;
}

View File

@ -0,0 +1,29 @@
package tech.powerjob.server.auth.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 鉴权错误信息
*
* @author tjq
* @since 2024/2/11
*/
@Getter
@AllArgsConstructor
public enum AuthErrorCode {
USER_NOT_LOGIN("-100", "UserNotLoggedIn"),
NO_PERMISSION("-200", "NoPermission"),
/**
* 无效请求一般是参数问题
*/
INVALID_REQUEST("-300", "INVALID_REQUEST")
;
private final String code;
private final String msg;
}

View File

@ -0,0 +1,27 @@
package tech.powerjob.server.auth.common;
import lombok.Getter;
/**
* 鉴权相关错误
*
* @author tjq
* @since 2024/2/10
*/
@Getter
public class PowerJobAuthException extends RuntimeException {
private final String code;
private final String msg;
public PowerJobAuthException(AuthErrorCode errorCode) {
this.code = errorCode.getCode();
this.msg = errorCode.getMsg();
}
public PowerJobAuthException(AuthErrorCode errorCode, String extraMsg) {
this.code = errorCode.getCode();
this.msg = errorCode.getMsg().concat(":").concat(extraMsg);
}
}

View File

@ -0,0 +1,16 @@
package tech.powerjob.server.auth.jwt;
import java.util.Map;
/**
* JWT 服务
*
* @author tjq
* @since 2023/3/20
*/
public interface JwtService {
String build(Map<String, Object> body, String extraSk);
Map<String, Object> parse(String jwt, String extraSk);
}

View File

@ -0,0 +1,13 @@
package tech.powerjob.server.auth.jwt;
/**
* JWT 安全性的核心
* 对安全性有要求的接入方可以自行重新该方法自定义自己的安全 token 生成策略
*
* @author tjq
* @since 2023/3/20
*/
public interface SecretProvider {
String fetchSecretKey();
}

View File

@ -0,0 +1,18 @@
package tech.powerjob.server.auth.jwt.impl;
import org.springframework.stereotype.Component;
import tech.powerjob.server.auth.jwt.SecretProvider;
/**
* PowerJob 默认实现
*
* @author tjq
* @since 2023/3/20
*/
@Component
public class DefaultSecretProvider implements SecretProvider {
@Override
public String fetchSecretKey() {
return "ZQQZJ";
}
}

View File

@ -0,0 +1,92 @@
package tech.powerjob.server.auth.jwt.impl;
import com.google.common.collect.Maps;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import tech.powerjob.server.auth.jwt.JwtService;
import tech.powerjob.server.auth.jwt.SecretProvider;
import javax.annotation.Resource;
import java.security.Key;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
/**
* JWT 默认实现
*
* @author tjq
* @since 2023/3/20
*/
@Service
public class JwtServiceImpl implements JwtService {
@Resource
private SecretProvider secretProvider;
/**
* JWT 客户端过期时间
*/
@Value("${oms.auth.security.jwt.expire-seconds:604800}")
private int jwtExpireTime;
/**
* <a href="https://music.163.com/#/song?id=167975">GoodSong</a>
*/
private static final String BASE_SECURITY =
"CengMengXiangZhangJianZouTianYa" +
"KanYiKanShiJieDeFanHua" +
"NianShaoDeXinZongYouXieQingKuang" +
"RuJinWoSiHaiWeiJia"
;
@Override
public String build(Map<String, Object> body, String extraSk) {
final String secret = fetchSk(extraSk);
return innerBuild(secret, jwtExpireTime, body);
}
static String innerBuild(String secret, int expireSeconds, Map<String, Object> body) {
JwtBuilder jwtBuilder = Jwts.builder()
.setHeaderParam("typ", "JWT")
.addClaims(body)
.setSubject("PowerJob")
.setExpiration(new Date(System.currentTimeMillis() + 1000L * expireSeconds))
.setId(UUID.randomUUID().toString())
.signWith(genSecretKey(secret), SignatureAlgorithm.HS256);
return jwtBuilder.compact();
}
@Override
public Map<String, Object> parse(String jwt, String extraSk) {
return innerParse(fetchSk(extraSk), jwt);
}
private String fetchSk(String extraSk) {
if (StringUtils.isEmpty(extraSk)) {
return secretProvider.fetchSecretKey();
}
return secretProvider.fetchSecretKey().concat(extraSk);
}
static Map<String, Object> innerParse(String secret, String jwtStr) {
final Jws<Claims> claimsJws = Jwts.parserBuilder()
.setSigningKey(genSecretKey(secret))
.build()
.parseClaimsJws(jwtStr);
Map<String, Object> ret = Maps.newHashMap();
ret.putAll(claimsJws.getBody());
return ret;
}
private static Key genSecretKey(String secret) {
byte[] keyBytes = Decoders.BASE64.decode(BASE_SECURITY.concat(secret));
return Keys.hmacShaKeyFor(keyBytes);
}
}

View File

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

View File

@ -1,5 +1,7 @@
package tech.powerjob.server.auth.login;
import javax.servlet.http.HttpServletRequest;
/**
* 第三方登录服务
*
@ -19,13 +21,13 @@ public interface ThirdPartyLoginService {
* @param loginContext 上下文
* @return 重定向地址
*/
String generateLoginUrl(LoginContext loginContext);
String generateLoginUrl(HttpServletRequest httpServletRequest);
/**
* 执行第三方登录
* @param loginContext 上下文
* @return 登录地址
*/
ThirdPartyUser login(LoginContext loginContext);
ThirdPartyUser login(ThirdPartyLoginRequest loginRequest);
}

View File

@ -10,12 +10,10 @@ 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.auth.login.*;
import tech.powerjob.server.common.Loggers;
import javax.servlet.http.HttpServletRequest;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@ -68,7 +66,7 @@ public class DingTalkLoginService implements ThirdPartyLoginService {
@Override
@SneakyThrows
public String generateLoginUrl(LoginContext loginContext) {
public String generateLoginUrl(HttpServletRequest httpServletRequest) {
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!");
}
@ -87,7 +85,7 @@ public class DingTalkLoginService implements ThirdPartyLoginService {
@Override
@SneakyThrows
public ThirdPartyUser login(LoginContext loginContext) {
public ThirdPartyUser login(ThirdPartyLoginRequest loginRequest) {
try {
com.aliyun.dingtalkoauth2_1_0.Client client = authClient();
GetUserTokenRequest getUserTokenRequest = new GetUserTokenRequest()
@ -95,7 +93,7 @@ public class DingTalkLoginService implements ThirdPartyLoginService {
.setClientId(dingTalkAppKey)
//应用基础信息-应用信息的AppSecret,请务必替换为开发的应用AppSecret
.setClientSecret(dingTalkAppSecret)
.setCode(loginContext.getHttpServletRequest().getParameter("authCode"))
.setCode(loginRequest.getHttpServletRequest().getParameter("authCode"))
.setGrantType("authorization_code");
GetUserTokenResponse getUserTokenResponse = client.getUserToken(getUserTokenRequest);
//获取用户个人 token

View File

@ -3,8 +3,8 @@ 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.ThirdPartyLoginRequest;
import tech.powerjob.server.auth.login.ThirdPartyLoginService;
import tech.powerjob.server.auth.login.ThirdPartyUser;
import tech.powerjob.server.common.Loggers;
@ -14,6 +14,7 @@ import tech.powerjob.server.persistence.remote.model.UserInfoDO;
import tech.powerjob.server.persistence.remote.repository.UserInfoRepository;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Optional;
@ -25,7 +26,7 @@ import java.util.Optional;
* @since 2023/3/20
*/
@Service
public class PowerJobLoginService implements ThirdPartyLoginService {
public class PowerJobThirdPartyLoginService implements ThirdPartyLoginService {
@Resource
private UserInfoRepository userInfoRepository;
@ -44,14 +45,14 @@ public class PowerJobLoginService implements ThirdPartyLoginService {
}
@Override
public String generateLoginUrl(LoginContext loginContext) {
public String generateLoginUrl(HttpServletRequest httpServletRequest) {
// 前端实现跳转服务端返回特殊指令
return "FE-REDIRECT:PowerJob";
}
@Override
public ThirdPartyUser login(LoginContext loginContext) {
final String loginInfo = loginContext.getOriginParams();
public ThirdPartyUser login(ThirdPartyLoginRequest loginRequest) {
final String loginInfo = loginRequest.getOriginParams();
if (StringUtils.isEmpty(loginInfo)) {
throw new IllegalArgumentException("can't find login Info");
}

View File

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

View File

@ -0,0 +1,47 @@
package tech.powerjob.server.auth.service.login;
import tech.powerjob.server.auth.PowerJobUser;
import tech.powerjob.server.auth.common.PowerJobAuthException;
import tech.powerjob.server.auth.login.LoginTypeInfo;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Optional;
/**
* PowerJob 登录服务
*
* @author tjq
* @since 2024/2/10
*/
public interface PowerJobLoginService {
/**
* 获取全部可登录的类型
* @return 全部可登录类型
*/
List<LoginTypeInfo> fetchSupportLoginTypes();
/**
* 获取第三方登录链接
* @param httpServletRequest http请求
* @return 重定向地址
*/
String fetchThirdPartyLoginUrl(HttpServletRequest httpServletRequest);
/**
* 执行真正的登录请求底层调用第三方登录服务完成登录
* @param loginRequest 登录请求
* @return 登录完成的 PowerJobUser
* @throws PowerJobAuthException 鉴权失败抛出异常
*/
PowerJobUser doLogin(LoginRequest loginRequest) throws PowerJobAuthException;
/**
* JWT 信息中解析用户登录信息
* @param httpServletRequest httpServletRequest
* @return PowerJob 用户
*/
Optional<PowerJobUser> ifLogin(HttpServletRequest httpServletRequest);
}

View File

@ -0,0 +1,160 @@
package tech.powerjob.server.auth.service.login.impl;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tech.powerjob.server.auth.PowerJobUser;
import tech.powerjob.server.auth.common.AuthErrorCode;
import tech.powerjob.server.auth.common.PowerJobAuthException;
import tech.powerjob.server.auth.jwt.JwtService;
import tech.powerjob.server.auth.login.LoginTypeInfo;
import tech.powerjob.server.auth.login.ThirdPartyLoginRequest;
import tech.powerjob.server.auth.login.ThirdPartyLoginService;
import tech.powerjob.server.auth.login.ThirdPartyUser;
import tech.powerjob.server.auth.service.login.LoginRequest;
import tech.powerjob.server.auth.service.login.PowerJobLoginService;
import tech.powerjob.server.common.Loggers;
import tech.powerjob.server.persistence.remote.model.UserInfoDO;
import tech.powerjob.server.persistence.remote.repository.UserInfoRepository;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* PowerJob 登录服务
*
* @author tjq
* @since 2024/2/10
*/
@Slf4j
@Service
public class PowerJobLoginServiceImpl implements PowerJobLoginService {
private final JwtService jwtService;
private final UserInfoRepository userInfoRepository;
private final Map<String, ThirdPartyLoginService> code2ThirdPartyLoginService;
private static final String JWT_NAME = "power_jwt";
private static final String KEY_USERNAME = "userName";
@Autowired
public PowerJobLoginServiceImpl(JwtService jwtService, UserInfoRepository userInfoRepository, List<ThirdPartyLoginService> thirdPartyLoginServices) {
this.jwtService = jwtService;
this.userInfoRepository = userInfoRepository;
code2ThirdPartyLoginService = Maps.newHashMap();
thirdPartyLoginServices.forEach(s -> {
code2ThirdPartyLoginService.put(s.loginType().getType(), s);
log.info("[PowerJobLoginService] register ThirdPartyLoginService: {}", s.loginType());
});
}
@Override
public List<LoginTypeInfo> fetchSupportLoginTypes() {
return Lists.newArrayList(code2ThirdPartyLoginService.values()).stream().map(ThirdPartyLoginService::loginType).collect(Collectors.toList());
}
@Override
public String fetchThirdPartyLoginUrl(HttpServletRequest httpServletRequest) {
return null;
}
@Override
public PowerJobUser doLogin(LoginRequest loginRequest) throws PowerJobAuthException {
final String loginType = loginRequest.getLoginType();
final ThirdPartyLoginService thirdPartyLoginService = fetchBizLoginService(loginType);
ThirdPartyLoginRequest thirdPartyLoginRequest = new ThirdPartyLoginRequest()
.setOriginParams(loginRequest.getOriginParams())
.setHttpServletRequest(loginRequest.getHttpServletRequest());
final ThirdPartyUser bizUser = thirdPartyLoginService.login(thirdPartyLoginRequest);
String dbUserName = String.format("%s_%s", loginType, bizUser.getUsername());
Optional<UserInfoDO> powerJobUserOpt = userInfoRepository.findByUsername(dbUserName);
// 如果不存在用户先同步创建用户
if (!powerJobUserOpt.isPresent()) {
UserInfoDO newUser = new UserInfoDO();
newUser.setUsername(dbUserName);
Loggers.WEB.info("[PowerJobLoginService] sync user to PowerJobUserSystem: {}", dbUserName);
userInfoRepository.saveAndFlush(newUser);
powerJobUserOpt = userInfoRepository.findByUsername(dbUserName);
}
PowerJobUser ret = new PowerJobUser();
// 理论上 100% 存在
if (powerJobUserOpt.isPresent()) {
final UserInfoDO dbUser = powerJobUserOpt.get();
BeanUtils.copyProperties(dbUser, ret);
ret.setUsername(dbUserName);
}
fillJwt(ret);
return ret;
}
@Override
public Optional<PowerJobUser> ifLogin(HttpServletRequest httpServletRequest) {
final Optional<String> userNameOpt = parseUserName(httpServletRequest);
return userNameOpt.flatMap(uname -> userInfoRepository.findByUsername(uname).map(userInfoDO -> {
PowerJobUser powerJobUser = new PowerJobUser();
BeanUtils.copyProperties(userInfoDO, powerJobUser);
return powerJobUser;
}));
}
private ThirdPartyLoginService fetchBizLoginService(String loginType) {
final ThirdPartyLoginService loginService = code2ThirdPartyLoginService.get(loginType);
if (loginService == null) {
throw new PowerJobAuthException(AuthErrorCode.INVALID_REQUEST, "can't find ThirdPartyLoginService by type: " + loginType);
}
return loginService;
}
private void fillJwt(PowerJobUser powerJobUser) {
Map<String, Object> jwtMap = Maps.newHashMap();
// 不能下发 userId容易被轮询爆破
jwtMap.put(KEY_USERNAME, powerJobUser.getUsername());
powerJobUser.setJwtToken(jwtService.build(jwtMap, null));
}
private Optional<String> parseUserName(HttpServletRequest httpServletRequest) {
// headercookie 都能获取
String jwtStr = httpServletRequest.getHeader(JWT_NAME);
if (StringUtils.isEmpty(jwtStr)) {
for (Cookie cookie : httpServletRequest.getCookies()) {
if (cookie.getName().equals(JWT_NAME)) {
jwtStr = cookie.getValue();
}
}
}
if (StringUtils.isEmpty(jwtStr)) {
return Optional.empty();
}
final Map<String, Object> jwtBodyMap = jwtService.parse(jwtStr, null);
final Object userName = jwtBodyMap.get(KEY_USERNAME);
if (userName == null) {
return Optional.empty();
}
return Optional.of(String.valueOf(userName));
}
}

View File

@ -8,16 +8,19 @@ import java.util.Date;
/**
* 用户信息表
* 5.0.0 可能不兼容改动为了支持第三方登录需要通过 username 与第三方登录系统做匹配该列需要声明为唯一索引确保全局唯一
*
* @author tjq
* @since 2020/4/12
*/
@Data
@Entity
@Table(indexes = {
@Index(name = "uidx01_user_info", columnList = "username"),
@Index(name = "uidx02_user_info", columnList = "email")
})
@Table(uniqueConstraints = {
@UniqueConstraint(name = "uidx01_user_name", columnNames = {"username"})
},
indexes = {
@Index(name = "uidx02_user_info", columnList = "email")
})
public class UserInfoDO {
@Id

View File

@ -43,6 +43,10 @@
<groupId>tech.powerjob</groupId>
<artifactId>powerjob-server-core</artifactId>
</dependency>
<dependency>
<groupId>tech.powerjob</groupId>
<artifactId>powerjob-server-auth</artifactId>
</dependency>
<dependency>
<groupId>tech.powerjob</groupId>
<artifactId>powerjob-server-migrate</artifactId>

View File

@ -0,0 +1,26 @@
package tech.powerjob.server.web.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.powerjob.common.response.ResultDTO;
import tech.powerjob.server.auth.login.LoginTypeInfo;
import java.util.List;
/**
* 登录 & 权限相关
*
* @author tjq
* @since 2023/4/16
*/
@RestController
@RequestMapping("/auth")
public class AuthController {
@GetMapping("/listSupportLoginTypes")
public ResultDTO<List<LoginTypeInfo>> listSupportLoginTypes() {
return null;
}
}