A Guide to One-Time Token Login in Spring Security
1. 개요
웹사이트에 원활한 로그인 경험을 제공하는 것은 미세한 균형을 요구합니다. 한편으로는 다양한 수준의 컴퓨터 활용 능력을 가진 사용자가 최대한 빠르게 로그인할 수 있도록 하고, 다른 한편으로는 시스템에 접근하는 사람의 신원을 확인해야 하며, 그렇지 않으면 잠재적으로 재앙적인 보안 사건의 위험이 있습니다.
이 튜토리얼에서는 Spring Boot 기반 애플리케이션에서 일회성 토큰 로그인을 사용하는 방법을 보여드리겠습니다. 이 메커니즘은 사용 용이성과 보안 특성 사이에서 좋은 균형을 이루며, Spring Boot 3.4 버전부터는 Spring Security 6.4 이상을 사용할 때 즉시 지원됩니다.
2. 일회성 토큰 로그인(OTT)이란?
전통적으로 컴퓨터 애플리케이션에서 사용자를 식별하는 방법은 사용자가 사용자 이름과 비밀번호를 제공하는 폼을 제공하는 것입니다. 그렇다면 사용자가 비밀번호를 잊어버린 경우에는 어떻게 될까요? 일반적인 접근 방식은 “비밀번호를 잊으셨나요?” 버튼을 제공하는 것입니다.
사용자가 이 버튼을 클릭하면, 백엔드는 사용자에게 메시지를 보내는데, 이 메시지에는 사용자가 비밀번호를 재정의할 수 있도록 하는 시간 제한이 있는 토큰이 포함됩니다.
그러나 다양한 애플리케이션의 경우 사용자가 사이트를 자주 방문하거나 비밀번호를 저장할 필요가 없습니다. 이러한 경우, 사용자는 비밀번호 재설정 기능을 지속적으로 사용하게 되어 불만이 쌓이고, 경우에 따라 고객 지원에 화를 내게 됩니다. 이 범주에 해당하는 애플리케이션은 다음과 같습니다:
- 커뮤니티 사이트(클럽, 학교, 교회, 게임)
- 문서 배포/서명 서비스
- 팝업 마케팅 사이트
대신 일회성 토큰 로그인(OTT) 메커니즘은 다음과 같이 작동합니다:
- 사용자는 보통 자신의 이메일 주소에 해당하는 사용자 이름을 입력합니다.
- 시스템은 시간 제한이 있는 토큰을 생성하고, 이메일, SMS 메시지, 모바일 알림 등과 같은 비상식적 메커니즘을 통해 전송합니다.
- 사용자는 이메일/메시징 애플리케이션에서 메시지를 열고, 일회성 토큰이 포함된 링크를 클릭합니다.
- 사용자의 장치 브라우저는 링크를 열어 시스템의 OTT 로그인 위치로 돌아갑니다.
- 시스템은 링크에 포함된 토큰 값을 확인합니다. 유효하다면 접근이 허용되며, 사용자는 계속 진행할 수 있습니다. 또는 토큰 제출 양식을 표시하여 제출 시 로그인 프로세스를 완료합니다.
3. OTT를 언제 사용해야 하나요?
특정 애플리케이션에 대해 일회성 로그인 메커니즘을 고려하기 전에 장단점을 확인하는 것이 좋습니다:
장점 | 단점 |
---|---|
사용자 비밀번호를 관리할 필요가 없으므로 보안 위험이 제거됨 | 최소한 애플리케이션의 엔드포인트에서 단일 요소 기반 인증 |
비 기술적 사용자가 이해하기 쉽고 간단하게 사용 가능 | 중간자 공격에 취약 |
이제 우리는 사회적 로그인을 사용하지 않는 이유에 대해 생각해볼 수 있습니다. 기술적으로 본다면, 일반적으로 OAuth2/OIDC에 기반한 사회적 로그인이 OTT보다 더 안전합니다.
그러나 이를 활성화하려면 더 많은 운영적 노력이 필요합니다(예: 각 제공자의 클라이언트 ID 요청 및 유지 관리) 및 개인 데이터 공유에 대한 인식이 높아짐에 따라 참여도가 낮아질 수 있습니다.
4. Spring Boot 및 Spring Security와 함께 OTT 구현하기
OTT 지원이 제공될 수 있었던 간단한 Spring Boot 애플리케이션을 만들어 봅시다. 먼저 필요한 Maven 의존성을 추가하여 시작하겠습니다:
<span class="hljs-tag"><<span class="hljs-name">dependency</span>></span>
<span class="hljs-tag"><<span class="hljs-name">groupId</span>></span>org.springframework.boot<span class="hljs-tag"></<span class="hljs-name">groupId</span>></span>
<span class="hljs-tag"><<span class="hljs-name">artifactId</span>></span>spring-boot-starter-web<span class="hljs-tag"></<span class="hljs-name">artifactId</span>></span>
<span class="hljs-tag"><<span class="hljs-name">version</span>></span>3.4.1<span class="hljs-tag"></<span class="hljs-name">version</span>></span>
<span class="hljs-tag"></<span class="hljs-name">dependency</span>></span>
<span class="hljs-tag"><<span class="hljs-name">dependency</span>></span>
<span class="hljs-tag"><<span class="hljs-name">groupId</span>></span>org.springframework.boot<span class="hljs-tag"></<span class="hljs-name">groupId</span>></span>
<span class="hljs-tag"><<span class="hljs-name">artifactId</span>></span>spring-boot-starter-security<span class="hljs-tag"></<span class="hljs-name">artifactId</span>></span>
<span class="hljs-tag"><<span class="hljs-name">version</span>></span>3.4.1<span class="hljs-tag"></<span class="hljs-name">version</span>></span>
<span class="hljs-tag"></<span class="hljs-name">dependency</span>></span>
이 의존성의 최신 버전은 Maven Central에서 확인할 수 있습니다:
5. OTT 구성
현재 버전에서 애플리케이션에 OTT를 활성화하려면 SecurityFilterChain 빈을 제공해야 합니다:
<span class="hljs-meta">@Bean</span>
SecurityFilterChain <span class="hljs-title function_">ottSecurityFilterChain</span><span class="hljs-params">(HttpSecurity http)</span> <span class="hljs-keyword">throws</span> Exception {
<span class="hljs-keyword">return</span> http
.authorizeHttpRequests(ht -> ht.anyRequest().authenticated())
.formLogin(withDefaults())
.oneTimeTokenLogin(withDefaults())
.build();
}
여기서 핵심은 버전 6.4에서 도입된 새로운 oneTimeTokenLogin() 메서드의 사용입니다. 이 메서드는 메커니즘의 모든 측면을 사용자 정의할 수 있게 해줍니다. 그러나 이번 경우에는 기본값을 수용하기 위해 Customizer.withDefaults()만 사용합니다.
또한, formLogin()을 구성에 추가했다는 점에 주목하세요. 이것이 없으면 Spring Security는 기본적으로 기본 인증을 사용하게 되며, 이는 OTT와 잘 맞지 않습니다.
마지막으로 authorizeHttpRequests() 섹션에서는 모든 요청에 대한 인증을 요구하는 구성을 추가했습니다.
6. 토큰 전송
OTT 메커니즘에는 실제로 사용자에게 토큰을 전송하는 메서드가 내장되어 있지 않습니다. 문서에서 설명한 바와 같이, 이는 단순히 구현해야 할 방법이 너무 많기 때문에 의도된 설계 결정입니다.
그 대신, OTT는 이 책임을 애플리케이션 코드에 위임하는데, 이는 OneTimeTokenGenerationSuccessHandler 인터페이스를 구현하는 빈을 노출해야 합니다. 대안으로 구성 DSL을 통해 이 인터페이스의 구현을 직접 전달할 수 있습니다.
이 인터페이스는 현재 서블릿 요청, 응답, 그리고 가장 중요한 OneTimeToken 객체를 취하는 handle()이라는 단일 메서드를 가지고 있습니다. 후자는 다음과 같은 속성을 가지고 있습니다:
- tokenValue: 사용자에게 전송해야 할 생성된 토큰
- username: 입력된 사용자 이름
- expiresAt: 생성된 토큰이 만료될 순간
전형적인 구현 과정은 다음과 같습니다:
- 제공된 사용자 이름을 키로 사용하여 필요한 전송 세부 정보를 찾습니다. 예를 들어, 이러한 세부 정보에는 이메일 주소나 전화번호 및 사용자의 지역 설정이 포함될 수 있습니다.
- 사용자를 OTT 로그인 페이지로 안내하는 URL을 구성합니다.
- OTT 링크가 포함된 메시지를 준비하여 사용자에게 전송합니다.
- 클라이언트에게 리다이렉트 응답을 전송하여 브라우저를 OTT 로그인 페이지로 안내합니다.
여기서는 책임을 1~3단계와 관련된 책임을 OttSenderService에 위임했습니다.
4단계에서는 리다이렉션 세부 사항을 Spring Security의 RedirectOneTimeTokenGenerationSuccessHandler에 위임합니다. 최종 구현은 다음과 같습니다:
public class OttLoginLinkSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
private final OttSenderService senderService;
private final OneTimeTokenGenerationSuccessHandler redirectHandler = new RedirectOneTimeTokenGenerationSuccessHandler("/login/ott");
// ... 생성자 생략
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
OneTimeToken oneTimeToken) throws IOException, ServletException {
senderService.sendTokenToUser(oneTimeToken.getUsername(),
oneTimeToken.getTokenValue(), oneTimeToken.getExpiresAt());
redirectHandler.handle(request, response, oneTimeToken);
}
}
RedirectOneTimeTokenGenerationSuccessHandler에 전달된 “/login/ott” 생성자 인자는 토큰 제출 양식의 기본 위치에 해당하며 OTT DSL을 사용하여 다른 위치로 구성할 수 있습니다.
OttSenderService에 대해서는, 우리는 사용자의 사용자 이름으로 색인화된 Map에 토큰을 저장하고 그 값을 기록하는 가짜 전송자 구현을 사용할 것입니다:
<span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">FakeOttSenderService</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">OttSenderService</span> {
<span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> Map<String,String> lastTokenByUser = <span class="hljs-keyword">new</span> <span class="hljs-title class_">HashMap</span><>();
<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">sendTokenToUser</span><span class="hljs-params">(String username, String token, Instant expiresAt)</span> {
lastTokenByUser.put(username, token);
log.info(<span class="hljs-string">"Sending token to username '{}'. token={}, expiresAt={}"</span>, username,token,expiresAt);
}
<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> Optional<String> <span class="hljs-title function_">getLastTokenForUser</span><span class="hljs-params">(String username)</span> {
<span class="hljs-keyword">return</span> Optional.ofNullable(lastTokenByUser.get(username));
}
}
OttSenderService에는 사용자 이름에 대한 토큰을 복구할 수 있는 선택적 메서드가 있습니다. 이 메서드의 주요 목적은 단위 테스트의 구현을 간소화하는 것입니다, 이는 다음 생성된 테스트 섹션에서 볼 수 있습니다.
7. 수동 테스트
OTT 메커니즘이 있는 애플리케이션의 동작을 간단한 탐색 테스트로 확인해봅시다. IDE를 통해 또는 mvn spring-boot:run을 사용하여 애플리케이션을 시작한 후, 원하는 브라우저를 사용하여 http://localhost:8080으로 이동합니다. 애플리케이션은 사용자 이름/비밀번호를 수락하는 표준 양식과 OTT 양식이 모두 포함된 로그인 페이지를 반환합니다:
지금까지 제공된 UserDetailsService가 없기 때문에, Spring Boot의 자동 구성은 “user”라는 단일 사용자로 구성된 기본 하나를 생성합니다. OTT의 사용자 이름 필드에 이를 입력하고 Send Token 버튼을 클릭하면 토큰 제출 양식으로 전환됩니다.
이제 애플리케이션 로그를 확인해 보면 다음과 유사한 메시지를 볼 수 있습니다:
로그인 프로세스를 완료하려면 토큰 값을 복사하여 양식에 붙여넣고 로그인 버튼을 클릭하세요. 결과적으로 현재 사용자 이름을 표시하는 환영 페이지를 보게 됩니다.
8. 자동화된 테스트
OTT 로그인 흐름을 테스트하기 위해 페이지의 일련의 탐색이 필요하므로 Jsoup 라이브러리를 사용합니다.
전체 코드는 온라인에서 확인할 수 있으며, 수동 테스트에서 우리가 간단히 진행한 단계와 동일한 단계를 따릅니다.
유일하게 까다로운 부분은 생성된 토큰에 접근하는 것입니다. 이럴 때 OttSenderService 인터페이스에서 제공하는 조회 메서드가 유용합니다. Spring Boot의 테스트 인프라를 활용하므로, 테스트 클래스에 서비스를 주입하고 이를 사용하여 토큰을 쿼리할 수 있습니다:
<span class="hljs-meta">@Test</span>
<span class="hljs-keyword">void</span> <span class="hljs-title function_">whenLoginWithOtt_thenSuccess</span><span class="hljs-params">()</span> <span class="hljs-keyword">throws</span> Exception {
<span class="hljs-comment">// ... Jsoup 설정 및 초기 탐색 생략</span>
<span class="hljs-type">var</span> <span class="hljs-variable">optToken</span> <span class="hljs-operator">=</span> <span class="hljs-built_in">this</span>.ottSenderService.getLastTokenForUser(<span class="hljs-string">"user"</span>);
assertTrue(optToken.isPresent());
<span class="hljs-type">var</span> <span class="hljs-variable">homePage</span> <span class="hljs-operator">=</span> conn.newRequest(baseUrl + tokenSubmitAction)
.data(<span class="hljs-string">"token"</span>, optToken.get())
.data(<span class="hljs-string">"_csrf"</span>,csrfToken)
.post();
<span class="hljs-type">var</span> <span class="hljs-variable">username</span> <span class="hljs-operator">=</span> requireNonNull(homePage.selectFirst(<span class="hljs-string">"span#current-username"</span>)).text();
assertEquals(<span class="hljs-string">"user"</span>,username);
}
9. 결론
이 튜토리얼에서는 일회성 토큰 로그인 메커니즘에 대해 설명하고 이를 Spring Boot 기반 애플리케이션에 추가하는 방법을 설명했습니다.
코드는 GitHub에서 확인할 수 있습니다.