fix(security): enforce mini token identity and score authorization

This commit is contained in:
2026-02-15 16:49:41 +08:00
parent 0e819f5c32
commit 7e2e9bb0bc
4 changed files with 257 additions and 58 deletions
@@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.tool.api.R; import org.springblade.core.tool.api.R;
import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.secure.utils.AuthUtil;
@@ -57,6 +58,7 @@ import java.util.stream.Collectors;
* *
* @author BladeX * @author BladeX
*/ */
@Slf4j
@RestController @RestController
@AllArgsConstructor @AllArgsConstructor
@RequestMapping("/mini") @RequestMapping("/mini")
@@ -96,7 +98,24 @@ public class MartialMiniController extends BladeController {
} }
@PostMapping("/score/submit") @PostMapping("/score/submit")
@Operation(summary = "提交评分", description = "裁判员提交评分") @Operation(summary = "提交评分", description = "裁判员提交评分")
public R submitScore(@RequestBody org.springblade.modules.martial.pojo.dto.MiniScoreSubmitDTO dto) { public R submitScore(
@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestHeader(value = "Blade-Auth", required = false) String bladeAuthToken,
@RequestBody org.springblade.modules.martial.pojo.dto.MiniScoreSubmitDTO dto
) {
if (dto == null) {
return R.fail("请求体不能为空");
}
JudgeAuthContext authContext = resolveJudgeAuthContext(authorization, bladeAuthToken);
if (authContext == null || authContext.getJudgeId() == null) {
return R.fail("登录状态无效或已过期");
}
Long payloadJudgeId = parseLong(dto.getJudgeId());
if (payloadJudgeId != null && !payloadJudgeId.equals(authContext.getJudgeId())) {
log.warn("评分提交身份字段不匹配,已忽略请求体judgeIdpayloadJudgeId={}, authJudgeId={}", payloadJudgeId, authContext.getJudgeId());
}
// 向后兼容:保留 judgeId 字段,但强制覆盖为 token 身份
dto.setJudgeId(String.valueOf(authContext.getJudgeId()));
return miniScoringService.submitScore(dto); return miniScoringService.submitScore(dto);
} }
@@ -231,6 +250,64 @@ public class MartialMiniController extends BladeController {
} }
} }
private JudgeAuthContext resolveJudgeAuthContext(String authorization, String bladeAuthToken) {
String accessToken = resolveAccessToken(authorization, bladeAuthToken);
if (accessToken != null) {
MartialJudgeInvite invite = findValidInviteByAccessToken(accessToken);
if (invite != null && invite.getJudgeId() != null) {
return JudgeAuthContext.fromInvite(invite);
}
}
BladeUser bladeUser = AuthUtil.getUser();
if (bladeUser == null || bladeUser.getUserId() == null) {
return null;
}
MartialJudge judge = judgeService.getById(bladeUser.getUserId());
if (judge == null) {
log.warn("Blade-Auth用户未绑定评委信息,userId={}", bladeUser.getUserId());
return null;
}
return JudgeAuthContext.fromJudge(judge);
}
private String resolveAccessToken(String authorization, String bladeAuthToken) {
String token = extractToken(authorization);
if (token != null) {
return token;
}
return extractToken(bladeAuthToken);
}
private String extractToken(String tokenHeader) {
if (Func.isEmpty(tokenHeader)) {
return null;
}
String token = tokenHeader.trim();
if (token.regionMatches(true, 0, "Bearer ", 0, 7)) {
token = token.substring(7).trim();
}
return Func.isEmpty(token) ? null : token;
}
private MartialJudgeInvite findValidInviteByAccessToken(String accessToken) {
LambdaQueryWrapper<MartialJudgeInvite> inviteQuery = new LambdaQueryWrapper<>();
inviteQuery.eq(MartialJudgeInvite::getAccessToken, accessToken);
inviteQuery.eq(MartialJudgeInvite::getStatus, 1);
inviteQuery.eq(MartialJudgeInvite::getIsDeleted, 0);
MartialJudgeInvite invite = judgeInviteService.getOne(inviteQuery, false);
if (invite == null) {
return null;
}
LocalDateTime now = LocalDateTime.now();
if (invite.getTokenExpireTime() == null || !invite.getTokenExpireTime().isAfter(now)) {
log.warn("accessToken已过期或无效,token={}, tokenExpireTime={}", accessToken, invite.getTokenExpireTime());
return null;
}
return invite;
}
/** /**
* 获取选手列表(支持分页) * 获取选手列表(支持分页)
* - 裁判员:获取所有选手,标记是否已评分 * - 裁判员:获取所有选手,标记是否已评分
@@ -443,10 +520,14 @@ public class MartialMiniController extends BladeController {
*/ */
@PostMapping("/logout") @PostMapping("/logout")
@Operation(summary = "退出登录", description = "清除登录状态") @Operation(summary = "退出登录", description = "清除登录状态")
public R logout(@RequestHeader(value = "Authorization", required = false) String token) { public R logout(
@RequestHeader(value = "Authorization", required = false) String token,
@RequestHeader(value = "Blade-Auth", required = false) String bladeAuthToken
) {
// 从Redis删除登录缓存 // 从Redis删除登录缓存
if (token != null && !token.isEmpty()) { String accessToken = resolveAccessToken(token, bladeAuthToken);
String cacheKey = MINI_LOGIN_CACHE_PREFIX + token; if (accessToken != null) {
String cacheKey = MINI_LOGIN_CACHE_PREFIX + accessToken;
bladeRedis.del(cacheKey); bladeRedis.del(cacheKey);
} }
return R.success("退出成功"); return R.success("退出成功");
@@ -457,13 +538,23 @@ public class MartialMiniController extends BladeController {
*/ */
@GetMapping("/verify") @GetMapping("/verify")
@Operation(summary = "Token验证", description = "验证token并返回登录信息,支持服务重启后恢复登录状态") @Operation(summary = "Token验证", description = "验证token并返回登录信息,支持服务重启后恢复登录状态")
public R<MiniLoginVO> verify(@RequestHeader(value = "Authorization", required = false) String token) { public R<MiniLoginVO> verify(
if (token == null || token.isEmpty()) { @RequestHeader(value = "Authorization", required = false) String token,
@RequestHeader(value = "Blade-Auth", required = false) String bladeAuthToken
) {
String accessToken = resolveAccessToken(token, bladeAuthToken);
if (accessToken == null) {
return R.fail("Token不能为空"); return R.fail("Token不能为空");
} }
MartialJudgeInvite invite = findValidInviteByAccessToken(accessToken);
if (invite == null) {
bladeRedis.del(MINI_LOGIN_CACHE_PREFIX + accessToken);
return R.fail("Token无效或已失效");
}
// 从Redis获取登录信息 // 从Redis获取登录信息
String cacheKey = MINI_LOGIN_CACHE_PREFIX + token; String cacheKey = MINI_LOGIN_CACHE_PREFIX + accessToken;
MiniLoginVO loginInfo = bladeRedis.get(cacheKey); MiniLoginVO loginInfo = bladeRedis.get(cacheKey);
if (loginInfo != null) { if (loginInfo != null) {
@@ -472,23 +563,14 @@ public class MartialMiniController extends BladeController {
return R.data(loginInfo); return R.data(loginInfo);
} }
// Redis中没有,尝试从数据库恢复
LambdaQueryWrapper<MartialJudgeInvite> inviteQuery = new LambdaQueryWrapper<>();
inviteQuery.eq(MartialJudgeInvite::getAccessToken, token);
inviteQuery.eq(MartialJudgeInvite::getIsDeleted, 0);
MartialJudgeInvite invite = judgeInviteService.getOne(inviteQuery);
if (invite == null) {
return R.fail("Token无效");
}
if (invite.getTokenExpireTime() != null && invite.getTokenExpireTime().isBefore(LocalDateTime.now())) {
return R.fail("Token已过期");
}
// 重建登录信息 // 重建登录信息
MartialCompetition competition = competitionService.getById(invite.getCompetitionId()); MartialCompetition competition = competitionService.getById(invite.getCompetitionId());
MartialJudge judge = judgeService.getById(invite.getJudgeId()); MartialJudge judge = judgeService.getById(invite.getJudgeId());
if (competition == null || judge == null) {
log.warn("Token验证失败:关联比赛或评委不存在,token={}, competitionId={}, judgeId={}",
accessToken, invite.getCompetitionId(), invite.getJudgeId());
return R.fail("登录信息不存在");
}
MartialVenue martialVenue = invite.getVenueId() != null ? venueService.getById(invite.getVenueId()) : null; MartialVenue martialVenue = invite.getVenueId() != null ? venueService.getById(invite.getVenueId()) : null;
// 获取项目列表:总裁判看所有项目,其他裁判根据场地获取项目 // 获取项目列表:总裁判看所有项目,其他裁判根据场地获取项目
List<MiniLoginVO.ProjectInfo> projects = new ArrayList<>(); List<MiniLoginVO.ProjectInfo> projects = new ArrayList<>();
@@ -509,7 +591,7 @@ public class MartialMiniController extends BladeController {
// 如果没有场地,projects保持为空列表 // 如果没有场地,projects保持为空列表
MiniLoginVO vo = new MiniLoginVO(); MiniLoginVO vo = new MiniLoginVO();
vo.setToken(token); vo.setToken(accessToken);
String role = invite.getRole(); String role = invite.getRole();
Integer refereeType = invite.getRefereeType(); Integer refereeType = invite.getRefereeType();
if ("general_judge".equals(role) || "general".equals(role) || (refereeType != null && refereeType == 3)) { if ("general_judge".equals(role) || "general".equals(role) || (refereeType != null && refereeType == 3)) {
@@ -718,13 +800,33 @@ public class MartialMiniController extends BladeController {
*/ */
@PostMapping("/chief/confirm") @PostMapping("/chief/confirm")
@Operation(summary = "主裁判确认分数", description = "主裁判确认或修改选手分数") @Operation(summary = "主裁判确认分数", description = "主裁判确认或修改选手分数")
public R confirmByChiefJudge(@RequestBody ChiefJudgeConfirmDTO dto) { public R confirmByChiefJudge(
Long resultId = parseLong(dto.getResultId()); @RequestHeader(value = "Authorization", required = false) String authorization,
Long chiefJudgeId = parseLong(dto.getChiefJudgeId()); @RequestHeader(value = "Blade-Auth", required = false) String bladeAuthToken,
if (resultId == null || chiefJudgeId == null) { @RequestBody ChiefJudgeConfirmDTO dto
) {
if (dto == null) {
return R.fail("参数错误"); return R.fail("参数错误");
} }
boolean success = resultService.confirmByChiefJudge(resultId, chiefJudgeId, dto.getScore(), dto.getNote()); JudgeAuthContext authContext = resolveJudgeAuthContext(authorization, bladeAuthToken);
if (authContext == null || authContext.getJudgeId() == null) {
return R.fail("登录状态无效或已过期");
}
if (!authContext.isChiefJudge()) {
log.warn("主裁判确认拒绝:无主裁判权限,judgeId={}, role={}, refereeType={}",
authContext.getJudgeId(), authContext.getRole(), authContext.getRefereeType());
return R.fail("无主裁判权限");
}
Long resultId = parseLong(dto.getResultId());
if (resultId == null) {
return R.fail("参数错误");
}
Long payloadChiefJudgeId = parseLong(dto.getChiefJudgeId());
if (payloadChiefJudgeId != null && !payloadChiefJudgeId.equals(authContext.getJudgeId())) {
log.warn("主裁判确认身份字段不匹配,已忽略请求体chiefJudgeIdpayloadChiefJudgeId={}, authJudgeId={}",
payloadChiefJudgeId, authContext.getJudgeId());
}
boolean success = resultService.confirmByChiefJudge(resultId, authContext.getJudgeId(), dto.getScore(), dto.getNote());
return success ? R.success("确认成功") : R.fail("确认失败"); return success ? R.success("确认成功") : R.fail("确认失败");
} }
@@ -733,13 +835,33 @@ public class MartialMiniController extends BladeController {
*/ */
@PostMapping("/general/confirm") @PostMapping("/general/confirm")
@Operation(summary = "总裁确认分数", description = "总裁确认或修改选手分数") @Operation(summary = "总裁确认分数", description = "总裁确认或修改选手分数")
public R confirmByGeneralJudge(@RequestBody GeneralJudgeConfirmDTO dto) { public R confirmByGeneralJudge(
Long resultId = parseLong(dto.getResultId()); @RequestHeader(value = "Authorization", required = false) String authorization,
Long generalJudgeId = parseLong(dto.getGeneralJudgeId()); @RequestHeader(value = "Blade-Auth", required = false) String bladeAuthToken,
if (resultId == null || generalJudgeId == null) { @RequestBody GeneralJudgeConfirmDTO dto
) {
if (dto == null) {
return R.fail("参数错误"); return R.fail("参数错误");
} }
boolean success = resultService.confirmByGeneralJudge(resultId, generalJudgeId, dto.getScore(), dto.getNote()); JudgeAuthContext authContext = resolveJudgeAuthContext(authorization, bladeAuthToken);
if (authContext == null || authContext.getJudgeId() == null) {
return R.fail("登录状态无效或已过期");
}
if (!authContext.isGeneralJudge()) {
log.warn("总裁确认拒绝:无总裁权限,judgeId={}, role={}, refereeType={}",
authContext.getJudgeId(), authContext.getRole(), authContext.getRefereeType());
return R.fail("无总裁权限");
}
Long resultId = parseLong(dto.getResultId());
if (resultId == null) {
return R.fail("参数错误");
}
Long payloadGeneralJudgeId = parseLong(dto.getGeneralJudgeId());
if (payloadGeneralJudgeId != null && !payloadGeneralJudgeId.equals(authContext.getJudgeId())) {
log.warn("总裁确认身份字段不匹配,已忽略请求体generalJudgeIdpayloadGeneralJudgeId={}, authJudgeId={}",
payloadGeneralJudgeId, authContext.getJudgeId());
}
boolean success = resultService.confirmByGeneralJudge(resultId, authContext.getJudgeId(), dto.getScore(), dto.getNote());
return success ? R.success("确认成功") : R.fail("确认失败"); return success ? R.success("确认成功") : R.fail("确认失败");
} }
@@ -976,6 +1098,57 @@ public class MartialMiniController extends BladeController {
return R.data(result); return R.data(result);
} }
private static class JudgeAuthContext {
private final Long judgeId;
private final String role;
private final Integer refereeType;
private JudgeAuthContext(Long judgeId, String role, Integer refereeType) {
this.judgeId = judgeId;
this.role = role;
this.refereeType = refereeType;
}
static JudgeAuthContext fromInvite(MartialJudgeInvite invite) {
return new JudgeAuthContext(invite.getJudgeId(), invite.getRole(), invite.getRefereeType());
}
static JudgeAuthContext fromJudge(MartialJudge judge) {
Integer refereeType = judge.getRefereeType();
String role;
if (refereeType != null && refereeType == 3) {
role = "general_judge";
} else if (refereeType != null && refereeType == 1) {
role = "chief_judge";
} else {
role = "judge";
}
return new JudgeAuthContext(judge.getId(), role, refereeType);
}
Long getJudgeId() {
return judgeId;
}
String getRole() {
return role;
}
Integer getRefereeType() {
return refereeType;
}
boolean isChiefJudge() {
return "chief_judge".equals(role) || (refereeType != null && refereeType == 1);
}
boolean isGeneralJudge() {
return "general_judge".equals(role)
|| "general".equals(role)
|| (refereeType != null && refereeType == 3);
}
}
private String generateTableNo(ScheduleGroupDetailVO detail) { private String generateTableNo(ScheduleGroupDetailVO detail) {
// 场地编号(简单取第一个数字或默认1) // 场地编号(简单取第一个数字或默认1)
int venueNo = 1; int venueNo = 1;
@@ -79,15 +79,18 @@ public class MartialScoreServiceImpl extends ServiceImpl<MartialScoreMapper, Mar
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public boolean save(MartialScore score) { public boolean save(MartialScore score) {
if (score == null) {
throw new ServiceException("评分数据不能为空");
}
if (score.getJudgeId() == null || score.getProjectId() == null) {
throw new ServiceException("评分缺少裁判或项目信息");
}
// Task 2.5: 权限验证 - 裁判只能给被分配的项目打分 // Task 2.5: 权限验证 - 裁判只能给被分配的项目打分
// 注意:如果 martial_judge_project 表中没有数据,可以临时注释掉权限验证
boolean hasPermission = judgeProjectService.hasPermission(score.getJudgeId(), score.getProjectId()); boolean hasPermission = judgeProjectService.hasPermission(score.getJudgeId(), score.getProjectId());
if (!hasPermission) { if (!hasPermission) {
log.warn("⚠️ 权限验证失败 - 裁判ID:{}, 项目ID:{} (如果是测试环境,请在 martial_judge_project 表中添加关联记录)", log.warn("权限验证失败,拒绝评分提交,judgeId={}, projectId={}", score.getJudgeId(), score.getProjectId());
score.getJudgeId(), score.getProjectId()); throw new ServiceException("您没有权限给该项目打分");
// 临时允许通过,但记录警告日志
// 生产环境请取消注释下面这行
// throw new ServiceException("您没有权限给该项目打分");
} }
// Task 2.2: 验证分数范围 // Task 2.2: 验证分数范围
@@ -35,16 +35,20 @@ public class MiniAuthServiceImpl implements IMiniAuthService {
@Override @Override
public R<MiniLoginVO> login(MiniLoginDTO dto) { public R<MiniLoginVO> login(MiniLoginDTO dto) {
LocalDateTime now = LocalDateTime.now();
LambdaQueryWrapper<MartialJudgeInvite> inviteQuery = new LambdaQueryWrapper<>(); LambdaQueryWrapper<MartialJudgeInvite> inviteQuery = new LambdaQueryWrapper<>();
inviteQuery.eq(MartialJudgeInvite::getInviteCode, dto.getInviteCode()); inviteQuery.eq(MartialJudgeInvite::getInviteCode, dto.getInviteCode());
inviteQuery.eq(MartialJudgeInvite::getIsDeleted, 0); inviteQuery.eq(MartialJudgeInvite::getIsDeleted, 0);
inviteQuery.eq(MartialJudgeInvite::getStatus, 1);
MartialJudgeInvite invite = judgeInviteService.getOne(inviteQuery); MartialJudgeInvite invite = judgeInviteService.getOne(inviteQuery);
if (invite == null) { if (invite == null) {
return R.fail("邀请码不存在"); log.warn("小程序登录失败:邀请码不存在或已禁用,inviteCode={}", dto.getInviteCode());
return R.fail("邀请码不存在或已失效");
} }
if (invite.getExpireTime() != null && invite.getExpireTime().isBefore(LocalDateTime.now())) { if (invite.getExpireTime() == null || !invite.getExpireTime().isAfter(now)) {
log.warn("小程序登录失败:邀请码已过期,inviteCode={}, expireTime={}", dto.getInviteCode(), invite.getExpireTime());
return R.fail("邀请码已过期"); return R.fail("邀请码已过期");
} }
@@ -64,9 +68,9 @@ public class MiniAuthServiceImpl implements IMiniAuthService {
String token = UUID.randomUUID().toString().replace("-", ""); String token = UUID.randomUUID().toString().replace("-", "");
invite.setAccessToken(token); invite.setAccessToken(token);
invite.setTokenExpireTime(LocalDateTime.now().plusDays(7)); invite.setTokenExpireTime(now.plusDays(7));
invite.setIsUsed(1); invite.setIsUsed(1);
invite.setUseTime(LocalDateTime.now()); invite.setUseTime(now);
invite.setLoginIp(dto.getLoginIp()); invite.setLoginIp(dto.getLoginIp());
invite.setDeviceInfo(dto.getDeviceInfo()); invite.setDeviceInfo(dto.getDeviceInfo());
judgeInviteService.updateById(invite); judgeInviteService.updateById(invite);
@@ -36,15 +36,43 @@ public class MiniScoringServiceImpl implements IMiniScoringService {
@Override @Override
public R<String> submitScore(MiniScoreSubmitDTO dto) { public R<String> submitScore(MiniScoreSubmitDTO dto) {
if (dto == null) {
return R.fail("评分参数不能为空");
}
Long athleteId = parseLong(dto.getAthleteId());
Long judgeId = parseLong(dto.getJudgeId());
Long projectId = parseLong(dto.getProjectId());
Long competitionId = parseLong(dto.getCompetitionId());
Long venueId = parseLong(dto.getVenueId());
Long scheduleId = parseLong(dto.getScheduleId());
if (athleteId == null || judgeId == null || projectId == null || competitionId == null || dto.getScore() == null) {
log.warn("评分提交参数缺失,athleteId={}, judgeId={}, projectId={}, competitionId={}",
dto.getAthleteId(), dto.getJudgeId(), dto.getProjectId(), dto.getCompetitionId());
return R.fail("评分参数不完整");
}
MartialJudge judge = judgeService.getById(judgeId);
if (judge == null) {
log.warn("评分提交失败:评委不存在,judgeId={}", judgeId);
return R.fail("评委信息不存在");
}
MartialAthlete athlete = athleteService.getById(athleteId);
if (athlete == null) {
log.warn("评分提交失败:选手不存在,athleteId={}", athleteId);
return R.fail("选手信息不存在");
}
MartialScore score = new MartialScore(); MartialScore score = new MartialScore();
score.setAthleteId(parseLong(dto.getAthleteId())); score.setAthleteId(athleteId);
score.setJudgeId(parseLong(dto.getJudgeId())); score.setJudgeId(judgeId);
score.setScore(dto.getScore()); score.setScore(dto.getScore());
score.setProjectId(parseLong(dto.getProjectId())); score.setProjectId(projectId);
score.setCompetitionId(parseLong(dto.getCompetitionId())); score.setCompetitionId(competitionId);
score.setVenueId(parseLong(dto.getVenueId())); score.setVenueId(venueId);
score.setScheduleId(parseLong(dto.getScheduleId())); score.setScheduleId(scheduleId);
score.setNote(dto.getNote()); score.setNote(dto.getNote());
score.setScoreTime(LocalDateTime.now()); score.setScoreTime(LocalDateTime.now());
@@ -56,20 +84,11 @@ public class MiniScoringServiceImpl implements IMiniScoringService {
score.setDeductionItems(com.alibaba.fastjson.JSON.toJSONString(deductionIds)); score.setDeductionItems(com.alibaba.fastjson.JSON.toJSONString(deductionIds));
} }
Long judgeId = parseLong(dto.getJudgeId()); score.setJudgeName(judge.getName());
if (judgeId != null) {
MartialJudge judge = judgeService.getById(judgeId);
if (judge != null) {
score.setJudgeName(judge.getName());
}
}
boolean success = scoreService.save(score); boolean success = scoreService.save(score);
if (success) { if (success) {
Long athleteId = parseLong(dto.getAthleteId());
Long projectId = parseLong(dto.getProjectId());
Long venueId = parseLong(dto.getVenueId());
if (athleteId != null && projectId != null) { if (athleteId != null && projectId != null) {
updateAthleteTotalScore(athleteId, projectId, venueId); updateAthleteTotalScore(athleteId, projectId, venueId);
} }