diff --git a/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CasController.java b/backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CasController.java new file mode 100644 index 00000000..200e2ec9 --- /dev/null +++ b/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 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().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()); + } + } +} diff --git a/web/src/views/login.vue b/web/src/views/login.vue index 2b39a8db..9fe330d4 100644 --- a/web/src/views/login.vue +++ b/web/src/views/login.vue @@ -52,16 +52,19 @@ --> - + +
{{ proxy.$t('login.switchRegisterPage') }}
@@ -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); + } }); @@ -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; +}