Przeglądaj źródła

单点登录相关代码

huangguoce 3 dni temu
rodzic
commit
2951a0d645

+ 5 - 0
jeeplus-api/jeeplus-system-api/src/main/java/com/jeeplus/sys/factory/UserApiFallbackFactory.java

@@ -73,6 +73,11 @@ public class UserApiFallbackFactory implements FallbackFactory <IUserApi> {
             }
 
             @Override
+            public UserDTO getByOidcIdentity(String issuer, String subject, String loginName, String tenantId) {
+                return null;
+            }
+
+            @Override
             public UserDTO getByLoginNameNoTen(String loginName) {
                 return null;
             }

+ 11 - 0
jeeplus-api/jeeplus-system-api/src/main/java/com/jeeplus/sys/feign/IUserApi.java

@@ -99,6 +99,17 @@ public interface IUserApi {
     UserDTO getByLoginName(@RequestParam(value = "loginName") String loginName, @RequestParam(value = "tenantId") String tenantId);
 
     /**
+     * Resolve a Keycloak identity to the local user in the target tenant.
+     * When loginName is present and no mapping exists, the system may create
+     * the first mapping from one exact, active login_name match.
+     */
+    @PostMapping(value = BASE_URL + "/getByOidcIdentity")
+    UserDTO getByOidcIdentity(@RequestParam("issuer") String issuer,
+                              @RequestParam("subject") String subject,
+                              @RequestParam("loginName") String loginName,
+                              @RequestParam("tenantId") String tenantId);
+
+    /**
      * 根据登录名获取用户(无租户)
      *
      * @param loginName

+ 4 - 0
jeeplus-auth/pom.xml

@@ -39,6 +39,10 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
+        </dependency>
 
         <!-- SpringCloud Alibaba Nacos -->
         <dependency>

+ 34 - 0
jeeplus-auth/src/main/java/com/jeeplus/auth/config/OidcProperties.java

@@ -0,0 +1,34 @@
+package com.jeeplus.auth.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
+import org.springframework.stereotype.Component;
+
+@Component
+@RefreshScope
+@ConfigurationProperties(prefix = "security.oidc")
+public class OidcProperties {
+    private boolean enabled = false;
+    private String issuerUri = "http://127.0.0.1:8180/realms/xg-cloud";
+    private String jwkSetUri = "http://127.0.0.1:8180/realms/xg-cloud/protocol/openid-connect/certs";
+    private String audience = "xg-cloud-api";
+    /**
+     * Bootstrap an identity mapping on the first successful Keycloak login by
+     * matching preferred_username to an exact login_name in the current tenant.
+     * Keep Keycloak self-registration disabled while this option is enabled.
+     */
+    private boolean autoBindByLoginName = true;
+
+    public boolean isEnabled() { return enabled; }
+    public void setEnabled(boolean enabled) { this.enabled = enabled; }
+    public String getIssuerUri() { return issuerUri; }
+    public void setIssuerUri(String issuerUri) { this.issuerUri = issuerUri; }
+    public String getJwkSetUri() { return jwkSetUri; }
+    public void setJwkSetUri(String jwkSetUri) { this.jwkSetUri = jwkSetUri; }
+    public String getAudience() { return audience; }
+    public void setAudience(String audience) { this.audience = audience; }
+    public boolean isAutoBindByLoginName() { return autoBindByLoginName; }
+    public void setAutoBindByLoginName(boolean autoBindByLoginName) {
+        this.autoBindByLoginName = autoBindByLoginName;
+    }
+}

+ 77 - 0
jeeplus-auth/src/main/java/com/jeeplus/auth/controller/LoginController.java

@@ -7,8 +7,10 @@ import cn.hutool.captcha.CaptchaUtil;
 import cn.hutool.captcha.LineCaptcha;
 import cn.hutool.extra.servlet.ServletUtil;
 import cn.hutool.extra.spring.SpringUtil;
+import com.jeeplus.auth.config.OidcProperties;
 import com.jeeplus.auth.model.LoginForm;
 import com.jeeplus.auth.service.DingTalkAuthService;
+import com.jeeplus.auth.service.OidcTokenVerifier;
 import com.jeeplus.common.SecurityUtils;
 import com.jeeplus.common.TokenProvider;
 import com.jeeplus.common.constant.CacheNames;
