fix(security): 무인증 계정 탈취 경로 차단 + 세션 고정 방어 + 거짓 주석 수정

보안 검토(4개 영역 병렬)에서 확인된 실제 취약점 수정:

[HIGH] 무인증 계정 탈취 연쇄 — find-id로 아이디 획득 → 아이디+이름만으로 비밀번호 재설정
  → 요청 2번으로 멘토 권한 탈취 가능했음 (공개 서버, 시드 이름 예측 가능)
  - 무인증 POST /api/auth/reset-password 제거
  - find-id 응답 아이디 마스킹 (mentor1 → me****1)
  - 대체 경로 신설: POST /api/auth/change-password(본인, 현재 비번 재인증),
    POST /api/mentor/users/{id}/reset-password(멘토 전용, 학생 계정만)
  - 프론트: 재설정 폼 → 안내로 교체, 비밀번호 변경 페이지·멘토 재설정 UI 추가

[MEDIUM] 세션 고정 — 로그인 시 세션 ID 미재발급 (직접 구현이라 formLogin의 방어가 빠져 있었음)
  - changeSessionId() 추가. 세션 부재 시 IllegalStateException 나는 것 확인해 가드 포함

[교재 품질] 코드와 어긋난 주석 수정 — 학생이 주석 신뢰를 잃는 것이 가장 큰 손해
  - Badge: 라벨표 이중화로 REVIEWED가 화면마다 다르게 표시되던 실제 버그 수정(PASSED 추가)
  - QuizResult: 정렬 없이 '가장 최근'을 가정하던 주석 → OrderBy 명시로 사실화
  - runJs: (0, eval) 설명 → 실제 코드(new Function)에 맞게 수정
  - runJs: Worker는 fetch가 되므로 '멘토 화면에서 학생 코드 실행 금지' 회귀 경고 명시

운영 검증: 로그인 200 / 무인증 재설정 401 / 학생→멘토API 403 / 멘토 재설정 200

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 07:58:50 +09:00
parent 241b784e1e
commit c5c4a5b1f9
16 changed files with 467 additions and 180 deletions

View File

@ -68,8 +68,14 @@ public class SecurityConfig {
// permitAll이어야 한다. 로그인해야만 가입/비밀번호 찾기가 가능하다면 모순이다.
.requestMatchers("/api/auth/login").permitAll() // 로그인
.requestMatchers("/api/auth/signup").permitAll() // 회원가입
.requestMatchers("/api/auth/find-id").permitAll() // 아이디 찾기
.requestMatchers("/api/auth/reset-password").permitAll() // 비밀번호 재설정
.requestMatchers("/api/auth/find-id").permitAll() // 아이디 찾기(마스킹된 아이디만 반환)
// 여기 있던 /api/auth/reset-password(무인증 비밀번호 재설정) 제거했다.
// 보안 검토에서 실제 계정 탈취 경로로 확인됐기 때문이다:
// find-id로 아이디를 얻고 아이디+이름만으로 비밀번호를 바꾸면 남의 계정 로그인.
// 대신 개의 안전한 경로로 나눴다:
// - 로그인한 본인: POST /api/auth/change-password (현재 비밀번호 확인 필요)
// - 정말 잊은 학생: 멘토가 POST /api/mentor/users/{id}/reset-password 처리
// 학습 포인트: "무인증으로 열어도 되는 문" 최소한이어야 한다.
.requestMatchers("/api/mentor/**").hasRole("MENTOR") // 멘토 전용
.requestMatchers("/api/**").authenticated() // 나머지 API는 로그인 필요
.anyRequest().permitAll()) // (정적 파일 ) 공개

View File

@ -11,6 +11,14 @@ import java.util.List;
*/
public interface QuizResultRepository extends JpaRepository<QuizResult, Long> {
/** 이 사용자의 모든 응시 기록 */
List<QuizResult> findByUser(User user);
/**
* 사용자의 모든 응시 기록 최신순.
*
* 학습 포인트: OrderBy를 붙였나? 원래는 findByUser(user)였는데,
* 이걸 쓰는 쪽에서 "마지막 것이 가장 최근"이라고 가정하는 코드가 있었다.
* 하지만 정렬을 지정하지 않으면 DB가 어떤 순서로 줄지 아무도 보장하지 않는다
* 지금 나오더라도 데이터가 늘거나 실행 계획이 바뀌면 조용히 순서가 달라진다.
* "순서가 필요하면 반드시 ORDER BY로 말한다" 우연히 맞는 코드는 언젠가 틀린다.
*/
List<QuizResult> findByUserOrderBySubmittedAtDesc(User user);
}

View File

