Files
HeuJung cf1d8ab52c fix(security): 보안 제보 14건 + 유사 결함 전수조사 수정
sir.kr 커뮤니티 제보(Xbuilder 님 외)와 전수조사 발굴분을 코어·게시판·
이커머스·페이지·ckeditor5 전반에 반영.

- 통합 검색 하이라이트: 코어 SearchHighlighter 로 원문 이스케이프 후 강조
 (저장형 XSS 차단, board/ecommerce/page 3중복제 통합)
- 공개 최신글·인기글·관리자 대시보드: 읽기권한 없는 게시판·블라인드·비활성
 게시판 글 필터. 비밀글은 기존 공개 정책대로 제목 노출 유지(본문만 보호)
- 게시판 관리 대시보드 권한 게이트, 이커머스 쿠폰 다운로드·리뷰 대상·주문옵션
 확정 스코프 강화, 취소/환불 항목 검증 회원/비회원 대칭
- 만료 토큰 거부(SetLocale), 회원 탈퇴·정보관리 실패 시 내부 오류 원문 마스킹
- 예외→응답 매핑 정비(generic catch 상태코드), ckeditor 업로드 권한 게이트 정리

정책 정합 정정: 착수 계획이 "비밀글 제목 노출=취약점"으로 전제해 공개 최신글·
대시보드에 넣었던 is_secret 필터는, 저장소가 확정한 "제목 공개, 본문만 보호"
정책(2026-01-02/02-04/06-18/06-19)과 배치되어 되돌림. 읽기권한·블라인드 필터는 유지.
Chrome MCP 실측(T1~T10) + 회귀 테스트로 확인.
2026-08-17 15:46:38 +09:00

