진행 중인 프로젝트의 관리자에 2차 인증 기능이 필요하게 되어 OTP 생성 및 검증을 통해 2차 인증을 구현했다.
해당 프로젝트는 관리자와 사용자가 공통으로 적용된 필터에서 로그인을 처리하고 있기 때문에 OTP 인증 필터를 구현하여 공통 로그인 필터 앞에 추가했다.
공통 로그인 필터
AbstractAuthenticationProcessingFilter
- authenticationManager를 필요로 하는 필터로, request Endpoint가 requestMatcher와 같으면 요청을 가로채서 인증 수행을 시도한다.
- attemptAuthentication() 메소드에서 requestBody의 검증과 authenticationManager를 통해 인증을 수행한다.
public class CustomUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = "/api/v1/login";
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HttpMethod.POST.name());
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 검증
if (request.getContentType() == null || !request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) {
throw new AuthenticationServiceException("요청 정보가 올바르지 않습니다.");
}
RequestLogin requestLogin;
try {
requestLogin = objectMapper.readValue(StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8), RequestLogin.class);
} catch (IOException e) {
throw new AuthenticationServiceException("요청 정보가 올바르지 않습니다.");
}
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(requestLogin.getUserId(), requestLogin.getPassword());
setDetails(request, authRequest);
// 인증 수행
return this.getAuthenticationManager().authenticate(authRequest);
}
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
}
OTP 인증 필터
- 필터에서 인증 과정을 수행하기 때문에 Spring Context 범위 안의 CustomExceptionHandler를 사용하지 못해서, try-catch 문으로 검증 및 인증 실패 시 에러 응답을 반환하게 했다.
public class CustomOtpAuthenticationFilter extends GenericFilterBean {
private final AdminRepository adminRepository;
private final OtpService otpService;
private final ObjectMapper objectMapper;
private String userInfoCheckPath = "/api/v1/login/info";
private String otpAuthenticationPath = "/api/v1/login/otp";
private String otpValidatePath = "/api/v1/login";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
/**
* 로그인 > 정보 확인 (Step 1)
*/
if (HttpMethod.POST.name().equalsIgnoreCase(req.getMethod()) && userInfoCheckPath.equals(req.getServletPath())) {
RequestLogin requestBody = objectMapper.readValue(StreamUtils.copyToString(req.getInputStream(), StandardCharsets.UTF_8), RequestLogin.class);
try {
Admin admin = adminRepository.findByUserId(requestBody.getUserId())
.orElseThrow(() -> new CustomException(ErrorCode.EMPTY_DATA.getErrorMsg()));
ResponseCheckUserInfoDto responseCheckUserInfoDto = ResponseCheckUserInfoDto.builder()
.phoneNumber(admin.getPhoneNumber())
.build();
successResponse(response, responseCheckUserInfoDto);
} catch (Exception e) {
errorResponse(response, e.getMessage());
}
}
/**
* 로그인 > 인증번호 전송 (Step 2)
*/
else if (HttpMethod.POST.name().equalsIgnoreCase(req.getMethod()) && otpAuthenticationPath.equals(req.getServletPath())) {
RequestLogin requestBody = objectMapper.readValue(StreamUtils.copyToString(req.getInputStream(), StandardCharsets.UTF_8), RequestLogin.class);
try {
Admin admin = adminRepository.findByUserId(requestBody.getUserId())
.orElseThrow(() -> new CustomException(ErrorCode.EMPTY_DATA.getErrorMsg()));
otpService.sendOtp(RequestSendOtpDto.builder().phoneNumber(requestBody.getPhoneNumber()).build(), ADMIN);
successResponse(response);
} catch (Exception e) {
errorResponse(response, e.getMessage());
}
}
/**
* 로그인 > 최종 로그인 (Step 3)
*/
else if (HttpMethod.POST.name().equalsIgnoreCase(req.getMethod()) && otpValidatePath.equals(req.getServletPath())) {
// HttpServletRequest InputStream을 재사용하기 위한 Wrapper Class
final CustomServletRequestWrapper requestWrapper = new CustomServletRequestWrapper(req);
RequestLogin requestBody = objectMapper.readValue(StreamUtils.copyToString(requestWrapper.getInputStream(), StandardCharsets.UTF_8), RequestLogin.class);
try {
Admin admin = adminRepository.findByUserId(requestBody.getUserId())
.orElseThrow(() -> new CustomException(ErrorCode.EMPTY_DATA.getErrorMsg()));
RequestCheckOtpDto otpValidateDto = RequestCheckOtpDto.builder()
.phoneNumber(admin.getPhoneNumber())
.otp(requestBody.getOtp())
.build();
if (otpService.checkOtp(otpValidateDto, ADMIN)) {
// 인증 성공 시 공통 로그인 Filter 호출
chain.doFilter(requestWrapper, response);
}
} catch (Exception e) {
errorResponse(response, e.getMessage());
}
}
/**
* 로그인 외의 모든 요청
*/
else {
chain.doFilter(request, response);
}
}
}
HttpServletRequestWrapper
- Request의 InputStream은 한번 읽으면 다시 읽을 수 없게 되는데, OTP 인증 필터, 공통 로그인 필터에서 RequestBody를 필요로 하기 때문에 CustomServletRequestWrapper를 구현하여 InputStream을 재사용 가능하게 했다.
public class CustomServletRequestWrapper extends HttpServletRequestWrapper {
private ByteArrayOutputStream cachedBytes;
public CustomServletRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public ServletInputStream getInputStream() throws IOException {
if (cachedBytes == null)
cacheInputStream();
return new CachedServletInputStream();
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
private void cacheInputStream() throws IOException {
cachedBytes = new ByteArrayOutputStream();
IOUtils.copy(super.getInputStream(), cachedBytes);
}
public class CachedServletInputStream extends ServletInputStream {
private ByteArrayInputStream input;
public CachedServletInputStream() {
input = new ByteArrayInputStream(cachedBytes.toByteArray());
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener listener) {
}
@Override
public int read() throws IOException {
return input.read();
}
}
}
Spring Security에서 공통 로그인 필터 앞단에 OTP 인증 필터를 배치하여 관리자 로그인 시 OTP 인증 후 로그인이 가능하게 되었다.
로그인 기능이 필터로 구현되어 있어서 우선적으로 이뤄져야 하는 OTP 인증 역시 필터로 구현하게 되었는데, 필터에서 Exception이 발생하면 필터에서 처리해줘야 했기 때문에 다음 프로젝트에서는 로그인 기능을 Spring Context 범위 내에서 구현할 것 같다.
'Spring Boot' 카테고리의 다른 글
[Spring Boot] Redisson 분산 락 (0) | 2024.06.09 |
---|---|
[Spring Boot] MapStruct (0) | 2024.04.26 |
[Spring Boot] @AuthenticationPrincipal 사용해서 사용자 정보 조회 (0) | 2023.12.25 |