添加企业微信通过手机号与CRM用户名对应自动登录的功能
parent
c5c0652088
commit
6479f6fd01
|
|
@ -1,11 +1,14 @@
|
||||||
package com.unis.crm;
|
package com.unis.crm;
|
||||||
|
|
||||||
|
import com.unis.crm.config.WecomProperties;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
|
||||||
@SpringBootApplication(scanBasePackages = "com.unis.crm")
|
@SpringBootApplication(scanBasePackages = "com.unis.crm")
|
||||||
@MapperScan("com.unis.crm.mapper")
|
@MapperScan("com.unis.crm.mapper")
|
||||||
|
@EnableConfigurationProperties(WecomProperties.class)
|
||||||
public class UnisCrmBackendApplication {
|
public class UnisCrmBackendApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package com.unis.crm.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "unisbase.app.wecom")
|
||||||
|
public class WecomProperties {
|
||||||
|
|
||||||
|
private boolean enabled;
|
||||||
|
private String corpId;
|
||||||
|
private String agentId;
|
||||||
|
private String secret;
|
||||||
|
private String redirectUri;
|
||||||
|
private String frontendCallbackPath = "/login/wecom";
|
||||||
|
private String scope = "snsapi_base";
|
||||||
|
private long stateTtlSeconds = 300;
|
||||||
|
private long ticketTtlSeconds = 180;
|
||||||
|
private long accessTokenSafetySeconds = 120;
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCorpId() {
|
||||||
|
return corpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCorpId(String corpId) {
|
||||||
|
this.corpId = corpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAgentId() {
|
||||||
|
return agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAgentId(String agentId) {
|
||||||
|
this.agentId = agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSecret() {
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSecret(String secret) {
|
||||||
|
this.secret = secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRedirectUri() {
|
||||||
|
return redirectUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRedirectUri(String redirectUri) {
|
||||||
|
this.redirectUri = redirectUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFrontendCallbackPath() {
|
||||||
|
return frontendCallbackPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFrontendCallbackPath(String frontendCallbackPath) {
|
||||||
|
this.frontendCallbackPath = frontendCallbackPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getScope() {
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setScope(String scope) {
|
||||||
|
this.scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStateTtlSeconds() {
|
||||||
|
return stateTtlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStateTtlSeconds(long stateTtlSeconds) {
|
||||||
|
this.stateTtlSeconds = stateTtlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTicketTtlSeconds() {
|
||||||
|
return ticketTtlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTicketTtlSeconds(long ticketTtlSeconds) {
|
||||||
|
this.ticketTtlSeconds = ticketTtlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getAccessTokenSafetySeconds() {
|
||||||
|
return accessTokenSafetySeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccessTokenSafetySeconds(long accessTokenSafetySeconds) {
|
||||||
|
this.accessTokenSafetySeconds = accessTokenSafetySeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.unis.crm.controller;
|
||||||
|
|
||||||
|
import com.unis.crm.common.ApiResponse;
|
||||||
|
import com.unis.crm.dto.wecom.WecomSsoExchangeRequest;
|
||||||
|
import com.unis.crm.service.WecomSsoService;
|
||||||
|
import com.unisbase.dto.TokenResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/wecom/sso")
|
||||||
|
public class WecomSsoController {
|
||||||
|
|
||||||
|
private final WecomSsoService wecomSsoService;
|
||||||
|
|
||||||
|
public WecomSsoController(WecomSsoService wecomSsoService) {
|
||||||
|
this.wecomSsoService = wecomSsoService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/entry")
|
||||||
|
public ResponseEntity<Void> entry(@RequestParam(value = "redirect", required = false) String redirectPath) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.add(HttpHeaders.LOCATION, wecomSsoService.buildAuthorizeRedirect(redirectPath));
|
||||||
|
return new ResponseEntity<>(headers, HttpStatus.FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/callback")
|
||||||
|
public ResponseEntity<Void> callback(@RequestParam("code") String code, @RequestParam("state") String state) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.add(HttpHeaders.LOCATION, wecomSsoService.handleCallback(code, state));
|
||||||
|
return new ResponseEntity<>(headers, HttpStatus.FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/exchange")
|
||||||
|
public ApiResponse<TokenResponse> exchange(@Valid @RequestBody WecomSsoExchangeRequest request) {
|
||||||
|
return ApiResponse.success(wecomSsoService.exchangeTicket(request.getTicket()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.unis.crm.dto.wecom;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public class WecomSsoExchangeRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "ticket不能为空")
|
||||||
|
private String ticket;
|
||||||
|
|
||||||
|
public String getTicket() {
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTicket(String ticket) {
|
||||||
|
this.ticket = ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.unis.crm.service;
|
||||||
|
|
||||||
|
import com.unisbase.dto.TokenResponse;
|
||||||
|
|
||||||
|
public interface WecomSsoService {
|
||||||
|
|
||||||
|
String buildAuthorizeRedirect(String redirectPath);
|
||||||
|
|
||||||
|
String handleCallback(String code, String state);
|
||||||
|
|
||||||
|
TokenResponse exchangeTicket(String ticket);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,441 @@
|
||||||
|
package com.unis.crm.service.impl;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.unis.crm.common.BusinessException;
|
||||||
|
import com.unis.crm.config.WecomProperties;
|
||||||
|
import com.unis.crm.service.WecomSsoService;
|
||||||
|
import com.unisbase.auth.JwtTokenProvider;
|
||||||
|
import com.unisbase.common.RedisKeys;
|
||||||
|
import com.unisbase.dto.TokenResponse;
|
||||||
|
import com.unisbase.entity.SysUser;
|
||||||
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
|
import com.unisbase.service.AuthVersionService;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class WecomSsoServiceImpl implements WecomSsoService {
|
||||||
|
|
||||||
|
private static final String WECOM_AUTHORIZE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize";
|
||||||
|
private static final String WECOM_GET_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
|
||||||
|
private static final String WECOM_GET_USER_INFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo";
|
||||||
|
private static final String WECOM_GET_USER_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/get";
|
||||||
|
private static final String ACCESS_TOKEN_CACHE_KEY = "crm:wecom:access-token";
|
||||||
|
private static final String STATE_CACHE_PREFIX = "crm:wecom:sso:state:";
|
||||||
|
private static final String TICKET_CACHE_PREFIX = "crm:wecom:sso:ticket:";
|
||||||
|
|
||||||
|
private final WecomProperties wecomProperties;
|
||||||
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
|
private final SysUserMapper sysUserMapper;
|
||||||
|
private final AuthVersionService authVersionService;
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final RestClient restClient;
|
||||||
|
|
||||||
|
public WecomSsoServiceImpl(
|
||||||
|
WecomProperties wecomProperties,
|
||||||
|
StringRedisTemplate stringRedisTemplate,
|
||||||
|
SysUserMapper sysUserMapper,
|
||||||
|
AuthVersionService authVersionService,
|
||||||
|
JwtTokenProvider jwtTokenProvider,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.wecomProperties = wecomProperties;
|
||||||
|
this.stringRedisTemplate = stringRedisTemplate;
|
||||||
|
this.sysUserMapper = sysUserMapper;
|
||||||
|
this.authVersionService = authVersionService;
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.restClient = RestClient.builder()
|
||||||
|
.defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String buildAuthorizeRedirect(String redirectPath) {
|
||||||
|
ensureEnabled();
|
||||||
|
validateRequiredConfig();
|
||||||
|
|
||||||
|
String safeRedirectPath = sanitizeRedirectPath(redirectPath);
|
||||||
|
String state = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
stringRedisTemplate.opsForValue().set(
|
||||||
|
STATE_CACHE_PREFIX + state,
|
||||||
|
safeRedirectPath,
|
||||||
|
Duration.ofSeconds(wecomProperties.getStateTtlSeconds()));
|
||||||
|
|
||||||
|
return WECOM_AUTHORIZE_URL
|
||||||
|
+ "?appid=" + urlEncode(wecomProperties.getCorpId())
|
||||||
|
+ "&redirect_uri=" + urlEncode(wecomProperties.getRedirectUri())
|
||||||
|
+ "&response_type=code"
|
||||||
|
+ "&scope=" + urlEncode(defaultIfBlank(wecomProperties.getScope(), "snsapi_base"))
|
||||||
|
+ "&agentid=" + urlEncode(wecomProperties.getAgentId())
|
||||||
|
+ "&state=" + urlEncode(state)
|
||||||
|
+ "#wechat_redirect";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String handleCallback(String code, String state) {
|
||||||
|
ensureEnabled();
|
||||||
|
validateRequiredConfig();
|
||||||
|
if (!StringUtils.hasText(code)) {
|
||||||
|
return buildFrontendErrorRedirect("wecom_code_invalid");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(state)) {
|
||||||
|
return buildFrontendErrorRedirect("wecom_state_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
String redirectPath = stringRedisTemplate.opsForValue().getAndDelete(STATE_CACHE_PREFIX + state);
|
||||||
|
if (!StringUtils.hasText(redirectPath)) {
|
||||||
|
return buildFrontendErrorRedirect("wecom_state_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String accessToken = getCorpAccessToken();
|
||||||
|
String userId = getWecomUserId(accessToken, code);
|
||||||
|
String mobile = getWecomUserMobile(accessToken, userId);
|
||||||
|
String normalizedMobile = normalizeMobile(mobile);
|
||||||
|
SysUser user = resolveCrmUserByUsername(normalizedMobile);
|
||||||
|
String ticket = createTicket(user, redirectPath);
|
||||||
|
return buildFrontendSuccessRedirect(ticket, redirectPath);
|
||||||
|
} catch (BusinessException ex) {
|
||||||
|
return buildFrontendErrorRedirect(resolveErrorCode(ex.getMessage()));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return buildFrontendErrorRedirect("wecom_login_failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TokenResponse exchangeTicket(String ticket) {
|
||||||
|
ensureEnabled();
|
||||||
|
if (!StringUtils.hasText(ticket)) {
|
||||||
|
throw new BusinessException("登录票据不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
String payload = stringRedisTemplate.opsForValue().getAndDelete(TICKET_CACHE_PREFIX + ticket);
|
||||||
|
if (!StringUtils.hasText(payload)) {
|
||||||
|
throw new BusinessException("登录票据无效或已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
WecomTicketContext context = deserializeTicket(payload);
|
||||||
|
SysUser user = sysUserMapper.selectByIdIgnoreTenant(context.userId());
|
||||||
|
if (user == null || user.getStatus() == null || user.getStatus() != 1
|
||||||
|
|| user.getIsDeleted() == null || user.getIsDeleted() != 0) {
|
||||||
|
throw new BusinessException("用户不存在或已停用");
|
||||||
|
}
|
||||||
|
|
||||||
|
Long tenantId = context.tenantId();
|
||||||
|
String sessionId = newSessionId();
|
||||||
|
long accessMinutes = resolveAccessDefaultMinutes();
|
||||||
|
long refreshDays = resolveRefreshDefaultDays();
|
||||||
|
TokenResponse response = issueTokens(user, tenantId, sessionId, accessMinutes, refreshDays);
|
||||||
|
response.setAvailableTenants(resolveAvailableTenants(user));
|
||||||
|
cacheIssuedTokens(user.getUserId(), tenantId, sessionId, response, accessMinutes, refreshDays);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureEnabled() {
|
||||||
|
if (!wecomProperties.isEnabled()) {
|
||||||
|
throw new BusinessException("企业微信单点登录未启用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRequiredConfig() {
|
||||||
|
if (!StringUtils.hasText(wecomProperties.getCorpId())
|
||||||
|
|| !StringUtils.hasText(wecomProperties.getAgentId())
|
||||||
|
|| !StringUtils.hasText(wecomProperties.getSecret())
|
||||||
|
|| !StringUtils.hasText(wecomProperties.getRedirectUri())) {
|
||||||
|
throw new BusinessException("企业微信登录配置不完整");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getCorpAccessToken() {
|
||||||
|
String cached = stringRedisTemplate.opsForValue().get(ACCESS_TOKEN_CACHE_KEY);
|
||||||
|
if (StringUtils.hasText(cached)) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode response = getJson(WECOM_GET_TOKEN_URL
|
||||||
|
+ "?corpid=" + urlEncode(wecomProperties.getCorpId())
|
||||||
|
+ "&corpsecret=" + urlEncode(wecomProperties.getSecret()));
|
||||||
|
assertWecomSuccess(response, "获取企业微信访问令牌失败");
|
||||||
|
String accessToken = response.path("access_token").asText("");
|
||||||
|
long expiresIn = response.path("expires_in").asLong(7200);
|
||||||
|
if (!StringUtils.hasText(accessToken)) {
|
||||||
|
throw new BusinessException("企业微信访问令牌为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
long ttlSeconds = Math.max(60L, expiresIn - wecomProperties.getAccessTokenSafetySeconds());
|
||||||
|
stringRedisTemplate.opsForValue().set(ACCESS_TOKEN_CACHE_KEY, accessToken, Duration.ofSeconds(ttlSeconds));
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getWecomUserId(String accessToken, String code) {
|
||||||
|
JsonNode response = getJson(WECOM_GET_USER_INFO_URL
|
||||||
|
+ "?access_token=" + urlEncode(accessToken)
|
||||||
|
+ "&code=" + urlEncode(code));
|
||||||
|
assertWecomSuccess(response, "获取企业微信用户身份失败");
|
||||||
|
String userId = response.path("UserId").asText("");
|
||||||
|
if (!StringUtils.hasText(userId)) {
|
||||||
|
throw new BusinessException("未识别到企业微信成员身份");
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getWecomUserMobile(String accessToken, String userId) {
|
||||||
|
JsonNode response = getJson(WECOM_GET_USER_URL
|
||||||
|
+ "?access_token=" + urlEncode(accessToken)
|
||||||
|
+ "&userid=" + urlEncode(userId));
|
||||||
|
assertWecomSuccess(response, "读取企业微信成员信息失败");
|
||||||
|
String mobile = response.path("mobile").asText("");
|
||||||
|
if (!StringUtils.hasText(mobile)) {
|
||||||
|
throw new BusinessException("企业微信账号未维护手机号");
|
||||||
|
}
|
||||||
|
return mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SysUser resolveCrmUserByUsername(String normalizedMobile) {
|
||||||
|
SysUser user = sysUserMapper.selectByUsernameIgnoreTenant(normalizedMobile);
|
||||||
|
if (user == null) {
|
||||||
|
throw new BusinessException("企业微信手机号未开通CRM账号");
|
||||||
|
}
|
||||||
|
if (user.getStatus() == null || user.getStatus() != 1 || user.getIsDeleted() == null || user.getIsDeleted() != 0) {
|
||||||
|
throw new BusinessException("CRM账号已停用");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createTicket(SysUser user, String redirectPath) {
|
||||||
|
List<TokenResponse.TenantInfo> tenants = resolveAvailableTenants(user);
|
||||||
|
Long tenantId = resolveActiveTenantId(user, tenants);
|
||||||
|
String ticket = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
WecomTicketContext context = new WecomTicketContext(user.getUserId(), tenantId, user.getUsername(), redirectPath);
|
||||||
|
stringRedisTemplate.opsForValue().set(
|
||||||
|
TICKET_CACHE_PREFIX + ticket,
|
||||||
|
serializeTicket(context),
|
||||||
|
Duration.ofSeconds(wecomProperties.getTicketTtlSeconds()));
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TokenResponse.TenantInfo> resolveAvailableTenants(SysUser user) {
|
||||||
|
List<TokenResponse.TenantInfo> tenants = sysUserMapper.selectTenantsByUsername(user.getUsername());
|
||||||
|
if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
|
||||||
|
boolean hasSystem = tenants.stream().anyMatch(item -> item.getTenantId() != null && item.getTenantId() == 0L);
|
||||||
|
if (!hasSystem) {
|
||||||
|
tenants.add(0, TokenResponse.TenantInfo.builder()
|
||||||
|
.tenantId(0L)
|
||||||
|
.tenantCode("SYSTEM")
|
||||||
|
.tenantName("系统平台")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tenants.isEmpty()) {
|
||||||
|
throw new BusinessException("该账号未关联任何租户");
|
||||||
|
}
|
||||||
|
return tenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveActiveTenantId(SysUser user, List<TokenResponse.TenantInfo> tenants) {
|
||||||
|
if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
return tenants.stream()
|
||||||
|
.map(TokenResponse.TenantInfo::getTenantId)
|
||||||
|
.filter(item -> item != null && item > 0)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(tenants.get(0).getTenantId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenResponse issueTokens(SysUser user, Long tenantId, String sessionId, long accessMinutes, long refreshDays) {
|
||||||
|
long authVersion = authVersionService.getVersion(user.getUserId(), tenantId);
|
||||||
|
|
||||||
|
Map<String, Object> accessClaims = new HashMap<>();
|
||||||
|
accessClaims.put("tokenType", "access");
|
||||||
|
accessClaims.put("userId", user.getUserId());
|
||||||
|
accessClaims.put("tenantId", tenantId);
|
||||||
|
accessClaims.put("username", user.getUsername());
|
||||||
|
accessClaims.put("authVersion", authVersion);
|
||||||
|
accessClaims.put("sessionId", sessionId);
|
||||||
|
|
||||||
|
Map<String, Object> refreshClaims = new HashMap<>();
|
||||||
|
refreshClaims.put("tokenType", "refresh");
|
||||||
|
refreshClaims.put("userId", user.getUserId());
|
||||||
|
refreshClaims.put("tenantId", tenantId);
|
||||||
|
refreshClaims.put("authVersion", authVersion);
|
||||||
|
refreshClaims.put("sessionId", sessionId);
|
||||||
|
|
||||||
|
String accessToken = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis());
|
||||||
|
String refreshToken = jwtTokenProvider.createToken(refreshClaims, Duration.ofDays(refreshDays).toMillis());
|
||||||
|
return TokenResponse.builder()
|
||||||
|
.accessToken(accessToken)
|
||||||
|
.refreshToken(refreshToken)
|
||||||
|
.accessExpiresInMinutes(accessMinutes)
|
||||||
|
.refreshExpiresInDays(refreshDays)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cacheIssuedTokens(Long userId, Long tenantId, String sessionId, TokenResponse response, long accessMinutes, long refreshDays) {
|
||||||
|
stringRedisTemplate.opsForValue().set(
|
||||||
|
RedisKeys.accessSessionTokenKey(sessionId),
|
||||||
|
response.getAccessToken(),
|
||||||
|
Duration.ofMinutes(accessMinutes));
|
||||||
|
stringRedisTemplate.opsForValue().set(
|
||||||
|
RedisKeys.refreshSessionTokenKey(sessionId),
|
||||||
|
response.getRefreshToken(),
|
||||||
|
Duration.ofDays(refreshDays));
|
||||||
|
String userSessionKey = RedisKeys.userSessionIndexKey(userId, tenantId);
|
||||||
|
stringRedisTemplate.opsForSet().add(userSessionKey, sessionId);
|
||||||
|
stringRedisTemplate.expire(userSessionKey, Duration.ofDays(refreshDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long resolveAccessDefaultMinutes() {
|
||||||
|
return 30L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long resolveRefreshDefaultDays() {
|
||||||
|
return 7L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String newSessionId() {
|
||||||
|
return UUID.randomUUID().toString().replace("-", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode getJson(String url) {
|
||||||
|
String body = restClient.get()
|
||||||
|
.uri(url)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
if (!StringUtils.hasText(body)) {
|
||||||
|
throw new BusinessException("企业微信接口返回为空");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readTree(body);
|
||||||
|
} catch (JsonProcessingException ex) {
|
||||||
|
throw new BusinessException("企业微信接口返回格式异常");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertWecomSuccess(JsonNode response, String fallbackMessage) {
|
||||||
|
int errCode = response.path("errcode").asInt(-1);
|
||||||
|
if (errCode != 0) {
|
||||||
|
String errMsg = response.path("errmsg").asText(fallbackMessage);
|
||||||
|
throw new BusinessException(fallbackMessage + ":" + errMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitizeRedirectPath(String redirectPath) {
|
||||||
|
if (!StringUtils.hasText(redirectPath)) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
String trimmed = redirectPath.trim();
|
||||||
|
if (!trimmed.startsWith("/") || trimmed.startsWith("//")) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildFrontendSuccessRedirect(String ticket, String redirectPath) {
|
||||||
|
return sanitizeFrontendCallbackPath()
|
||||||
|
+ "?ticket=" + urlEncode(ticket)
|
||||||
|
+ "&redirect=" + urlEncode(redirectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildFrontendErrorRedirect(String errorCode) {
|
||||||
|
return sanitizeFrontendCallbackPath() + "?error=" + urlEncode(errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitizeFrontendCallbackPath() {
|
||||||
|
String path = defaultIfBlank(wecomProperties.getFrontendCallbackPath(), "/login/wecom");
|
||||||
|
if (!path.startsWith("/")) {
|
||||||
|
return "/login/wecom";
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeMobile(String mobile) {
|
||||||
|
if (!StringUtils.hasText(mobile)) {
|
||||||
|
throw new BusinessException("企业微信账号未维护手机号");
|
||||||
|
}
|
||||||
|
String digits = mobile.replaceAll("[^0-9]", "");
|
||||||
|
if (digits.startsWith("86") && digits.length() == 13) {
|
||||||
|
digits = digits.substring(2);
|
||||||
|
}
|
||||||
|
if (digits.length() != 11) {
|
||||||
|
throw new BusinessException("企业微信手机号格式不正确");
|
||||||
|
}
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String urlEncode(String value) {
|
||||||
|
return URLEncoder.encode(defaultIfBlank(value, ""), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String defaultIfBlank(String value, String defaultValue) {
|
||||||
|
return StringUtils.hasText(value) ? value : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String serializeTicket(WecomTicketContext context) {
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("userId", context.userId());
|
||||||
|
payload.put("tenantId", context.tenantId());
|
||||||
|
payload.put("username", context.username());
|
||||||
|
payload.put("redirectPath", context.redirectPath());
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(payload);
|
||||||
|
} catch (JsonProcessingException ex) {
|
||||||
|
throw new BusinessException("企业微信登录票据生成失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private WecomTicketContext deserializeTicket(String payload) {
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(payload);
|
||||||
|
return new WecomTicketContext(
|
||||||
|
node.path("userId").asLong(),
|
||||||
|
node.path("tenantId").asLong(),
|
||||||
|
node.path("username").asText(""),
|
||||||
|
sanitizeRedirectPath(node.path("redirectPath").asText("/")));
|
||||||
|
} catch (JsonProcessingException ex) {
|
||||||
|
throw new BusinessException("登录票据解析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveErrorCode(String message) {
|
||||||
|
if (!StringUtils.hasText(message)) {
|
||||||
|
return "wecom_login_failed";
|
||||||
|
}
|
||||||
|
if (message.contains("手机号")) {
|
||||||
|
return "wecom_mobile_missing";
|
||||||
|
}
|
||||||
|
if (message.contains("未开通CRM账号")) {
|
||||||
|
return "crm_user_not_found";
|
||||||
|
}
|
||||||
|
if (message.contains("停用")) {
|
||||||
|
return "crm_user_disabled";
|
||||||
|
}
|
||||||
|
if (message.contains("配置")) {
|
||||||
|
return "wecom_config_error";
|
||||||
|
}
|
||||||
|
if (message.contains("成员身份")) {
|
||||||
|
return "wecom_userid_not_found";
|
||||||
|
}
|
||||||
|
if (message.contains("state")) {
|
||||||
|
return "wecom_state_invalid";
|
||||||
|
}
|
||||||
|
return "wecom_login_failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
private record WecomTicketContext(Long userId, Long tenantId, String username, String redirectPath) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,3 +55,14 @@ unisbase:
|
||||||
token:
|
token:
|
||||||
access-default-minutes: 30
|
access-default-minutes: 30
|
||||||
refresh-default-days: 7
|
refresh-default-days: 7
|
||||||
|
wecom:
|
||||||
|
enabled: ${WECOM_ENABLED:false}
|
||||||
|
corp-id: ${WECOM_CORP_ID:}
|
||||||
|
agent-id: ${WECOM_AGENT_ID:}
|
||||||
|
secret: ${WECOM_SECRET:}
|
||||||
|
redirect-uri: ${WECOM_REDIRECT_URI:https://crm.unissense.top/api/wecom/sso/callback}
|
||||||
|
frontend-callback-path: ${WECOM_FRONTEND_CALLBACK_PATH:/login/wecom}
|
||||||
|
scope: ${WECOM_SCOPE:snsapi_base}
|
||||||
|
state-ttl-seconds: 300
|
||||||
|
ticket-ttl-seconds: 180
|
||||||
|
access-token-safety-seconds: 120
|
||||||
|
|
|
||||||
|
|
@ -60,3 +60,14 @@ unisbase:
|
||||||
token:
|
token:
|
||||||
access-default-minutes: 30
|
access-default-minutes: 30
|
||||||
refresh-default-days: 7
|
refresh-default-days: 7
|
||||||
|
wecom:
|
||||||
|
enabled: false
|
||||||
|
corp-id: ${WECOM_CORP_ID:}
|
||||||
|
agent-id: ${WECOM_AGENT_ID:}
|
||||||
|
secret: ${WECOM_SECRET:}
|
||||||
|
redirect-uri: ${WECOM_REDIRECT_URI:https://crm.unissense.top/api/wecom/sso/callback}
|
||||||
|
frontend-callback-path: ${WECOM_FRONTEND_CALLBACK_PATH:/login/wecom}
|
||||||
|
scope: ${WECOM_SCOPE:snsapi_base}
|
||||||
|
state-ttl-seconds: 300
|
||||||
|
ticket-ttl-seconds: 180
|
||||||
|
access-token-safety-seconds: 120
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import Work from "./pages/Work";
|
||||||
import Profile from "./pages/Profile";
|
import Profile from "./pages/Profile";
|
||||||
import { ThemeProvider } from "./components/ThemeProvider";
|
import { ThemeProvider } from "./components/ThemeProvider";
|
||||||
import LoginPage from "./pages/Login";
|
import LoginPage from "./pages/Login";
|
||||||
|
import WecomLoginCallbackPage from "./pages/WecomLoginCallback";
|
||||||
import { isAuthed } from "./lib/auth";
|
import { isAuthed } from "./lib/auth";
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: ReactNode }) {
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
|
@ -29,6 +30,7 @@ export default function App() {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/login/wecom" element={<WecomLoginCallbackPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ export interface PlatformConfig {
|
||||||
systemDescription?: string;
|
systemDescription?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WecomExchangePayload {
|
||||||
|
ticket: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardStat {
|
export interface DashboardStat {
|
||||||
name: string;
|
name: string;
|
||||||
value?: number;
|
value?: number;
|
||||||
|
|
@ -681,6 +685,13 @@ export async function login(payload: LoginPayload) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exchangeWecomTicket(payload: WecomExchangePayload) {
|
||||||
|
return request<TokenResponse>("/api/wecom/sso/exchange", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSystemParamValue(key: string, defaultValue?: string) {
|
export async function getSystemParamValue(key: string, defaultValue?: string) {
|
||||||
const params = new URLSearchParams({ key });
|
const params = new URLSearchParams({ key });
|
||||||
if (defaultValue !== undefined) {
|
if (defaultValue !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { clearAuth, exchangeWecomTicket, getCurrentUser, persistLogin } from "@/lib/auth";
|
||||||
|
import "./login.css";
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
wecom_config_error: "企业微信登录配置不完整,请联系管理员。",
|
||||||
|
wecom_state_invalid: "企业微信登录状态已失效,请从企业微信应用重新进入。",
|
||||||
|
wecom_code_invalid: "企业微信授权失败,请重新从应用进入。",
|
||||||
|
wecom_userid_not_found: "未识别到企业微信成员身份,请确认应用可见范围。",
|
||||||
|
wecom_mobile_missing: "企业微信账号未维护手机号,无法自动登录。",
|
||||||
|
crm_user_not_found: "企业微信手机号未开通CRM账号。",
|
||||||
|
crm_user_disabled: "CRM账号已停用,无法自动登录。",
|
||||||
|
wecom_login_failed: "企业微信自动登录失败,请稍后重试。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WecomLoginCallbackPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [status, setStatus] = useState<"loading" | "error">("loading");
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const ticket = searchParams.get("ticket")?.trim() || "";
|
||||||
|
const redirectPath = searchParams.get("redirect")?.trim() || "/";
|
||||||
|
const errorCode = searchParams.get("error")?.trim() || "";
|
||||||
|
|
||||||
|
const targetPath = useMemo(() => {
|
||||||
|
if (!redirectPath.startsWith("/") || redirectPath.startsWith("//")) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
return redirectPath;
|
||||||
|
}, [redirectPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (errorCode) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage(ERROR_MESSAGES[errorCode] || "企业微信自动登录失败,请稍后重试。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage("缺少登录票据,请从企业微信应用重新进入。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
clearAuth();
|
||||||
|
const tokenData = await exchangeWecomTicket({ ticket });
|
||||||
|
persistLogin(tokenData, "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await getCurrentUser();
|
||||||
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||||
|
const username = profile.username?.trim();
|
||||||
|
if (username) {
|
||||||
|
localStorage.setItem("username", username);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem("userProfile");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
navigate(targetPath || "/", { replace: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearAuth();
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : "企业微信自动登录失败,请稍后重试。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [errorCode, navigate, targetPath, ticket]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page-shell">
|
||||||
|
<div className="login-page-backdrop" />
|
||||||
|
<div className="login-page-grid" style={{ gridTemplateColumns: "minmax(0, 460px)" }}>
|
||||||
|
<section className="login-panel">
|
||||||
|
<div className="login-panel-card">
|
||||||
|
<div className="login-panel-header">
|
||||||
|
<p className="login-panel-eyebrow">企业微信登录</p>
|
||||||
|
<h3>{status === "loading" ? "正在自动登录..." : "自动登录失败"}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "loading" ? (
|
||||||
|
<div className="login-form">
|
||||||
|
<div className="login-error" style={{ marginTop: 0 }}>
|
||||||
|
正在校验企业微信身份并进入CRM,请稍候。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="login-form">
|
||||||
|
<div className="login-error" style={{ marginTop: 0 }}>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
<Link className="login-submit" to="/login">
|
||||||
|
返回普通登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue