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

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

스프링 시큐리티는 여러 기능을 제공하는데 그 중 필터를 사용하여 2차 인증을 거친 후 로그인을 진행할 수 있다.

구현

인증코드 생성기

1
2
3
4
5
6
7
8
9
10
11
12
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자리의 인증코드를 생성한다.

로그인 폼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<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차 로그인 요청 컨트롤러

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@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차 인증 서비스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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차 인증에 대한 결과를 리턴해준다.

커스텀 필터

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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 로 요청한다면 커스텀 필터에서 각종 값을 비교하여 로그인 처리를 한다.

로그인 성공 핸들러

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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차 인증이 완료된다.

실행결과

메인페이지

실행결과1

최초 로그인

실행결과1

2차 인증

실행결과1

실행결과1

로그인 성공 후 메인페이지

실행결과1

특이사항

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

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

This post is licensed under CC BY 4.0 by the author.