스프링 시큐리티는 여러 기능을 제공하는데 그 중 필터를 사용하여 2차 인증을 거친 후 로그인을 진행할 수 있다.
public class GenerateCode {
public static String generate() {
Random random = new Random();
StringBuilder result = new StringBuilder();
for(int i = 0; i < 6; i++) { // 총 6자리 난수 생성
result.append(random.nextInt(9)); // 0 ~ 9 까지의 숫자 랜덤으로 뽑기
}
return result.toString();
}
}
자바의 Random 클래스를 사용하여 6자리의 인증코드를 생성한다.
<form method="post" th:action="${secondaryAuth} ? '/loginProc' : '/secondaryAuth'">
<div class="form-floating mb-3">
<input class="form-control" id="inputId" name="username" type="text" required th:value="${secondaryAuth} ? ${username} : ''" />
<label for="inputId">아이디</label>
</div>
<div class="form-floating mb-3">
<input class="form-control" id="inputPassword" name="password" type="password" required th:value="${secondaryAuth} ? ${password} : ''" />
<label for="inputPassword">비밀번호</label>
</div>
<span th:if="${secondaryAuth}">
<p id="secondaryCheck" class="alert alert-info">메일함의 인증번호를 입력해주세요.</p>
</span>
<div class="form-floating mb-3" th:if="${secondaryAuth}"> <!-- 2차 인증을 요청하는 경우 -->
<input class="form-control" id="email-auth" name="email-auth" type="text" required />
<label for="email-auth">이메일 인증번호</label>
</div>
<span th:if="${error}">
<p id="valid" class="alert alert-danger" th:text="${exception}"></p>
</span>
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a class="small" href="/register">회원가입</a>
<input class="btn btn-primary" type="submit" value="로그인" />
</div>
</form>
타임리프를 사용하여 첫번째 인증의 경우 /secondaryAuth
로 연결되도록 설정하고 인증코드를 넣은 2차 인증의 경우 /loginProc
를 요청하도록 로그인 폼을 구성한다.
@PostMapping("/secondaryAuth")
public String loginProc(@ModelAttribute UserDto userDto) {
Optional<UserEntity> optionalUser = userRepository.findByUsername(userDto.getUsername());
if(optionalUser.isEmpty()) { // 회원 정보를 찾지 못하는 경우
errorMap.put("status", "error");
errorMap.put("error", "true");
errorMap.put("exception", "아이디 또는 비밀번호가 맞지 않습니다. 다시 확인해 주세요.");
}
else {
UserEntity user = optionalUser.get();
if(bCryptPasswordEncoder.matches(userDto.getPassword(), user.getPassword())) { // 비밀번호가 맞다면
secondaryAuthMap.put("status", "ok");
secondaryAuthMap.put("secondaryAuth", "auth");
secondaryAuthMap.put("username", userDto.getUsername());
secondaryAuthMap.put("password", userDto.getPassword());
try {
secondaryAuthService.sendSecondaryAuth(user.getEmail());
} catch (Exception e) {
errorMap.put("status", "error");
errorMap.put("error", "true");
errorMap.put("exception", "2차 로그인에 필요한 인증코드를 전송하는데 실패했습니다.");
secondaryAuthMap.put("status", "no");
}
}
else { // 비밀번호가 틀렸다면
errorMap.put("status", "error");
errorMap.put("error", "true");
errorMap.put("exception", "아이디 또는 비밀번호가 맞지 않습니다. 다시 확인해 주세요.");
}
}
return "redirect:/login";
}
2차 인증에 대한 요청이 들어오면 2차 인증에 대한 요청을 수행한 후 정보를 담아 로그인 폼으로 리다이렉션 시켜준다.
package pjh5365.linuxserviceweb.domain.auth.service;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import pjh5365.linuxserviceweb.domain.auth.GenerateCode;
import pjh5365.linuxserviceweb.domain.mail.Mail;
import pjh5365.linuxserviceweb.domain.user.UserEntity;
import pjh5365.linuxserviceweb.repository.UserRepository;
import java.time.LocalDateTime;
import java.util.Optional;
@Slf4j
@Getter
@Service
public class SecondaryAuthService {
// 2. 생성된 코드를 필터에서 가져다 쓰기
private String code;
private final UserRepository userRepository;
private final Mail mail;
@Autowired
public SecondaryAuthService(UserRepository userRepository, Mail mail) {
this.userRepository = userRepository;
this.mail = mail;
}
@Async("threadPool") // 쓰레드 풀을 사용하기 위해 설정한 빈을 사용
public void sendSecondaryAuth(String email) { // 1. 해당 메서드가 호출되면 코드를 생성하고
code = GenerateCode.generate();
mail.sendEmailAuth(email, code);
UserEntity user = userRepository.findByEmail(email);
user.setSecondaryCode(code); // 인증코드 설정
user.setExpiredAt(LocalDateTime.now().plusMinutes(5)); // 유효기간은 지금으로 부터 +5분
userRepository.save(user); // 인증코드와 유효기간 DB 에 업로드
}
public boolean checkSecondaryCode(String username, String code) {
Optional<UserEntity> optionalUser = userRepository.findByUsername(username);
if(optionalUser.isEmpty()) { // 사용자 정보를 찾지 못하는 경우
return false;
}
else {
UserEntity user = optionalUser.get();
// 유효기간과 인증코드를 확인한 결과를 리턴
return user.getSecondaryCode().matches(code) && LocalDateTime.now().isBefore(user.getExpiredAt());
}
}
}
인증코드 생성기로부터 인증코드를 리턴받아 인증코드를 데이터베이스에 5분 후 만료되도록 저장하고 해당 인증코드를 사용자의 메일로 전송한다. 2차 인증 요청의 경우 데이터베이스에 저장된 인증코드와 만료시간을 확인하여 2차 인증에 대한 결과를 리턴해준다.
package pjh5365.linuxserviceweb.domain.auth.filter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import pjh5365.linuxserviceweb.domain.auth.service.SecondaryAuthService;
public class SecondaryAuthFilter extends AbstractAuthenticationProcessingFilter {
private final SecondaryAuthService secondaryAuthService;
@Autowired
public SecondaryAuthFilter(SecondaryAuthService secondaryAuthService) {
super("/loginProc");
this.secondaryAuthService = secondaryAuthService;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String username = request.getParameter("username");
String password = request.getParameter("password");
String emailAuth = request.getParameter("email-auth");
if(!secondaryAuthService.checkSecondaryCode(username, emailAuth)) // 2차 인증 코드와 맞지않다면 로그인에 실패하기 위해 비밀번호를 틀리게 설정
password = password + "!!!!";
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(username.trim(), password);
setDetails(request, authenticationToken);
return getAuthenticationManager().authenticate(authenticationToken);
}
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
}
로그인 폼에서 인증코드를 포함하여 /loginProc
로 요청한다면 커스텀 필터에서 각종 값을 비교하여 로그인 처리를 한다.
package pjh5365.linuxserviceweb.domain.auth.handler;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class CustomAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 로그인에 성공했으므로 세션등록
HttpSession session = request.getSession();
SecurityContext securityContext = SecurityContextHolder.getContext();
session.setMaxInactiveInterval(5 * 60); // 아무동작도 하지않으면 세션은 5분 후 만료
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext);
// 부모 클래스의 동작 호출 (기본적으로는 리다이렉션)
super.onAuthenticationSuccess(request, response, authentication);
}
}
필터를 거쳐 로그인에 성공하면 성공핸들러로 넘어온다. 넘어온 정보를 바탕으로 핸들러에서 세션등록을 해주면 2차 인증이 완료된다.
이유는 모르겠지만 핸들러까지는 시큐리티 세션이 유지가 되는데 핸들러에서 세션을 새롭게 등록하지 않는다면 로그인처리가 종료된 후 메인페이지로 이동했을때 시큐리티 세션이 사라진다. 따라서 핸들러에서 시큐리티 세션을 새롭게 등록해주어야 정상작동한다.
또한 이런 방식으로 세션을 유지하면 시큐리티 설정에서 세션에 관련된 설정이 적용되지 않는다.