spring oauth2 单点登录

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());
        }
    }
}
相关推荐
c++之路4 小时前
C++ STL
java·开发语言·c++
前端那点事5 小时前
Vue前端SEO优化全攻略(实操落地版,新手也能上手)
前端·vue.js
白晨并不是很能熬夜5 小时前
【RPC】第 4 篇:服务发现 — Zookeeper + 缓存容错
java·后端·程序人生·缓存·zookeeper·rpc·服务发现
EvenBoy5 小时前
IDEA中使用CodeX
java·ide·intellij-idea
日取其半万世不竭5 小时前
Minecraft Java版社区服搭建教程(Windows版)
java·开发语言·windows
逸Y 仙X5 小时前
文章十六:ElasticSearch 使用enrich策略实现大宽表
java·大数据·数据库·elasticsearch·搜索引擎·全文检索
小雅痞5 小时前
[Java][Leetcode middle] 15. 三数之和
java·算法·leetcode
苍煜5 小时前
Java自定义注解-SpringBoot实战
java·开发语言·spring boot
XS0301065 小时前
Java ArrayList
java·开发语言