zlwu 9 months ago
parent
commit
df640dffad

+ 187 - 0
backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CasController.java

@@ -0,0 +1,187 @@
+package org.dromara.web.controller;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.domain.model.LoginUser;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.web.domain.vo.LoginVo;
+import org.dromara.system.domain.vo.SysClientVo;
+import org.dromara.system.domain.vo.SysUserVo;
+import org.dromara.system.mapper.SysUserMapper;
+import org.dromara.web.service.SysLoginService;
+import org.dromara.system.service.ISysClientService;
+import org.dromara.common.tenant.helper.TenantHelper;
+
+import org.dromara.common.core.exception.user.UserException;
+import org.dromara.common.core.constant.SystemConstants;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.dromara.system.domain.SysUser;
+import cn.dev33.satoken.stp.StpUtil;
+import cn.dev33.satoken.stp.parameter.SaLoginParameter;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import cn.hutool.http.HttpUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
+import cn.dev33.satoken.annotation.SaIgnore;
+import java.net.URLEncoder;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * CAS 代理接口,用于前端通过后端获取 CAS accessToken(避免浏览器 CORS 限制)
+ */
+@Slf4j
+@SaIgnore
+@RestController
+@RequestMapping("/cas")
+@RequiredArgsConstructor
+public class CasController {
+
+    // 固定 CAS 地址与 client 配置(按需求写死)
+    private static final String CAS_BASE = "http://172.16.0.82";
+    private static final String CLIENT_ID = "0i7tcT5tULqvmmbbV8";
+    private static final String CLIENT_SECRET = "68c4f7641523452aaf5ef6d0432869f7";
+    private static final String REDIRECT_URI_ENCODED = "http%3A%2F%2F172.16.0.67%3A8882";
+
+    private final SysUserMapper userMapper;
+    private final SysLoginService loginService;
+    private final ISysClientService clientService;
+
+    /**
+     * 代理获取 accessToken(由后端向 CAS 发起请求,返回 CAS 原始响应)
+     *
+     * 示例:GET /cas/accessToken?code=ST-xxx
+     */
+    @GetMapping("/accessToken")
+    public R<LoginVo> accessToken(@RequestParam("code") String code) {
+        if (StrUtil.isBlank(code)) {
+            return R.fail("code 不能为空");
+        }
+
+        try {
+            String url = String.format(
+                    "%s/cas/oauth2.0/accessToken?client_id=%s&client_secret=%s&code=%s&redirect_uri=%s",
+                    CAS_BASE, CLIENT_ID, CLIENT_SECRET, URLEncoder.encode(code, StandardCharsets.UTF_8.toString()),
+                    REDIRECT_URI_ENCODED);
+
+            log.info("请求 CAS accessToken: {}", url);
+            String resp = HttpUtil.get(url);
+            log.info("CAS accessToken 响应: {}", resp);
+
+            // 尝试解析 access_token(CAS 可能返回 JSON 或 query-string 风格)
+            String accessToken = null;
+            try {
+                // 优先尝试作为 JSON 解析
+                try {
+                    accessToken = JSONUtil.parseObj(resp).getStr("access_token");
+                } catch (Exception ignore) {
+                    // 作为 query-string 风格解析,例如: access_token=xxx&expires_in=...
+                    for (String pair : resp.split("&")) {
+                        if (pair.startsWith("access_token=")) {
+                            accessToken = java.net.URLDecoder.decode(pair.split("=", 2)[1],
+                                    StandardCharsets.UTF_8.toString());
+                            break;
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("解析 CAS accessToken 响应出错", e);
+            }
+
+            if (StrUtil.isNotBlank(accessToken)) {
+                // 使用 access_token 请求 profile 接口
+                String profileUrl = String.format("%s/cas/oauth2.0/profile?access_token=%s", CAS_BASE,
+                        URLEncoder.encode(accessToken, StandardCharsets.UTF_8.toString()));
+                log.info("请求 CAS profile: {}", profileUrl);
+                String profileResp = HttpUtil.get(profileUrl);
+                log.info("CAS profile 响应: {}", profileResp);
+
+                // 解析 profile,尝试从 attributes 中找到 CODE(用户名) 和 TENANTID
+                try {
+                    var profileObj = JSONUtil.parseObj(profileResp);
+                    var attrs = profileObj.getJSONArray("attributes");
+                    String username = null;
+                    String tenantId = null;
+                    if (attrs != null) {
+                        for (Object o : attrs) {
+                            try {
+                                var obj = JSONUtil.parseObj(o);
+                                if (obj.containsKey("CODE")) {
+                                    username = obj.getStr("CODE");
+                                }
+                                if (obj.containsKey("TENANTID")) {
+                                    tenantId = obj.getStr("TENANTID");
+                                }
+                            } catch (Exception ignore) {
+                            }
+                        }
+                    }
+
+                    if (StrUtil.isBlank(username)) {
+                        return R.fail("CAS profile 未包含用户信息");
+                    }
+                    // 选定客户端(使用前端默认 clientId,如需可改为配置)
+                    final String defaultClientId = "e5cd7e4891bf95d1d19206ce24a7b32e";
+                    SysClientVo client = clientService.queryByClientId(defaultClientId);
+
+                    // 把外层变量复制为 final 以便在 lambda 中使用
+                    final String finalUsername = username;
+                    // 强制使用默认租户 000000(忽略 CAS 返回的 TENANTID)
+                    final String finalTenantId = "000000";
+
+                    // 在对应租户上下文中查找用户并构建 LoginUser 并登录
+                    log.info("CAS auto-login attempt, username={}, tenantId={}", finalUsername, finalTenantId);
+                    LoginUser loginUser = TenantHelper.dynamic(finalTenantId, () -> {
+                        SysUserVo user = userMapper
+                                .selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, finalUsername));
+                        if (user == null) {
+                            log.warn("CAS auto-login: user not found, username={}, tenantId={}", finalUsername, finalTenantId);
+                            throw new UserException("user.not.exists", finalUsername);
+                        } else {
+                            log.info("CAS auto-login: found user: userId={}, userName={}, status={}, tenantId={}",
+                                    user.getUserId(), user.getUserName(), user.getStatus(), user.getTenantId());
+                        }
+                        if (SystemConstants.DISABLE.equals(user.getStatus())) {
+                            log.warn("CAS auto-login: user disabled, username={}", finalUsername);
+                            throw new UserException("user.blocked", finalUsername);
+                        }
+                        return loginService.buildLoginUser(user);
+                    });
+
+                    // 填充 client 信息并生成 token
+                    if (client != null) {
+                        loginUser.setClientKey(client.getClientKey());
+                    }
+                    SaLoginParameter model = new SaLoginParameter();
+                    if (client != null) {
+                        model.setTimeout(client.getTimeout());
+                        model.setActiveTimeout(client.getActiveTimeout());
+                        model.setExtra(LoginHelper.CLIENT_KEY, client.getClientId());
+                    }
+                    LoginHelper.login(loginUser, model);
+
+                    LoginVo loginVo = new LoginVo();
+                    loginVo.setAccessToken(StpUtil.getTokenValue());
+                    loginVo.setExpireIn(StpUtil.getTokenTimeout());
+                    loginVo.setClientId(client != null ? client.getClientId() : defaultClientId);
+                    return R.ok(loginVo);
+                } catch (UserException ue) {
+                    log.warn("CAS 登录用户验证失败: {}", ue.getMessage());
+                    return R.fail(ue.getMessage());
+                } catch (Exception e) {
+                    log.error("处理 CAS profile 失败", e);
+                    return R.fail("处理 CAS profile 失败: " + e.getMessage());
+                }
+            } else {
+                return R.fail("无法解析 CAS access_token,原始响应: " + resp);
+            }
+        } catch (Exception e) {
+            log.error("请求 CAS accessToken 失败", e);
+            return R.fail("请求 CAS accessToken 失败: " + e.getMessage());
+        }
+    }
+}

+ 137 - 10
web/src/views/login.vue

@@ -52,16 +52,19 @@
         </el-button>
       </el-form-item> -->
       <el-form-item style="width: 100%">
-        <el-button
-          :loading="loading"
-          size="large"
-          style="width: 100%; background-color: rgb(var(--art-primary)) !important"
-          class="login-button"
-          @click.prevent="handleLogin"
-        >
-          <span v-if="!loading">{{ proxy.$t('login.login') }}</span>
-          <span v-else>{{ proxy.$t('login.logging') }}</span>
-        </el-button>
+        <div class="login-actions">
+          <el-button :loading="loading" size="large" class="login-button primary" @click.prevent="handleLogin">
+            <span v-if="!loading">{{ proxy.$t('login.login') }}</span>
+            <span v-else>{{ proxy.$t('login.logging') }}</span>
+          </el-button>
+
+          <!-- 统一认证登录按钮(已注释,方便恢复)
+          <el-button size="large" class="login-button cas-login" type="primary" @click.prevent="handleUnifiedLogin">
+            统一认证登录
+          </el-button>
+          -->
+        </div>
+
         <div v-if="register" style="float: right">
           <router-link class="link-type" :to="'/register'">{{ proxy.$t('login.switchRegisterPage') }}</router-link>
         </div>
@@ -82,6 +85,7 @@ import { LoginData, TenantVO } from '@/api/types';
 import { to } from 'await-to-js';
 import { HttpStatus } from '@/enums/RespEnum';
 import { useI18n } from 'vue-i18n';
+import { setToken } from '@/utils/auth';
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
@@ -233,12 +237,109 @@ const doSocialLogin = (type: string) => {
   });
 };
 