@@ -43,6 +45,7 @@ import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
 import org.springframework.web.bind.annotation.*;
 
@@ -79,6 +82,11 @@ public class LoginController {
     @Autowired
     private DingTalkAuthService dingTalkAuthService;
 
+    @Autowired
+    private OidcTokenVerifier oidcTokenVerifier;
+
+    @Autowired
+    private OidcProperties oidcProperties;
 
     @PostMapping("/login")
     @ApiLog(value = "用户登录", type = LogTypeEnum.LOGIN)
@@ -389,6 +397,75 @@ public class LoginController {
      * cas登录
      * vue 传递ticket参数验证,并返回token
      */
+    /**
+     * Exchange a verified Keycloak access token for the existing JeePlus token.
+     * Keycloak authenticates the person; the current request domain still selects
+     * the target tenant and therefore the target tenant's roles and permissions.
+     */
+    @PostMapping("/oidc/login")
+    @ApiLog(value = "OIDC单点登录", type = LogTypeEnum.LOGIN)
+    @ApiOperation("OIDC Token兑换系统Token")
+    public ResponseEntity oidcLogin(@RequestHeader("Authorization") String authorization) {
+        Jwt oidcToken = oidcTokenVerifier.verify(authorization);
+        String loginName = oidcToken.getClaimAsString("preferred_username");
+        if (StringUtils.isBlank(loginName)) {
+            throw new UsernameNotFoundException("OIDC Token中缺少preferred_username");
+        }
+        if (StringUtils.isBlank(oidcToken.getSubject()) || oidcToken.getIssuer() == null) {
+            throw new UsernameNotFoundException("OIDC Token中缺少统一用户标识");
+        }
+
+        String tenantId = tenantApi.getCurrentTenantId();
+        String bootstrapLoginName = oidcProperties.isAutoBindByLoginName() ? loginName : "";
+        UserDTO userDTO = userApi.getByOidcIdentity(
+                oidcToken.getIssuer().toString(),
+                oidcToken.getSubject(),
+                bootstrapLoginName,
+                tenantId);
+        if (userDTO == null) {
+            log.warn("OIDC identity is not bound to a target tenant account: issuer={}, subject={}, "
+                            + "preferredUsername={}, tenantId={}",
+                    oidcToken.getIssuer(), oidcToken.getSubject(), loginName, tenantId);
+            throw new UsernameNotFoundException(ErrorConstants.LOGIN_ERROR_NOTFOUND);
+        }
+        if (CommonConstants.NO.equals(userDTO.getLoginFlag())) {
+            throw new LockedException(ErrorConstants.LOGIN_ERROR_FORBIDDEN);
+        }
+
+        String localLoginName = userDTO.getLoginName();
+        String domain = RequestUtils.getHeader("domain");
+        if ((domain == null || !domain.contains("ydddl"))
+                && !userApi.isEnableLogin(tenantId, localLoginName)) {
+            throw new DisabledException(ErrorConstants.LOGIN_ERROR_FORBID_LOGGED_IN_ELSEWHERE);
+        }
+        validateMutuallyExclusiveLogin(userDTO);
+
+        String token = TokenProvider.createAccessToken(localLoginName);
+        Authentication authentication = TokenProvider.getAuthentication(token);
+        SecurityContextHolder.getContext().setAuthentication(authentication);
+
+        ResponseUtil responseUtil = new ResponseUtil();
+        responseUtil.add(TokenProvider.TOKEN, token);
+        updateUserLoginInfo(responseUtil, userDTO, token);
+        log.info("OIDC token exchanged: subject={}, keycloakUsername={}, localLoginName={}, tenantId={}",
+                oidcToken.getSubject(), loginName, localLoginName, tenantId);
+        return responseUtil.ok();
+    }
+
+    private void validateMutuallyExclusiveLogin(UserDTO userDTO) {
+        if ("樊莉".equals(userDTO.getName())) {
+            List<UserDTO> onlineUsers = userApi.getOnLineUserList("黄玮", "10002");
+            if (!onlineUsers.isEmpty()) {
+                throw new DisabledException("当前黄玮已登录系统," + ErrorConstants.LOGIN_ERROR);
+            }
+        } else if ("黄玮".equals(userDTO.getName())) {
+            List<UserDTO> onlineUsers = userApi.getOnLineUserList("樊莉", "10002");
+            if (!onlineUsers.isEmpty()) {
+                throw new DisabledException("当前樊莉已登录系统," + ErrorConstants.LOGIN_ERROR);
+            }
+        }
+    }
+
     @ApiLog(value = "单点登录", type = LogTypeEnum.ACCESS)
     @RequestMapping("/casLogin")
     public ResponseEntity casLogin(@RequestParam(name = "ticket") String ticket,

+ 75 - 0
jeeplus-auth/src/main/java/com/jeeplus/auth/service/OidcTokenVerifier.java

@@ -0,0 +1,75 @@
+package com.jeeplus.auth.service;
+
+import com.jeeplus.auth.config.OidcProperties;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtException;
+import org.springframework.security.oauth2.jwt.JwtValidators;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.stereotype.Service;
+
+@Service
+public class OidcTokenVerifier {
+    private static final String BEARER_PREFIX = "Bearer ";
+
+    private final OidcProperties properties;
+    private volatile JwtDecoder decoder;
+
+    public OidcTokenVerifier(OidcProperties properties) {
+        this.properties = properties;
+    }
+
+    public Jwt verify(String authorizationHeader) {
+        if (!properties.isEnabled()) {
+            throw new BadCredentialsException("OIDC login is disabled");
+        }
+        if (StringUtils.isBlank(authorizationHeader)
+                || !authorizationHeader.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) {
+            throw new BadCredentialsException("Missing OIDC bearer token");
+        }
+        String accessToken = authorizationHeader.substring(BEARER_PREFIX.length()).trim();
+        if (accessToken.isEmpty()) {
+            throw new BadCredentialsException("Missing OIDC bearer token");
+        }
+        try {
+            return getDecoder().decode(accessToken);
+        } catch (JwtException e) {
+            throw new BadCredentialsException("Invalid or expired OIDC access token", e);
+        }
+    }
+
+    private JwtDecoder getDecoder() {
+        JwtDecoder current = decoder;
+        if (current == null) {
+            synchronized (this) {
+                current = decoder;
+                if (current == null) {
+                    current = createDecoder();
+                    decoder = current;
+                }
+            }
+        }
+        return current;
+    }
+
+    private JwtDecoder createDecoder() {
+        NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(properties.getJwkSetUri()).build();
+        OAuth2TokenValidator<Jwt> issuerValidator = JwtValidators.createDefaultWithIssuer(properties.getIssuerUri());
+        OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
+            if (StringUtils.isBlank(properties.getAudience()) || jwt.getAudience().contains(properties.getAudience())) {
+                return OAuth2TokenValidatorResult.success();
+            }
+            OAuth2Error error = new OAuth2Error("invalid_token",
+                    "OIDC token does not contain the required audience", null);
+            return OAuth2TokenValidatorResult.failure(error);
+        };
+        jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(issuerValidator, audienceValidator));
+        return jwtDecoder;
+    }
+}

+ 8 - 0
jeeplus-modules/jeeplus-system/src/main/java/com/jeeplus/sys/feign/UserApiImpl.java

@@ -88,6 +88,14 @@ public class UserApiImpl implements IUserApi {
     }
 
     @Override
+    public UserDTO getByOidcIdentity(String issuer, String subject, String loginName, String tenantId) {
+        if (StrUtil.isBlank(issuer) || StrUtil.isBlank(subject) || StrUtil.isBlank(tenantId)) {
+            return null;
+        }
+        return userService.getByOidcIdentity(issuer, subject, loginName, tenantId);
+    }
+
+    @Override
     public UserDTO getByLoginNameNoTen(String loginName) {
         return userService.getByLoginNameNoTen(loginName);
     }

+ 17 - 0
jeeplus-modules/jeeplus-system/src/main/java/com/jeeplus/sys/mapper/UserMapper.java

@@ -346,4 +346,21 @@ public interface UserMapper extends BaseMapper <User> {
      */
     @InterceptorIgnore(tenantLine = "true")
     UserDTO getByLoginName(@Param("loginName") String loginName, @Param("tenantId")String tenantId);
+
+    @InterceptorIgnore(tenantLine = "true")
+    String getLoginNameByOidcIdentity(@Param("issuer") String issuer,
+                                      @Param("subject") String subject,
+                                      @Param("tenantId") String tenantId);
+
+    @InterceptorIgnore(tenantLine = "true")
+    List<String> findActiveUserIdsByExactLoginName(@Param("loginName") String loginName,
+                                                    @Param("tenantId") String tenantId);
+
+    @InterceptorIgnore(tenantLine = "true")
+    int insertOidcUserMapping(@Param("id") String id,
+                              @Param("issuer") String issuer,
+                              @Param("subject") String subject,
+                              @Param("tenantId") String tenantId,
+                              @Param("userId") String userId,
+                              @Param("identityUsername") String identityUsername);
 }

+ 34 - 0
jeeplus-modules/jeeplus-system/src/main/java/com/jeeplus/sys/mapper/xml/UserMapper.xml

@@ -835,4 +835,38 @@ select a.id, a.company_id as "companyDTO.id", a.office_id as "officeDTO.id", a.l
         order by a.update_time desc
         limit 1
     </select>
+
+    <select id="getLoginNameByOidcIdentity" resultType="java.lang.String">
+        select u.login_name
+        from sys_sso_user_mapping m
+        inner join sys_user u
+            on u.id = m.user_id
+            and u.tenant_id = m.tenant_id
+            and u.del_flag = '0'
+        where m.issuer = #{issuer}
+          and m.subject = #{subject}
+          and m.tenant_id = #{tenantId}
+          and m.enabled = '1'
+        limit 1
+    </select>
+
+    <select id="findActiveUserIdsByExactLoginName" resultType="java.lang.String">
+        select u.id
+        from sys_user u
+        where u.tenant_id = #{tenantId}
+          and u.login_name = #{loginName}
+          and u.del_flag = '0'
+          and u.login_flag = '1'
+        order by u.id
+        limit 2
+    </select>
+
+    <insert id="insertOidcUserMapping">
+        insert ignore into sys_sso_user_mapping
+            (id, issuer, subject, tenant_id, user_id, identity_username,
+             enabled, create_time, update_time)
+        values
+            (#{id}, #{issuer}, #{subject}, #{tenantId}, #{userId},
+             #{identityUsername}, '1', now(), now())
+    </insert>
 </mapper>

+ 38 - 0
jeeplus-modules/jeeplus-system/src/main/java/com/jeeplus/sys/service/UserService.java

@@ -291,6 +291,44 @@ public class UserService extends ServiceImpl<UserMapper, User> {
         return userDTO;
     }
 
+    /**
+     * Resolve the immutable Keycloak identity (issuer + subject) to a local
+     * tenant user. On first login, an exact login_name match can bootstrap the
+     * mapping. INSERT IGNORE plus unique keys makes concurrent first logins
+     * idempotent and prevents one local account being attached to two subjects.
+     */
+    public UserDTO getByOidcIdentity(String issuer, String subject, String loginName, String tenantId) {
+        String localLoginName = userMapper.getLoginNameByOidcIdentity(issuer, subject, tenantId);
+        if (StringUtils.isNotBlank(localLoginName)) {
+            return getUserByLoginName(localLoginName, tenantId);
+        }
+
+        if (StringUtils.isBlank(loginName)) {
+            return null;
+        }
+
+        List<String> candidateUserIds =
+                userMapper.findActiveUserIdsByExactLoginName(loginName, tenantId);
+        if (candidateUserIds == null || candidateUserIds.size() != 1) {
+            return null;
+        }
+
+        userMapper.insertOidcUserMapping(
+                UUID.randomUUID().toString().replace("-", ""),
+                issuer,
+                subject,
+                tenantId,
+                candidateUserIds.get(0),
+                loginName);
+
+        // Re-read the persisted mapping. An ignored insert can mean either
+        // another request won the race or this local user belongs to another
+        // Keycloak subject; only the former resolves successfully here.
+        localLoginName = userMapper.getLoginNameByOidcIdentity(issuer, subject, tenantId);
+        return StringUtils.isBlank(localLoginName)
+                ? null
+                : getUserByLoginName(localLoginName, tenantId);
+    }
 
     /**
      * 自定义分页检索