332 lines
11 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Auth;
use App\Enums\AttachmentSourceType;
use App\Exceptions\CannotDeleteSuperAdminException;
use App\Http\Controllers\Api\Base\AuthBaseController;
use App\Http\Requests\Auth\VerifyPasswordRequest;
use App\Http\Requests\User\ChangePasswordRequest;
use App\Http\Requests\User\UpdateProfileRequest;
use App\Http\Requests\User\UploadAvatarRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\AttachmentService;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
/**
* 사용자 프로필 관리 컨트롤러
*
* 일반 사용자가 자신의 프로필을 관리할 수 있는 기능을 제공합니다.
*/
class ProfileController extends AuthBaseController
{
public function __construct(
private UserService $userService,
private AttachmentService $attachmentService
) {
parent::__construct();
}
/**
* 현재 로그인한 사용자의 프로필 정보를 조회합니다.
*
* @return JsonResponse 사용자 프로필 정보를 포함한 JSON 응답
*/
public function show(): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
$this->logUserActivity('profile.show');
return $this->success(
'user.profile_success',
(new UserResource($user))->toProfileArray()
);
} catch (\Exception $e) {
return $this->error('user.profile_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 사용자 프로필을 업데이트합니다.
*
* @param UpdateProfileRequest $request 프로필 업데이트 요청 데이터
* @return JsonResponse 업데이트된 사용자 정보를 포함한 JSON 응답
*/
public function update(UpdateProfileRequest $request): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
$updatedUser = $this->userService->updateUser($user, $request->validated());
$this->logUserActivity('profile.update', $request->validated());
return $this->successWithResource(
'user.update_success',
new UserResource($updatedUser)
);
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'user.update_failed');
} catch (\Exception $e) {
return $this->error('user.update_failed', 500, $e->getMessage());
}
}
/**
* 사용자의 언어 설정을 변경합니다.
*
* @return JsonResponse 언어 변경 결과와 사용자 정보를 포함한 JSON 응답
*/
public function updateLanguage(): JsonResponse
{
try {
$user = $this->getCurrentUser();
$language = request('language');
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
if (! in_array($language, config('app.supported_locales', ['ko', 'en']), true)) {
return $this->error('user.invalid_language', 400);
}
$updatedUser = $this->userService->updateUser($user, ['language' => $language]);
$this->logUserActivity('profile.update_language', ['language' => $language]);
return $this->successWithResource(
'user.language_update_success',
new UserResource($updatedUser)
);
} catch (\Exception $e) {
return $this->error('user.language_update_failed', 500, $e->getMessage());
}
}
/**
* 사용자의 활동 기록을 조회합니다.
*
* @return JsonResponse 사용자 활동 기록을 포함한 JSON 응답
*/
public function activityLog(): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
$activities = $this->userService->getUserActivityLogs($user->id);
$this->logUserActivity('profile.activity_log');
return $this->success(
'user.activity_log_success',
['activities' => $activities]
);
} catch (\Exception $e) {
return $this->error('user.activity_log_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 사용자 아바타를 업로드합니다.
*
* attachments 테이블의 다형성 관계를 사용하여 아바타를 저장합니다.
*
* @param UploadAvatarRequest $request 아바타 업로드 요청 데이터
* @return JsonResponse 업로드 결과를 포함한 JSON 응답
*/
public function uploadAvatar(UploadAvatarRequest $request): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
// 기존 아바타 Attachment 삭제
$existingAttachment = $user->avatarAttachment;
if ($existingAttachment) {
$this->attachmentService->delete($existingAttachment->id);
}
// 새 Attachment 생성 (다형성 관계)
$attachment = $this->attachmentService->upload(
$request->file('avatar'),
User::class,
$user->id,
'avatar',
AttachmentSourceType::Core
);
$this->logUserActivity('profile.avatar.upload', [
'attachment_id' => $attachment->id,
]);
// 관계 새로고침
$user->load('avatarAttachment');
return $this->success(
'user.avatar_upload_success',
[
'avatar' => $user->getAvatarUrl(),
'attachment_id' => $attachment->id,
]
);
} catch (\Exception $e) {
return $this->error('user.avatar_upload_failed', 500, $e->getMessage());
}
}
/**
* 사용자 아바타를 삭제합니다.
*
* attachments 테이블의 다형성 관계를 사용하여 아바타를 삭제합니다.
*
* @return JsonResponse 삭제 결과를 포함한 JSON 응답
*/
public function deleteAvatar(): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
// 아바타 Attachment 확인
$attachment = $user->avatarAttachment;
if (! $attachment) {
return $this->error('user.avatar_not_found', 404);
}
// Attachment 삭제 (파일도 함께 삭제됨)
$this->attachmentService->delete($attachment->id);
$this->logUserActivity('profile.avatar.delete');
return $this->success('user.avatar_delete_success');
} catch (\Exception $e) {
return $this->error('user.avatar_delete_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 사용자 비밀번호를 확인합니다.
*
* @param Request $request 비밀번호 확인 요청 데이터
* @return JsonResponse 확인 결과를 포함한 JSON 응답
*/
public function verifyPassword(VerifyPasswordRequest $request): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
if (! Hash::check($request->password, $user->password)) {
return $this->error('user.password_incorrect', 401);
}
$this->logUserActivity('profile.verify_password');
return $this->success('user.password_verified');
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'user.password_verify_failed');
} catch (\Exception $e) {
return $this->error('user.password_verify_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 사용자 비밀번호를 변경합니다.
*
* @param ChangePasswordRequest $request 비밀번호 변경 요청 데이터
* @return JsonResponse 비밀번호 변경 결과를 포함한 JSON 응답
*/
public function changePassword(ChangePasswordRequest $request): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
// 새 비밀번호로 업데이트 (UserService에서 해싱 처리)
$this->userService->updateUser($user, [
'password' => $request->password,
]);
$this->logUserActivity('profile.change_password');
return $this->success('user.password_change_success');
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'user.password_change_failed');
} catch (\Exception $e) {
// 주의: IdentityVerificationRequiredException 은 \Error 자식이라 이 catch 에 잡히지 않음.
// 글로벌 핸들러가 자동으로 428 매핑 — 모든 컨트롤러 (코어/모듈/플러그인) 에 동일 보장.
return $this->error('user.password_change_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 사용자 계정을 탈퇴 처리합니다.
*
* @return JsonResponse 탈퇴 처리 결과를 포함한 JSON 응답
*/
public function destroy(): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
// 사용자 탈퇴 처리 (아바타, 토큰 삭제 및 익명화)
$this->userService->withdrawUser($user);
// 활동 로그는 성공한 뒤에만 남긴다 — 실패한 탈퇴가 기록으로 남으면
// 이력만 보고는 탈퇴한 것으로 읽힌다.
$this->logUserActivity('profile.withdraw');
return $this->success('user.withdraw_success');
} catch (ValidationException $e) {
// 관리자/수퍼관리자 탈퇴 차단은 요청이 잘못된 것이지 서버 오류가 아니다.
return $this->validationError($e->errors(), 'user.withdraw_failed');
} catch (CannotDeleteSuperAdminException $e) {
// 이 예외는 ValidationException 을 상속하지 않으므로 별도 매핑이 필요하다.
return $this->validationError(
['general' => [$e->getMessage()]],
'user.withdraw_failed'
);
} catch (\Exception $e) {
// 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다.
Log::error('User withdraw failed (profile)', ['exception' => $e]);
return $this->error('user.withdraw_failed', 500);
}
}
}