11.1. 예제의 시나리오와 요구 사항
구성 요소
인증 서버
자격 증명(이름, 암호)를 기준으로 사용자를 인증하고 SMS로 OTP를 전송한다.
비즈니스 논리 서버
엔드포인트를 호출하기 위해 사용자는 먼저 이름과 암호로 인증하고 OTP를 보내야 한다.
로직 흐름은 다음과 같다.
비즈니스 논리 서버의 /login을 호출해 이름과 암호를 인증하고 OTP를 받는다.
사용자 이름과 OTP로 /login을 호출하고 토큰을 얻는다.
클라이언트가 두 번째 인증을 위해 OTP와 함꼐 사용자 이름을 보낸다.
비즈니스 논리 서버가 인증 서버를 호출해 OTP를 검증한다.
OTP가 유효하면 비즈니스 논리 서버가 토큰을 발급한다.
얻은 토큰을 Authorization 헤더에 추가하고 다른 엔드포인트를 호출한다.
다단계 인증
이름과 암호의 인증이 성공하면 OTP를 보내는 인증 방식
MFA(multi-factor authentication)
11.2. 토큰의 구현과 이용
11.2.1. 토큰이란?
애플리케이션이 사용자를 인증했음을 증명하는 방법을 제공해 리소스에 액세스할 수 있게 한다.
클라이언트가 인증하면 서버는 토큰을 생성하고 반환한다. 클라이언트는 이 토큰으로 서버에 접근한다.
토큰을 이용하면 요청할 때마다 자격 증명을 공유할 필요가 없다.
자격 증명은 자주 보낼 수록 노출도 많이 된다.
자격 증명을 무효로 하지 않고 토큰을 무효화할 수 있다.
클라이언트가 요청할 때 보내야 하는 사용자 권한 등 세부 정보를 저장할 수 있다.
이렇게 하면 서버 쪽 세션을 클라이언트 쪽 세션으로 대체하여 수평 확장을 위한 유연성을 달성할 수 있다.
클라이언트에서 정보를 가지게 되니까 어느 서버를 접속하든 같은 정보를 사용할 수 있다는 얘긴가?
토큰을 이용하면 인증 책임을 시스템의 다른 구성 요소에 위임할 수 있다.
깃헙, 트위터 등 다른 플랫폼의 자격 증명으로 인증할 수 있다.
이렇게 구현을 별도로 만들 수 있으면 유연성에 유리하다.
11.2.2. JSON 웹 토큰이란?
JSON으로 형식이 지정되고 Base64로 인코딩한다.
헤더, 본문, 디지털 서명으로 이루어져 있다.
헤더와 본문에는 세부 정보를 저장할 수 있다.
토큰이 너무 길면 요청 속도가 느려지고 서명하는 경우 암호화 알고리즘이 서명하는 시간이 길어진다.
보통 헤더와 본문에 서명하는 것을 선호하며 서명이 없으면 토큰을 누가 가로채고 변경하지 않았는지 확신할 수 없다.
11.3. 인증 서버 구현
사용자를 인증하고 OTP를 생성, 저장한 뒤 SMS를 보낸다.
OTP 값이 인증 서버가 해당 사용자를 위해 이전에 생성한 값인지 확인한다.
@Service
@Transactional
public class UserService {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserRepository userRepository;
@Autowired
private OtpRepository otpRepository;
public void addUser(User user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
userRepository.save(user);
}
public void auth(User user) {
Optional<User> o =
userRepository.findUserByUsername(user.getUsername());
if (o.isPresent()) {
User u = o.get();
if (passwordEncoder.matches(user.getPassword(), u.getPassword())) {
renewOtp(u);
} else {
throw new BadCredentialsException("Bad credentials.");
}
} else {
throw new BadCredentialsException("Bad credentials.");
}
}
public boolean check(Otp otpToValidate) {
Optional<Otp> userOtp = otpRepository.findOtpByUsername(otpToValidate.getUsername());
if (userOtp.isPresent()) {
Otp otp = userOtp.get();
if (otpToValidate.getCode().equals(otp.getCode())) {
return true;
}
}
return false;
}
private void renewOtp(User u) {
String code = GenerateCodeUtil.generateCode();
Optional<Otp> userOtp = otpRepository.findOtpByUsername(u.getUsername());
if (userOtp.isPresent()) {
Otp otp = userOtp.get();
otp.setCode(code);
} else {
Otp otp = new Otp();
otp.setUsername(u.getUsername());
otp.setCode(code);
otpRepository.save(otp);
}
}
}
11.4. 비즈니스 논리 서버 구현
클라이언트가 이름과 암호를 논리 서버로 보내고 로그인하는 첫 번째 인증 단계를 구현한다.
클라이언트가 인증서버에서 받은 OTP를 비즈니스 논리 서버로 보내는 두 번째 인증 단계를 구현한다.
두 인증 단계를 나타내는 역할을 하는 Authentication 객체를 구현한다.
인증 서버와 비즈니스 논리 서버 간 통신을 수행하는 프락시를 구현한다.
Authentication 객체로 인증 로직을 구현하는 AuthenticationProvider를 정의한다.
AuthenticationProvider를 적용하는 필터를 정의한다.
OTP가 인증되면 클라이언트는 JWT를 받는다.
이제 더 이상 Basic 인증은 적합하지 않다. 대안에는 2가지가 있다.
Authentication 3개, AuthenticationProvider 3개, 필터 1개를 정의하고 AuthenticationManager를 통해 위임한다.
왜 3개지? 이름, OTP, JWT 이렇게인가?
Authentication 2개, AuthenticationProvider 2개를 이용한다.
인증 필터를 통해 사용자 이름과 암호로 사용자를 인증하고 OTP로 사용자를 인증한다.
InitialAuthenticationFilter -> AuthenticationManager -> UsernamePasswordAuthenticationProvider,
OtpAuthenticationProvider
JWT는 별도의 필터로 검증한다.
여러 필터를 이용할 수 있고 OncePerRequestFilter의 shouldNotFilter()를 활용할 수 있으므로 선택함.
11.4.1. Authentication 객체 구현
public class UsernamePasswordAuthentication extends UsernamePasswordAuthenticationToken {
public UsernamePasswordAuthentication(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(principal, credentials, authorities);
}
public UsernamePasswordAuthentication(Object principal, Object credentials) {
super(principal, credentials);
}
}
2개인 생성자를 호출하면 인증되지 않은 상태로 유지된다.
3개인 생성자를 호출하면 Authentication 객체가 인증된다.
11.4.2. 인증 서버에 대한 프락시 구현
@Component
public class AuthenticationServerProxy {
@Autowired
private RestTemplate rest;
@Value("${auth.server.base.url}")
private String baseUrl;
public void sendAuth(String username, String password) {
String url = baseUrl + "/user/auth";
var body = new User();
body.setUsername(username);
body.setPassword(password);
var request = new HttpEntity<>(body);
rest.postForEntity(url, request, Void.class);
}
public boolean sendOTP(String username, String code) {
String url = baseUrl + "/otp/check";
var body = new User();
body.setUsername(username);
body.setCode(code);
var request = new HttpEntity<>(body);
var response = rest.postForEntity(url, request, Void.class);
return response.getStatusCode().equals(HttpStatus.OK);
}
}
인증 서버가 노출하면 REST 엔드포인트를 호출한다.
11.4.3. AuthenticationProvider 인터페이스 구현
@Component
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
@Autowired
private AuthenticationServerProxy proxy;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
// 인증 서버를 호출한다.
proxy.sendAuth(username, password);
return new UsernamePasswordAuthenticationToken(username, password);
}
@Override
public boolean supports(Class<?> aClass) {
// Authentication 객체가 UsernamePasswordAuthentication 형식을 지원하는 UsernamePasswordAuthenticationProvider를 설계한다는 의미
return UsernamePasswordAuthentication.class.isAssignableFrom(aClass);
}
}
@Component
public class OtpAuthenticationProvider implements AuthenticationProvider {
@Autowired
private AuthenticationServerProxy proxy;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String code = String.valueOf(authentication.getCredentials());
boolean result = proxy.sendOTP(username, code);
if (result) {
return new OtpAuthentication(username, code);
} else {
throw new BadCredentialsException("Bad credentials.");
}
}
@Override
public boolean supports(Class<?> aClass) {
return OtpAuthentication.class.isAssignableFrom(aClass);
}
}
11.4.4. 필터 구현
@Component
public class InitialAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private AuthenticationManager manager;
@Value("${jwt.signing.key}")
private String signingKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String username = request.getHeader("username");
String password = request.getHeader("password");
String code = request.getHeader("code");
if (code == null) {
// 첫번째 인증 단계 수행
Authentication a = new UsernamePasswordAuthentication(username, password);
manager.authenticate(a);
} else {
// 두번째 인증 단계 수행
Authentication a = new OtpAuthentication(username, code);
manager.authenticate(a);
// 인증에 성공했다면 JWT 반환
// 서명한 키는 비즈니스 논리 서버만 알고 있다.
SecretKey key = Keys.hmacShaKeyFor(signingKey.getBytes(StandardCharsets.UTF_8));
String jwt = Jwts.builder()
.setClaims(Map.of("username", username))
.signWith(key)
.compact();
response.setHeader("Authorization", jwt);
}
}
// login 엔드포인트만 이 필터를 거친다.
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getServletPath().equals("/login");
}
}
login 엔드포인트에 접근 시 이름, 암호나 OTP를 검증한다.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Value("${jwt.signing.key}")
private String signingKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String jwt = request.getHeader("Authorization");
SecretKey key = Keys.hmacShaKeyFor(signingKey.getBytes(StandardCharsets.UTF_8));
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jwt)
.getBody();
String username = String.valueOf(claims.get("username"));
GrantedAuthority a = new SimpleGrantedAuthority("user");
var auth = new UsernamePasswordAuthentication(username, null, List.of(a));
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return request.getServletPath().equals("/login");
}
}
login 엔드포인트 외에 접근 시 JWT를 검증한다.
11.4.5. 보안 구성 작성
@Configuration
public class ProjectConfig {
@Autowired
private InitialAuthenticationFilter initialAuthenticationFilter;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private OtpAuthenticationProvider otpAuthenticationProvider;
@Autowired
private UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider;
@Bean
public AuthenticationManager authenticationManager() {
List<AuthenticationProvider> providers = List.of(
otpAuthenticationProvider,
usernamePasswordAuthenticationProvider
);
return new ProviderManager(providers);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 다른 출처를 이용하는 프로젝트가 아니므로 비활성화 한다.
// JWT가 CSRF 토큰 검증을 대체할 수 있다.
http.csrf(AbstractHttpConfigurer::disable);
http.addFilterAt(
initialAuthenticationFilter,
BasicAuthenticationFilter.class)
.addFilterAfter(
jwtAuthenticationFilter,
BasicAuthenticationFilter.class
);
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
}