Commit a26f8c54 authored by jiatao's avatar jiatao

feat: 完善 CAS 认证及当前分支更新

parent f2954a9f
# CAS 2.0 对接说明
CAS 是新增认证方式,现有账号密码登录保持不变。测试阶段不显示 CAS 登录按钮,
通过手写认证中心地址发起登录。
## 测试登录
当前默认 `service` 为:
```text
http://gwyy.xjtu.edu.cn/cas/callback
```
手写测试登录链接:
```text
https://login-test.xjtu.edu.cn/cas/login?service=http%3A%2F%2Fgwyy.xjtu.edu.cn%2Fcas%2Fcallback
```
认证中心注册的地址、登录链接中的 `service` 和后端
`cas.service-url` 必须逐字一致。CAS Service Ticket 只能使用一次。
登录流程:
1. 认证中心跳转到 `/cas/callback?ticket=ST-...`。
2. 前端调用 `GET /api/cas/callback?ticket=ST-...`。
3. 后端以固定 `service` 请求认证中心 `serviceValidate`。
4. 后端读取与 `cas:attributes` 同级的 `cas:user`,精确匹配
`sys_user.user_name`。
5. 匹配或创建账号后,签发若依原有 Token。
## 身份与建号
| 身份代码 | 身份 | 首次登录处理 |
| --- | --- | --- |
| `S01` | 本科生 | 创建账号并分配 `student` 角色 |
| `S02` | 研究生 | 创建账号并分配 `student` 角色 |
| `S03` | 留学生 | 创建账号并分配 `student` 角色 |
| `XY` | 校友 | 已有账号可登录,不自动创建 |
| `T01` | 教职工 | 创建账号并分配 `teacher` 角色 |
首次建号使用按用户名区分的 Redisson 分布式锁;用户和角色关系由
`ISysUserService.insertUser` 在同一事务中写入。
## 正式环境切换
正式启用时配置以下环境变量,无需修改代码:
```text
CAS_SERVER_URL=https://login.xjtu.edu.cn/cas
CAS_LOGIN_URL=https://login.xjtu.edu.cn/cas/login
CAS_VALIDATE_URL=https://login.xjtu.edu.cn/cas/serviceValidate
CAS_LOGOUT_URL=https://login.xjtu.edu.cn/cas/logout
CAS_SERVICE_URL=认证中心正式注册的回调地址
CAS_LOGOUT_REDIRECT_URL=退出后返回地址
```
# 签到验证功能使用说明
## 功能概述
本功能实现了预约签到的验证,包括时间验证和位置验证两个主要功能。
## 配置参数
在 `sys_config` 表中添加了以下配置参数:
1. **sign_before_start_time**: 签到开始时间(分钟)
- 默认值:30
- 说明:预约开始前多少分钟可以签到
2. **sign_before_end_time**: 签到结束时间(分钟)
- 默认值:10
- 说明:预约结束前多少分钟停止签到
## 核心组件
### 1. Location 类
位置信息对象,包含经纬度信息和距离计算功能。
```java
Location location = new Location(39.9042, 116.4074); // 纬度,经度
Double distance = location.distanceTo(anotherLocation); // 计算距离(米)
```
### 2. SignTimeVerifier 类
签到时间验证器,负责验证签到时间是否在允许范围内。
### 3. SignLocationVerifier 类
签到位置验证器,负责验证用户位置与房间位置的距离。
### 4. VerifySignApprovalProcessor 接口
签到验证处理器接口。
### 5. VerifySignApprovalProcessorImpl 实现类
签到验证处理器的具体实现,组合使用时间验证器和位置验证器。
### 6. SpaceSeatService 扩展
在 ISpaceSeatService 接口中添加了 `getSpaceSeatWithRoom(String seatCode)` 方法,用于获取座位信息(包含房间信息)。
### 7. Space 实体类扩展
在 Space 实体类中添加了 `location` 字段,用于存储房间的经纬度信息。
## API 接口
### 1. 基础签到接口
```
POST /system/approval/sign/{approvalId}
```
- 功能:进行基础签到验证(仅验证时间)
- 参数:approvalId(预约ID)
### 2. 带位置验证的签到接口
```
POST /system/approval/signWithLocation/{approvalId}
```
- 功能:进行签到验证(包含时间和位置验证)
- 参数:
- approvalId(预约ID)
- location(用户位置信息,JSON格式)
请求体示例:
```json
{
"latitude": 39.9042,
"longitude": 116.4074
}
```
## 使用示例
### 1. 在 Controller 中使用
```java
@Autowired
private VerifySignApprovalProcessorWithLocation verifySignApprovalProcessor;
@Autowired
private ISpaceSeatService spaceSeatService;
// 获取座位和房间信息(在Controller层)
SpaceSeat spaceSeat = spaceSeatService.getSpaceSeatWithRoom(approval.getSeatCode());
// 基础验证
verifySignApprovalProcessor.verifySignRule(approval);
// 带位置验证
Location userLocation = new Location(39.9042, 116.4074);
verifySignApprovalProcessor.verifySignRuleWithLocation(approval, userLocation);
```
### 2. 单独使用验证器
```java
@Autowired
private SignTimeVerifier signTimeVerifier;
@Autowired
private SignLocationVerifier signLocationVerifier;
// 仅验证时间
signTimeVerifier.verifySignTime(approval);
// 仅验证位置
Location userLocation = new Location(39.9042, 116.4074);
signLocationVerifier.verifyLocation(approval, userLocation);
```
### 2. 数据库配置
执行 `sql/sign_config.sql` 脚本添加配置参数:
```sql
INSERT INTO sys_config (config_name, config_key, config_value, config_type, create_by, create_time, remark)
VALUES ('签到开始时间(分钟)', 'sign_before_start_time', '30', 'N', 'admin', NOW(), '预约开始前多少分钟可以签到');
INSERT INTO sys_config (config_name, config_key, config_value, config_type, create_by, create_time, remark)
VALUES ('签到结束时间(分钟)', 'sign_before_end_time', '10', 'N', 'admin', NOW(), '预约结束前多少分钟停止签到');
```
### 3. 房间位置设置
在 Space 表中设置房间的经纬度信息:
```sql
UPDATE space SET location = '{"latitude": 39.9042, "longitude": 116.4074}' WHERE space_id = 1;
```
## 验证规则
### 时间验证
- 当前时间必须在允许的签到时间范围内
- 允许签到时间范围:[预约开始时间 - sign_before_start_time, 预约结束时间 - sign_before_end_time]
### 位置验证
- 如果房间设置了经纬度信息,会验证用户位置与房间位置的距离
- 最大允许距离:100米
- 如果房间未设置位置信息,跳过位置验证
## 错误信息
- "签到时间未到,请在预约开始前X分钟内签到"
- "签到时间已过,无法签到"
- "座位信息不存在"
- "房间信息不存在"
- "距离房间位置超过100米,无法签到"
## 架构优化说明
本次优化将验证逻辑进行了职责分离:
1. **SignTimeVerifier**: 专门负责时间验证逻辑
2. **SignLocationVerifier**: 专门负责位置验证逻辑
3. **VerifySignApprovalProcessorImpl**: 作为组合器,协调各个验证器
4. **数据获取前置**: 将座位和房间信息的获取前置到Controller层,通过Service封装
## 注意事项
1. 位置验证需要前端获取用户当前位置并传递给后端
2. 房间位置信息需要在数据库中预先设置
3. 配置参数可以通过系统管理界面动态修改
4. 距离计算使用 Haversine 公式,考虑了地球曲率
5. 座位和房间信息获取已封装到Service层,推荐使用 `getSpaceSeatWithRoom()` 方法
......@@ -52,18 +52,19 @@ services:
image: redis:7.0.5 # 镜像'redis:7.0.5'
container_name: redis # 容器名为'redis'
restart: unless-stopped # 指定容器退出后的重启策略为始终重启,但是不考虑在Docker守护进程启动时就已经停止了的容器
command: redis-server /etc/redis/redis.conf --appendonly no # 启动redis服务并添加密码为:123456,默认不开启redis-aof方式持久化配置
command: sh -c 'redis-server /etc/redis/redis.conf --requirepass "$$REDIS_PASSWORD" --appendonly no'
# command: redis-server --requirepass 123456 --appendonly yes # 启动redis服务并添加密码为:123456,并开启redis持久化配置
environment: # 设置环境变量,相当于docker run命令中的-e
TZ: Asia/Shanghai
LANG: en_US.UTF-8
REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes: # 数据卷挂载路径设置,将本机目录映射到容器目录
- "./redis/data:/data"
- "./redis/conf/redis.conf:/etc/redis/redis.conf" # `redis.conf`文件内容`http://download.redis.io/redis-stable/redis.conf`
ports: # 映射端口
- "6379:6379"
healthcheck: # Redis健康检查
test: ["CMD", "redis-cli", "ping"] # 发送PING命令,期待返回PONG
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping"] # 需要带密码认证,否则返回NOAUTH
interval: 5s
timeout: 3s
retries: 5
......@@ -81,9 +82,9 @@ services:
SPRING_DATASOURCE_URL: jdbc:mysql://10.47.2.11:3306/rs_dev?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
SPRING_DATASOURCE_USERNAME: rs_service
SPRING_DATASOURCE_PASSWORD: ezGkNdezsZJE
REDIS_HOST: 10.47.2.10
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD:
REDIS_PASSWORD: ${REDIS_PASSWORD}
UPLOAD_PATH: /home/rs/uploadPath
# 将时区设置为Asia/Shanghai
TZ: Asia/Shanghai
......
......@@ -6,80 +6,55 @@ import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.web.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/cas")
public class CasController extends BaseController {
@Autowired
private CasAuthService casAuthService;
@Autowired
private TokenService tokenService;
/**
* 跳转到CAS登录
* 返回固定 service 的 CAS 登录地址,供手工调试或前端跳转使用。
*/
@GetMapping("/login")
public AjaxResult login(HttpServletRequest request,
@RequestParam String redirect) throws IOException {
String loginUrl = casAuthService.buildLoginUrl(redirect);
return AjaxResult.success().put("loginUrl",loginUrl);
public AjaxResult login() {
return AjaxResult.success().put(
"loginUrl",
casAuthService.buildLoginUrl()
);
}
/**
* CAS回调接口
* 前端 /cas/callback 收到 ticket 后调用本接口换取本地 Token。
*/
@GetMapping("/callback")
public AjaxResult callback(
@RequestParam String ticket,
@RequestParam(required = false) String redirect) throws IOException {
try {
String service = buildServiceUrl(redirect);
return casAuthService.validateTicket(ticket, service);
} catch (Exception e) {
logger.error("CAS回调处理异常", e);
return AjaxResult.error(e.getMessage());
}
public AjaxResult callback(@RequestParam String ticket) {
return casAuthService.validateTicket(ticket);
}
/**
* 退出登录
* 注销本地 Token,并返回统一认证中心注销地址。
*/
@PostMapping("/logout")
public AjaxResult logout( @RequestParam(required = false) String redirect) {
// 获取当前登录用户
LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser != null) {
// 删除本地用户缓存
tokenService.delLoginUser(loginUser.getToken());
public AjaxResult logout() {
String centralLogoutUrl = casAuthService.buildLogoutUrl();
try {
LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser != null) {
tokenService.delLoginUser(loginUser.getToken());
}
} catch (RuntimeException exception) {
logger.debug("CAS本地Token已失效", exception);
}
// 构建CAS登出URL
String logoutUrl = casAuthService.buildLogoutUrl(redirect);
return AjaxResult.success().put("logoutUrl",logoutUrl);
}
/**
* 构建服务URL
*/
private String buildServiceUrl(String redirect) {
String baseUrl = getBaseUrl();
return baseUrl + redirect;
}
/**
* 获取基础URL
*/
private String getBaseUrl() {
// 根据实际情况实现获取当前系统URL的逻辑
return "http://gwyy.xjtu.edu.cn";
return AjaxResult.success().put("logoutUrl", centralLogoutUrl);
}
}
\ No newline at end of file
}
......@@ -108,7 +108,7 @@ public class ReservationApprovalController extends BaseController {
List<ReservationApproval> list = reservationApprovalService.list(new LambdaQueryWrapper<ReservationApproval>()
.eq(ReservationApproval::getSubmitBy,SecurityUtils.getUsername())
.in(ReservationApproval::getStatus, ApprovalStatus.PASS.getCode(),ApprovalStatus.WAITING.getCode())
.ge(ReservationApproval::getStartAt,new Date()))
.ge(ReservationApproval::getEndAt,new Date()))
//按特定顺序排序 待签到 -> 待审核 -> 已签到
.stream().sorted(Comparator.comparingInt(r->{
int status = Integer.parseInt(r.getStatus());
......
......@@ -104,6 +104,11 @@ swagger:
# CAS配置
cas:
server-url: https://login.xjtu.edu.cn/cas
client-host-url: http://gwyy.xjtu.edu.cn # 你的业务系统地址
validation-type: CAS3
\ No newline at end of file
server-url: ${CAS_SERVER_URL:https://login-test.xjtu.edu.cn/cas}
client-host-url: ${CAS_CLIENT_HOST_URL:http://gwyy.xjtu.edu.cn}
login-url: ${CAS_LOGIN_URL:https://login-test.xjtu.edu.cn/cas/login}
validate-url: ${CAS_VALIDATE_URL:https://login-test.xjtu.edu.cn/cas/serviceValidate}
logout-url: ${CAS_LOGOUT_URL:https://login-test.xjtu.edu.cn/cas/logout}
service-url: ${CAS_SERVICE_URL:http://gwyy.xjtu.edu.cn/cas/callback}
logout-redirect-url: ${CAS_LOGOUT_REDIRECT_URL:http://gwyy.xjtu.edu.cn/}
timeout: ${CAS_TIMEOUT:5000}
......@@ -73,7 +73,7 @@ spring:
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: ruoyi
login-password: 123456
login-password: ${DRUID_LOGIN_PASSWORD:}
filter:
stat:
enabled: true
......@@ -104,6 +104,11 @@ swagger:
# CAS配置
cas:
server-url: https://login.xjtu.edu.cn/cas
client-host-url: http://gwyy.xjtu.edu.cn # 你的业务系统地址
validation-type: CAS3
\ No newline at end of file
server-url: ${CAS_SERVER_URL:https://login-test.xjtu.edu.cn/cas}
client-host-url: ${CAS_CLIENT_HOST_URL:http://gwyy.xjtu.edu.cn}
login-url: ${CAS_LOGIN_URL:https://login-test.xjtu.edu.cn/cas/login}
validate-url: ${CAS_VALIDATE_URL:https://login-test.xjtu.edu.cn/cas/serviceValidate}
logout-url: ${CAS_LOGOUT_URL:https://login-test.xjtu.edu.cn/cas/logout}
service-url: ${CAS_SERVICE_URL:http://gwyy.xjtu.edu.cn/cas/callback}
logout-redirect-url: ${CAS_LOGOUT_REDIRECT_URL:http://gwyy.xjtu.edu.cn/}
timeout: ${CAS_TIMEOUT:5000}
......@@ -106,7 +106,6 @@ mybatis-plus:
mapperLocations: classpath*:mapper/**/*Mapper.xml
# 加载全局的配置文件
configLocation: classpath:mybatis/mybatis-config.xml
global-config:
db-config:
logic-delete-field: delFlag # 全局逻辑删除字段名(deleted为实体类属性名称)
......
......@@ -3,6 +3,7 @@ package com.ruoyi.common.core.redis;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
......@@ -30,7 +31,12 @@ public class RedissonConfig
public RedissonClient redissonClient()
{
Config config = new Config();
config.useSingleServer().setAddress("redis://" + host + ":" + port); // 更多.set
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress("redis://" + host + ":" + port);
if (password != null && !password.trim().isEmpty())
{
serverConfig.setPassword(password);
}
return Redisson.create(config);
}
}
\ No newline at end of file
}
package com.ruoyi.common.enums;
import java.util.Locale;
public enum IdentityTypeCode {
......@@ -8,7 +8,7 @@ public enum IdentityTypeCode {
GraduateStudent("S02","student","研究生"),
InternationalStudent("SO3","student","留学生"),
InternationalStudent("S03","student","留学生"),
Alumnus("XY","","校友"),
......@@ -44,9 +44,12 @@ public enum IdentityTypeCode {
}
public static IdentityTypeCode getEnumByCode(String code){
if (code == null || code.trim().isEmpty()) {
return null;
}
String normalizedCode = code.trim().toUpperCase(Locale.ROOT);
for (IdentityTypeCode value : IdentityTypeCode.values()) {
if (value.getCode().equals(code)){
if (value.getCode().equals(normalizedCode)){
return value;
}
}
......
......@@ -112,7 +112,7 @@ public class SecurityConfig
permitAllUrl.getUrls().forEach(url -> requests.antMatchers(url).permitAll());
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
requests.antMatchers("/login", "/register", "/captchaImage").permitAll()
.antMatchers("/api/cas/login","/api/cas/callback").permitAll()
.antMatchers("/api/cas/login","/api/cas/callback").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
......
......@@ -18,12 +18,18 @@
<dependencies>
<!-- 通用工具-->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</dependencies>
</project>
\ No newline at end of file
</project>
......@@ -29,8 +29,14 @@ public class CasUserInfo {
}
public String getName() {
return authenticationSuccess != null && authenticationSuccess.getAttributes() != null ?
authenticationSuccess.getAttributes().getName() : null;
if (authenticationSuccess == null
|| authenticationSuccess.getAttributes() == null) {
return null;
}
String userName = authenticationSuccess.getAttributes().getUserName();
return userName == null || userName.trim().isEmpty()
? authenticationSuccess.getAttributes().getName()
: userName;
}
public String getUserId() {
......@@ -52,4 +58,4 @@ public class CasUserInfo {
return authenticationSuccess != null && authenticationSuccess.getAttributes() != null ?
authenticationSuccess.getAttributes().getOrganizationName() : null;
}
}
\ No newline at end of file
}
package com.ruoyi.system.cas;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Unmarshaller;
import com.ruoyi.common.exception.ServiceException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;
public class CasXmlParser {
/**
* CAS 2.0 serviceValidate 响应解析器。
*
* 本地账号只允许取 authenticationSuccess 下、与 attributes 同级的 user,
* 不读取 attributes 内可能存在的同名字段。
*/
public final class CasXmlParser {
private static final String CAS_NAMESPACE =
"http://www.yale.edu/tp/cas";
private CasXmlParser() {
}
public static CasUserInfo parseCasResponse(String xmlString) {
if (xmlString == null || xmlString.trim().isEmpty()) {
throw new ServiceException("CAS票据校验返回为空");
}
try {
JAXBContext jaxbContext = JAXBContext.newInstance(CasUserInfo.class);
Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();
Document document = newDocumentBuilderFactory()
.newDocumentBuilder()
.parse(new InputSource(new StringReader(xmlString)));
Element failure = firstElement(document, "authenticationFailure");
if (failure != null) {
String code = failure.getAttribute("code");
String message = failure.getTextContent();
throw new ServiceException(
"CAS票据校验失败:"
+ (isBlank(code) ? "UNKNOWN_ERROR" : code)
+ (isBlank(message) ? "" : " - " + message.trim())
);
}
Element success = firstElement(document, "authenticationSuccess");
if (success == null) {
throw new ServiceException("CAS票据校验返回格式不正确");
}
Element userElement = directChild(success, "user");
if (userElement == null || isBlank(userElement.getTextContent())) {
throw new ServiceException("CAS响应缺少登录账号");
}
AuthenticationSuccess authenticationSuccess =
new AuthenticationSuccess();
authenticationSuccess.setUser(userElement.getTextContent().trim());
Element attributesElement = directChild(success, "attributes");
if (attributesElement != null) {
authenticationSuccess.setAttributes(
parseAttributes(attributesElement)
);
}
CasUserInfo userInfo = new CasUserInfo();
userInfo.setAuthenticationSuccess(authenticationSuccess);
return userInfo;
} catch (ServiceException exception) {
throw exception;
} catch (Exception exception) {
throw new ServiceException("CAS票据校验返回格式不正确");
}
}
private static Attributes parseAttributes(Element attributesElement) {
Attributes attributes = new Attributes();
attributes.setAccountName(text(attributesElement, "accountName"));
attributes.setIdentityTypeName(
text(attributesElement, "identityTypeName")
);
attributes.setOrganizationId(
text(attributesElement, "organizationId")
);
attributes.setLang(text(attributesElement, "lang"));
attributes.setIdentityTypeCode(
text(attributesElement, "identityTypeCode")
);
attributes.setOrganizationName(
text(attributesElement, "organizationName")
);
attributes.setUserName(text(attributesElement, "userName"));
attributes.setUserId(text(attributesElement, "userId"));
attributes.setAccountId(text(attributesElement, "accountId"));
attributes.setOrganizationCode(
text(attributesElement, "organizationCode")
);
attributes.setName(text(attributesElement, "name"));
attributes.setIdentityTypeId(
text(attributesElement, "identityTypeId")
);
return attributes;
}
private static String text(Element parent, String name) {
Element element = directChild(parent, name);
return element == null || isBlank(element.getTextContent())
? null
: element.getTextContent().trim();
}
StringReader reader = new StringReader(xmlString);
return (CasUserInfo) unmarshaller.unmarshal(reader);
private static Element firstElement(Document document, String name) {
NodeList elements = document.getElementsByTagNameNS(
CAS_NAMESPACE,
name
);
if (elements.getLength() == 0) {
elements = document.getElementsByTagNameNS("*", name);
}
return elements.getLength() == 0
? null
: (Element) elements.item(0);
}
} catch (JAXBException e) {
throw new RuntimeException("解析CAS响应失败", e);
private static Element directChild(Element parent, String name) {
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE
&& name.equals(localName(child))) {
return (Element) child;
}
}
return null;
}
private static String localName(Node node) {
return node.getLocalName() == null
? node.getNodeName()
: node.getLocalName();
}
private static DocumentBuilderFactory newDocumentBuilderFactory()
throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setFeature(
"http://apache.org/xml/features/disallow-doctype-decl",
true
);
factory.setFeature(
"http://xml.org/sax/features/external-general-entities",
false
);
factory.setFeature(
"http://xml.org/sax/features/external-parameter-entities",
false
);
factory.setFeature(
"http://apache.org/xml/features/nonvalidating/load-external-dtd",
false
);
setAttributeIfSupported(factory, XMLConstants.ACCESS_EXTERNAL_DTD, "");
setAttributeIfSupported(
factory,
XMLConstants.ACCESS_EXTERNAL_SCHEMA,
""
);
return factory;
}
private static void setAttributeIfSupported(
DocumentBuilderFactory factory,
String name,
String value
) {
try {
factory.setAttribute(name, value);
} catch (IllegalArgumentException ignored) {
// 兼容 Java 8 中可能存在的旧版 XML 解析器。
}
}
private static boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}
\ No newline at end of file
}
......@@ -53,7 +53,11 @@ public class ReservationApprovalImpl extends ServiceImpl<ReservationApprovalMapp
reservationApproval.setSubmitAt(new Date());
reservationApproval.setSubmitBy(SecurityUtils.getUsername());
reservationApproval.setStatus(ApprovalStatus.WAITING.getCode());
// reservationApproval.setStatus(ApprovalStatus.WAITING.getCode());
// 默认审核通过
reservationApproval.setStatus(ApprovalStatus.PASS.getCode());
return save(reservationApproval);
} finally {
......
package com.ruoyi.system.cas;
import com.ruoyi.common.exception.ServiceException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CasXmlParserTest {
@Test
void parsesDirectSiblingUserAsLocalUsername() {
String xml =
"<cas:serviceResponse xmlns:cas=\"http://www.yale.edu/tp/cas\">"
+ "<cas:authenticationSuccess>"
+ "<cas:user>student001</cas:user>"
+ "<cas:attributes>"
+ "<cas:user>attribute-user-must-not-win</cas:user>"
+ "<cas:userName>测试学生</cas:userName>"
+ "<cas:identityTypeCode>S03</cas:identityTypeCode>"
+ "</cas:attributes>"
+ "</cas:authenticationSuccess>"
+ "</cas:serviceResponse>";
CasUserInfo userInfo = CasXmlParser.parseCasResponse(xml);
assertEquals("student001", userInfo.getUsername());
assertEquals("测试学生", userInfo.getName());
assertEquals("S03", userInfo.getIdentityTypeCode());
}
@Test
void rejectsAuthenticationFailure() {
String xml =
"<cas:serviceResponse xmlns:cas=\"http://www.yale.edu/tp/cas\">"
+ "<cas:authenticationFailure code=\"INVALID_TICKET\">"
+ "Ticket not recognized"
+ "</cas:authenticationFailure>"
+ "</cas:serviceResponse>";
ServiceException exception = assertThrows(
ServiceException.class,
() -> CasXmlParser.parseCasResponse(xml)
);
assertEquals(
"CAS票据校验失败:INVALID_TICKET - Ticket not recognized",
exception.getMessage()
);
}
@Test
void rejectsDoctypeAndExternalEntity() {
String xml =
"<?xml version=\"1.0\"?>"
+ "<!DOCTYPE serviceResponse ["
+ "<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>"
+ "<cas:serviceResponse "
+ "xmlns:cas=\"http://www.yale.edu/tp/cas\">"
+ "<cas:authenticationSuccess>"
+ "<cas:user>&xxe;</cas:user>"
+ "</cas:authenticationSuccess>"
+ "</cas:serviceResponse>";
assertThrows(
ServiceException.class,
() -> CasXmlParser.parseCasResponse(xml)
);
}
}
package com.ruoyi.system.cas;
import com.ruoyi.common.enums.IdentityTypeCode;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class IdentityTypeCodeTest {
@Test
void mapsStudentAndFacultyCodes() {
assertEquals(
"student",
IdentityTypeCode.getEnumByCode("S01").getRoleKey()
);
assertEquals(
"student",
IdentityTypeCode.getEnumByCode("s03").getRoleKey()
);
assertEquals(
"teacher",
IdentityTypeCode.getEnumByCode(" T01 ").getRoleKey()
);
}
}
......@@ -65,7 +65,7 @@
"node": ">=8.9",
"npm": ">= 3.0.0"
},
"browserslist": [
"browsers": [
"> 1%",
"last 2 versions"
]
......
......@@ -40,12 +40,32 @@ export function getInfo() {
}
// 退出方法
export function logout() {
return request({
url: '/logout',
method: 'post'
})
}
export function logout() {
return request({
url: '/logout',
method: 'post'
})
}
// CAS票据换取本地Token,与现有账号登录并存
export function casTicketLogin(ticket) {
return request({
url: '/api/cas/callback',
headers: {
isToken: false
},
method: 'get',
params: { ticket }
})
}
// 注销本地CAS会话,并取得统一认证中心退出地址
export function casLogout() {
return request({
url: '/api/cas/logout',
method: 'post'
})
}
// 获取验证码
export function getCodeImg() {
......@@ -57,4 +77,4 @@ export function getCodeImg() {
method: 'get',
timeout: 20000
})
}
\ No newline at end of file
}
......@@ -100,9 +100,9 @@ export default {
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$store.dispatch('LogOut').then(() => {
location.href = '/index'
})
this.$store.dispatch('LogOut').then(logoutUrl => {
location.href = logoutUrl || '/index'
})
}).catch(() => {})
}
}
......
......@@ -9,7 +9,7 @@ import { isRelogin } from '@/utils/request'
NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/register']
const whiteList = ['/login', '/register', '/cas/callback']
const isWhiteList = (path) => {
return whiteList.some(pattern => isPathMatch(pattern, path))
......
......@@ -46,6 +46,11 @@ export const constantRoutes = [
component: () => import('@/views/login'),
hidden: true
},
{
path: '/cas/callback',
component: () => import('@/views/cas/callback'),
hidden: true
},
{
path: '/register',
component: () => import('@/views/register'),
......
import router from '@/router'
import { MessageBox, } from 'element-ui'
import { login, logout, getInfo } from '@/api/login'
import { login, logout, casLogout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { isHttp, isEmpty } from "@/utils/validate"
import defAva from '@/assets/images/profile.jpg'
......@@ -48,9 +48,10 @@ const user = {
const code = userInfo.code
const uuid = userInfo.uuid
return new Promise((resolve, reject) => {
login(username, password, code, uuid).then(res => {
setToken(res.token)
commit('SET_TOKEN', res.token)
login(username, password, code, uuid).then(res => {
setToken(res.token)
localStorage.setItem('authMode', 'local')
commit('SET_TOKEN', res.token)
resolve()
}).catch(error => {
reject(error)
......@@ -96,27 +97,41 @@ const user = {
})
},
// 退出系统
LogOut({ commit, state }) {
return new Promise((resolve, reject) => {
logout(state.token).then(() => {
commit('SET_TOKEN', '')
commit('SET_ROLES', [])
commit('SET_PERMISSIONS', [])
removeToken()
resolve()
}).catch(error => {
reject(error)
})
// 退出系统
LogOut({ commit, state }) {
return new Promise((resolve, reject) => {
const isCasLogin = localStorage.getItem('authMode') === 'cas'
const logoutRequest = isCasLogin ? casLogout() : logout(state.token)
logoutRequest.then(res => {
commit('SET_TOKEN', '')
commit('SET_ROLES', [])
commit('SET_PERMISSIONS', [])
removeToken()
localStorage.removeItem('authMode')
resolve(isCasLogin && res ? res.logoutUrl : '')
}).catch(error => {
if (isCasLogin) {
// 统一认证退出异常时也必须清除本地会话,避免用户卡在登录态。
commit('SET_TOKEN', '')
commit('SET_ROLES', [])
commit('SET_PERMISSIONS', [])
removeToken()
localStorage.removeItem('authMode')
resolve('')
} else {
reject(error)
}
})
})
},
// 前端 登出
FedLogOut({ commit }) {
return new Promise(resolve => {
commit('SET_TOKEN', '')
removeToken()
resolve()
return new Promise(resolve => {
commit('SET_TOKEN', '')
removeToken()
localStorage.removeItem('authMode')
resolve()
})
}
}
......
......@@ -82,12 +82,17 @@ service.interceptors.response.use(res => {
return res.data
}
if (code === 401) {
// CAS退出时Token可能刚好过期,由Vuex退出动作负责清理本地状态,
// 避免在这里再次派发LogOut形成递归请求。
if (res.config && res.config.url === '/api/cas/logout') {
return Promise.reject('CAS本地会话已过期。')
}
if (!isRelogin.show) {
isRelogin.show = true
MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {
isRelogin.show = false
store.dispatch('LogOut').then(() => {
location.href = '/index'
store.dispatch('LogOut').then(logoutUrl => {
location.href = logoutUrl || '/index'
})
}).catch(() => {
isRelogin.show = false
......
<template>
<div class="cas-callback">
<div class="cas-card">
<i v-if="loading" class="el-icon-loading cas-icon" />
<i v-else class="el-icon-warning-outline cas-icon cas-error" />
<h2>{{ loading ? 'CAS 登录中' : 'CAS 登录失败' }}</h2>
<p>{{ message }}</p>
<el-button v-if="!loading" type="primary" @click="backToLogin">
返回登录页
</el-button>
</div>
</div>
</template>
<script>
import { casTicketLogin } from '@/api/login'
import { setToken } from '@/utils/auth'
export default {
name: 'CasCallback',
data() {
return {
loading: true,
message: '正在校验统一身份认证票据,请稍候…'
}
},
created() {
this.completeLogin()
},
methods: {
async completeLogin() {
const ticket = Array.isArray(this.$route.query.ticket)
? this.$route.query.ticket[0]
: this.$route.query.ticket
window.history.replaceState({}, document.title, this.$route.path)
if (!ticket) {
this.showError('认证系统未返回票据,请重新发起登录。')
return
}
try {
const response = await casTicketLogin(ticket)
const token = response && response.data && response.data.token
if (!token) {
throw new Error('CAS登录未返回本地Token')
}
setToken(token)
localStorage.setItem('authMode', 'cas')
this.$store.commit('SET_TOKEN', token)
this.$router.replace('/')
} catch (error) {
this.showError(error && error.message
? error.message
: 'CAS登录失败,请重新尝试。')
}
},
showError(message) {
this.loading = false
this.message = message
},
backToLogin() {
this.$router.replace('/login')
}
}
}
</script>
<style scoped>
.cas-callback {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
}
.cas-card {
width: 420px;
padding: 48px 40px;
border-radius: 8px;
background: #fff;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
text-align: center;
}
.cas-icon {
display: block;
margin-bottom: 20px;
color: #409eff;
font-size: 40px;
}
.cas-error {
color: #e6a23c;
}
.cas-card h2 {
margin: 0 0 12px;
color: #303133;
}
.cas-card p {
margin: 0 0 24px;
color: #606266;
}
</style>
This source diff could not be displayed because it is too large. You can view the blob instead.
-- 签到配置参数
INSERT INTO sys_config (config_name, config_key, config_value, config_type, create_by, create_time, remark)
VALUES ('签到开始时间(分钟)', 'sign_before_start_time', '30', 'N', 'admin', NOW(), '预约开始前多少分钟可以签到');
INSERT INTO sys_config (config_name, config_key, config_value, config_type, create_by, create_time, remark)
VALUES ('签到结束时间(分钟)', 'sign_before_end_time', '10', 'N', 'admin', NOW(), '预约结束前多少分钟停止签到');
/*
Navicat Premium Data Transfer
Source Server : localhost
Source Server Type : MySQL
Source Server Version : 80034
Source Host : localhost:3306
Source Schema : rs_dev
Target Server Type : MySQL
Target Server Version : 80034
File Encoding : 65001
Date: 10/11/2025 08:58:30
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sys_dept
-- ----------------------------
DROP TABLE IF EXISTS `sys_dept`;
CREATE TABLE `sys_dept` (
`dept_id` bigint NOT NULL AUTO_INCREMENT COMMENT '部门id',
`dept_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '部门编码',
`parent_id` bigint NULL DEFAULT 0 COMMENT '父部门id',
`ancestors` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '祖级列表',
`dept_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '部门名称',
`order_num` int NULL DEFAULT 0 COMMENT '显示顺序',
`leader` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '负责人',
`phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '联系电话',
`email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱',
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '部门状态(0正常 1停用)',
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
`create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`dept_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 201 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '部门表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_dept
-- ----------------------------
INSERT INTO `sys_dept` VALUES (100, NULL, 0, '0', '西安交通大学', 0, '若依', '15888888888', 'ry@qq.com', '0', '0', 'admin', '2025-10-31 16:29:01', '', NULL);
INSERT INTO `sys_dept` VALUES (200, '11005000', 100, '0,100', '团委', 1, NULL, NULL, NULL, '0', '0', 'admin', '2025-11-05 10:42:50', '', NULL);
INSERT INTO `sys_dept` VALUES (201, '13002000', 100, '0,100', '材料科学与工程学院', 2, NULL, NULL, NULL, '0', '0', 'admin', '2025-11-05 10:43:48', '', NULL);
SET FOREIGN_KEY_CHECKS = 1;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment