Files
Gnuboard7/app/Http/Controllers/Api/Auth/AuthController.php
T
HeuJung 7b56d5f23a feat(core,board,ecommerce,page,ckeditor5,kginicis): 업로드 이미지 축소·로그인 2단계 인증 도입 + 파생 결함 수정
인터뷰에서 방향만 정하고 남겨 뒀던 후속 5건을 구현하고, 커밋 전 종합 점검에서
발견한 결함 2건을 함께 고쳤다.

기능 도입:
- 업로드 이미지 축소 — `upload.image_max_width` / `image_max_height` / `image_quality`
 는 저장·검증만 되고 리사이즈 코드가 0건이었다. 비율 유지, 상한 이내면 재인코딩하지
 않음(불필요한 화질 손실 방지), 한계 미설정이면 무동작(기존 사이트 이미지 보호).
 PNG·GIF·WebP 투명도 보존. 실패해도 업로드는 계속되고 원본이 저장된다.
- 로그인 2단계 인증 — `security.two_factor_auth` 는 설정 항목만 있고 구현도 화면
 컨트롤도 없었다. 코어 IDV 인프라를 재사용해 purpose `login` 을 추가하고, 비밀번호
 확인 통과 후 challenge 만 발행한다(토큰 미발급 + logout). `POST /api/auth/login/two-factor`
 가 코드를 확인해야 토큰이 나가며, 로그인과 같은 요청 제한을 적용한다. challenge 의
 purpose 를 대조해 다른 흐름의 challenge 로 로그인하는 것을 막는다. 코드 발송이
 실패하면 실패로 알린다 — 2단계를 건너뛰고 로그인시키면 통제가 조용히 열린다.

결함 수정:
- 게시글 수정 시 사용자 경로가 `attachment_ids` 를 Service 로 넘기지 않아, 검증과
 권한은 통과하고 200 을 받는데 첨부만 조용히 연결되지 않았다. 관리자 경로는 넘기고
 있어 같은 요청이 화면에 따라 다르게 동작했다.
- 설정 화면 라디오가 `pointer-events-none` + Label click 으로만 상태를 바꿔, 키보드
 방향키(change) 조작 시 화면 표시와 저장될 값이 어긋났다. 동일 패턴 4개 레이아웃
 12개 라디오에 change 액션을 부여하고 params 를 click 과 동일하게 고정한다.
- 주문완료 화면이 결제수단 확인보다 영수증 조회를 앞세워, PG 를 거치지 않는 주문에서도
 매번 404 요청이 나갔다.
- 개별 게시판 폼의 숫자 항목에 경계값 바인딩이 없어 환경설정 화면과 안내가 달랐다.

커밋 전 점검에서 발견:
- 2단계 인증이 반쪽으로 들어가 있었다. enum 에 case 만 추가하고 코어 purpose
 레지스트리와 ko/en 라벨을 빠뜨려, 목적 목록 API 에서 통째로 빠지고 `hasPurpose`
 가 false 였으며 라벨 대신 i18n 키 원문이 반환됐다. 관리자가 이 목적의 메시지
 템플릿·정책을 만들 수 없는 상태였다. 세 곳을 enum 기준으로 전수 대조하는 회귀
 테스트를 함께 둔다.
- 업로드 이미지 축소가 적용되지 않은 8번째 서비스(레이아웃 편집기 첨부)가 있었다.
 열거형 커버리지 테스트는 내 조사 결과를 green 으로 고정할 뿐 전수를 증명하지
 못한다 — 같은 규약을 조건으로 표현한 audit 룰이 첫 실행에서 검출했다.

재발 방지:
- audit 룰 `upload-service-image-resize` (신규 업로드 서비스의 축소 누락 검출)
- 트러블슈팅 사례 20 (코어 설정이 일부 경로에만 적용되는 증상의 진단 절차)
- `sirsoft-ckeditor5` 의 코어 최소 요구 버전을 7.0.6 으로 상향 (신설 클래스 의존)

공개 이슈: gnuboard/g7
2026-07-30 07:57:06 +09:00

