You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
180 lines
7.0 KiB
180 lines
7.0 KiB
package com.mydreamjob.auth;
|
|
|
|
import ch.qos.logback.classic.Level;
|
|
import ch.qos.logback.classic.Logger;
|
|
import ch.qos.logback.classic.spi.ILoggingEvent;
|
|
import ch.qos.logback.core.read.ListAppender;
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.slf4j.LoggerFactory;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.boot.test.context.SpringBootTest;
|
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
import org.springframework.data.redis.core.RedisTemplate;
|
|
import org.springframework.data.redis.core.ValueOperations;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.test.context.ActiveProfiles;
|
|
|
|
import java.time.Instant;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.concurrent.atomic.AtomicLong;
|
|
import java.util.stream.Collectors;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.mockito.ArgumentMatchers.anyString;
|
|
import static org.mockito.Mockito.mock;
|
|
import static org.mockito.Mockito.when;
|
|
|
|
/**
|
|
* S02 日志与审计脱敏(设计第十二节,任务书 §十六)。
|
|
*
|
|
* <p>日志断言捕获**真实 Logback 输出**(不是 Mockito verify logger);
|
|
* 审计断言读取真实 audit_logs 行并校验 details 白名单。</p>
|
|
*/
|
|
@SpringBootTest
|
|
@ActiveProfiles("integration-test")
|
|
class AuthTokenRedactionTest {
|
|
|
|
private static final AtomicLong PHONE_SEQ = new AtomicLong(9700);
|
|
|
|
@MockBean
|
|
private RedisTemplate<String, Object> redisTemplate;
|
|
|
|
@Autowired
|
|
private UserRepository userRepository;
|
|
|
|
@Autowired
|
|
private AuthSessionService authSessionService;
|
|
|
|
@Autowired
|
|
private AuthTokenHasher authTokenHasher;
|
|
|
|
@Autowired
|
|
private JdbcTemplate jdbcTemplate;
|
|
|
|
private Long userId;
|
|
private ListAppender<ILoggingEvent> appender;
|
|
private Logger appLogger;
|
|
|
|
@BeforeEach
|
|
@SuppressWarnings("unchecked")
|
|
void setUp() {
|
|
ValueOperations<String, Object> ops = mock(ValueOperations.class);
|
|
when(redisTemplate.opsForValue()).thenReturn(ops);
|
|
when(redisTemplate.hasKey(anyString())).thenReturn(false);
|
|
|
|
userId = userRepository.save(User.builder()
|
|
.phone("13" + String.format("%09d", PHONE_SEQ.incrementAndGet()))
|
|
.nickname("S02 脱敏")
|
|
.role(User.Role.STUDENT)
|
|
.roles(Set.of(User.Role.STUDENT))
|
|
.active(true)
|
|
.verified(true)
|
|
.build()).getId();
|
|
|
|
appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob");
|
|
appender = new ListAppender<>();
|
|
appender.start();
|
|
appLogger.addAppender(appender);
|
|
appLogger.setLevel(Level.TRACE);
|
|
}
|
|
|
|
@AfterEach
|
|
void tearDown() {
|
|
if (appLogger != null && appender != null) {
|
|
appLogger.detachAppender(appender);
|
|
appender.stop();
|
|
}
|
|
jdbcTemplate.update("DELETE FROM audit_logs WHERE action LIKE 'AUTH_%' AND user_id = ?", userId);
|
|
if (userId != null) {
|
|
userRepository.deleteById(userId);
|
|
}
|
|
}
|
|
|
|
private String capturedLog() {
|
|
return appender.list.stream()
|
|
.map(ILoggingEvent::getFormattedMessage)
|
|
.collect(Collectors.joining("\n"));
|
|
}
|
|
|
|
@Test
|
|
void loginRefreshAndLogoutNeverLogTokensOrHashes() {
|
|
AuthSessionService.SessionTokens first =
|
|
authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null);
|
|
AuthSessionService.SessionTokens rotated =
|
|
authSessionService.refresh(first.refreshToken(), Instant.now(), null);
|
|
authSessionService.logout(userId, rotated.sessionId(), Instant.now(), null);
|
|
|
|
// 重放路径同样不得泄漏
|
|
try {
|
|
authSessionService.refresh(first.refreshToken(), Instant.now(), null);
|
|
} catch (InvalidTokenException expected) {
|
|
// 预期
|
|
}
|
|
|
|
String log = capturedLog();
|
|
for (String secret : List.of(first.accessToken(), first.refreshToken(),
|
|
rotated.accessToken(), rotated.refreshToken())) {
|
|
assertThat(log).as("日志不得包含 Token 原文").doesNotContain(secret);
|
|
}
|
|
String sessionHash = authTokenHasher.hash("sid:" + first.sessionId());
|
|
assertThat(log).as("日志不得包含会话摘要").doesNotContain(sessionHash);
|
|
assertThat(log).doesNotContain("Bearer ");
|
|
assertThat(log).doesNotContain("Authorization");
|
|
}
|
|
|
|
@Test
|
|
void auditDetailsUseWhitelistOnly() {
|
|
AuthSessionService.SessionTokens first =
|
|
authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null);
|
|
AuthSessionService.SessionTokens rotated =
|
|
authSessionService.refresh(first.refreshToken(), Instant.now(), null);
|
|
authSessionService.logout(userId, rotated.sessionId(), Instant.now(), null);
|
|
try {
|
|
authSessionService.refresh(first.refreshToken(), Instant.now(), null);
|
|
} catch (InvalidTokenException expected) {
|
|
// 预期
|
|
}
|
|
|
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
|
"SELECT action, details FROM audit_logs WHERE user_id = ? AND action LIKE 'AUTH_%'", userId);
|
|
|
|
assertThat(rows).as("应写入会话相关审计").isNotEmpty();
|
|
for (Map<String, Object> row : rows) {
|
|
String action = (String) row.get("action");
|
|
assertThat(action).isIn(
|
|
"AUTH_SESSION_CREATED", "AUTH_TOKEN_REFRESHED",
|
|
"AUTH_SESSION_REVOKED", "AUTH_REFRESH_REUSE_DETECTED");
|
|
|
|
String details = String.valueOf(row.get("details"));
|
|
assertThat(details).as("审计 details 不得包含 Token/摘要/手机号")
|
|
.doesNotContain("accessToken")
|
|
.doesNotContain("refreshToken")
|
|
.doesNotContain("Bearer")
|
|
.doesNotContain("tokenHash")
|
|
.doesNotContain(first.refreshToken())
|
|
.doesNotContain(first.accessToken());
|
|
// 只允许 sessionRef / reason 两个键
|
|
assertThat(details).contains("\"sessionRef\"");
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void auditNeverContainsRawTokens() {
|
|
AuthSessionService.SessionTokens tokens =
|
|
authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null);
|
|
|
|
List<String> allDetails = jdbcTemplate.queryForList(
|
|
"SELECT details FROM audit_logs WHERE user_id = ?", String.class, userId);
|
|
|
|
for (String details : allDetails) {
|
|
assertThat(details).doesNotContain(tokens.accessToken());
|
|
assertThat(details).doesNotContain(tokens.refreshToken());
|
|
// 数据库里的摘要也不得出现在审计中
|
|
assertThat(details).doesNotContain(authTokenHasher.hash(tokens.refreshToken()));
|
|
}
|
|
}
|
|
}
|
|
|