Post

[Security] 비밀번호 재설정 기능을 토큰 폐기까지 포함해 설계한 방법

[Security] 비밀번호 재설정 기능을 토큰 폐기까지 포함해 설계한 방법

서론

비밀번호 재설정은 화면만 만들면 끝나는 기능처럼 보이지만, 실제로는 계정 보안 설계와 아주 가깝다.
특히 “토큰을 어떻게 발급할지”, “이미 발급된 토큰은 어떻게 무효화할지”, “운영 환경과 개발 환경에서 메일 전송을 어떻게 분리할지”까지 생각해야 비로소 안전한 기능이 된다.

loslung에서는 2026년 4월 6일 PR #16에서 비밀번호 변경과 이메일 기반 재설정 기능이 함께 추가되었다.
이번 글에서는 그 설계를 코드 중심으로 정리해본다.

요구사항 정리

이번 기능에서 중요한 요구사항은 단순히 “이메일로 링크 보내기”가 아니었다.

  • 비밀번호를 바꾸면 기존 로그인 토큰은 모두 무효화할 것
  • 비밀번호 재설정 토큰은 한 번만 사용 가능해야 할 것
  • 같은 유저가 여러 번 재설정을 요청해도 오래된 토큰은 정리할 것
  • 개발 환경에서는 실제 메일 없이도 플로우를 확인할 수 있을 것
  • 운영 환경에서는 SMTP로 실제 메일을 전송할 것

즉, 화면보다 토큰 정책이 먼저 정리되어야 하는 기능이었다.

토큰 만료 시간과 종류 정의

먼저 AuthService에서는 액세스, 리프레시, 비밀번호 재설정 토큰의 TTL을 명확히 분리했다.

1
2
3
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(15);
private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(14);
private static final Duration PASSWORD_RESET_TOKEN_TTL = Duration.ofMinutes(30);

여기서 중요한 점은 비밀번호 재설정 토큰이 로그인 세션과 별개로 관리된다는 것이다.
즉, 단순 문자열 하나가 아니라 AuthTokenType.PASSWORD_RESET이라는 별도 토큰 타입으로 처리했다.

비밀번호 재설정 요청 시 처리 방식

이메일을 입력받았을 때는 아래 순서로 동작한다.

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
public void requestPasswordReset(AuthDtos.ForgotPasswordRequest request) {
    Optional<UserAccount> optionalUser =
        userAccountRepository.findByEmailIgnoreCase(request.email().trim());
    if (optionalUser.isEmpty()) {
        return;
    }

    UserAccount user = optionalUser.get();
    Instant now = Instant.now(clock);
    String tokenValue = randomTokenValue();

    AuthToken newToken = authTokenRepository.save(
        new AuthToken(
            tokenValue,
            user,
            AuthTokenType.PASSWORD_RESET,
            now.plus(PASSWORD_RESET_TOKEN_TTL)
        )
    );

    String resetLink = frontendUrl + "/auth/reset-password?token=" + tokenValue;
    mailService.sendPasswordResetEmail(user.getEmail(), resetLink);

    authTokenRepository.findAllByUserAndTypeAndRevokedFalse(user, AuthTokenType.PASSWORD_RESET)
        .stream()
        .filter(token -> !token.getToken().equals(tokenValue))
        .forEach(AuthToken::revoke);
}

여기서 눈여겨볼 포인트는 두 가지다.

  1. 존재하지 않는 이메일이면 조용히 종료한다.
    계정 존재 여부를 외부에 드러내지 않기 위한 선택이다.

  2. 새 토큰을 발급한 뒤 이전의 활성화된 재설정 토큰은 revoke한다.
    즉, 같은 사용자의 재설정 링크가 여러 개 살아남지 않도록 정리한다.

토큰 소비는 읽고 나서 revoke가 아니라, 먼저 차단해야 했다