@ -75,12 +75,21 @@ public class AuthService {
}
/**
* 아이디 찾기: 이름이 일치하는 계정들의 아이디 목록을 돌려준다.
* 아이디 찾기: 이름이 일치하는 계정들의 아이디 "가려서(마스킹)" 돌려준다.
*
* 학습 포인트: 실무에서는 이름만으로 아이디를 알려주면 개인정보 유출 위험이 있다
* 남의 이름을 넣어 보는 것만으로 사람의 계정 아이디를 수집할 있기 때문이다.
* 그래서 실서비스는 "가입 시 등록한 이메일로 인증 메일을 보내는" 방식을 쓴다.
* 여기서는 사내 4명이 쓰는 학습용 플랫폼이라 단순한 방식으로 구현했다.
* 보안 리뷰에서 실제로 잡힌 문제 코드가 살아있는 교훈이다.
* 원래 메서드는 아이디 전문(mentor1) 그대로 돌려줬다. 자체로도 정보 노출이지만,
* 진짜 문제는 옆에 있던 "아이디+이름으로 비밀번호 재설정" API와 결합됐을 때였다:
* 1) find-id {"name":"멘토"} "mentor1" (아이디를 서버가 알려줌)
* 2) reset-password {"username":"mentor1","name":"멘토",...} 비밀번호 교체
* 3) 로그인 멘토 권한 획득
* 각각은 "학습용 단순화"였지만 둘을 이으니 인증이 통째로 사라졌다.
*
* 학습 포인트: 취약점은 기능 하나가 아니라 기능의 "조합"에서 태어나는 일이 많다.
* 그래서 보안 검토는 파일 하나가 아니라 공격자의 경로 전체를 따라가야 한다.
*
* 지금은 (1) 아이디를 마스킹하고 (2) 무인증 재설정 API를 아예 없애서 연쇄를 곳에서 끊었다.
* 방어를 겹만 두지 않는 것을 심층 방어(defense in depth)라고 한다.
*/
@Transactional(readOnly = true)
public List<String> findUsernamesByName(String name) {
@ -89,34 +98,72 @@ public class AuthService {
// 스트림 체이닝 대신 for문으로 풀어쓴다 무슨 일이 일어나는지 줄씩 보이도록.
List<String> usernames = new ArrayList<>();
for (User user : users) {
usernames.add(user.getUsername());
usernames.add(maskUsername(user.getUsername()));
}
return usernames;
}
/**
* 비밀번호 재설정: 아이디와 이름이 모두 일치하면 비밀번호로 바꾼다. 불일치면 404.
* 아이디를 가린다: "mentor1" "me****1" ( 2글자 + 마지막 1글자만 남김).
* 본인은 "아, 내 아이디가 그거였지" 하고 떠올릴 있지만, 남이 그대로 수는 없다.
*
* 학습 포인트: 실무에서는 "이메일로 재설정 링크 + 만료 토큰" 방식을 쓴다. 이유:
* 1) 이름은 비밀이 아니다 동료·지인이면 누구나 알고 있어 본인 확인 수단이 된다.
* 2) 이메일 링크는 "그 메일함에 접근할 수 있는 사람 = 본인"이라는 훨씬 강한 증거다.
* 3) 토큰에 만료 시간을 두면, 링크가 유출되어도 짧은 시간이 지나면 없게 된다.
* 여기서는 사내 4명용 학습 구현이라 아이디+이름 대조로 단순화했다.
* 학습 포인트: 보안과 편의는 저울질이다. 아이디를 아예 알려주면 가장 안전하지만
* 정말 잊어버린 사람이 곤란해진다. "떠올리기엔 충분하고 악용하기엔 부족한" 지점을 고른 것이다.
*/
private String maskUsername(String username) {
if (username.length() <= 3) {
// 너무 짧으면 글자만 남긴다 (가릴 없으면 많이 가린다)
return username.charAt(0) + "**";
}
String head = username.substring(0, 2);
String tail = username.substring(username.length() - 1);
String stars = "*".repeat(username.length() - 3);
return head + stars + tail;
}
/**
* 비밀번호 변경 (로그인한 본인). 현재 비밀번호를 확인한 뒤에만 바꿔 준다.
*
* 학습 포인트: "현재 비밀번호" 묻나? 이미 로그인했는데?
* 로그인한 브라우저를 잠깐 빌린 사람(자리를 비운 사이) 비밀번호를 바꿔
* 계정을 통째로 뺏는 것을 막기 위해서다. 이걸 재인증(re-authentication)이라 하고,
* 비밀번호 변경·결제·탈퇴처럼 되돌리기 어려운 동작 앞에 세우는 관문이다.
*/
@Transactional
public void resetPassword(String username, String name, String newRawPassword) {
// 학습 포인트: "아이디는 맞는데 이름이 틀림" 구분해 주지 않고 똑같이 404를 준다.
// 구분해 주면 공격자가 아이디의 존재 여부를 하나씩 확인해 있기 때문이다.
User user = userRepository.findByUsernameAndName(username, name)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "일치하는 계정을 찾을 수 없습니다."));
// 비밀번호도 로그인 비교가 가능하도록 같은 방식(BCrypt)으로 해시해서 저장한다.
public void changePassword(User user, String currentRawPassword, String newRawPassword) {
if (!passwordEncoder.matches(currentRawPassword, user.getPasswordHash())) {
throw new ResponseStatusException(
HttpStatus.UNAUTHORIZED, "현재 비밀번호가 올바르지 않습니다.");
}
user.changePassword(passwordEncoder.encode(newRawPassword));
// 학습 포인트: save() 부르지 않아도 된다. @Transactional 안에서 조회한 엔티티는
// JPA가 변경을 감지해(더티 체킹) 트랜잭션 커밋 시점에 자동으로 UPDATE 한다.
}
/**
* 비밀번호 재설정 (멘토가 학생 계정에 대해 수행). 컨트롤러가 MENTOR 권한을 이미 확인한다.
*
* 학습 포인트: 진짜로 비밀번호를 잊은 사람은 어떻게 하나?
* 실서비스는 "등록된 이메일로 만료되는 재설정 링크" 보낸다 메일함에 접근할 있는
* 사람이 본인이라는 강한 증거이기 때문이다. 우리는 메일 시스템이 없으므로,
* "옆자리 멘토에게 직접 요청한다" 오프라인 본인 확인으로 대신한다.
* 사람이 눈으로 확인하는 것도 훌륭한 인증이다 5명이 사무실에 있다면 더더욱.
*/
@Transactional
public void resetPasswordByMentor(Long userId, String newRawPassword) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."));
// 멘토가 다른 멘토(또는 자기 자신) 비밀번호를 바꾸는 것은 막는다 학생 계정만 대상.
if (!"STUDENT".equals(user.getRole())) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "학생 계정만 재설정할 수 있습니다.");
}
user.changePassword(passwordEncoder.encode(newRawPassword));
}
/**
* 현재 요청의 인증 정보(Authentication)로부터 User 엔티티를 찾아온다.
* 인증 정보가 없거나 사용자를 찾을 없으면 401을 던진다.

View File

@ -13,6 +13,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* 파일이 하는 :
@ -87,21 +88,28 @@ public class QuizService {
*/
@Transactional(readOnly = true)
public List<ResultSummary> summaryFor(User user) {
Map<String, List<QuizResult>> bySlug = new java.util.TreeMap<>();
for (QuizResult result : quizResultRepository.findByUser(user)) {
// 리포지토리가 최신순(submittedAt 내림차순)으로 준다 코스 목록의 건이 가장 최근 응시다.
Map<String, List<QuizResult>> bySlug = new TreeMap<>();
for (QuizResult result : quizResultRepository.findByUserOrderBySubmittedAtDesc(user)) {
bySlug.computeIfAbsent(result.getCourseSlug(), key -> new ArrayList<>()).add(result);
}
List<ResultSummary> summaries = new ArrayList<>();
for (Map.Entry<String, List<QuizResult>> entry : bySlug.entrySet()) {
List<QuizResult> attempts = entry.getValue();
int best = 0;
int total = 0;
for (QuizResult result : entry.getValue()) {
for (QuizResult result : attempts) {
if (result.getScore() > best) {
best = result.getScore();
}
total = result.getTotal(); // 가장 최근 기준 문항
}
summaries.add(new ResultSummary(entry.getKey(), best, total, entry.getValue().size()));
// 분모( 문항 ) 가장 최근 응시 기준 문항이 나중에 늘거나 줄어도
// "최근에 푼 시험" 만점 기준을 쓰는 것이 자연스럽다.
// 학습 포인트: 최신순 정렬이 보장되므로 get(0) 가장 최근이다.
// 정렬을 리포지토리 메서드 이름으로 선언했기 때문에 가정이 안전하다.
int total = attempts.get(0).getTotal();
summaries.add(new ResultSummary(entry.getKey(), best, total, attempts.size()));
}
return summaries;
}

View File

@ -5,7 +5,7 @@ import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.web.dto.FindIdRequest;
import dev.awesomedev.mirim.web.dto.FindIdResponse;
import dev.awesomedev.mirim.web.dto.LoginRequest;
import dev.awesomedev.mirim.web.dto.ResetPasswordRequest;
import dev.awesomedev.mirim.web.dto.ChangePasswordRequest;
import dev.awesomedev.mirim.web.dto.SignupRequest;
import dev.awesomedev.mirim.web.dto.SignupResponse;
import dev.awesomedev.mirim.web.dto.UserResponse;
@ -57,6 +57,22 @@ public class AuthController {
// 1) 아이디·비밀번호 검증 (틀리면 AuthService가 401을 던진다)
User user = authService.authenticate(loginRequest.username(), loginRequest.password());
// 1-1) 세션 ID 재발급 세션 고정(session fixation) 공격 방어.
// 학습 포인트: 공격자가 자기 세션 ID를 피해자 브라우저에 미리 심어 ,
// 피해자가 세션으로 로그인하면 "공격자가 아는 ID" 그대로 인증된 세션이 된다.
// 로그인 순간 ID를 새로 발급하면 심어 ID는 무용지물이 된다.
// (formLogin을 쓰면 스프링이 자동으로 해주지만, 우리는 직접 구현했으므로 직접 챙긴다
// 프레임워크를 걷어낼 그것이 대신 해주던 일까지 함께 가져와야 한다.)
//
// 학습 포인트 : getSession(false) 먼저 확인하는 이유
// changeSessionId() "바꿀 세션이 없으면" IllegalStateException을 던진다(500 에러!).
// /curl처럼 쿠키 없이 바로 로그인하면 세션이 없는 정상이므로 그때는 건너뛴다.
// 어차피 아래 saveContext() 세션을 만들고, 세션의 ID는 아무도 모르니 안전하다.
// "메서드가 어떤 조건에서 예외를 던지는지" 문서에서 확인하는 습관이 이래서 중요하다.
if (request.getSession(false) != null) {
request.changeSessionId();
}
// 2) 인증 토큰 생성. principal은 username, 권한은 "ROLE_" + 역할.
// 학습 포인트: Spring Security의 hasRole("MENTOR") 내부적으로 "ROLE_MENTOR" 권한을 찾는다.
// 그래서 권한 이름 앞에 반드시 "ROLE_" 접두어를 붙여야 한다.
@ -113,9 +129,9 @@ public class AuthController {
}
/**
* POST /api/auth/find-id 이름으로 아이디 찾기.
* POST /api/auth/find-id 이름으로 아이디 찾기. 아이디는 마스킹해서 준다(me****1).
* 이름이 일치하는 계정이 없으면 배열을 돌려준다(에러가 아니다).
* 개인정보 관련 주의사항은 AuthService.findUsernamesByName 주석 참고.
* 마스킹하는지는 AuthService.findUsernamesByName 주석 참고 보안 검토의 실제 교훈이 담겨 있다.
*/
@PostMapping("/find-id")
public FindIdResponse findId(@Valid @RequestBody FindIdRequest findIdRequest) {
@ -123,16 +139,19 @@ public class AuthController {
}
/**
* POST /api/auth/reset-password 비밀번호 재설정.
* 아이디+이름이 일치하면 비밀번호를 저장하고 200, 불일치하면 404.
* 방식이 실무에선 부족한지는 AuthService.resetPassword 주석 참고.
* POST /api/auth/change-password 로그인한 본인의 비밀번호 변경.
* 현재 비밀번호를 함께 보내야 한다(재인증). 틀리면 401.
*
* 학습 포인트: API는 SecurityConfig의 "/api/** → authenticated()" 규칙에 걸려
* 로그인한 사람만 호출할 있다. permitAll 목록에 없다는 자체가 보안 설계다.
*/
@PostMapping("/reset-password")
public void resetPassword(@Valid @RequestBody ResetPasswordRequest resetPasswordRequest) {
authService.resetPassword(
resetPasswordRequest.username(),
resetPasswordRequest.name(),
resetPasswordRequest.newPassword());
@PostMapping("/change-password")
public void changePassword(@Valid @RequestBody ChangePasswordRequest changePasswordRequest,
Authentication authentication) {
User user = authService.requireUser(authentication);
authService.changePassword(user,
changePasswordRequest.currentPassword(),
changePasswordRequest.newPassword());
// 반환할 내용이 없으므로 본문 없이 200 OK만 내려간다.
}
}

View File

@ -3,6 +3,7 @@ package dev.awesomedev.mirim.web;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.Submission;
import dev.awesomedev.mirim.repository.UserRepository;
import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.CodingService;
import dev.awesomedev.mirim.service.CourseProgressService;
import dev.awesomedev.mirim.service.ProgressService;
@ -10,8 +11,10 @@ import dev.awesomedev.mirim.service.QuizService;
import dev.awesomedev.mirim.service.SubmissionService;
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
import dev.awesomedev.mirim.web.dto.FeedbackRequest;
import dev.awesomedev.mirim.web.dto.MentorResetPasswordRequest;
import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse;
import dev.awesomedev.mirim.web.dto.StudentCourseProgressResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -35,6 +38,7 @@ public class MentorController {
private final ProgressService progressService;
private final QuizService quizService;
private final CodingService codingService;
private final AuthService authService;
private final UserRepository userRepository;
public MentorController(SubmissionService submissionService,
@ -42,12 +46,14 @@ public class MentorController {
ProgressService progressService,
QuizService quizService,
CodingService codingService,
AuthService authService,
UserRepository userRepository) {
this.submissionService = submissionService;
this.courseProgressService = courseProgressService;
this.progressService = progressService;
this.quizService = quizService;
this.codingService = codingService;
this.authService = authService;
this.userRepository = userRepository;
}
@ -112,6 +118,20 @@ public class MentorController {
.toList();
}
/**
* POST /api/mentor/users/{id}/reset-password 멘토가 학생의 비밀번호를 재설정한다.
*
* 학습 포인트: 원래 기능은 "아이디+이름만 맞으면 누구나" 호출할 있는 무인증 API였다.
* 보안 검토에서 계정 탈취 경로로 확인되어 자리로 옮겼다
* /api/mentor/** SecurityConfig가 MENTOR 권한을 요구하므로, 이제 멘토만 호출할 있다.
* 본인 확인은 "옆자리 멘토가 얼굴을 보고 확인"이라는 오프라인 절차가 대신한다.
*/
@PostMapping("/users/{id}/reset-password")
public void resetStudentPassword(@PathVariable Long id,
@Valid @RequestBody MentorResetPasswordRequest request) {
authService.resetPasswordByMentor(id, request.newPassword());
}
/** POST /api/mentor/submissions/{id}/feedback — 피드백 등록, 상태는 REVIEWED로 변경 */
@PostMapping("/submissions/{id}/feedback")
public MentorSubmissionResponse giveFeedback(@PathVariable Long id,

View File

@ -0,0 +1,24 @@
package dev.awesomedev.mirim.web.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 파일이 하는 :
* 비밀번호 변경 요청 본문 {currentPassword, newPassword} 담는 DTO다.
*
* 학습 포인트: 로그인했는데 현재 비밀번호를 받나? 재인증(re-authentication).
* 자리를 비운 사이 남이 브라우저로 비밀번호를 바꿔 계정을 뺏는 것을 막는 관문이다.
* "누구인지(인증)" "정말 본인이 맞는지(재인증)" 다른 질문이다.
*/
public record ChangePasswordRequest(
@NotBlank(message = "현재 비밀번호는 필수입니다.")
String currentPassword,
// 비밀번호도 가입 때와 같은 규칙(8자 이상) 적용한다.
@NotBlank(message = "새 비밀번호는 필수입니다.")
@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.")
String newPassword
) {
}

View File

@ -0,0 +1,19 @@
package dev.awesomedev.mirim.web.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 파일이 하는 :
* 멘토가 학생 비밀번호를 재설정할 보내는 요청 본문 {newPassword} 담는 DTO다.
*
* 학습 포인트: 대상 학생은 본문이 아니라 URL 경로(/api/mentor/users/{id}/reset-password) 받는다.
* REST에서 "무엇에 대한 동작인가" URL이, "어떻게 바꿀 것인가" 본문이 맡는 관례다.
*/
public record MentorResetPasswordRequest(
@NotBlank(message = "새 비밀번호는 필수입니다.")
@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.")
String newPassword
) {
}

View File

@ -1,25 +0,0 @@
package dev.awesomedev.mirim.web.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 파일이 하는 :
* 비밀번호 재설정 요청 본문 {username, name, newPassword} 담는 DTO다.
* "아이디 + 이름" 모두 일치해야 재설정을 허용한다.
*/
public record ResetPasswordRequest(
@NotBlank(message = "아이디는 필수입니다.")
String username,
// 본인 확인용 이름. 아이디만으로 재설정을 허용하면 아이디를 아는 누구나 계정을 뺏을 있다.
@NotBlank(message = "이름은 필수입니다.")
String name,
// 비밀번호도 가입 때와 같은 규칙(8자 이상) 적용한다.
@NotBlank(message = "새 비밀번호는 필수입니다.")
@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.")
String newPassword
) {
}

View File

@ -17,6 +17,7 @@ import DocsPage from './pages/DocsPage';
import DocViewerPage from './pages/DocViewerPage';
import AssignmentsPage from './pages/AssignmentsPage';
import SetupGuidePage from './pages/SetupGuidePage';
import ChangePasswordPage from './pages/ChangePasswordPage';
import QuizPage from './pages/QuizPage';
import CodingPage from './pages/CodingPage';
import CodingProblemPage from './pages/CodingProblemPage';
@ -87,6 +88,10 @@ function Layout() {
{user?.name}
{user?.role === 'MENTOR' ? ' 멘토' : ''}
</span>
{/* 비밀번호 변경 — 처음 받은 비밀번호를 바꿀 수 있는 통로. 눈에 띄는 곳에 둔다. */}
<NavLink to="/change-password" className="btn btn-ghost" title="비밀번호 변경">
🔑
</NavLink>
<button className="btn btn-ghost" onClick={logout}>
로그아웃
</button>
@ -132,6 +137,7 @@ export default function App() {
72 퀴즈 라우트를 하나로 처리한다 QuizPage가 useParams() 꺼내 쓴다. */}
<Route path="/learn/:slug/quiz" element={<QuizPage />} />
<Route path="/setup" element={<SetupGuidePage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/docs/:slug" element={<DocViewerPage />} />
<Route path="/assignments" element={<AssignmentsPage />} />

View File

@ -7,8 +7,9 @@ const STYLES = {
MAIN: { label: '본 과제', className: 'badge-primary' },
EXTRA: { label: '예비 과제', className: 'badge-amber' },
DESIGN: { label: '디자이너 과제', className: 'badge-teal' },
//
// ( · )
SUBMITTED: { label: '제출됨', className: 'badge-amber' },
PASSED: { label: '통과', className: 'badge-teal' },
REVIEWED: { label: '리뷰 완료', className: 'badge-teal' },
//
GUIDE: { label: '가이드', className: 'badge-primary' },

View File

@ -6,12 +6,13 @@
// .
import { useEffect, useState } from 'react';
import client from '../api/client';
import Badge from './Badge';
const STATUS_LABEL = {
SUBMITTED: '제출됨',
PASSED: '통과',
REVIEWED: '피드백 완료',
};
// : STATUS_LABEL Badge.jsx
// , REVIEWED " ",
// " " .
// Badge.jsx " " .
// (Badge.jsx) .
/** 학생별 코딩 진도표 (전체 20문제 중 몇 개 제출/통과) */
function CodingProgressTable({ rows }) {
@ -181,9 +182,7 @@ export default function MentorCodingSection() {
{s.attempts}
</td>
<td>
<span className={`status-badge status-badge--${s.status.toLowerCase()}`}>
{STATUS_LABEL[s.status] ?? s.status}
</span>
<Badge value={s.status} />
</td>
</tr>
))}

View File

@ -10,6 +10,17 @@
* 1) 오래 걸리면 terminate() 방을 통째로 없애 버릴 있다 무한루프 방어
* 2) Worker 안에는 document도 window도 없다 화면을 만질 없다
* 그래서 서버에 Docker 샌드박스를 짓지 않고도, 브라우저가 이미 가진 격리 장치를 빌려 쓴다.
*
* 안전함이 성립하는 조건 반드시 지킬 .
* Worker는 완벽한 감옥이 아니다. document는 없지만 fetch는 되고, 같은 출처 API를
* "그 브라우저의 세션 쿠키를 달고" 호출할 있다.
* 지금 안전한 이유는 하나 "자기 코드를 자기 브라우저에서 자기 권한으로만" 돌리기 때문이다.
* (실행 지점은 CodingProblemPage 곳뿐이고, 코드는 서버가 본인 것만 내려준다.)
*
* 그러므로 절대 하지 :
* 멘토 화면에 "학생 코드 실행해 보기" 버튼 붙이기
* 학생이 fetch('/api/mentor/...') 심어두면 멘토 세션으로 실행된다 = 권한 탈취.
* 남의 코드를 남의 브라우저에서 돌려야 한다면, 그때는 서버 샌드박스가 필요하다.
*/
/** 코드 한 번 실행에 허용할 시간(ms). 넘으면 무한루프로 보고 Worker를 없앤다. */
@ -31,7 +42,10 @@ self.onmessage = function (event) {
// 학생 코드를 평가해서 함수를 꺼낸다.
var fn;
try {
// (0, eval) 형태로 부르면 전역 스코프에서 평가된다.
// 학습 포인트: new Function(코드문자열)은 그 코드를 담은 함수를 만들어 준다.
// 뒤에 return을 덧붙여, 학생이 정의한 함수를 밖으로 꺼내오게 했다.
// eval()과 달리 바깥 지역 변수에 접근하지 못해(전역 스코프에서 평가된다)
// 우리 채점 코드의 변수를 학생 코드가 건드릴 수 없다.
var factory = new Function(code + '\\nreturn typeof ' + functionName + ' === "function" ? ' + functionName + ' : null;');
fn = factory();
} catch (e) {

View File

@ -0,0 +1,134 @@
// : .
// ().
//
// : ?
// .
// "()" " ()" ,
// ( ··) .
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import client from '../api/client';
export default function ChangePasswordPage() {
const navigate = useNavigate();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const [saving, setSaving] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError('');
// : " " .
// (ChangePasswordRequest @Size). .
if (newPassword.length < 8) {
setError('새 비밀번호는 8자 이상이어야 해요.');
return;
}
if (newPassword !== confirmPassword) {
setError('새 비밀번호 확인이 일치하지 않아요.');
return;
}
if (newPassword === currentPassword) {
setError('현재 비밀번호와 다른 비밀번호를 써 주세요.');
return;
}
setSaving(true);
try {
await client.post('/auth/change-password', { currentPassword, newPassword });
setDone(true);
} catch (err) {
// 401 = . .
if (err.response?.status === 401) {
setError('현재 비밀번호가 올바르지 않아요.');
} else {
setError('변경 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
}
} finally {
setSaving(false);
}
}
if (done) {
return (
<div>
<div className="hero compact">
<div className="eyebrow">Security</div>
<h1>비밀번호를 바꿨어요 🎉</h1>
<p>다음 로그인부터 비밀번호를 쓰면 됩니다.</p>
</div>
<button className="btn btn-primary" onClick={() => navigate('/')}>
대시보드로
</button>
</div>
);
}
return (
<div>
<div className="hero compact">
<div className="eyebrow">Security</div>
<h1>비밀번호 변경</h1>
<p>
처음 받은 비밀번호를 쓰고 있다면 지금 바꿔 주세요. 8 이상이면 되지만,
길수록 안전해요.
</p>
</div>
<div className="card" style={{ maxWidth: 460 }}>
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="cur-pw">현재 비밀번호</label>
<input
id="cur-pw"
className="input"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="field">
<label htmlFor="new-pw"> 비밀번호 (8 이상)</label>
<input
id="new-pw"
className="input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="field">
<label htmlFor="new-pw2"> 비밀번호 확인</label>
<input
id="new-pw2"
className="input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? '변경 중...' : '비밀번호 변경'}
</button>
</form>
</div>
<div className="tip" style={{ marginTop: 14, maxWidth: 460 }}>
<b>비밀번호를 아예 잊었다면?</b> 멘토에게 요청하세요. 멘토가 재설정해 드립니다.
(예전엔 이름만 알면 아무나 재설정할 있었는데, 보안 점검에서 발견해 닫았어요
<strong> 방화벽</strong> 코스에서 배우는 "최소 권한" 원칙이 우리 코드에 적용된 사례예요.)
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
// : / .
// - : POST /api/auth/find-id
// - : ++ POST /api/auth/reset-password
// : / .
// - : POST /api/auth/find-id (me****1)
// - : ( ForgotPasswordTab )
// Layout( ) .
import { useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
@ -28,7 +28,9 @@ function FindIdTab() {
setSubmitting(true);
try {
const res = await client.post('/auth/find-id', { name: name.trim() });
// : { usernames: ["student1", ...] } .
// : { usernames: ["st*****1", ...] } () .
// : .
// "" .
setFoundIds(res.data.usernames || []);
} catch {
setError('조회 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
@ -77,7 +79,7 @@ function FindIdTab() {
) : (
<>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--primary)' }}>
찾은 아이디
찾은 아이디 (일부만 표시)
</div>
{/* 같은 이름으로 여러 계정이 있을 수 있어 목록으로 보여준다. */}
<ul style={{ paddingLeft: 20 }}>
@ -95,106 +97,45 @@ function FindIdTab() {
);
}
// ---------- 2: ----------
function ResetPasswordTab() {
const [username, setUsername] = useState('');
const [name, setName] = useState('');
const [newPassword, setNewPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError('');
// (8 ) .
// : .
if (newPassword.length < 8) {
setError('새 비밀번호는 8자 이상이어야 해요.');
return;
}
setSubmitting(true);
try {
// + .
// (
// .)
await client.post('/auth/reset-password', {
username: username.trim(),
name: name.trim(),
newPassword,
});
setDone(true);
} catch (err) {
if (err.response?.status === 404 || err.response?.status === 400) {
setError('아이디와 이름이 일치하는 계정을 찾지 못했어요.');
} else {
setError('재설정 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
}
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<div style={{ textAlign: 'center' }}>
<p style={{ fontWeight: 700, marginBottom: 6 }}>비밀번호를 바꿨어요!</p>
<p className="muted" style={{ fontSize: 14, marginBottom: 16 }}>
비밀번호로 로그인해 주세요.
</p>
<Link to="/login" className="btn btn-primary" style={{ justifyContent: 'center' }}>
로그인하러 가기
</Link>
</div>
);
}
// ---------- 2: ----------
// "+ " .
//
// ? .
// 1) ( ) ,
// 2) + .
// " " .
// {"name":""} .
//
// " ", .
// (1) (2) , :
// - : [ ] ( )
// - : ( = )
// , .
function ForgotPasswordTab() {
return (
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="reset-username">아이디</label>
<input
id="reset-username"
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
<div>
<p style={{ fontWeight: 700, marginBottom: 8 }}>비밀번호를 잊으셨나요?</p>
<p className="muted" style={{ fontSize: 14, lineHeight: 1.7 }}>
<strong>멘토에게 요청하세요.</strong> 멘토가 비밀번호로 재설정해 드립니다.
<br />
비밀번호가 기억난다면 로그인한 <strong>[비밀번호 변경]</strong> 메뉴에서 직접 바꿀 있어요.
</p>
<div className="tip" style={{ marginTop: 14 }}>
<b> 여기서 바로 바꾸나요?</b> 예전엔 아이디와 이름만 넣으면 바로 바뀌었어요.
그런데 이름은 비밀이 아니죠 사람도, 사실은 누구나 있어요. 그래서 남의 계정을
가져갈 있는 문이 됐고, 보안 점검에서 발견해 닫았습니다. 앱의 <strong>방화벽</strong>·
<strong>무선 보안</strong> 코스에서 배우는 내용이 실제로 우리 코드에 적용된 사례예요.
</div>
<div className="field">
<label htmlFor="reset-name">이름</label>
<input
id="reset-name"
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
required
/>
</div>
<div className="field">
<label htmlFor="reset-password"> 비밀번호 (8 이상)</label>
<input
id="reset-password"
className="input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
{error && <p className="error-text">{error}</p>}
<button
<Link
to="/login"
className="btn btn-primary"
type="submit"
disabled={submitting}
style={{ width: '100%', justifyContent: 'center' }}
style={{ width: '100%', justifyContent: 'center', marginTop: 14 }}
>
{submitting ? '재설정 중...' : '비밀번호 재설정'}
</button>
</form>
로그인하러 가기
</Link>
</div>
);
}
@ -243,11 +184,11 @@ export default function FindAccountPage() {
className={`tab${tab === 'reset-password' ? ' active' : ''}`}
onClick={() => setTab('reset-password')}
>
비밀번호 재설정
비밀번호잊었어요
</button>
</div>
{tab === 'find-id' ? <FindIdTab /> : <ResetPasswordTab />}
{tab === 'find-id' ? <FindIdTab /> : <ForgotPasswordTab />}
<p className="muted" style={{ textAlign: 'center', fontSize: 14, marginTop: 16 }}>
<Link to="/login">로그인으로 돌아가기</Link>

View File

@ -16,6 +16,33 @@ function StudentProgressCard({ student }) {
const doneCount = ALL_COURSES.filter((c) => done.has(c.slug)).length;
const percent = total === 0 ? 0 : Math.round((doneCount / total) * 100);
// .
const [resetOpen, setResetOpen] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [resetMessage, setResetMessage] = useState('');
const [resetError, setResetError] = useState('');
const [resetting, setResetting] = useState(false);
async function resetPassword(e) {
e.preventDefault();
setResetError('');
setResetMessage('');
if (newPassword.length < 8) {
setResetError('8자 이상으로 정해 주세요.');
return;
}
setResetting(true);
try {
await client.post(`/mentor/users/${student.userId}/reset-password`, { newPassword });
setResetMessage(`재설정 완료 — 학생에게 직접 알려주고, 로그인 후 바꾸게 하세요.`);
setNewPassword('');
} catch {
setResetError('재설정 중 문제가 생겼어요.');
} finally {
setResetting(false);
}
}
return (
<div className="card" style={{ height: '100%' }}>
<div className="assign-title-row">
@ -49,6 +76,45 @@ function StudentProgressCard({ student }) {
);
})}
</div>
{/* .
학습 포인트: 기능은 원래 "아이디+이름만 알면 누구나" 있는 무인증 API였다.
보안 검토에서 계정 탈취 경로로 확인되어, 멘토 권한 뒤로 옮겼다.
본인 확인은 "학생 얼굴을 보고 확인한다" 오프라인 절차가 대신한다. */}
<div style={{ marginTop: 12, borderTop: '1px solid var(--line)', paddingTop: 10 }}>
{!resetOpen ? (
<button
className="btn btn-ghost"
style={{ fontSize: 13, padding: '4px 0' }}
onClick={() => setResetOpen(true)}
>
🔑 비밀번호 재설정
</button>
) : (
<form onSubmit={resetPassword}>
<div style={{ display: 'flex', gap: 6 }}>
<input
className="input"
type="text"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="새 비밀번호 (8자 이상)"
style={{ fontSize: 13 }}
/>
<button className="btn btn-primary" type="submit" disabled={resetting}>
{resetting ? '...' : '설정'}
</button>
<button className="btn btn-ghost" type="button" onClick={() => setResetOpen(false)}>
취소
</button>
</div>
{resetError && <p className="error-text" style={{ fontSize: 12.5 }}>{resetError}</p>}
{resetMessage && (
<p style={{ color: 'var(--teal)', fontSize: 12.5, marginTop: 6 }}>{resetMessage}</p>
)}
</form>
)}
</div>
</div>
);
}