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);
	}
}