1 前端login.vue 添加单点登录按钮
<div class="mt-6" v-if="loginType === LoginTypeEnum.PASSWORD">
<el-divider>{{ $t('divider.or') }}</el-divider>
<el-button
type="primary"
class="w-full mt-4"
@click="handleSsoLogin"
>
<el-icon class="mr-2"><Connection /></el-icon>
{{ $t('sso.login') || '单点登录' }}
</el-button>
</div>
// SSO配置 - 从环境变量读取
const ssoAuthUrl = import.meta.env.VITE_SSO_AUTH_URL
const ssoClientId = import.meta.env.VITE_SSO_CLIENT_ID ;
// 根据环境动态设置redirect_uri
const getSsoRedirectUri = () => {
// 如果环境变量中有配置,使用环境变量
if (import.meta.env.VITE_SSO_REDIRECT_URI) {
return import.meta.env.VITE_SSO_REDIRECT_URI;
}
// 否则根据当前环境自动生成
if (import.meta.env.DEV) {
return `${window.location.origin}/sso-callback`;
} else {
return 'https://xxx.xxx.com.cn/sso-callback';
}
};
const ssoRedirectUri = getSsoRedirectUri();
console.log('【SSO配置】Auth URL:', ssoAuthUrl);
console.log('【SSO配置】Client ID:', ssoClientId);
console.log('【SSO配置】Redirect URI:', ssoRedirectUri);
// SSO登录处理
const handleSsoLogin = () => {
// 固定state值(客户需求)
const state = 'hgyy';
// 构建SSO授权URL
const params = new URLSearchParams({
client_id: ssoClientId,
redirect_uri: ssoRedirectUri,
response_type: 'code',
state: state,
});
const ssoUrl = `${ssoAuthUrl}?${params.toString()}`;
console.log('【SSO登录】跳转到SSO授权页面:', ssoUrl);
// 跳转到SSO认证中心
window.location.href = ssoUrl;
};
2 前端 sso-callback.vue ,用户输入账号密码跳转回原应用,获取到授权码code,去请求后台oauth2 sso
<template>
<div class="sso-callback-container">
<div class="loading-wrapper">
<el-icon class="is-loading" :size="50">
<Loading />
</el-icon>
<p class="loading-text">正在处理SSO登录...</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useUserInfo } from '/@/stores/userInfo';
import { useMessage } from '/@/hooks/message';
import { Session } from '/@/utils/storage';
import { Loading } from '@element-plus/icons-vue';
import other from '/@/utils/other';
import { validateNull } from '/@/utils/validate';
const router = useRouter();
const route = useRoute();
const userStore = useUserInfo();
onMounted(async () => {
console.log('SSO回调获取授权码onMounted');
console.log('【SSO】当前URL:', window.location.href);
console.log('【SSO】window.location.search:', window.location.search);
console.log('【SSO】window.location.hash:', window.location.hash);
try {
// 兼容 IAM 回调参数位置:
// 情况1: https://domain/#/sso-callback?code=xxx&state=xxx (参数在hash后)
// 情况2: https://domain/?code=xxx&state=xxx#/login (参数在hash前)
// 优先从 window.location.search 获取(兼容情况2)
const searchParams = new URLSearchParams(window.location.search);
let code = searchParams.get('code') as string;
let state = searchParams.get('state') as string;
console.log('【SSO】从location.search获取 - code:', code, 'state:', state);
// 如果search中没有,再从route.query获取(情况1)
if (!code) {
code = route.query.code as string;
state = route.query.state as string;
console.log('【SSO】从route.query获取 - code:', code, 'state:', state);
}
console.log('SSO回调,授权码code:', code);
console.log('SSO回调,授权码state:', state);
if (!code) {
useMessage().error('缺少授权码参数');
console.error('【SSO】缺少授权码,URL参数:', window.location.search, 'route.query:', route.query);
setTimeout(() => { router.push('/login');}, 20000);
return;
}
console.log('SSO回调,授权码:', code);
// 使用授权码进行登录
await userStore.loginBySso(code);
useMessage().success('SSO登录成功');
// 获取用户信息
await userStore.setUserInfos();
// 跳转到首页或redirect指定的页面
const redirect = route.query.redirect as string;
if (redirect) {
router.push(decodeURIComponent(redirect));
} else {
router.push('/');
}
} catch (error: any) {
console.error('SSO登录失败:', error);
useMessage().error(error?.msg || 'SSO登录失败,请重试');
setTimeout(() => { router.push('/login'); }, 20000);
}
});
</script>
<style scoped lang="scss">
.sso-callback-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
.loading-wrapper {
text-align: center;
color: white;
.loading-text {
margin-top: 20px;
font-size: 16px;
animation: pulse 1.5s ease-in-out infinite;
}
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>
/**
* SSO授权码登录
* @param code 授权码
*/
export const loginBySso = (code: string) => {
const grant_type = 'sso';
const scope = 'server';
const basicAuth = 'Basic ' + window.btoa(import.meta.env.VITE_OAUTH2_SSO_CLIENT || 'sso:sso');
Session.set('basicAuth', basicAuth);
return request({
url: '/auth/oauth2/token',
headers: {
skipToken: true,
skipTenant: true,
Authorization: basicAuth,
'Content-Type': FORM_CONTENT_TYPE,
},
method: 'post',
params: { username: code, grant_type, scope },
});
};
async loginBySso(code: string) {
return new Promise(async (resolve, reject) => {
try {
console.log('【SSO后端代理】开始登录流程, code:', code);
// 调用后端接口,后端负责与IAM交互并返回系统JWT
const res = await loginBySso(code);
// 存储后端返回的系统JWT token
Session.set('token', res.access_token);
Session.set('refresh_token', res.refresh_token);
console.log('【SSO后端代理】登录成功,已存储系统Token');
resolve(res);
} catch (error: any) {
console.error('【SSO后端代理】登录失败:', error);
useMessage().error(error?.msg || 'SSO登录失败');
reject(error);
}
});
},
3 后端auth 服务,根据前端传递过来的授权码code,换取token
package com.shpl.scp.auth.support.sso;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
/**
* 认证中心服务调用客户端
* @author scp
* @date 2026/04/25
*/
@Slf4j
@Component
public class 认证中心Client {
@Autowired
private 认证中心Properties 认证中心Properties;
@Autowired
private RestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
/**
* 使用授权码换取认证中心访问令牌
* @param code 认证中心授权码
* @return 认证中心令牌响应
*/
public 认证中心TokenResponse exchangeToken(String code) {
log.info("=== 开始调用认证中心 Token交换接口 ===");
log.info("认证中心 Token交换URL: {}", 认证中心Properties.getTokenUrl());
log.info("认证中心授权码: {}", code);
log.info("认证中心客户端ID: {}", 认证中心Properties.getClientId());
try {
// 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 构建Basic认证头
String auth = 认证中心Properties.getClientId() + ":" + 认证中心Properties.getClientSecret();
String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8));
headers.set("Authorization", "Basic " + encodedAuth);
log.info("构建Basic认证头完成,认证信息: {}", auth);
// 构建请求参数
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("code", code);
log.info("构建请求参数完成: {}", params);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
log.info("开始发送认证中心 Token交换请求...");
// 发送请求
ResponseEntity<String> response = restTemplate.postForEntity(
认证中心Properties.getTokenUrl(), request, String.class);
log.info("认证中心 Token交换请求完成,状态码: {}", response.getStatusCode());
log.info("认证中心 Token交换响应头: {}", response.getHeaders());
log.info("认证中心 Token交换响应体: {}", response.getBody());
if (response.getStatusCode() == HttpStatus.OK) {
认证中心TokenResponse tokenResponse = objectMapper.readValue(response.getBody(), 认证中心TokenResponse.class);
log.info("认证中心 Token交换成功,access_token: {}", maskSecret(tokenResponse.getAccessToken()));
log.info("认证中心 Token交换成功,token_type: {}", tokenResponse.getTokenType());
log.info("认证中心 Token交换成功,expires_in: {}", tokenResponse.getExpiresIn());
log.info("=== 认证中心 Token交换完成 ===");
return tokenResponse;
} else {
log.error("认证中心 Token交换失败,状态码:{},响应:{}", response.getStatusCode(), response.getBody());
throw new 认证中心Exception("认证中心 Token交换失败:" + response.getStatusCode());
}
} catch (HttpClientErrorException e) {
log.error("认证中心 Token交换HTTP错误,状态码:{},响应:{}", e.getStatusCode(), e.getResponseBodyAsString());
throw new 认证中心Exception("认证中心 Token交换HTTP错误:" + e.getStatusCode());
} catch (Exception e) {
log.error("认证中心 Token交换异常", e);
throw new 认证中心Exception("认证中心 Token交换异常:" + e.getMessage());
}
}
/**
* 使用认证中心访问令牌获取用户信息
* @param accessToken 认证中心访问令牌
* @return 认证中心用户信息
*/
public 认证中心UserInfoResponse getUserInfo(String accessToken) {
log.info("=== 开始调用认证中心用户信息接口 ===");
log.info("认证中心用户信息URL: {}", 认证中心Properties.getUserinfoUrl());
log.info("认证中心访问令牌: {}", maskSecret(accessToken));
try {
// 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.set("Authorization", "Bearer " + accessToken);
log.info("构建Bearer认证头完成");
HttpEntity<String> request = new HttpEntity<>(headers);
log.info("开始发送认证中心用户信息请求...");
// 发送请求
ResponseEntity<String> response = restTemplate.exchange(
认证中心Properties.getUserinfoUrl(), HttpMethod.GET, request, String.class);
log.info("认证中心用户信息请求完成,状态码: {}", response.getStatusCode());
log.info("认证中心用户信息响应体: {}", response.getBody());
if (response.getStatusCode() == HttpStatus.OK) {
认证中心UserInfoResponse userInfo = objectMapper.readValue(response.getBody(), 认证中心UserInfoResponse.class);
log.info("认证中心用户信息获取成功,用户名:{},用户ID:{}",
userInfo.getUserName(), userInfo.getUserId());
log.info("认证中心用户信息获取成功,角色列表:{}", userInfo.getSpRoleList());
log.info("=== 认证中心用户信息获取完成 ===");
return userInfo;
} else {
log.error("认证中心用户信息获取失败,状态码:{},响应:{}", response.getStatusCode(), response.getBody());
throw new 认证中心Exception("认证中心用户信息获取失败:" + response.getStatusCode());
}
} catch (HttpClientErrorException e) {
log.error("认证中心用户信息获取HTTP错误,状态码:{},响应:{}", e.getStatusCode(), e.getResponseBodyAsString());
throw new 认证中心Exception("认证中心用户信息获取HTTP错误:" + e.getStatusCode());
} catch (Exception e) {
log.error("认证中心用户信息获取异常", e);
throw new 认证中心Exception("认证中心用户信息获取异常:" + e.getMessage());
}
}
/**
* 认证中心令牌响应
*/
@Data
public static class 认证中心TokenResponse {
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("refresh_token")
private String refreshToken;
@JsonProperty("expires_in")
private Integer expiresIn;
@JsonProperty("token_type")
private String tokenType;
}
/**
* 认证中心用户信息响应
*/
@Data
public static class 认证中心UserInfoResponse {
@JsonProperty("userName")
private String userName;
@JsonProperty("userId")
private String userId;
@JsonProperty("spRoleList")
private String[] spRoleList;
@JsonProperty("fullName")
private String fullName;
}
/**
* 隐藏敏感信息
*/
private String maskSecret(String secret) {
if (secret == null || secret.length() <= 8) {
return "***";
}
return secret.substring(0, 4) + "****" + secret.substring(secret.length() - 4);
}
/**
* 认证中心异常
*/
public static class 认证中心Exception extends RuntimeException {
public 认证中心Exception(String message) {
super(message);
}
public 认证中心Exception(String message, Throwable cause) {
super(message, cause);
}
}
}
package com.shpl.scp.auth.support.sso;
import com.shpl.scp.auth.support.base.OAuth2ResourceOwnerBaseAuthenticationConverter;
import com.shpl.scp.common.security.util.OAuth2EndpointUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import java.util.Map;
import java.util.Set;
/**
* SSO登录转换器
* @author scp
* @date 2026/04/15
*/
@Slf4j
public class OAuth2ResourceOwnerSsoAuthenticationConverter
extends OAuth2ResourceOwnerBaseAuthenticationConverter<OAuth2ResourceOwnerSsoAuthenticationToken> {
private static final String SSO_GRANT_TYPE = "sso";
/**
* 是否支持此convert
* @param grantType 授权类型
* @return true/false
*/
@Override
public boolean support(String grantType) {
log.info("SSO认证转换器检查grant_type: {}, 期望值: {}", grantType, SSO_GRANT_TYPE);
boolean supported = SSO_GRANT_TYPE.equals(grantType);
log.info("SSO认证转换器支持结果: {}", supported);
return supported;
}
@Override
public OAuth2ResourceOwnerSsoAuthenticationToken buildToken(Authentication clientPrincipal,
Set requestedScopes,
Map additionalParameters) {
return new OAuth2ResourceOwnerSsoAuthenticationToken(
new AuthorizationGrantType(SSO_GRANT_TYPE),
clientPrincipal,
requestedScopes,
additionalParameters);
}
/**
* 校验参数
* @param request 请求
*/
@Override
public void checkParams(HttpServletRequest request) {
log.info("=== SSO认证转换器开始处理请求 ===");
log.info("请求URI: {}", request.getRequestURI());
log.info("请求方法: {}", request.getMethod());
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
// 打印所有请求参数用于调试
log.info("SSO认证转换器接收到请求参数: {}", parameters);
// 检查grant_type参数
String grantType = parameters.getFirst("grant_type");
log.info("SSO认证转换器检查grant_type参数: {}", grantType);
// username (REQUIRED) - SSO返回的用户名
String username = parameters.getFirst("username");
log.info("Converter checkParams SSO登录,用户名:{}", username);
// 检查client_id参数
String clientId = parameters.getFirst("client_id");
log.info("SSO认证转换器检查client_id参数: {}", clientId);
if (!StringUtils.hasText(username)) {
log.error("SSO认证失败:username参数为空");
OAuth2EndpointUtils.throwError(
OAuth2ErrorCodes.INVALID_REQUEST,
"username",
OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
}
log.info("=== SSO认证转换器参数检查完成 ===");
}
}
package com.shpl.scp.auth.support.sso;
import com.shpl.scp.admin.api.dto.UserInfo;
import com.shpl.scp.admin.api.feign.RemoteUserService;
import com.shpl.scp.auth.support.base.OAuth2ResourceOwnerBaseAuthenticationProvider;
import com.shpl.scp.common.core.util.R;
import com.shpl.scp.common.data.tenant.TenantContextHolder;
import com.shpl.scp.common.security.service.ScpUser;
import com.shpl.scp.common.security.service.ScpUserDetailsService;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
import java.util.Map;
import java.util.Optional;
/**
* SSO登录的核心处理
* @author scp
* @date 2026/04/15
*/
@Slf4j
public class OAuth2ResourceOwnerSsoAuthenticationProvider
extends OAuth2ResourceOwnerBaseAuthenticationProvider<OAuth2ResourceOwnerSsoAuthenticationToken> {
private static final Logger LOGGER = LogManager.getLogger(OAuth2ResourceOwnerSsoAuthenticationProvider.class);
private final ScpUserDetailsService userDetailsService;
private final RemoteUserService remoteUserService;
@Autowired
private ApplicationContext applicationContext;
private IamClient iamClient;
private SsoUserService ssoUserService;
public void setIamClient(IamClient iamClient) {
this.iamClient = iamClient;
}
public void setSsoUserService(SsoUserService ssoUserService) {
this.ssoUserService = ssoUserService;
}
/**
* Constructs an {@code OAuth2ResourceOwnerSsoAuthenticationProvider} using the provided parameters.
* @param authenticationManager 认证管理器
* @param authorizationService 授权服务
* @param tokenGenerator token生成器
* @param userDetailsService 用户详情服务
* @param remoteUserService 远程用户服务
*/
public OAuth2ResourceOwnerSsoAuthenticationProvider(AuthenticationManager authenticationManager,
OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator,
ScpUserDetailsService userDetailsService,
RemoteUserService remoteUserService) {
super(authenticationManager, authorizationService, tokenGenerator);
this.userDetailsService = userDetailsService;
this.remoteUserService = remoteUserService;
}
@Override
public UsernamePasswordAuthenticationToken buildToken(Map<String, Object> reqParameters) {
log.info("=== SSO认证提供者开始处理请求 ===");
log.info("SSO认证提供者接收到请求参数: {}", reqParameters);
String code = (String) reqParameters.get("username"); // 这里username字段实际是IAM授权码
LOGGER.info("Provider buildToken SSO登录,IAM授权码:{}", code);
if (code == null || code.trim().isEmpty()) {
log.error("SSO认证失败:IAM授权码为空");
throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, "IAM授权码不能为空", null));
}
// 延迟初始化依赖
log.info("开始初始化IAM客户端和SSO用户服务");
if (iamClient == null) {
iamClient = applicationContext.getBean(IamClient.class);
log.info("IAM客户端初始化完成: {}", iamClient != null);
}
if (ssoUserService == null) {
ssoUserService = applicationContext.getBean(SsoUserService.class);
log.info("SSO用户服务初始化完成: {}", ssoUserService != null);
}
log.info("=== 开始调用IAM服务进行SSO认证 ===");
// 1. 使用授权码换取IAM访问令牌
log.info("步骤1: 调用IAM服务换取访问令牌,授权码: {}", maskSecret(code));
IamClient.IamTokenResponse tokenResponse = iamClient.exchangeToken(code);
log.info("IAM令牌交换成功,访问令牌: {}", maskSecret(tokenResponse != null ? tokenResponse.getAccessToken() : "null"));
if (tokenResponse == null || tokenResponse.getAccessToken() == null) {
log.error("IAM令牌交换失败,响应为空");
throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, "IAM令牌交换失败", null));
}
// 2. 使用IAM访问令牌获取用户信息
log.info("步骤2: 调用IAM服务获取用户信息");
IamClient.IamUserInfoResponse iamUserInfo = iamClient.getUserInfo(tokenResponse.getAccessToken());
log.info("IAM用户信息获取成功,用户名: {}", iamUserInfo != null ? iamUserInfo.getUserName() : "null");
if (iamUserInfo == null || iamUserInfo.getUserName() == null) {
log.error("IAM用户信息获取失败,响应为空");
throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, "IAM用户信息获取失败", null));
}
// 3. 查找或创建本地用户
log.info("步骤3: 查找或创建本地用户,IAM用户名: {}", iamUserInfo.getUserName());
ScpUser user = ssoUserService.findOrCreateUser(iamUserInfo);
log.info("本地用户查找/创建完成,用户: {}", user != null ? user.getUsername() : "null");
if (user == null) {
LOGGER.error("SSO用户匹配失败,IAM用户名:{}", iamUserInfo.getUserName());
throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, "SSO用户匹配失败", null));
}
LOGGER.info("SSO登录成功,本地用户名:{},IAM用户ID:{}", user.getUsername(), iamUserInfo.getUserId());
log.info("=== SSO认证提供者处理完成 ===");
// SSO登录不需要密码,使用空字符串占位
return new UsernamePasswordAuthenticationToken(user.getUsername(), "");
}
/**
* 隐藏敏感信息
*/
private String maskSecret(String secret) {
if (secret == null || secret.length() <= 8) {
return "***";
}
return secret.substring(0, 4) + "****" + secret.substring(secret.length() - 4);
}
@Override
public boolean supports(Class<?> authentication) {
boolean supports = OAuth2ResourceOwnerSsoAuthenticationToken.class.isAssignableFrom(authentication);
LOGGER.debug("supports authentication=" + authentication + " returning " + supports);
return supports;
}
@Override
public void checkClient(RegisteredClient registeredClient) {
assert registeredClient != null;
log.info("SSO认证提供者检查客户端: {}, 授权类型: {}",
registeredClient.getClientId(),
registeredClient.getAuthorizationGrantTypes());
if (!registeredClient.getAuthorizationGrantTypes().contains(new AuthorizationGrantType("sso"))) {
log.error("SSO认证失败:客户端不支持sso授权类型");
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
}
log.info("SSO认证提供者客户端检查通过");
}
protected UserDetails loadUserByUsername(String username) {
LOGGER.info("Provider loadUserByUsername SSO登录,用户名:{}", username);
try {
// 调用远程服务获取用户信息(包含角色、权限)
R<UserInfo> userInfoResult = remoteUserService.info(username);
if (userInfoResult == null || userInfoResult.getData() == null) {
LOGGER.warn("SSO用户[{}]在本地系统中不存在", username);
return null;
}
UserInfo userInfo = userInfoResult.getData();
LOGGER.info("成功获取SSO用户[{}]信息,角色数:{},权限数:{}",
username,
userInfo.getRoleList() != null ? userInfo.getRoleList().size() : 0,
userInfo.getPermissions() != null ? userInfo.getPermissions().size() : 0);
// 转换为UserDetails对象
UserDetails userDetails = userDetailsService.getUserDetails(Optional.of(userInfo));
// 设置租户上下文,确保后续操作(如日志记录)能正确获取租户ID
if (userDetails instanceof ScpUser scpUser && scpUser.getTenantId() != null) {
TenantContextHolder.setTenantId(scpUser.getTenantId());
LOGGER.info("SSO登录设置租户上下文,租户ID:{}", scpUser.getTenantId());
}
return userDetails;
} catch (Exception e) {
LOGGER.error("获取SSO用户[{}]信息失败", username, e);
return null;
}
}
}
package com.shpl.scp.auth.support.sso;
import com.shpl.scp.auth.support.base.OAuth2ResourceOwnerBaseAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import java.util.Map;
import java.util.Set;
/**
* SSO OAuth2认证Token
* @author scp
* @date 2026/04/15
*/
public class OAuth2ResourceOwnerSsoAuthenticationToken extends OAuth2ResourceOwnerBaseAuthenticationToken {
public OAuth2ResourceOwnerSsoAuthenticationToken(AuthorizationGrantType authorizationGrantType,
Authentication clientPrincipal,
Set<String> scopes,
Map<String, Object> additionalParameters) {
super(authorizationGrantType, clientPrincipal, scopes, additionalParameters);
}
}
package com.shpl.scp.auth.support.sso;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
/**
* IAM配置属性
* @author scp
* @date 2026/04/25
*/
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "iam")
public class IamProperties {
/**
* IAM Token交换地址prod
*/
private String tokenUrl = "";
/**
* IAM 用户信息地址prod
*/
private String userinfoUrl = "";
/**
* 应用标识
*/
private String clientId = "";
/**
* 应用密钥prod
*/
private String clientSecret = "";
/**
* 是否自动注册新用户
*/
private boolean autoRegister = false;
/**
* 连接超时时间(毫秒)
*/
private int connectTimeout = 5000;
/**
* 读取超时时间(毫秒)
*/
private int readTimeout = 10000;
/**
* 配置加载完成后验证
*/
@PostConstruct
public void validateConfig() {
log.info("=== IAM配置加载验证开始 ===");
log.info("IAM Token交换地址: {}", tokenUrl);
log.info("IAM 用户信息地址: {}", userinfoUrl);
log.info("应用标识(clientId): {}", clientId);
log.info("应用密钥(clientSecret): {}", clientSecret);
log.info("自动注册用户: {}", autoRegister);
log.info("连接超时时间: {}ms", connectTimeout);
log.info("读取超时时间: {}ms", readTimeout);
// 验证必要配置
if (tokenUrl == null || tokenUrl.trim().isEmpty()) {
log.error("IAM配置错误: tokenUrl不能为空");
}
if (userinfoUrl == null || userinfoUrl.trim().isEmpty()) {
log.error("IAM配置错误: userinfoUrl不能为空");
}
if (clientId == null || clientId.trim().isEmpty()) {
log.error("IAM配置错误: clientId不能为空");
}
if (clientSecret == null || clientSecret.trim().isEmpty()) {
log.error("IAM配置错误: clientSecret不能为空");
}
log.info("=== IAM配置加载验证完成 ===");
}
}
package com.shpl.scp.auth.support.sso;
import com.shpl.scp.admin.api.dto.UserDTO;
import com.shpl.scp.admin.api.feign.RemoteUserService;
import com.shpl.scp.common.core.util.R;
import com.shpl.scp.common.security.service.ScpUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
/**
* SSO用户服务
* @author scp
* @date 2026/04/25
*/
@Slf4j
@Service
public class SsoUserService {
@Autowired
private RemoteUserService remoteUserService;
@Autowired
private IamProperties iamProperties;
/**
* 根据IAM用户信息查找或创建本地用户
* @param iamUserInfo IAM用户信息
* @return 本地用户信息
*/
public ScpUser findOrCreateUser(IamClient.IamUserInfoResponse iamUserInfo) {
log.info("开始处理SSO用户匹配,IAM用户名:{},IAM用户ID:{}",
iamUserInfo.getUserName(), iamUserInfo.getUserId());
// 1. 首先尝试根据IAM用户ID查找
ScpUser user = findUserByIamUserId(iamUserInfo.getUserId());
if (user != null) {
log.info("根据IAM用户ID找到本地用户:{}", user.getUsername());
return user;
}
// 2. 尝试根据用户名查找
user = findUserByUsername(iamUserInfo.getUserName());
if (user != null) {
log.info("根据用户名找到本地用户:{}", user.getUsername());
// 更新IAM用户ID关联
updateUserIamId(user, iamUserInfo.getUserId());
return user;
}
// 3. 如果未找到用户且允许自动注册
if (iamProperties.isAutoRegister()) {
log.info("开始自动注册SSO用户:{}", iamUserInfo.getUserName());
return autoRegisterUser(iamUserInfo);
} else {
log.warn("SSO用户[{}]未在本地系统注册,且未开启自动注册", iamUserInfo.getUserName());
throw new RuntimeException("该账号未在本系统注册,请联系管理员");
}
}
/**
* 根据IAM用户ID查找本地用户 (临时实现),后续可以进行精确匹配
* @param iamUserId IAM用户ID
* @return 本地用户信息
*/
private ScpUser findUserByIamUserId(String iamUserId) {
try {
// 最小改动方案:暂时通过用户名查找(假设IAM用户名就是本地用户名)
// 后续可以扩展数据库字段和接口来实现精确匹配
log.info("临时通过IAM用户ID[{}]查找用户,使用用户名查找方式", iamUserId);
return null; // 暂时返回null,让后续逻辑通过用户名查找
} catch (Exception e) {
log.warn("根据IAM用户ID查找用户异常:{}", e.getMessage());
}
return null;
}
/**
* 根据用户名查找本地用户
* @param username 用户名
* @return 本地用户信息
*/
private ScpUser findUserByUsername(String username) {
try {
// 最小改动方案:使用现有的info接口,但需要进行类型转换
R<com.shpl.scp.admin.api.dto.UserInfo> userInfoResult = remoteUserService.info(username);
if (userInfoResult != null && userInfoResult.getData() != null) {
com.shpl.scp.admin.api.dto.UserInfo userInfo = userInfoResult.getData();
// 最小改动方案:直接使用UserInfo中的字段创建ScpUser
// 注意:这里需要根据实际UserInfo结构调整
ScpUser scpUser = new ScpUser(
userInfo.getUserId(), // id
userInfo.getUsername(), // username
null, // deptId
userInfo.getPhone(), // phone
userInfo.getAvatar(), // avatar
userInfo.getNickname(), // nickname
userInfo.getName(), // name
userInfo.getEmail(), // email
userInfo.getTenantId(), // tenantId
userInfo.getPassword(), // password
true, // enabled
true, // accountNonExpired
"", // userType
true, // credentialsNonExpired
true, // accountNonLocked
new ArrayList<>() // authorities
);
return scpUser;
}
} catch (Exception e) {
log.warn("根据用户名查找用户异常:{}", e.getMessage());
}
return null;
}
/**
* 更新用户的IAM用户ID关联
* @param user 用户信息
* @param iamUserId IAM用户ID
*/
private void updateUserIamId(ScpUser user, String iamUserId) {
try {
// 最小改动方案:记录日志,后续扩展接口
log.info("需要更新用户[{}]的IAM关联ID:{}(功能待扩展)", user.getUsername(), iamUserId);
// 后续扩展:调用remoteUserService.update方法更新用户信息
} catch (Exception e) {
log.warn("更新用户IAM关联ID异常:{}", e.getMessage());
}
}
/**
* 自动注册SSO用户
* @param iamUserInfo IAM用户信息
* @return 新注册的用户信息
*/
private ScpUser autoRegisterUser(IamClient.IamUserInfoResponse iamUserInfo) {
try {
// 最小改动方案:抛出提示信息,后续扩展自动注册功能
log.warn("SSO用户自动注册功能待扩展,用户:{}", iamUserInfo.getUserName());
throw new RuntimeException("请联系管理员手动创建用户,系统暂不支持自动注册新用户:" + iamUserInfo.getUserName());
// 后续扩展代码:
/*
UserDTO userDTO = new UserDTO();
userDTO.setUsername(iamUserInfo.getUserName());
userDTO.setPhone(iamUserInfo.getUserName());
// 使用现有字段临时存储IAM信息
userDTO.setEmail(iamUserInfo.getUserId()); // 临时使用email字段
userDTO.setPassword("sso_default_password");
// 调用现有的用户创建接口
R<ScpUser> result = remoteUserService.save(userDTO);
*/
} catch (Exception e) {
log.error("SSO用户自动注册异常:{}", e.getMessage(), e);
throw new RuntimeException("SSO用户自动注册异常:" + e.getMessage());
}
}
}