258 lines
9.4 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Auth;
use App\Exceptions\Auth\AccountLockedException;
use App\Http\Controllers\Api\Base\AuthBaseController;
use App\Http\Requests\Auth\AuthenticatedRequest;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
use App\Http\Requests\Auth\ValidateResetTokenRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class AuthController extends AuthBaseController
{
public function __construct(
private AuthService $authService
) {
// 부모 생성자 호출하지 않음 - 인증 미들웨어를 수동으로 설정
// parent::__construct();
// 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용
$this->middleware('auth:sanctum')->except([
'login',
// 2단계 인증 확인은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
'verifyTwoFactor',
'register',
'forgotPassword',
'resetPassword',
'validateResetToken',
]);
}
/**
* 사용자를 로그인시킵니다.
*
* @param LoginRequest $request 로그인 요청 데이터
* @return JsonResponse 로그인 결과와 사용자 정보, 토큰을 포함한 JSON 응답
*/
public function login(LoginRequest $request): JsonResponse
{
try {
$data = $this->authService->login(
$request->validated()['email'],
$request->validated()['password']
);
// 2단계 인증이 켜져 있으면 아직 토큰이 없다 — 인증 코드 확인 단계로 안내한다
if ($data['two_factor_required'] ?? false) {
return $this->success('auth.two_factor_required', $data);
}
// 사용자 정보는 Resource로, 토큰은 그대로
$data['user'] = new UserResource($data['user']);
return $this->success('auth.login_success', $data);
} catch (AccountLockedException $e) {
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
return $this->error(
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
423,
[
'locked_until' => $e->lockedUntil?->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
'permanent' => $e->isPermanent(),
],
['minutes' => $e->remainingMinutes]
);
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
}
/**
* 2단계 인증 코드를 확인하고 로그인을 완료합니다.
*
* 비밀번호 확인 단계(`login`)는 토큰 대신 challenge 를 돌려주며, 이 엔드포인트가
* 코드 확인에 성공해야 비로소 토큰이 발급됩니다.
*
* @param TwoFactorChallengeRequest $request challenge 확인 요청
* @return JsonResponse 로그인 결과와 사용자 정보, 토큰을 포함한 JSON 응답
*/
public function verifyTwoFactor(TwoFactorChallengeRequest $request): JsonResponse
{
$validated = $request->validated();
try {
$data = $this->authService->completeTwoFactor(
$validated['challenge_id'],
['code' => $validated['code']]
);
$data['user'] = new UserResource($data['user']);
return $this->success('auth.login_success', $data);
} catch (ValidationException $e) {
return $this->unauthorized('auth.two_factor_failed');
}
}
/**
* 새로운 사용자를 등록시킵니다.
*
* @param RegisterRequest $request 등록 요청 데이터
* @return JsonResponse 등록 결과와 사용자 정보, 토큰을 포함한 JSON 응답
*/
public function register(RegisterRequest $request): JsonResponse
{
try {
$data = $this->authService->register($request->validated());
// 사용자 정보는 Resource로, 토큰은 그대로
$data['user'] = new UserResource($data['user']);
return $this->success('auth.register_success', $data, 201);
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'auth.register_failed');
}
}
/**
* 사용자를 로그아웃시킵니다. (현재 디바이스만)
*
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logout(AuthenticatedRequest $request): JsonResponse
{
$this->authService->logout($request->user());
return $this->success('auth.logout_success');
}
/**
* 모든 디바이스에서 사용자를 로그아웃시킵니다.
*
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logoutFromAllDevices(AuthenticatedRequest $request): JsonResponse
{
$this->authService->logoutFromAllDevices($request->user());
return $this->success('auth.logout_all_devices_success');
}
/**
* 현재 로그인된 사용자의 정보를 반환합니다.
*
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 사용자 정보를 포함한 JSON 응답
*/
public function user(AuthenticatedRequest $request): JsonResponse
{
$user = $request->user();
// 역할 관계 로드 (권한은 역할을 통해 간접 연결)
$user->load(['roles.permissions']);
// toAuthArray(): core.user.filter_resource_data 필터를 적용해 모듈 필드(결제 통화 등)를 병합.
// 프론트 currentUser 출처라, 로그인 시 계정 영속 통화 덮어씀(D-LOGIN-CUR)을 충족한다.
return $this->success(
'common.success',
(new UserResource($user))->toAuthArray($request)
);
}
/**
* 사용자의 인증 토큰을 갱신합니다.
*
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 새로운 토큰과 사용자 정보를 포함한 JSON 응답
*/
public function refresh(AuthenticatedRequest $request): JsonResponse
{
$data = $this->authService->refreshToken($request->user());
// 사용자 정보는 Resource로, 토큰은 그대로
if (isset($data['user'])) {
$data['user'] = new UserResource($data['user']);
}
return $this->success('common.success', $data);
}
/**
* 비밀번호 재설정 토큰을 검증합니다.
*
* @param ValidateResetTokenRequest $request 토큰 검증 요청 데이터
* @return JsonResponse 토큰 유효성 검증 결과 JSON 응답
*/
public function validateResetToken(ValidateResetTokenRequest $request): JsonResponse
{
$validated = $request->validated();
$result = $this->authService->validateResetToken(
$validated['token'],
$validated['email']
);
if (! $result['valid']) {
return $this->validationError(
['token' => [$result['error']]],
'auth.reset_token_invalid'
);
}
return $this->success('common.success', $result);
}
/**
* 비밀번호 찾기 요청을 처리하고 인증 이메일을 발송합니다.
*
* @param ForgotPasswordRequest $request 비밀번호 찾기 요청 데이터
* @return JsonResponse 이메일 발송 결과 JSON 응답
*/
public function forgotPassword(ForgotPasswordRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$this->authService->forgotPassword(
$validated['email'],
$validated['redirect_prefix'] ?? null
);
return $this->success('auth.password_reset_email_sent');
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'auth.password_reset_failed');
}
}
/**
* 비밀번호를 재설정합니다.
*
* @param ResetPasswordRequest $request 비밀번호 재설정 요청 데이터
* @return JsonResponse 비밀번호 재설정 결과 JSON 응답
*/
public function resetPassword(ResetPasswordRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$this->authService->resetPassword(
$validated['token'],
$validated['email'],
$validated['password']
);
return $this->success('auth.password_reset_success');
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'auth.password_reset_failed');
}
}
}