비밀번호 재설정 기능에서 더 중요한 부분은 실제 토큰 소비 시점이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void resetPassword(AuthDtos.ResetPasswordRequest request) {
    String tokenValue = request.token().trim();
    int consumed = authTokenRepository.revokeByTokenAndTypeIfActive(
        tokenValue,
        AuthTokenType.PASSWORD_RESET,
        Instant.now(clock)
    );

    if (consumed == 0) {
        throw new BadRequestException("유효하지 않거나 만료된 토큰입니다.");
    }

    AuthToken token = authTokenRepository.findByTokenAndType(tokenValue, AuthTokenType.PASSWORD_RESET)
        .orElseThrow(() -> new BadRequestException("유효하지 않은 토큰입니다."));

    UserAccount user = token.getUser();
    user.updatePassword(passwordEncoder.encode(request.newPassword()));
    revokeAllTokens(user);
}

핵심은 revokeByTokenAndTypeIfActive()를 통해 먼저 원자적으로 소비 처리를 시도한다는 점이다.

레포지토리 쿼리는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Modifying
@Query("""
UPDATE AuthToken t
SET t.revoked = true
WHERE t.token = :token
  AND t.type = :type
  AND t.revoked = false
  AND t.expiresAt > :now
""")
int revokeByTokenAndTypeIfActive(
    @Param("token") String token,
    @Param("type") AuthTokenType type,
    @Param("now") Instant now
);

이 방식의 장점은 명확하다.

  • 먼저 읽고 나중에 revoke하는 방식보다 동시 요청에 강하다
  • 이미 사용된 토큰은 두 번째 요청에서 바로 차단된다
  • “한 번만 사용할 수 있는 링크”라는 성질을 더 잘 지킬 수 있다

비밀번호 변경 후 기존 세션까지 모두 무효화

재설정 토큰만 처리하면 끝나는 게 아니었다.
비밀번호가 바뀌었는데 기존 로그인 세션이 계속 살아 있으면 보안상 좋지 않다.

그래서 비밀번호 변경 이후에는 모든 기존 토큰을 revoke하도록 구성했다.

1
2
3
4
public void revokeAllTokens(UserAccount user) {
    authTokenRepository.findAllByUserAndRevokedFalse(user)
        .forEach(AuthToken::revoke);
}

이 덕분에 비밀번호 변경은 단순한 값 변경이 아니라, 기존 세션을 포함한 인증 상태 초기화까지 포함하는 동작이 되었다.

개발/운영 환경별 메일 전송 분리

메일 전송도 인터페이스로 분리되어 있었다.

1
2
3
public interface MailService {
    void sendPasswordResetEmail(String toEmail, String resetLink);
}

운영 환경에서는 SMTP를 사용하고,

1
2
3
4
5
6
7
8
@Service
@ConditionalOnProperty(name = "loslung.mail.console-only", havingValue = "false")
public class SmtpMailService implements MailService {
    @Override
    public void sendPasswordResetEmail(String toEmail, String resetLink) {
        // 템플릿 렌더링 후 실제 메일 전송
    }
}

개발 환경에서는 콘솔 로그로 대체했다.

1
2
3
4
5
6
7
8
9
10
11
12
@Service
@ConditionalOnProperty(
    name = "loslung.mail.console-only",
    havingValue = "true",
    matchIfMissing = true
)
public class ConsoleMailService implements MailService {
    @Override
    public void sendPasswordResetEmail(String toEmail, String resetLink) {
        log.info("password reset link: {}", resetLink);
    }
}

이 구조 덕분에 개발 중에는 SMTP 없이도 플로우를 확인할 수 있고, 운영에서는 구현체만 바꿔 실제 메일 발송으로 연결할 수 있었다.

정리

loslung의 비밀번호 재설정 기능은 단순히 이메일 링크를 보내는 기능이 아니었다.
실제로는 아래 네 가지를 함께 설계한 결과였다.

  • 재설정 토큰 TTL 분리
  • 이전 재설정 토큰 정리
  • 토큰의 원자적 1회 소비
  • 비밀번호 변경 후 기존 세션 전체 무효화

여기에 개발/운영 메일 전송 방식까지 분리하면서, 기능 구현과 운영 현실을 같이 고려한 형태가 되었다.

비밀번호 재설정은 UI보다 토큰 설계가 먼저라는 점을 다시 확인한 작업이었다.

Reference

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