배경
이메일 인증 시스템에서는 악의적인 사용자를 막기 위해 일일 인증 횟수를 5회로 제한하고 있다. 하지만 이러한 제한은 정상적인 사용자에게도 영향을 미칠 수 있기에 필요한 경우 CS팀이 사용자의 인증 횟수를 초기화할 수 있는 기능이 필요했다.
처음에는 DataGrip이나 Redis CLI 등을 사용해 Redis 데이터에 직접 접근하여 값을 수정하는 방안을 고려했다. 하지만 이 방법은 다음과 같은 문제가 있었다.
- 데이터베이스 직접 접근에 따른 보안 위험
- 작업 내역 추적 불가능
- 실수로 인한 데이터 손상 가능성
관리자 API
API 설계
우선 관리자 인증 방식에 대해 다음과 같은 논의가 있었다.
- 별도의 인증 없이 사용하는 방법
- Request Body로 관리자 비밀번호를 전달하는 방법
- Request Header로 API Key를 전달하는 방법
첫 번째 방법은 별도의 인증 없이 누구나 API에 접근할 수 있어 보안상 부적절하다고 판단하여 제외했다. 나머지 방법 중에서 논의 결과, 아래와 같은 이유로 Header에 API Key를 포함하는 세 번째 방법을 채택했다.
- REST API의 표준적인 인증 방식을 준수한다.
- 매 요청마다 비밀번호를 입력할 필요가 없다.
- 인터셉터를 통해 인증 로직을 분리하기가 용이하다.
어디에서 관리할까?
이후 해당 API를 관리할 위치를 고민하였다. 처음에는 이메일 인증을 관련 API가 모인 Verification 컨트롤러에 인증 초기화 API를 위치시키려고 했다. 하지만 접근 제어와 확장성 측면에서 별도의 Admin 컨트롤러를 두는 것이 좋을 것이라고 판단하였다. 실제로 이후에 Admin 컨트롤러에 유저 삭제, 환불, 캐시 워밍과 같이 다양한 관리자용 API가 추가되었다.
설정
.env
1
ADMIN_API_KEY=your_api_key
application.yml
1 2 3
api: admin: key: ${ADMIN_API_KEY}
AdminApiKeyInterceptor.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
class AdminApiKeyInterceptor(private val adminApiKey: String) : HandlerInterceptor { companion object { private const val API_KEY_HEADER = "X-API-Key" } override fun preHandle( request: HttpServletRequest, response: HttpServletResponse, handler: Any ): Boolean { val apiKey = request.getHeader(API_KEY_HEADER) ?: throw ApiKeyNotFoundException() if (apiKey != adminApiKey) { throw ApiKeyInvalidException() } return true } }
WebConfig.kt
1 2 3 4 5 6 7 8 9 10
@Configuration class WebConfig : WebMvcConfigurer { @Value("\${api.admin.key}") private lateinit var adminApiKey: String override fun addInterceptors(registry: InterceptorRegistry) { registry .addInterceptor(AdminApiKeyInterceptor(adminApiKey)) .addPathPatterns("/api/admin/**") } }
SecurityConfig.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
@Configuration @EnableWebSecurity class SecurityConfig( private val jwtAuthenticationFilter: JwtAuthenticationFilter, @Qualifier("handlerExceptionResolver") private val resolver: HandlerExceptionResolver, ) { @Bean @Throws(Exception::class) fun filterChain(http: HttpSecurity): SecurityFilterChain? { http.authorizeHttpRequests { it.requestMatchers( ... "/api/admin/**" ) // 토큰 검사 미실시 리스트 .permitAll() } http.addFilterBefore( jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java ) return http.build() }
API 구현
API 엔드포인트:
POST /api/admin/verification/send-email/reset
AdminApi.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
@RestController @RequestMapping("/api/admin") class AdminApi( private val adminService: AdminService, private val requestUtils: RequestUtils, ) { @PostMapping("/verification/send-email/reset") fun resetEmailSendCount( request: HttpServletRequest, @RequestBody body: ResetEmailRequest ): ResponseEntity<Unit> { val requestInfo = requestUtils.toRequestInfoDto(request) adminService.resetEmailSendCount(body.email, requestInfo) return ResponseEntity.status(HttpStatus.NO_CONTENT).build() } }
AdminService.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14
@Service class AdminService( private val redisTemplate: RedisTemplate<String, Any>, ) { fun resetEmailSendCount(email: String, requestInfo: RequestInfoDto) { val sendCountKey = VerificationUtils.generateRedisKey(VerificationConstants.SEND_COUNT_PREFIX, email, true) redisTemplate.opsForValue().set(sendCountKey, "0") redisTemplate.expire(sendCountKey, Duration.ofDays(1)) logger.info("[ADMIN-이메일 발송 횟수 초기화] targetEmail: $email, $requestInfo") } }
보안 고려사항
- API Key는 환경 변수로 관리하여 코드에 노출되지 않도록 하였다.
- 모든 관리자 작업에 대해 로깅을 하여 추적성을 확보하였다.
1
logger.info("[ADMIN-이메일 발송 횟수 초기화] targetEmail: $email, $requestInfo")
1
2024-12-13T00:58:24.037+09:00 INFO 96508 --- [nio-8081-exec-3] u.s.admin.service.AdminService: [ADMIN-이메일 발송 횟수 초기화] targetEmail: example@uos.ac.kr, ip: 0:0:0:0:0:0:0:1, userAgent: PostmanRuntime/7.43.0