Java/Spring Boot Security
Spring boot Security 2FA OTP 적용
바리새인
2024. 8. 19. 21:59
OTP 등록
Google 또는 MS Authenticator에 OTP 계정 추가
- 비밀키 생성
public class Base32 {
private static final int SECRET_SIZE = 10;
private static final SecureRandom RANDOM = new SecureRandom();
public static String random() {
// Allocating the buffer
byte[] buffer = new byte[SECRET_SIZE];
// Filling the buffer with random numbers.
RANDOM.nextBytes(buffer);
// Getting the key and converting it to Base32
byte[] secretKey = Arrays.copyOf(buffer, SECRET_SIZE);
return encode(secretKey);
}
}
# DB나 파일등에 비밀키 저장
# 비밀키가 변경되면, OTP에서도 업데이트해야 함
String secret = Base32.random();
- OTP에 비밀키 등록
# 계정은 어느 계정이든 상관없음
계정이름: 계정
# 서버에서 생성하여 보관한 비밀키 입력
내키: 비밀키
# 시간기간이 아니면, 작동안함
키유형: 시간기준
- OTP 등록 QR코드 생성
구글에서 지원하던 API 지원하지 않음: 별도로 생성해야 함
형식: otpauth://totp/{계정}?secret={비밀키}
public String uri(String name) {
try {
return String.format("otpauth://totp/%s?secret=%s", URLEncoder.encode(name, "UTF-8"), secret);
} catch (UnsupportedEncodingException e) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
로그인창에 OTP Code 입력필드 추가
<body>
<div class="container">
<form class="form-signin" th:action="@{/login}" method="post">
<h2 class="form-signin-heading">Please sign in</h2>
<div th:if="${message != null}" class="alert alert-info" th:utext="${message}">
message
</div>
<div th:if="${error != null && session[SPRING_SECURITY_LAST_EXCEPTION] != null}" class="alert alert-danger" th:utext="${session[SPRING_SECURITY_LAST_EXCEPTION].message}">
error
</div>
<div class="alert alert-danger" th:if="${param.error}" th:utext="${session[SPRING_SECURITY_LAST_EXCEPTION].message}">
Invalid username and password.
</div>
<div class="alert alert-success" th:if="${param.logout}">
You have been logged out.
</div>
<p>
<input type="text" name="username" class="form-control" placeholder="Username" required autofocus>
</p>
<p>
<input type="password" name="password" class="form-control" placeholder="Password" required>
</p>
<p>
<label>Google Authenticator Verification Code</label>
<input type="text" name="code" class="form-control" placeholder="Code">
</p>
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
</form>
</div>
</body>
로그인창에 추가된 OTP Code 입력필드 인식하게 처리
- AuthenticationProvider 변경
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Autowired
CustomWebAuthenticationDetailsSource authenticationDetailsSource;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((requests) -> requests
.anyRequest().authenticated())
.formLogin((form) -> form
.loginPage("/login")
.authenticationDetailsSource(authenticationDetailsSource)
.permitAll())
.logout((logout) -> logout.permitAll());
return http.build();
}
@Autowired
private MemberUserDetailService memberUserDetailService;
@Bean
public DaoAuthenticationProvider authProvider() {
CustomAuthenticationProvider authProvider = new CustomAuthenticationProvider();
authProvider.setUserDetailsService(memberUserDetailService);
return authProvider;
}
생략
}
public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {
private String verificationCode;
public CustomWebAuthenticationDetails(HttpServletRequest request) {
super(request);
// TODO Auto-generated constructor stub
verificationCode = request.getParameter("code");
}
public String getVerificationCode() {
return verificationCode;
}
}
@Component
public class CustomWebAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
@Override
public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
// TODO Auto-generated method stub
return new CustomWebAuthenticationDetails(context);
}
}
- 로그인 프로세스에 OTP Code 체크 로직 추가
public class CustomAuthenticationProvider extends DaoAuthenticationProvider {
@Autowired
private MemberMapper memberMapper;
@Override
public Authentication authenticate(Authentication auth) throws AuthenticationException {
String verificationCode = ((CustomWebAuthenticationDetails) auth.getDetails()).getVerificationCode();
Member member = memberMapper.getMember(auth.getName());
if(member == null) {
throw new BadCredentialsException("Invalid username and password.");
} else {
log.debug(this.getPasswordEncoder().encode((CharSequence)auth.getCredentials()));
log.debug(member.getPasswd());
if(this.getPasswordEncoder().matches((CharSequence)auth.getCredentials(), member.getPasswd())) {
log.debug(Boolean.toString(auth.isAuthenticated()));
} else {
throw new BadCredentialsException("Invalid username and password.");
}
}
if(member.is2fa()) {
log.debug(member.getSecret());
log.debug(verificationCode);
Totp totp = new Totp(member.getSecret());
if(!isValidating(verificationCode) || !totp.verify(verificationCode)) {
throw new BadCredentialsException("Invalid verification code");
}
}
Authentication result = super.authenticate(auth);
return new UsernamePasswordAuthenticationToken(member, result.getCredentials(), result.getAuthorities());
}
private boolean isValidating(String code) {
try {
Long.parseLong(code);
} catch(NumberFormatException e) {
return false;
}
return true;
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}