스프링 시큐리티를 이용한 이메일인증 추가하기

pjh5365·2023년 12월 20일
0

[Spring / Spring Boot]

목록 보기
10/10
post-thumbnail

스프링 시큐리티는 여러 기능을 제공하는데 그 중 필터를 사용하여 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 를 요청하도록 로그인 폼을 구성한다.

2차 로그인 요청 컨트롤러

@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차 인증에 대한 요청을 수행한 후 정보를 담아 로그인 폼으로 리다이렉션 시켜준다.

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차 인증이 완료된다.

실행결과

메인페이지

최초 로그인

2차 인증

로그인 성공 후 메인페이지

특이사항

이유는 모르겠지만 핸들러까지는 시큐리티 세션이 유지가 되는데 핸들러에서 세션을 새롭게 등록하지 않는다면 로그인처리가 종료된 후 메인페이지로 이동했을때 시큐리티 세션이 사라진다. 따라서 핸들러에서 시큐리티 세션을 새롭게 등록해주어야 정상작동한다.

또한 이런 방식으로 세션을 유지하면 시큐리티 설정에서 세션에 관련된 설정이 적용되지 않는다.

0개의 댓글