Contents
see ListSpring 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의 네이티브 지원으로 두 방식 모두 별도 라이브러리 없이 구현할 수 있게 되었습니다.