관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다. 설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자 리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다. 경계값 SSoT 도입: - config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다. - 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다. 적용되지 않던 정책을 실제로 적용: - 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함) - 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로 - 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책, 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세 파생 결함: - 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고, 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다. - 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로 두고, 빈 목록 자체를 캐시하지 않는다. - 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다. - 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여). 재발 방지: - audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프) - 테스트 실행 범위 강제 훅(check-test-scope) - 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착) 공개 이슈: gnuboard/g7
487 lines
17 KiB
PHP
487 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Extension\IdentityVerification\Providers;
|
|
|
|
use App\Contracts\Extension\IdentityVerificationInterface;
|
|
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
|
|
use App\Enums\IdentityVerificationStatus;
|
|
use App\Extension\IdentityVerification\DTO\VerificationChallenge;
|
|
use App\Extension\IdentityVerification\DTO\VerificationResult;
|
|
use App\Models\User;
|
|
use App\Services\IdentityMessageDispatcher;
|
|
use Carbon\CarbonInterval;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* 코어 기본 본인인증 프로바이더 — 메일 채널.
|
|
*
|
|
* 두 가지 렌더링 힌트를 purpose 별로 분기합니다.
|
|
* - purpose=password_reset → render_hint=link, 기존 `password_reset_tokens` 흐름 재사용 가능
|
|
* - 그 외 purpose → render_hint=text_code, 6자리 코드를 발송
|
|
*
|
|
* 모든 challenge 는 identity_verification_logs 테이블에 기록됩니다.
|
|
*
|
|
* @since 7.0.0-beta.4
|
|
*/
|
|
class MailIdentityProvider implements IdentityVerificationInterface
|
|
{
|
|
/** @var int 인증코드 최소 길이 */
|
|
public const MIN_CODE_LENGTH = 4;
|
|
|
|
/** @var int 인증코드 최대 길이 */
|
|
public const MAX_CODE_LENGTH = 10;
|
|
|
|
/** @var int 인증코드 기본 길이 (경계 위반 시 폴백) */
|
|
public const DEFAULT_CODE_LENGTH = 6;
|
|
|
|
public const ID = 'g7:core.mail';
|
|
|
|
/**
|
|
* @param array $config withConfig() 로 전달되는 런타임 설정 (code_length, code_ttl_minutes, link_ttl_minutes 등)
|
|
*/
|
|
public function __construct(
|
|
protected IdentityVerificationLogRepositoryInterface $logRepository,
|
|
protected array $config = [],
|
|
) {}
|
|
|
|
/**
|
|
* 프로바이더 식별자를 반환합니다.
|
|
*
|
|
* @return string 프로바이더 ID
|
|
*/
|
|
public function getId(): string
|
|
{
|
|
return self::ID;
|
|
}
|
|
|
|
/**
|
|
* 프로바이더 표시 라벨을 반환합니다.
|
|
*
|
|
* @return string 다국어 라벨
|
|
*/
|
|
public function getLabel(): string
|
|
{
|
|
return __('identity.providers.mail.label');
|
|
}
|
|
|
|
/**
|
|
* 지원 채널 목록을 반환합니다.
|
|
*
|
|
* @return array<string> 채널 키 배열
|
|
*/
|
|
public function getChannels(): array
|
|
{
|
|
return ['email'];
|
|
}
|
|
|
|
/**
|
|
* 채널 키 → 다국어 표시 라벨 맵을 반환합니다.
|
|
*
|
|
* @return array<string, string> 채널 키 → 라벨 맵
|
|
*/
|
|
public function getChannelLabels(): array
|
|
{
|
|
return [
|
|
'email' => __('identity.channels.email'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 기본 렌더 힌트를 반환합니다.
|
|
*
|
|
* @return string 렌더 힌트 (text_code, link 등)
|
|
*/
|
|
public function getRenderHint(): string
|
|
{
|
|
// 기본값 — purpose 별 분기는 requestChallenge 내부에서 수행
|
|
return 'text_code';
|
|
}
|
|
|
|
/**
|
|
* 지정된 purpose 를 지원하는지 반환합니다.
|
|
*
|
|
* @param string $purpose 본인인증 목적
|
|
* @return bool 지원 여부
|
|
*/
|
|
public function supportsPurpose(string $purpose): bool
|
|
{
|
|
// 메일 프로바이더는 모든 코어·플러그인 purpose 를 범용 지원
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 프로바이더 사용 가능 여부를 반환합니다.
|
|
*
|
|
* @return bool 메일러 설정 존재 시 true
|
|
*/
|
|
public function isAvailable(): bool
|
|
{
|
|
$mailer = (string) config('mail.default', '');
|
|
|
|
return $mailer !== '';
|
|
}
|
|
|
|
/**
|
|
* 인증 챌린지를 발급하고 메일을 발송합니다.
|
|
*
|
|
* @param User|array $target 대상 사용자 또는 식별 정보
|
|
* @param array $context 요청 컨텍스트 (purpose, ip_address 등)
|
|
* @return VerificationChallenge 발급된 챌린지
|
|
*
|
|
* @throws \InvalidArgumentException 이메일이 비어있는 경우
|
|
*/
|
|
public function requestChallenge(User|array $target, array $context = []): VerificationChallenge
|
|
{
|
|
$email = $target instanceof User
|
|
? $target->email
|
|
: (string) ($target['email'] ?? '');
|
|
|
|
if ($email === '') {
|
|
throw new \InvalidArgumentException('MailIdentityProvider requires an email target.');
|
|
}
|
|
|
|
$purpose = (string) ($context['purpose'] ?? 'sensitive_action');
|
|
$renderHint = $this->resolveRenderHint($purpose);
|
|
$ttlMinutes = (int) config('settings.identity.challenge_ttl_minutes', 15);
|
|
$maxAttempts = (int) config('settings.identity.max_attempts', 5);
|
|
$targetHash = hash('sha256', mb_strtolower($email));
|
|
|
|
$metadata = [];
|
|
$publicPayload = [];
|
|
$code = null;
|
|
$linkToken = null;
|
|
|
|
if ($renderHint === 'text_code') {
|
|
$code = $this->generateNumericCode((int) ($this->config['code_length'] ?? 6));
|
|
$metadata['code_hash'] = Hash::make($code);
|
|
$publicPayload['code_length'] = strlen($code);
|
|
} else {
|
|
// link 흐름 — 수신자에게는 서명 링크 전달, 서버는 해시만 저장
|
|
$linkToken = Str::random(64);
|
|
$metadata['link_token_hash'] = Hash::make($linkToken);
|
|
$publicPayload['link_hint'] = 'email_link';
|
|
}
|
|
|
|
$expiresAt = Carbon::now()->add(CarbonInterval::minutes($ttlMinutes));
|
|
|
|
$log = $this->logRepository->create([
|
|
'provider_id' => self::ID,
|
|
'purpose' => $purpose,
|
|
'channel' => 'email',
|
|
'user_id' => $target instanceof User ? $target->id : null,
|
|
'target_hash' => $targetHash,
|
|
'status' => IdentityVerificationStatus::Requested->value,
|
|
'render_hint' => $renderHint,
|
|
'attempts' => 0,
|
|
'max_attempts' => $maxAttempts,
|
|
'ip_address' => $context['ip_address'] ?? null,
|
|
'user_agent' => $context['user_agent'] ?? null,
|
|
'origin_type' => $context['origin_type'] ?? null,
|
|
'origin_identifier' => $context['origin_identifier'] ?? null,
|
|
'origin_policy_key' => $context['origin_policy_key'] ?? null,
|
|
'properties' => $context['properties'] ?? null,
|
|
'metadata' => $metadata,
|
|
'expires_at' => $expiresAt,
|
|
]);
|
|
|
|
$sent = $this->dispatchMessage(
|
|
email: $email,
|
|
purpose: $purpose,
|
|
renderHint: $renderHint,
|
|
challengeId: $log->id,
|
|
policyKey: $context['origin_policy_key'] ?? null,
|
|
code: $code,
|
|
linkToken: $linkToken,
|
|
ttlMinutes: $ttlMinutes,
|
|
expiresAt: $expiresAt,
|
|
);
|
|
|
|
$this->logRepository->updateById($log->id, [
|
|
'status' => $sent ? IdentityVerificationStatus::Sent->value : IdentityVerificationStatus::Failed->value,
|
|
]);
|
|
|
|
return new VerificationChallenge(
|
|
id: $log->id,
|
|
providerId: self::ID,
|
|
purpose: $purpose,
|
|
channel: 'email',
|
|
targetHash: $targetHash,
|
|
expiresAt: $expiresAt,
|
|
renderHint: $renderHint,
|
|
publicPayload: $publicPayload,
|
|
metadata: [],
|
|
maxAttempts: $maxAttempts,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 사용자가 제출한 코드/토큰을 검증합니다.
|
|
*
|
|
* @param string $challengeId 챌린지 ID
|
|
* @param array $input 사용자 입력 (code 또는 token)
|
|
* @param array $context 검증 컨텍스트
|
|
* @return VerificationResult 검증 결과
|
|
*/
|
|
public function verify(string $challengeId, array $input, array $context = []): VerificationResult
|
|
{
|
|
$log = $this->logRepository->findById($challengeId);
|
|
|
|
if (! $log) {
|
|
return VerificationResult::failure($challengeId, self::ID, 'NOT_FOUND', 'identity.errors.challenge_not_found');
|
|
}
|
|
|
|
if ($log->provider_id !== self::ID) {
|
|
return VerificationResult::failure($challengeId, self::ID, 'WRONG_PROVIDER', 'identity.errors.wrong_provider');
|
|
}
|
|
|
|
if (in_array($log->status, [
|
|
IdentityVerificationStatus::Verified->value,
|
|
IdentityVerificationStatus::Expired->value,
|
|
IdentityVerificationStatus::Cancelled->value,
|
|
], true)) {
|
|
return VerificationResult::failure($challengeId, self::ID, 'INVALID_STATE', 'identity.errors.invalid_state');
|
|
}
|
|
|
|
if ($log->isExpired()) {
|
|
$this->logRepository->updateById($log->id, [
|
|
'status' => IdentityVerificationStatus::Expired->value,
|
|
]);
|
|
|
|
return VerificationResult::failure($challengeId, self::ID, 'EXPIRED', 'identity.errors.expired');
|
|
}
|
|
|
|
if ($log->attempts >= $log->max_attempts) {
|
|
return VerificationResult::failure($challengeId, self::ID, 'MAX_ATTEMPTS', 'identity.errors.max_attempts');
|
|
}
|
|
|
|
$storedHash = $log->metadata['code_hash'] ?? $log->metadata['link_token_hash'] ?? null;
|
|
$provided = (string) ($input['code'] ?? $input['token'] ?? '');
|
|
|
|
$this->logRepository->updateById($log->id, [
|
|
'attempts' => $log->attempts + 1,
|
|
]);
|
|
|
|
if ($storedHash === null || ! Hash::check($provided, $storedHash)) {
|
|
// 이번 오답으로 max_attempts 에 도달하면 잠금(Failed)으로 전환하고, 사용자에게
|
|
// 일반 오답 안내 대신 "최대 시도 초과 + 재요청" 안내(MAX_ATTEMPTS)를 반환한다.
|
|
// 프론트 모달은 도달 즉시 확인 버튼을 비활성화하므로 추가 시도를 기대할 수 없어,
|
|
// 막 소진된 이 응답에서 안내하지 않으면 max_attempts 문구가 사용자에게 영영 도달하지 못한다.
|
|
if (($log->attempts + 1) >= $log->max_attempts) {
|
|
$this->logRepository->updateById($log->id, [
|
|
'status' => IdentityVerificationStatus::Failed->value,
|
|
]);
|
|
|
|
return VerificationResult::failure($challengeId, self::ID, 'MAX_ATTEMPTS', 'identity.errors.max_attempts');
|
|
}
|
|
|
|
return VerificationResult::failure($challengeId, self::ID, 'INVALID_CODE', 'identity.errors.invalid_code');
|
|
}
|
|
|
|
$verifiedAt = Carbon::now();
|
|
$verificationToken = $this->generateVerificationToken($log->purpose, $log->target_hash);
|
|
|
|
$this->logRepository->updateById($log->id, [
|
|
'status' => IdentityVerificationStatus::Verified->value,
|
|
'verified_at' => $verifiedAt,
|
|
'verification_token' => $verificationToken,
|
|
]);
|
|
|
|
return VerificationResult::success(
|
|
challengeId: $challengeId,
|
|
providerId: self::ID,
|
|
verifiedAt: $verifiedAt,
|
|
identityHash: null, // 메일 프로바이더는 PII 정규화 식별자 없음 (KCP/이니시스에서만 반환)
|
|
claims: ['verification_token' => $verificationToken],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 챌린지를 취소 상태로 전환합니다.
|
|
*
|
|
* @param string $challengeId 챌린지 ID
|
|
* @return bool 성공 여부
|
|
*/
|
|
public function cancel(string $challengeId): bool
|
|
{
|
|
return $this->logRepository->updateById($challengeId, [
|
|
'status' => IdentityVerificationStatus::Cancelled->value,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 프로바이더 설정 스키마를 반환합니다.
|
|
*
|
|
* @return array 설정 필드 정의 배열
|
|
*/
|
|
public function getSettingsSchema(): array
|
|
{
|
|
return [
|
|
'code_length' => [
|
|
'label' => __('identity.providers.mail.settings.code_length'),
|
|
'type' => 'integer',
|
|
'default' => self::DEFAULT_CODE_LENGTH,
|
|
// 경계는 스키마가 선언한다 — 서비스가 자체 상수로 조용히 클램프하면
|
|
// 관리자가 설정한 값이 화면 안내와 다르게 동작한다.
|
|
'min' => self::MIN_CODE_LENGTH,
|
|
'max' => self::MAX_CODE_LENGTH,
|
|
'help' => __('identity.providers.mail.settings.code_length_help'),
|
|
],
|
|
'from_address' => [
|
|
'label' => __('identity.providers.mail.settings.from_address'),
|
|
'type' => 'string',
|
|
'default' => null,
|
|
'help' => __('identity.providers.mail.settings.from_address_help'),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 런타임 설정을 병합한 프로바이더 인스턴스를 반환합니다.
|
|
*
|
|
* @param array $config 병합할 설정 배열
|
|
* @return static 설정이 병합된 새 인스턴스
|
|
*/
|
|
public function withConfig(array $config): static
|
|
{
|
|
$clone = clone $this;
|
|
$clone->config = array_merge($this->config, $config);
|
|
|
|
return $clone;
|
|
}
|
|
|
|
protected function resolveRenderHint(string $purpose): string
|
|
{
|
|
return $purpose === 'password_reset' ? 'link' : 'text_code';
|
|
}
|
|
|
|
/**
|
|
* 지정 길이의 숫자 인증코드를 생성합니다.
|
|
*
|
|
* 경계를 벗어난 값은 조용히 클램프하지 않습니다 — 클램프는 관리자가 설정한 값과
|
|
* 실제 동작을 어긋나게 만듭니다. 다만 런타임 인증 흐름을 중단시키는 것은 위험하므로
|
|
* 예외 대신 경고 로그 + 스키마 기본값 폴백으로 처리합니다.
|
|
*
|
|
* @param int $length 요청 코드 길이
|
|
* @return string 생성된 숫자 코드
|
|
*/
|
|
protected function generateNumericCode(int $length): string
|
|
{
|
|
if ($length < self::MIN_CODE_LENGTH || $length > self::MAX_CODE_LENGTH) {
|
|
Log::warning('IDV 인증코드 길이가 허용 범위를 벗어나 기본값으로 대체됨', [
|
|
'provider_id' => self::ID,
|
|
'requested' => $length,
|
|
'min' => self::MIN_CODE_LENGTH,
|
|
'max' => self::MAX_CODE_LENGTH,
|
|
'applied' => self::DEFAULT_CODE_LENGTH,
|
|
]);
|
|
|
|
$length = self::DEFAULT_CODE_LENGTH;
|
|
}
|
|
|
|
$code = '';
|
|
for ($i = 0; $i < $length; $i++) {
|
|
$code .= (string) random_int(0, 9);
|
|
}
|
|
|
|
return $code;
|
|
}
|
|
|
|
protected function generateVerificationToken(string $purpose, string $targetHash): string
|
|
{
|
|
return hash_hmac(
|
|
'sha256',
|
|
$purpose.'|'.$targetHash.'|'.Str::uuid()->toString(),
|
|
(string) config('app.key', 'fallback-secret')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* IDV 전용 메시지 디스패처를 통해 메일을 발송합니다.
|
|
*
|
|
* @param string $renderHint text_code | link
|
|
* @param string|null $code text_code 흐름 시 평문 인증 코드
|
|
* @param string|null $linkToken link 흐름 시 서명 링크용 raw 토큰
|
|
* @return bool 발송 성공 여부
|
|
*/
|
|
protected function dispatchMessage(
|
|
string $email,
|
|
string $purpose,
|
|
string $renderHint,
|
|
string $challengeId,
|
|
?string $policyKey,
|
|
?string $code,
|
|
?string $linkToken,
|
|
int $ttlMinutes,
|
|
Carbon $expiresAt,
|
|
): bool {
|
|
try {
|
|
$actionUrl = $linkToken !== null
|
|
? $this->buildSignedLink($challengeId, $linkToken, $expiresAt)
|
|
: null;
|
|
|
|
return app(IdentityMessageDispatcher::class)->dispatch(
|
|
providerId: self::ID,
|
|
purpose: $purpose,
|
|
policyKey: $policyKey,
|
|
renderHint: $renderHint,
|
|
channel: 'mail',
|
|
target: $email,
|
|
data: [
|
|
'code' => $code,
|
|
'action_url' => $actionUrl,
|
|
'expire_minutes' => $ttlMinutes,
|
|
'purpose_label' => $this->resolvePurposeLabel($purpose),
|
|
'app_name' => (string) config('app.name'),
|
|
'site_url' => (string) config('app.url'),
|
|
'recipient_email' => $email,
|
|
],
|
|
context: [
|
|
'challenge_id' => $challengeId,
|
|
'render_hint' => $renderHint,
|
|
],
|
|
);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('[IDV] Mail dispatch failed', [
|
|
'email' => $email,
|
|
'purpose' => $purpose,
|
|
'policy_key' => $policyKey,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* link 흐름용 서명 링크를 생성합니다.
|
|
*/
|
|
protected function buildSignedLink(string $challengeId, string $linkToken, Carbon $expiresAt): string
|
|
{
|
|
try {
|
|
return URL::temporarySignedRoute(
|
|
'api.identity.challenges.verify',
|
|
$expiresAt,
|
|
['challenge_id' => $challengeId, 'token' => $linkToken],
|
|
);
|
|
} catch (\Throwable) {
|
|
// 라우트 미존재 환경(테스트 등)에서는 경로 + query 로 fallback
|
|
return rtrim((string) config('app.url'), '/').'/identity/verify?challenge='.$challengeId.'&token='.$linkToken;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* purpose 라벨(다국어)을 현재 로케일 문자열로 해석합니다.
|
|
*/
|
|
protected function resolvePurposeLabel(string $purpose): string
|
|
{
|
|
$key = 'identity.purposes.'.$purpose.'.label';
|
|
$translated = __($key);
|
|
|
|
return is_string($translated) && $translated !== $key ? $translated : $purpose;
|
|
}
|
|
}
|