Spring Security 7에서는 비밀번호 없는 인증 방식이 본격적으로 강화되었습니다. One-Time Token(OTT) 로그인은 이메일이나 SMS로 일회용 토큰을 보내 인증하는 매직 링크 방식이고, Passkey는 FIDO2/WebAuthn 기반의 생체 인증을 지원합니다. 이 글에서는 두 가지 비밀번호 없는 인증 방식의 실전 구현 방법을 다룹니다.

One-Time Token 로그인 구현

Spring Security 6.4부터 도입된 OTT 로그인은 사용자가 이메일 주소만 입력하면 일회용 인증 토큰이 포함된 링크를 전송하여 로그인하는 방식입니다.

의존성 추가

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>

Security 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login/**", "/ott/**").permitAll()
                .anyRequest().authenticated()
            )
            .oneTimeTokenLogin(ott -> ott
                .tokenGenerationSuccessHandler(ottSuccessHandler())
                .defaultSubmitPageUrl("/ott/submit")
                .loginProcessingUrl("/ott/verify")
                .authenticationSuccessHandler(
                    new SavedRequestAwareAuthenticationSuccessHandler())
            )
            .build();
    }

    @Bean
    public OneTimeTokenGenerationSuccessHandler ottSuccessHandler() {
        return new EmailOttSuccessHandler();
    }
}

토큰 전송 핸들러

@Component
public class EmailOttSuccessHandler
        implements OneTimeTokenGenerationSuccessHandler {

    private final JavaMailSender mailSender;

    public EmailOttSuccessHandler(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       OneTimeToken oneTimeToken) throws IOException {

        String email = oneTimeToken.getUsername();
        String token = oneTimeToken.getTokenValue();

        // 매직 링크 생성
        String loginLink = "https://myapp.com/ott/verify?token=" + token;

        // 이메일 발송
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(email);
        message.setSubject("로그인 링크");
        message.setText("아래 링크를 클릭하여 로그인하세요:\n\n" + loginLink
            + "\n\n이 링크는 5분간 유효합니다.");
        mailSender.send(message);

        // 토큰 전송 완료 안내 페이지로 리다이렉트
        response.sendRedirect("/ott/sent?email=" + email);
    }
}

JPA 기반 토큰 저장소

@Entity
@Table(name = "one_time_tokens")
public class OneTimeTokenEntity {
    @Id
    private String token;
    private String username;
    private Instant expiresAt;
    private boolean used;
}

@Component
public class JpaOneTimeTokenService implements OneTimeTokenService {

    private final OneTimeTokenRepository repository;

    @Override
    public OneTimeToken generate(GenerateOneTimeTokenRequest request) {
        String token = UUID.randomUUID().toString();
        var entity = new OneTimeTokenEntity();
        entity.setToken(token);
        entity.setUsername(request.getUsername());
        entity.setExpiresAt(Instant.now().plusSeconds(300)); // 5분
        entity.setUsed(false);
        repository.save(entity);

        return new DefaultOneTimeToken(token, request.getUsername(),
            entity.getExpiresAt());
    }

    @Override
    public OneTimeToken consume(OneTimeTokenAuthenticationToken token) {
        var entity = repository.findByTokenAndUsedFalse(token.getTokenValue())
            .orElseThrow(() -> new BadCredentialsException("유효하지 않은 토큰"));

        if (entity.getExpiresAt().isBefore(Instant.now())) {
            throw new BadCredentialsException("만료된 토큰");
        }

        entity.setUsed(true);
        repository.save(entity);

        return new DefaultOneTimeToken(
            entity.getToken(), entity.getUsername(), entity.getExpiresAt());
    }
}

Passkey(WebAuthn) 인증 구현

Spring Security 7은 FIDO2 WebAuthn을 네이티브로 지원하여 지문, 얼굴 인식, 하드웨어 키 등으로 로그인할 수 있습니다.

Security 설정에 WebAuthn 추가

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/login/**", "/webauthn/**").permitAll()
            .anyRequest().authenticated()
        )
        .webAuthn(webAuthn -> webAuthn
            .rpName("My Application")        // 서비스 이름
            .rpId("myapp.com")               // 도메인
            .allowedOrigins("https://myapp.com")
        )
        .build();
}

@Bean
public UserCredentialRepository credentialRepository(DataSource ds) {
    return new JdbcUserCredentialRepository(ds);
}

Passkey 등록 API

@RestController
@RequestMapping("/api/passkeys")
public class PasskeyController {

    private final PublicKeyCredentialCreationOptionsRepository optionsRepo;

    @PostMapping("/register/options")
    public PublicKeyCredentialCreationOptions registerOptions(
            Authentication auth) {
        // 클라이언트에 전달할 등록 옵션 생성
        return optionsRepo.generate(auth.getName(),
            new AuthenticatorSelectionCriteria(
                AuthenticatorAttachment.PLATFORM,
                ResidentKeyRequirement.REQUIRED,
                UserVerificationRequirement.REQUIRED
            ));
    }

    @PostMapping("/register/verify")
    public ResponseEntity<?> verifyRegistration(
            @RequestBody RegistrationResponse response,
            Authentication auth) {
        // Passkey 등록 검증 및 저장
        credentialService.registerCredential(auth.getName(), response);
        return ResponseEntity.ok().build();
    }
}

프론트엔드 WebAuthn API 호출

// Passkey 등록 (JavaScript)
async function registerPasskey() {
    // 1. 서버에서 등록 옵션 가져오기
    const optionsRes = await fetch('/api/passkeys/register/options',
        { method: 'POST' });
    const options = await optionsRes.json();

    // 2. 브라우저 WebAuthn API로 Passkey 생성
    const credential = await navigator.credentials.create({
        publicKey: {
            ...options,
            challenge: base64ToBuffer(options.challenge),
            user: {
                ...options.user,
                id: base64ToBuffer(options.user.id)
            }
        }
    });

    // 3. 서버에 Passkey 등록
    await fetch('/api/passkeys/register/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            id: credential.id,
            response: {
                attestationObject: bufferToBase64(
                    credential.response.attestationObject),
                clientDataJSON: bufferToBase64(
                    credential.response.clientDataJSON)
            }
        })
    });
}

// Passkey 로그인
async function loginWithPasskey() {
    const optionsRes = await fetch('/webauthn/authenticate/options');
    const options = await optionsRes.json();

    const assertion = await navigator.credentials.get({
        publicKey: {
            ...options,
            challenge: base64ToBuffer(options.challenge)
        }
    });

    // 서버에 인증 결과 전송
    await fetch('/login/webauthn', {
        method: 'POST',
        body: JSON.stringify({
            id: assertion.id,
            response: {
                authenticatorData: bufferToBase64(
                    assertion.response.authenticatorData),
                clientDataJSON: bufferToBase64(
                    assertion.response.clientDataJSON),
                signature: bufferToBase64(
                    assertion.response.signature)
            }
        })
    });
}

비밀번호 없는 인증은 보안성과 사용자 경험을 동시에 개선합니다. One-Time Token은 기존 이메일 인프라만으로 빠르게 도입할 수 있고, Passkey는 피싱에 완전히 면역인 최고 수준의 보안을 제공합니다. Spring Security 7의 네이티브 지원으로 두 방식 모두 별도 라이브러리 없이 구현할 수 있게 되었습니다.