Auth 모듈
이 문서는 검토 중입니다. 내용이 변경될 수 있습니다.
Sanctum 기반 인증 서비스를 제공하는 Core 모듈입니다.
개요
Auth 모듈은 Laravel Sanctum을 래핑한 인증 서비스를 제공합니다. 세션 기반 웹 인증과 토큰 기반 API 인증을 모두 지원하며, Interface 기반 설계로 확장이 용이합니다.
┌─────────────────────────────────────────────────────────────┐
│ Auth 모듈 │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Web 인증 │ │ API 인증 │ │ Token 관리 │ │
│ │ (Session) │ │ (Sanctum) │ │ │ │
│ └──────┬──────┘ └─── ───┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼─────────────────┘ │
│ ↓ │
│ AuthService │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ ↓ ↓ ↓ │
│ Permission Tenant Audit │
│ (레벨 확인) (컨텍스트) (로그 기록) │
└─────────────────────────────────────────────────────────────┘
핵심 컴포넌트
| 컴포넌트 | 역할 |
|---|---|
AuthService | 인증 로직 통합 서비스 |
AuthServiceInterface | 테스트 용이성을 위한 인터페이스 |
UserAuthenticated | 로그인 성공 이벤트 |
UserLoggedOut | 로그아웃 이벤트 |
TokenCreated | API 토큰 생성 이벤트 |
AuthService 사용법
의존성 주입
<?php
namespace App\Http\Controllers;
use App\Core\Base\Auth\Contracts\AuthServiceInterface;
class AuthController extends Controller
{
public function __construct(
private AuthServiceInterface $authService
) {}
public function showLoginForm()
{
if ($this->authService->check()) {
return redirect('/dashboard');
}
return view('auth.login');
}
}
기본 인증
// 로그인 시도
$user = $this->authService->login([
'email' => $request->email,
'password' => $request->password,
], $request->boolean('remember'));
if ($user) {
return redirect('/dashboard');
}
return back()->withErrors(['email' => '인증에 실패했습니다.']);
로그아웃
public function logout()
{
$this->authService->logout();
return redirect('/');
}
웹 인증 (Session 기반)
로그인 컨트롤러 예시
<?php
namespace App\Http\Controllers\Auth;
use App\Core\Base\Auth\Contracts\AuthServiceInterface;
use App\Http\Requests\LoginRequest;
use Illuminate\Http\Request;
class LoginController extends Controller
{
public function __construct(
private AuthServiceInterface $authService
) {}
public function login(LoginRequest $request)
{
$user = $this->authService->login(
$request->only('email', 'password'),
$request->boolean('remember')
);
if (!$user) {
return back()
->withInput($request->only('email'))
->withErrors(['email' => '이메일 또는 비밀번호가 올바르지 않습니다.']);
}
// 인증 가능 여부 확인 (이메일 인증, 계정 활성화 등)
if (!$this->authService->canAuthenticate($user)) {
$this->authService->logout();
return back()->withErrors(['email' => '이 계정으로 로그인할 수 없습니다.']);
}
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}
public function logout(Request $request)
{
$this->authService->logout();
return redirect('/');
}
}
Remember Me
// Remember 옵션 활성화
$user = $this->authService->login($credentials, remember: true);
// Remember 쿠키 설정 커스 터마이징 (config/auth.php)
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
],
API 인증 (Token 기반)
토큰 발급
<?php
namespace App\Http\Controllers\Api;
use App\Core\Base\Auth\Contracts\AuthServiceInterface;
use App\Http\Requests\Api\LoginRequest;
class AuthController extends Controller
{
public function __construct(
private AuthServiceInterface $authService
) {}
public function login(LoginRequest $request)
{
$user = $this->authService->attempt($request->only('email', 'password'));
if (!$user) {
return response()->json([
'message' => '인증에 실패했습니다.',
], 401);
}
// 토큰 생성
$token = $this->authService->createToken(
user: $user,
name: $request->device_name ?? 'api-token',
abilities: ['*'] // 모든 권한
);
return response()->json([
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
]);
}
public function logout()
{
$user = $this->authService->user();
if ($user) {
// 현재 토큰만 폐기
$token = $this->authService->currentAccessToken();
if ($token && method_exists($token, 'delete')) {
$token->delete();
}
}
return response()->json(['message' => '로그아웃되었습니다.']);
}
public function user()
{
return response()->json($this->authService->user());
}
}
토큰 권한 (Abilities)
// 제한된 권한으로 토큰 생성
$readOnlyToken = $this->authService->createToken(
user: $user,
name: 'read-only-token',
abilities: ['read:users', 'read:products']
);
// 특정 권한으로 토큰 생성
$adminToken = $this->authService->createToken(
user: $user,
name: 'admin-token',
abilities: ['*'] // 모든 권한
);
// 라우트에서 권한 확인
Route::middleware(['auth:sanctum', 'abilities:read:users'])->group(function () {
Route::get('/users', [UserController::class, 'index']);
});
Route::middleware(['auth:sanctum', 'ability:write:users'])->group(function () {
Route::post('/users', [UserController::class, 'store']);
});
토큰 관리
// 모든 토큰 폐기 (비밀번호 변경 시 등)
$this->authService->revokeAllTokens($user);
// 특정 토큰 폐기
$this->authService->revokeToken($user, $tokenId);
// 현재 토큰 정보
$currentToken = $this->authService->currentAccessToken();
API 라우트 설정
// routes/api.php
use Illuminate\Support\Facades\Route;
// 공개 API
Route::post('/auth/login', [AuthController::class, 'login']);
Route::post('/auth/register', [AuthController::class, 'register']);
// 인증 필요 API
Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::get('/auth/user', [AuthController::class, 'user']);
// 테넌트 컨텍스트 필요
Route::middleware('tenant.context')->group(function () {
Route::apiResource('products', ProductController::class);
Route::apiResource('orders', OrderController::class);
});
});
인증 이벤트
Auth 모듈은 주요 인증 이벤트를 발행합니다.
UserAuthenticated 이벤트
use App\Core\Base\Auth\Events\UserAuthenticated;
// 이벤트 리스너 등록 (EventServiceProvider)
protected $listen = [
UserAuthenticated::class => [
RecordLoginActivity::class,
UpdateLastLoginTimestamp::class,
],
];
// 리스너 예시
class RecordLoginActivity
{
public function handle(UserAuthenticated $event): void
{
$user = $event->user;
$remember = $event->remember;
Log::info("User {$user->id} logged in", [
'remember' => $remember,
'ip' => request()->ip(),
]);
}
}
UserLoggedOut 이벤트
use App\Core\Base\Auth\Events\UserLoggedOut;
class ClearUserSession
{
public function handle(UserLoggedOut $event): void
{
// 사용자 캐시 정리
Cache::forget("user.{$event->user->id}.permissions");
}
}
TokenCreated 이벤트
use App\Core\Base\Auth\Events\TokenCreated;
class NotifyTokenCreation
{
public function handle(TokenCreated $event): void
{
// 보안 알림 발송
$event->user->notify(new ApiTokenCreatedNotification($event->name));
}
}
설정 (config/core.php)
'auth' => [
// 기본 가드
'guard' => env('CORE_AUTH_GUARD', 'web'),
// 이메일 인증 필요 여부
'require_email_verification' => env('CORE_REQUIRE_EMAIL_VERIFICATION', false),
// 비밀번호 정책
'password' => [
'min_length' => 8,
'require_uppercase' => true,
'require_lowercase' => true,
'require_numbers' => true,
'require_symbols' => false,
],
// 세션 설정
'session' => [
'lifetime' => 120, // 분
'expire_on_close' => false,
],
// API 토큰 설정
'api_token' => [
'expiration' => null, // null = 만료 없음, 분 단위
],
],
AuthService 메서드 레퍼런스
| 메서드 | 설명 | 반환값 |
|---|---|---|
attempt($credentials) | 인증 시도 (로그인 X) | ?UserInterface |
login($credentials, $remember) | 로그인 | ?UserInterface |
logout() | 로그아웃 | void |
user() | 현재 사용자 | ?UserInterface |
check() | 인증 여부 | bool |
guest() | 게스트 여부 | bool |
id() | 사용자 ID | ?int |
createToken($user, $name, $abilities) | API 토큰 생성 | string |
revokeAllTokens($user) | 모든 토큰 폐기 | void |
revokeToken($user, $tokenId) | 특정 토큰 폐기 | void |
currentAccessToken() | 현재 토큰 | ?object |
validate($credentials) | 자격 증명 검증 | bool |
loginUser($user, $remember) | 사용자 직접 로그인 | void |
verifyPassword($user, $password) | 비밀번호 확인 | bool |
canAuthenticate($user) | 인증 가능 여부 | bool |
비밀번호 확인
비밀번호 변경이나 민감한 작업 전 현재 비밀번호 확인:
public function changePassword(Request $request)
{
$user = $this->authService->user();
// 현재 비밀번호 확인
if (!$this->authService->verifyPassword($user, $request->current_password)) {
return back()->withErrors([
'current_password' => '현재 비밀번호가 올바르지 않습니다.',
]);
}
// 새 비밀번호 설정
$user->password = Hash::make($request->new_password);
$user->save();
// 다른 세션/토큰 폐기 (선택)
$this->authService->revokeAllTokens($user);
return back()->with('success', '비밀번호가 변경되었습니다.');
}
Permission과 연동
Auth 모듈은 Permission 모듈과 함께 사용됩니다.
// 인증 + 레벨 확인
Route::middleware(['auth', 'level:2'])->group(function () {
// Tenant Admin 이상만 접근
Route::resource('settings', SettingController::class);
});
// API 인증 + 레벨 확인
Route::middleware(['auth:sanctum', 'level:1'])->group(function () {
// SaaS Admin 이상만 접근
Route::get('/admin/tenants', [TenantController::class, 'index']);
});
테스트 작성
웹 인증 테스트
use App\Models\User;
test('사용자가 로그인할 수 있다', function () {
$user = User::factory()->create([
'password' => bcrypt('password123'),
]);
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
});
test('잘못된 비밀번호로 로그인할 수 없다', function () {
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors('email');
$this->assertGuest();
});
test('로그아웃하면 인증이 해제된다', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/logout')
->assertRedirect('/');
$this->assertGuest();
});
API 인증 테스트
test('API 토큰으로 인증된 요청을 할 수 있다', function () {
$user = User::factory()->create();
$token = $user->createToken('test-token')->plainTextToken;
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/user');
$response->assertOk()
->assertJsonPath('id', $user->id);
});
test('토큰 없이 보호된 API에 접근할 수 없다', function () {
$response = $this->getJson('/api/user');
$response->assertUnauthorized();
});
test('API 로그인 후 토큰을 받을 수 있다', function () {
$user = User::factory()->create([
'password' => bcrypt('password123'),
]);
$response = $this->postJson('/api/auth/login', [
'email' => $user->email,
'password' => 'password123',
'device_name' => 'test-device',
]);
$response->assertOk()
->assertJsonStructure([
'user' => ['id', 'name', 'email'],
'token',
'token_type',
]);
});
AuthService 단위 테스트
use App\Core\Base\Auth\Contracts\AuthServiceInterface;
test('AuthService로 사용자 로그인할 수 있다', function () {
$authService = app(AuthServiceInterface::class);
$user = User::factory()->create([
'password' => bcrypt('password123'),
]);
$result = $authService->login([
'email' => $user->email,
'password' => 'password123',
]);
expect($result)->not->toBeNull()
->and($result->id)->toBe($user->id)
->and($authService->check())->toBeTrue();
});
흔한 실수와 해결
1. 세션 재생성 누락
// ❌ 잘못된 예: 세션 재생성 없음
public function login(Request $request)
{
$this->authService->login($request->only('email', 'password'));
return redirect('/dashboard');
}
// ✅ 올바른 예: 세션 재생성으로 세션 고정 공격 방지
public function login(Request $request)
{
$this->authService->login($request->only('email', 'password'));
$request->session()->regenerate();
return redirect('/dashboard');
}
2. API와 Web Guard 혼동
// ❌ 잘못된 예: API 컨트롤러에서 web guard 사용
Route::middleware('auth')->group(function () {
Route::get('/api/user', ...); // web guard 적용
});
// ✅ 올바른 예: API에는 sanctum guard 사용
Route::middleware('auth:sanctum')->group(function () {
Route::get('/api/user', ...); // sanctum guard 적용
});
3. 토큰 노출
// ❌ 잘못된 예: 토큰을 로그에 기록
Log::info("Token created: {$token}");
// ✅ 올바른 예: 토큰 정보만 기록
Log::info("Token created for user {$user->id}", [
'token_name' => $name,
'abilities' => $abilities,
]);
Cross-Domain SSO (전용 도메인 진입)
ADR-040. Platform Admin 이 SaaS 별 전용 도메인의 admin 패널로 안전하게 진입하기 위한 1회용 nonce 기반 SSO 입니다. Core 모듈
Base/Auth/CrossDomain/에 구현되어 있으며, symlink 로 모든 프로젝트가 공유합니다.
배경 — Multi-Tab Cross-SaaS 세션 오염
Multi-SaaS 환경에서 Platform Admin (Level 0) 은 여러 SaaS 의 admin 패널을 오갈 수 있습니다. 그런데 Platform Admin 은 RLS(행 수준 격리) 를 bypass 하는 권한이라, 한 브라우저에서 여러 탭을 열고 SaaS 를 전환하면 세션이 서로 덮어쓰여 다른 SaaS 의 데이터를 잘못 다룰 위험(cross-SaaS 세션 오염) 이 있습니다.
이 문제는 SaaS 마다 전용 도메인 을 두고, 도메인별로 브라우저 쿠키가 자동 분리되도록 하면 해결됩니다. 다만 플랫폼 도메인과 다른 도메인으로 진입할 때 로그인 상태를 자연스럽게 이어주려면 SSO 가 필요합니다.
두 가지 진입 메커니즘
Platform Admin 의 SaaS 진입은 두 메커니즘이 공존 합니다. 둘 다 SaasProductResource 의 record action 으로 제공됩니다.
| 메커니즘 | 액션 | 동작 | 격리 |
|---|---|---|---|
| Same-domain 세션 전환 | goToSaas ("SaaS 관리") | 같은 도메인에서 session(current_saas_product_id) 만 변경 후 /saas 로 이동 | Multi-tab 시 세션 덮어쓰기 가능 |
| Cross-domain SSO | goToSaasOnDomain ("전용 도메인으로 열기") | 1회용 토큰 발급 후 SaaS 전용 도메인의 consume endpoint 로 redirect | 도메인별 쿠키 분리로 multi-tab 완전 격리 |
goToSaasOnDomain 액션은 도메인이 등록된 SaaS (settings.domains 가 비어있지 않음) 에만, Level 0 사용자에게만 노출됩니다. 도메인이 등록되지 않은 SaaS 는 액션 자체가 보이지 않으며, 운영자는 기존 "SaaS 관리" 를 사용합니다.
1회용 nonce 토큰 흐름 (issue → consume)
Core 기본 구현은 SimpleCrossDomainSsoService 이며 CrossDomainSsoInterface (메서드 issue / consume) 를 구현합니다. 토큰은 Laravel Cache 에 저장되는 64자리 16진수(256-bit) nonce 로, 1회용, host 바인딩, 짧은 TTL(기본 60초) 입니다.
[Platform Admin 도메인] [Target SaaS 전용 도메인]
Filament "전용 도메인으로 열기" 클릭
↓
goToSaasOnDomain action
- Level 0 검증
- app(CrossDomainSsoInterface::class)->issue(userId, saasProductId, domain)
↓
SimpleCrossDomainSsoService::issue
- Level 0 재검증 + 도메인 형식 검증
- 64-hex nonce 발급 (cache key: sso:cross-domain:{token})
- Cache::put(payload, ttl=60s)
- Event: CrossDomainSsoIssued
- AuditLog: sso.cross_domain.issued
↓
redirect → https://{domain}/sso/cross-domain/consume?token={64hex}
↓
GET /sso/cross-domain/consume
(name: sso.cross-domain.consume, web 미들웨어)
↓
CrossDomainSsoController::consume
- HTTPS 검사 + 토큰 regex 검증
- SsoService::consume(token, request->getHost())
· Cache get + 즉시 forget (1회용)
· host 바인딩 검증
- User::find(payload.user_id) + Level 0 재검증
- Auth::login + session(current_saas_product_id)
- Event: CrossDomainSsoConsumed
- AuditLog: sso.cross_domain.consumed
↓
redirect → /saas
Multi-Tab 격리 효과: a.example.com/saas 는 도메인 a.example.com 쿠키, b.example.com/saas 는 도메인 b.example.com 쿠키를 사용합니다. 브라우저가 도메인별로 쿠키를 자동 분리하므로 두 탭의 SaaS 세션이 서로 간섭하지 않습니다.
보안 다층 방어
발급(issue) 과 소비(consume) 모두 UI → Service → Controller 여러 계층에서 권한을 재검증합니다. 어느 한 계층이 우회되어도 다음 계층에서 차단됩니다.
| 계층 | 방어 내용 |
|---|---|
UI (Filament visible) | Level 0 + settings.domains 등록된 SaaS 만 액션 노출 |
| Service::issue | Level 0 재검증 + 대상 도메인 형식 검증(정규식). 실패 시 RuntimeException |
| 토큰 | 64-hex(256-bit) nonce, Cache TTL 60초, 1회용 |
| Service::consume | 토큰 형식 regex 확인 + Cache hit + 즉시 Cache::forget(replay 방지) + host 바인딩 검증(issue 시 저장한 도메인과 현재 요청 host 일치 확인) |
| Controller::consume | payload 의 user_id 로 사용자를 다시 조회해 Level 0 재검증(payload 위조 방어) |
CrossDomainSsoController::consume 은 모든 실패 사유를 동일하게 처리합니다. HTTPS 누락, 토큰 형식 오류, 만료/조작, 사용 자 부재, 비-Level0 등 어떤 실패든 외부 응답은 sso_failed 로 통일되고(정보 누설 방지), 구체적 사유는 sso.cross_domain.failed 감사 로그에만 기록됩니다.
주요 위협별 방어:
| 위협 | 방어 |
|---|---|
| Token URL 노출 (history / log / Referer) | 60초 TTL + 1회용 |
| MITM | HTTPS 강제 (force_https, SESSION_SECURE_COOKIE=true) |
| Token 탈취 후 다른 도메인 재사용 | host 바인딩 검증 |
| Replay | consume 시 즉시 Cache::forget |
| 토큰 brute-force | 256-bit nonce — 추측 불가 |
| 비-Level0 의 발급 / consume / payload 위조 | UI + Service + Controller 다층 검증 |
감사 로그 이벤트
발급/소비/실패가 모두 감사 로그로 남습니다.
| 이벤트 | 발생 시점 | 메타데이터 |
|---|---|---|
sso.cross_domain.issued | issue() 성공 | target_saas_product_id, target_domain, token_prefix(앞 8자), ttl_seconds |
sso.cross_domain.consumed | consume() 성공 | saas_product_id, domain, ip |
sso.cross_domain.failed | consume() 실패 | reason, domain, ip, saas_product_id(payload 가 있을 때) |
도메인 이벤트로 CrossDomainSsoIssued, CrossDomainSsoConsumed 가 발행되므로, 외부 알림(Slack 등) 이나 SIEM 연동 리스너를 프로젝트에서 등록할 수 있습니다.
외부 서비스를 호출하는 리스너는 ShouldQueue 구현을 권장합니다. 동기(sync) 실행 시 consume 응답이 지연되고, 외부 서비스 장애가 인증 실패로 이어질 수 있습니다.
설정 (config/core.php · .env)
설정은 config/core.php 의 auth.cross_domain_sso 에 있습니다.
'auth' => [
// ...
'cross_domain_sso' => [
'enabled' => env('CORE_CROSS_DOMAIN_SSO_ENABLED', true),
'ttl_seconds' => (int) env('CORE_CROSS_DOMAIN_SSO_TTL', 60),
'cache_prefix' => 'sso:cross-domain:',
'allow_levels' => [0], // Platform Admin only
'audit' => env('CORE_CROSS_DOMAIN_SSO_AUDIT', true),
'force_https' => env('CORE_CROSS_DOMAIN_SSO_FORCE_HTTPS', true),
],
],
주요 환경 변수:
| 환경 변수 | 기본값 | 역할 |
|---|---|---|
CORE_CROSS_DOMAIN_SSO_ENABLED | true | 기능 on/off. false 시 Core 가 라우트 등록 자체를 스킵하고, Service 는 issue 시 예외 / consume 시 null |
CORE_CROSS_DOMAIN_SSO_TTL | 60 | 토큰 TTL(초) |
CORE_CROSS_DOMAIN_SSO_AUDIT | true | 감사 로그 기록 여부 |
CORE_CROSS_DOMAIN_SSO_FORCE_HTTPS | true | redirect URL 의 scheme 을 항상 https 로 고정 + consume 시 비-HTTPS 요청 차단. local/testing 에서만 false |
운영 환경에서는 HTTPS 가 사실상 필수입니다.
SESSION_SECURE_COOKIE=true # HTTPS 전용 쿠키
APP_URL=https://... # https 스킴
TRUSTED_PROXIES=... # 리버스 프록시(NPM/CDN)
외부 IdP (OAuth / SAML) 확장
Core 기본 구현(SimpleCrossDomainSsoService) 은 Cache nonce 방식입니다. Google / Microsoft / SAML 같은 외부 IdP 연동은 Plugin 으로 분리 하여, CrossDomainSsoInterface 를 구현한 다른 서비스로 binding 을 교체하는 방식을 권장합니다.
// Plugin 의 ServiceProvider::register
$this->app->singleton(
CrossDomainSsoInterface::class,
OauthCrossDomainSsoService::class,
);
binding 만 교체하면 UI(Filament 액션) / Controller / 라우트는 그대로 재사용됩니다. 토큰 형식(예: 64-hex → JWT) 변경도 같은 방식으로 처리합니다.
관련 문서
- Permission 모듈 - 레벨별 접근 제어
- Tenant 모듈 - 테넌트 컨텍스트
- Audit 모듈 - 인증 활동 로깅
- 보안 베스트 프랙티스 - 인증 보안