Files
Gnuboard7/app/Services/AuthService.php
T
2026-04-01 10:30:52 +09:00

494 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Contracts\Repositories\PasswordResetTokenRepositoryInterface;
use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Contracts\Repositories\UserConsentRepositoryInterface;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Enums\ConsentType;
use App\Enums\UserStatus;
use App\Extension\HookManager;
use App\Models\User;
use App\Notifications\Auth\PasswordChangedNotification;
use App\Notifications\Auth\ResetPasswordNotification;
use App\Notifications\Auth\WelcomeNotification;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class AuthService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private RoleRepositoryInterface $roleRepository,
private UserConsentRepositoryInterface $userConsentRepository,
private PasswordResetTokenRepositoryInterface $passwordResetTokenRepository
) {}
/**
* 환경설정의 토큰 유지시간 기반으로 만료 시간을 계산합니다.
*
* @return \DateTimeInterface|null 만료 시간 (0이면 null로 무한대)
*/
private function getTokenExpiresAt(): ?\DateTimeInterface
{
// 환경설정에서 토큰 유지시간 조회 (분 단위, 기본값 30분)
$lifetime = (int) g7_core_settings('security.auth_token_lifetime', 30);
// 0이면 무한대 (만료 없음)
if ($lifetime === 0) {
return null;
}
return now()->addMinutes($lifetime);
}
/**
* Accept-Language 헤더에서 국가 코드를 추출합니다.
*
* @return string|null ISO 3166-1 alpha-2 국가 코드 (2자리 대문자)
*/
private function detectCountryFromAcceptLanguage(): ?string
{
$acceptLanguage = request()->header('Accept-Language');
if (empty($acceptLanguage)) {
return null;
}
// Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8 형식 파싱
// 첫 번째 언어에서 국가 코드 추출 시도
if (preg_match('/^([a-z]{2})-([A-Z]{2})/', $acceptLanguage, $matches)) {
return $matches[2];
}
// 언어 코드만 있는 경우 (ko, en, ja 등) → 기본 국가 매핑
if (preg_match('/^([a-z]{2})/', $acceptLanguage, $matches)) {
$languageToCountry = [
'ko' => 'KR',
'en' => 'US',
'ja' => 'JP',
'zh' => 'CN',
'de' => 'DE',
'fr' => 'FR',
'es' => 'ES',
];
return $languageToCountry[$matches[1]] ?? null;
}
return null;
}
/**
* 사용자를 로그인시키고 인증 토큰을 발급합니다.
*
* @param string $email 사용자 이메일
* @param string $password 사용자 비밀번호
* @return array 사용자 정보와 토큰을 포함한 배열
*
* @throws ValidationException 인증 정보가 올바르지 않을 때
*/
public function login(string $email, string $password): array
{
if (! Auth::attempt(['email' => $email, 'password' => $password])) {
throw ValidationException::withMessages([
'email' => [__('auth.invalid_credentials')],
]);
}
$user = Auth::user();
// 사용자 상태 체크 - Active만 로그인 허용
if ($user->status !== UserStatus::Active->value) {
Auth::logout();
$messageKey = match ($user->status) {
UserStatus::Inactive->value => 'auth.account_inactive',
UserStatus::Blocked->value => 'auth.account_blocked',
UserStatus::Withdrawn->value => 'auth.account_withdrawn',
default => 'auth.invalid_credentials',
};
throw ValidationException::withMessages([
'email' => [__($messageKey)],
]);
}
$token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
// 세션에 사용자 저장 (/dev 대시보드 인증용)
// StartApiSession 미들웨어가 세션을 시작한 경우에만 동작
if (request()->hasSession() && request()->session()->isStarted()) {
Auth::guard('web')->login($user);
}
// Hook 발생 (로그인 완료)
HookManager::doAction('core.auth.after_login', $user, [
'email' => $email,
'login_time' => now(),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return [
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
];
}
/**
* 새로운 사용자를 등록하고 인증 토큰을 발급합니다.
*
* @param array $data 사용자 등록 데이터
* @return array 사용자 정보와 토큰을 포함한 배열
*/
public function register(array $data): array
{
$now = now();
$userData = [
'name' => $data['name'],
'nickname' => $data['nickname'] ?? null,
'email' => $data['email'],
'password' => Hash::make($data['password']),
'language' => $data['language'] ?? 'ko',
'country' => $this->detectCountryFromAcceptLanguage(),
'ip_address' => request()->ip(),
];
$user = $this->userRepository->create($userData);
// 약관 동의 이력 기록
$this->recordConsents($user, $data, $now);
// 'user' 역할 자동 할당 (UserService 패턴과 동일)
$userRole = $this->roleRepository->findByIdentifier('user');
if ($userRole) {
$user->roles()->sync([$userRole->id]);
}
$token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
// Hook 발생 (회원가입 완료)
HookManager::doAction('core.auth.register', $user, [
'registration_time' => now(),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
// 회원가입 완료 알림 발송
try {
$user->notify(new WelcomeNotification);
} catch (\Exception $e) {
Log::error('회원가입 완료 알림 발송 실패', [
'user_id' => $user->id,
'email' => $user->email,
'error' => $e->getMessage(),
]);
}
return [
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
];
}
/**
* 사용자를 로그아웃시키고 현재 디바이스의 인증 토큰만 삭제합니다.
*
* @param User $user 로그아웃할 사용자
*/
public function logout(User $user): void
{
// Hook 발생 (로그아웃 시작)
HookManager::doAction('core.auth.before_logout', $user);
// 토큰 삭제 처리
$currentToken = $user->currentAccessToken();
// Case 1: 토큰만 보낸 경우 - currentAccessToken()이 PersonalAccessToken 반환
if ($currentToken instanceof \Laravel\Sanctum\PersonalAccessToken) {
$currentToken->delete();
}
// Case 2: 쿠키 + 토큰을 함께 보낸 경우 또는 TransientToken
// Authorization 헤더에서 Bearer 토큰을 직접 추출하여 삭제
else {
$bearerToken = request()->bearerToken();
if ($bearerToken && str_contains($bearerToken, '|')) {
// plainTextToken 형식: {tokenId}|{actualToken}
// DB에는 actualToken 부분만 해시되어 저장됨
$parts = explode('|', $bearerToken, 2);
if (count($parts) === 2) {
$hashedToken = hash('sha256', $parts[1]);
$personalAccessToken = $user->tokens()
->where('token', $hashedToken)
->first();
if ($personalAccessToken) {
$personalAccessToken->delete();
}
}
}
}
// 세션 무효화 (StartApiSession 미들웨어가 세션을 시작한 경우)
if (request()->hasSession() && request()->session()->isStarted() && Auth::guard('web')->check()) {
Auth::guard('web')->logout();
request()->session()->invalidate();
request()->session()->regenerateToken();
}
// Hook 발생 (로그아웃 완료)
HookManager::doAction('core.auth.logout', $user);
}
/**
* 사용자의 인증 토큰을 갱신합니다.
*
* @param User $user 토큰을 갱신할 사용자
* @return array 새로운 토큰 정보
*/
public function refreshToken(User $user): array
{
// 현재 토큰 삭제 (다른 디바이스는 유지)
$currentToken = $user->currentAccessToken();
// PersonalAccessToken만 삭제 (TransientToken은 세션 기반이므로 삭제 불필요)
if ($currentToken instanceof \Laravel\Sanctum\PersonalAccessToken) {
$currentToken->delete();
}
// 새 토큰 생성
$token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
return [
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
];
}
/**
* 모든 디바이스에서 사용자를 로그아웃시킵니다.
*
* @param User $user 로그아웃할 사용자
*/
public function logoutFromAllDevices(User $user): void
{
// Hook 발생 (모든 디바이스 로그아웃 완료)
HookManager::doAction('auth.logout_all_devices', $user);
// 사용자의 모든 토큰 삭제
$user->tokens()->delete();
}
/**
* 비밀번호 찾기 요청을 처리합니다.
*
* @param string $email 사용자 이메일
* @param string|null $redirectPrefix 리다이렉트 경로 접두사 (예: 'admin')
*
* @throws ValidationException 등록되지 않은 이메일일 때
*/
public function forgotPassword(string $email, ?string $redirectPrefix = null): void
{
$user = $this->userRepository->findByEmail($email);
if (! $user) {
throw ValidationException::withMessages([
'email' => [__('auth.email_not_registered')],
]);
}
// 토큰 생성 (64자 랜덤 문자열)
$token = Str::random(64);
// 기존 토큰 삭제 후 새 토큰 저장 (해시로 저장)
$this->passwordResetTokenRepository->updateOrCreateByEmail($email, [
'token' => Hash::make($token),
'created_at' => now(),
]);
// 비밀번호 재설정 알림 발송
try {
$user->notify(new ResetPasswordNotification($token, $redirectPrefix));
} catch (\Exception $e) {
Log::error('비밀번호 재설정 이메일 발송 실패', [
'email' => $email,
'error' => $e->getMessage(),
]);
throw ValidationException::withMessages([
'email' => [__('auth.email_send_failed')],
]);
}
// Hook 발생 (비밀번호 찾기 완료)
HookManager::doAction('core.auth.forgot_password', $user);
}
/**
* 비밀번호 재설정 토큰을 검증합니다.
*
* @param string $token 비밀번호 재설정 토큰
* @param string $email 사용자 이메일
* @return array{valid: bool, error?: string}
*/
public function validateResetToken(string $token, string $email): array
{
// 1. 사용자 존재 확인
$user = $this->userRepository->findByEmail($email);
if (! $user) {
return [
'valid' => false,
'error' => __('auth.email_not_registered'),
];
}
// 2. 토큰 레코드 조회
$record = $this->passwordResetTokenRepository->findByEmail($email);
if (! $record) {
return [
'valid' => false,
'error' => __('auth.reset_token_invalid'),
];
}
// 3. 토큰 해시 검증
if (! Hash::check($token, $record->token)) {
return [
'valid' => false,
'error' => __('auth.reset_token_invalid'),
];
}
// 4. 만료 시간 체크
$expireMinutes = config('auth.passwords.users.expire', 60);
if ($record->created_at->addMinutes($expireMinutes)->isPast()) {
$record->delete();
return [
'valid' => false,
'error' => __('auth.reset_token_expired'),
];
}
return ['valid' => true];
}
/**
* 사용자의 비밀번호를 재설정합니다.
*
* @param string $token 비밀번호 재설정 토큰
* @param string $email 사용자 이메일
* @param string $password 새로운 비밀번호
*
* @throws ValidationException 등록되지 않은 이메일이거나 토큰이 유효하지 않을 때
*/
public function resetPassword(string $token, string $email, string $password): void
{
$user = $this->userRepository->findByEmail($email);
if (! $user) {
throw ValidationException::withMessages([
'email' => [__('auth.email_not_registered')],
]);
}
// 토큰 조회
$record = $this->passwordResetTokenRepository->findByEmail($email);
if (! $record) {
throw ValidationException::withMessages([
'token' => [__('auth.reset_token_invalid')],
]);
}
// 토큰 검증 (해시 비교)
if (! Hash::check($token, $record->token)) {
throw ValidationException::withMessages([
'token' => [__('auth.reset_token_invalid')],
]);
}
// 만료 시간 체크 (기본 60분)
$expireMinutes = config('auth.passwords.users.expire', 60);
if ($record->created_at->addMinutes($expireMinutes)->isPast()) {
// 만료된 토큰 삭제
$record->delete();
throw ValidationException::withMessages([
'token' => [__('auth.reset_token_expired')],
]);
}
// Hook 발생 (비밀번호 재설정 시작)
HookManager::doAction('core.auth.before_reset_password', $user);
// 비밀번호 업데이트
$this->userRepository->update($user, [
'password' => Hash::make($password),
]);
// 사용된 토큰 삭제
$record->delete();
// 비밀번호 변경 완료 알림 발송
try {
$user->notify(new PasswordChangedNotification);
} catch (\Exception $e) {
// 알림 발송 실패는 로그만 남기고 계속 진행 (비밀번호는 이미 변경됨)
Log::error('비밀번호 변경 완료 알림 발송 실패', [
'user_id' => $user->id,
'email' => $user->email,
'error' => $e->getMessage(),
]);
}
// Hook 발생 (비밀번호 재설정 완료)
HookManager::doAction('core.auth.reset_password', $user);
}
/**
* 회원가입 시 약관 동의 이력을 기록합니다.
*
* 코어 타입(terms, privacy)을 기록하고,
* core.auth.record_consents 훅으로 플러그인 확장 동의 처리를 허용합니다.
*
* @param User $user 가입 완료된 사용자
* @param array $data 요청 데이터
* @param Carbon $agreedAt 동의 일시
*/
private function recordConsents(User $user, array $data, Carbon $agreedAt): void
{
$ip = request()->ip();
$coreConsents = [
ConsentType::Terms->value,
ConsentType::Privacy->value,
];
foreach ($coreConsents as $type) {
$this->userConsentRepository->record([
'user_id' => $user->id,
'consent_type' => $type,
'agreed_at' => $agreedAt,
'ip_address' => $ip,
]);
}
// 플러그인 확장 동의 처리 (마케팅 등 추가 동의)
HookManager::doAction('core.auth.record_consents', $user, $data, $agreedAt, $ip);
}
}