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:
parent
241b784e1e
commit
c5c4a5b1f9
@ -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()) // 그 외(정적 파일 등)는 공개
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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을 던진다.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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만 내려간다.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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 />} />
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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) {
|
||||
|
||||
134
frontend/src/pages/ChangePasswordPage.jsx
Normal file
134
frontend/src/pages/ChangePasswordPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user