이번 블로그에 사용되는 코드는 아래 링크 통해 확인 할 수 있습니다.
Authorization Server https://github.com/syh8088/spring-restful-authorization-v2 (Spring Boot)
Resource Server https://github.com/syh8088/Kiwi-Board (Spring Boot)
Client Server https://github.com/syh8088/Kiwi-Board-Front (Vue Nuxt)
시작하기 앞써서 회원 정보 및 권한 그리고 리소스 관련 테이블을 설명 하도록 하겠습니다.
Spring Boot 및 Social(KaKao) 를 이용하여 JWT 인증 및 인가 구현 하고 더불어 Social(KaKao) 인증 기능 통합 하는 방법에 대해 알아보는 시간을 갖겠습니다.
Social 로그인 경우 KaKao 뿐만 아니라 Google, Naver 등 가능 하도록 구현 했습니다.
본 글에 앞써 JWT 및 OAuth 2.0 기본적인 내용을 파악 하는 것이 좋습니다.
JWT 개념 파악하기 - https://docfriends.github.io/DevStrory/2021-09-27/jwt
OAuth 2.0 개념 파악하기 - https://docfriends.github.io/DevStrory/2021-09-27/oauth2.0
그럼 시작 하도록 하겠습니다.
JWT 인증 다이어그램 입니다.
첫째로 Client 서버로 부터 해당 계정의 ID/PASSWORD 데이터를 Authorization Server 에 전달 하게 되면(로그인 시도)
해당 데이터를 실제로 존재하는지 그리고 올바른 Password 를 입력했는지 검증하게 됩니다.
검증을 완료 되었으면 JWT Token 을 만들어서 응답 하게 됩니다.
이때 Client 는 Resource 를 접근 할때 발급 받았던 JWT Token 을 이용해서 Request 하게 됩니다.
검증이 완료 된다면 접근한 Resource 데이터를 응답 하는 형식 입니다.
보안 설정 라이브러리는 Spring Security 를 사용했습니다.
인증 및 인가 처리에 대한 각각의 Filter 제공 및 Custom 가능하고 보안과 관련해서 체계적으로 많은 옵션을 제공해주기 때문에
선택 하였습니다. 개발자는 보안 이슈에 대한 하나하나 로직 구현 수고를 없애고 오로지 비니지니 로직에 집중 할 수 있습니다.
Spring Security 를 적용하기전 기본적인 제공하는 각각의 Filter 가 어떤 방식으로 구현되는지 및 전반적인 지식이 필요 합니다.
이는 나중에 하나하나 블로그를 제작 할 예정입니다.
지금은 우선 Spring Security 설정 적용 했다는 의미로 받아주시면 되겠습니다.
SecurityConfig.java
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.csrf().disable()
.cors().disable()
.formLogin().disable()
.logout().disable()
.httpBasic().disable()
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.antMatchers("/csrf-token").permitAll()
.antMatchers(HttpMethod.POST, "/authorize", "/authorize/refresh", "/users").anonymous()
.antMatchers(HttpMethod.POST, "/oauth/unlink").authenticated()
.antMatchers("/oauth/**").permitAll()
.anyRequest().authenticated().and()
.exceptionHandling()
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
}
Spring Security 기본적인 사용자 정의 보안 설정 입니다.
Spring boot 가 기동 될때 해당 설정값을 통해 보안 전략이 변경 됩니다.
하나하나씩 알아보도록 하겠습니다.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
이번에 개발하는 웹 통신 방식은 Stateless 로 구현해야 하기 때문에 설정하였습니다.
이는 JWT Token 특성 때문에 가능 합니다.
.formLogin().disable()
.logout().disable()
.httpBasic().disable()
더불어 JWT 토큰 방식으로 인증 및 인가 처리 하기 때문에 Security 에서 기본적으로 제공하는 FORM 로그인, Logout 등 옵션을 비활성화 하고 자체적으로 Custom 하기로 했습니다.
authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.antMatchers(HttpMethod.POST, "/authorize", "/authorize/refresh", "/users").anonymous()
.antMatchers(HttpMethod.POST, "/oauth/unlink").authenticated()
.antMatchers("/oauth/**").permitAll()
.anyRequest().authenticated().and()
.antMatchers(HttpMethod.POST, "/authorize", "/authorize/refresh", "/users").anonymous()
OPTIONS 메소드와 POST 메소드 중 “/authorize”, “/authorize/refresh” 는 인증 및 인가 처리 하지 않는 다는 의미 입니다.
POST - /oauth/unlin
해당 URL 에 인증을 받겠다는 의미 입니다.
.antMatchers("/oauth/**").permitAll()
해당 URL 는 인증 하지 않는다는 의미 입니다.
.anyRequest().authenticated()
그외 모든 요청은 인증을 받겠다는 의미 입니다.
SecurityConfig.java
private final UserServiceHandler userServiceHandler;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userServiceHandler).passwordEncoder(passwordEncoder());
}
AuthenticationManager (인증 처리 manager) 에서 authenticate 메소드 통해 인증 처리시
Custom 한 UserServiceHandler, PasswordEncoder 사용 하겠다는 의미 입니다.
UserServiceHandler 는 client 에서 전달 받은 ID/PASSWORD 데이터가 실제로 존재 하는지 그리고 패스워드가 올바르게 일치 하는지
검증 하는 Custom 구현한 클래스 입니다.
그리고 패스워드 암호화는 PasswordEncoder 를 사용하겠다는 의미입니다.
client 으로 부터 ID/PASSWORD 요청 받으면 Spring Security 에서 어떻게 인증(로그인) 처리 하는지 알아보겠습니다.
AuthenticationController.java -> @PostMapping(“/authorize”)
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
authorizationRequest.getUsername(),
authorizationRequest.getPassword()
)
);
client 으로 부터 ID/PASSWORD 요청 받는 데이터는
UsernamePasswordAuthenticationToken 통해 요청 받은 ID/PASSWORD 를 담아서 인증 객체를 생성 하게 됩니다.
그런 다음 Spring Security 에 authenticationManager 에 인증 위임을 전달 하게 됩니다.
전달 받은 authenticationManager 는 우리가 Custom 한 UserServiceHandler 클래스에서 인증 검증을 하게 됩니다.
UserServiceHandler.java
@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceHandler implements UserDetailsService {
private final MemberQueryService memberQueryService;
private final RoleQueryService roleQueryService;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberQueryService.selectMemberById(username);
if (member == null) {
throw new UserIdNotFoundException(MemberErrorCode.NOT_FOUND_USERNAME);
}
List<Role> roles = roleQueryService.selectAllRolesByMember(member);
List<SimpleGrantedAuthority> grants = roles.stream().map(role -> new SimpleGrantedAuthority(role.getName())).collect(Collectors.toList());
PrincipalDetails userDetails = PrincipalDetails.builder()
.id(member.getMemberNo())
.username(member.getId())
.name(member.getName())
.email(member.getEmail())
.password(member.getPassword())
.memberType(member.getMemberType())
.authorities(grants)
.build();
return userDetails;
}
}
앞서 설명한 UserServiceHandler 는 ID/PASSWORD 검증 및 구현한 로직 입니다.
Default 로 Spring Security 에서는 UserDetailsService 에서 처리 하게 되는데 이것을 Override 를 하게 되어 대신 사용 하겠다는 의미 입니다.
client 으로 부터 ID/PASSWORD 요청 받게 된다면 Spring Security 는 Authentication 인증 객체를 담아서
AuthenticationManager 에 전달하게 되고 인증 처리 하게 됩니다.
그 이후 UserServiceHandler 클래스의 loadUserByUsername 매소드가 실행 되면서 직접 Custom 한 인증 처리를 하게 됩니다.
성공적으로 인증 처리가 완료 된다면 Spring Security 가 제공하는 인증 객체 UserDetails Override 한 PrincipalDetails 객체에
해당 계정 정보를 담아서 return 하게 됩니다.
그런 후 Spring Security 에서는 자체적으로 해당 인증 객체를 SecurityContext 에 저장 하게 됩니다.
SecurityContext 는 Local Thread 에 한에서 전역적으로 해당 인증 객체를 참조 가능 합니다.
AuthenticationController.java -> @PostMapping(“/authorize”)
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
String accessToken = generateAccessToken(principalDetails, request, response);
String refreshToken = generateRefreshToken(principalDetails, request, response);
AuthorizationResponse authorizationResponse = AuthorizationResponse.builder()
.access_token(accessToken)
.refresh_token(refreshToken)
.expires_in(jwtProperties.getAccessTokenExpired())
.member_seq(principalDetails.getId())
.member_id(principalDetails.getUsername())
.authorities(principalDetails.getAuthorities())
.build();
return ResponseEntity.ok().body(authorizationResponse);
UserServiceHandler 에서 인증 검증을 완료하고 인증 객체를 return 받게 됩니다.
해당 인증 객체를 이용해 JWT Access Token 을 생성 할 차례 입니다.
JwtTokenProvider.java
private String generateToken(Map<String, Object> claims, String subject, String key, Long expiryTime) {
LocalDateTime expiryDate = LocalDateTime.now().plusSeconds(expiryTime);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(TimeConverter.toDate(LocalDateTime.now()))
.setExpiration(TimeConverter.toDate(expiryDate))
.signWith(jwtProperties.getSignatureAlgorithm(), key)
.compact();
}
application.yml 에서 설정한 토큰 유효시간 및 비밀키 그리고 return 받았던 인증 객체 정보를 이용해
JWT 토큰을 생성하는 로직입니다.
생성 한 토큰은 AuthorizationResponse 클래스에 담아서 최종적으로 응답 하게 됩니다.