+// ======= 统一认证(CAS)相关 =======
+// 固定的 CAS 地址与生产前端地址(按需求写死)
+const CAS_BASE = 'http://172.16.0.82';
+const CAS_CLIENT_ID = '0i7tcT5tULqvmmbbV8';
+const CAS_CLIENT_SECRET = '68c4f7641523452aaf5ef6d0432869f7';
+const FRONTEND_REDIRECT_URI_ENCODED = encodeURIComponent('http://172.16.0.67:8882');
+
+/**
+ * 点击“统一认证登录”按钮,直接跳转到 CAS 授权页面
+ */
+const handleUnifiedLogin = () => {
+  const authorizeUrl = `${CAS_BASE}/cas/oauth2.0/authorize?response_type=code&client_id=${CAS_CLIENT_ID}&redirect_uri=${FRONTEND_REDIRECT_URI_ENCODED}`;
+  window.location.href = authorizeUrl;
+};
+
+/**
+ * 从当前路由或 redirect 字段中提取 code 参数
+ */
+const extractCodeFromRoute = (route: any): string | null => {
+  // 1) 直接从 query.code 获取
+  if (route && route.query && route.query.code) {
+    return String(route.query.code);
+  }
+
+  // 2) redirect 参数里可能包含 ?code=...,例如 redirect=/index?code=ST-...
+  const redirectParam = route && route.query && route.query.redirect ? String(route.query.redirect) : null;
+  if (redirectParam) {
+    const m = redirectParam.match(/[?&]code=([^&]+)/);
+    if (m && m[1]) return decodeURIComponent(m[1]);
+  }
+
+  // 3) 尝试从 fullPath 中找
+  if (route && route.fullPath) {
+    const m2 = String(route.fullPath).match(/[?&]code=([^&]+)/);
+    if (m2 && m2[1]) return decodeURIComponent(m2[1]);
+  }
+
+  return null;
+};
+
+/**
+ * 使用 code 去请求 accessToken,并打印返回信息
+ */
+const fetchAccessToken = async (code: string) => {
+  try {
+    // 由于直接请求 CAS 会触发 CORS,改为请求后端接口,由后端向 CAS 请求 accessToken
+    // 后端接口示例:GET ${VITE_APP_BASE_API}/cas/accessToken?code=...
+    const backendUrl = `${import.meta.env.VITE_APP_BASE_API}/cas/accessToken?code=${encodeURIComponent(code)}`;
+
+    const res = await fetch(backendUrl, {
+      method: 'GET'
+    });
+
+    // 读取后端返回(期望后端返回 JSON 或文本)
+    const contentType = res.headers.get('content-type') || '';
+    let body: any;
+    if (contentType.includes('application/json')) {
+      body = await res.json();
+    } else {
+      const txt = await res.text();
+      try {
+        body = JSON.parse(txt);
+      } catch {
+        body = txt;
+      }
+    }
+    console.log('[Backend -> CAS accessToken response]', { status: res.status, ok: res.ok, body });
+
+    // 如果后端返回了登录令牌,前端直接用它完成登录后的行为(存 token & 跳转)
+    // 兼容多种返回结构:{ data: { access_token: '...' } } 或 { access_token: '...' } 或 raw LoginVo
+    const tokenCandidate =
+      (body && body.data && body.data.access_token) ||
+      (body && body.access_token) ||
+      (body && body.data && body.data.accessToken) ||
+      (body && body.accessToken);
+
+    if (tokenCandidate) {
+      const tokenStr = String(tokenCandidate);
+      // 持久化 token,更新 store,并跳转到重定向或首页
+      setToken(tokenStr);
+      userStore.token = tokenStr;
+      const redirectUrl = redirect.value || '/';
+      await router.push(redirectUrl);
+      return;
+    }
+  } catch (err) {
+    console.error('fetchAccessToken error', err);
+  }
+};
+
 onMounted(() => {
   initTenantList();
   getLoginData();
 
   // 开始预加载背景图片
   preloadBackgroundImage();
+
+  // 如果 URL 中携带 CAS 的 code,则使用该 code 请求 accessToken 并打印返回信息
+  const code = extractCodeFromRoute(router.currentRoute.value);
+  if (code) {
+    // 发起请求(异步,不阻塞页面加载)
+    fetchAccessToken(code);
+  }
 });
 </script>
 
@@ -419,4 +520,30 @@ onMounted(() => {
 .login-button {
   color: #ffffff;
 }
+
+.login-actions {
+  display: flex;
+  gap: 12px;
+  /* 让容器占满可用宽度,保证子按钮能拉伸 */
+  width: 100%;
+  justify-content: stretch;
+  align-items: center;
+}
+
+/* 单按钮占满整个宽度 */
+.login-actions .login-button {
+  flex: 1 1 0%;
+  min-width: 0; /* 允许缩小以配合 flex */
+  display: block;
+  width: 100%;
+  background-color: rgb(var(--art-primary)) !important;
+  border-color: transparent !important;
+  color: #ffffff;
+  text-align: center;
+}
+
+.login-button.cas-login {
+  /* 可个性化保留此类;目前与主按钮外观一致 */
+  color: #ffffff !important;
+}
 </style>