Oauth2 적용
OAuth2
STOMP를 활용하면 header에 token 값을 넣어 인증을 할 수 있다는 글을 많이 보게 되었다.
실제로 적용하는 곳은 많지 않았지만 해당 기능을 적용할 수 있다면 한 번쯤 직접 구현해보는 것도 의미가 있다고 생각했다.
이를 위해 Spring Security 적용이 필요했는데 기존 프로젝트에는 이미 Security가 적용되어 있었다.
기존 방식과 다른 로그인 흐름을 실험해보고 싶어서 OAuth2 방식을 활용하게 되었다.
Authentication은 인증을 의미하고 Authorization은 인가를 의미한다.
Security Flow
Spring Security에서 별도의 설정 없이 기본 formLogin 방식을 사용할 경우 다음과 같은 흐름으로 인증이 진행된다.
클라이언트로부터 요청이 들어오면 Servlet Filter 단계에서 SecurityFilterChain으로 요청 처리가 위임된다.
SecurityFilterChain 내부에서 UsernamePasswordAuthenticationFilter가 인증을 담당하게 된다.
UsernamePasswordAuthenticationFilter는 AuthenticationFilter에 해당하는 구현체이다.
AuthenticationFilter는 요청을 가로채 사용자 입력 정보를 기반으로
UsernamePasswordAuthenticationToken 객체를 생성한다.
이 시점의 Authentication 객체는 아직 검증되지 않은 상태이다.
AuthenticationFilter는 HttpServletRequest에서 username과 password를 추출하여 토큰을 생성한다.
생성된 토큰은 AuthenticationManager에게 전달된다.
AuthenticationManager는 인터페이스이며 일반적으로 ProviderManager 구현체가 사용된다.
ProviderManager는 인증 처리를 위해 AuthenticationProvider에게 토큰을 전달한다.
AuthenticationProvider는 전달받은 UsernamePasswordAuthenticationToken을 기반으로 UserDetailsService를 호출한다.
UserDetailsService는 DB에서 사용자 정보를 조회하여 UserDetails 객체를 생성한다.
생성된 UserDetails 객체는 다시 AuthenticationProvider로 전달된다.
AuthenticationProvider는 사용자 인증을 수행하고 성공 시 권한 정보가 포함된 Authentication 객체를 생성한다.
ProviderManager는 인증이 완료된 Authentication 객체를 AuthenticationFilter로 반환한다.
AuthenticationFilter는 인증이 완료된 Authentication 객체를 SecurityContextHolder의 SecurityContext에 저장한다.
UsernamePasswordAuthenticationFilter
UsernamePasswordAuthenticationFilter는 Form 기반 인증 방식에서 사용되는 필터이다.
로그인 요청 시 전달되는 username과 password 파라미터를 파싱하는 역할을 한다.
파싱한 정보를 기반으로 인증용 토큰을 생성하고 인증 처리를 AuthenticationManager에게 위임한다.
Spring Boot 환경에서 HttpSecurity 설정에 http.formLogin()을 사용하면 기본적으로 이 필터가 사용된다.
1
2
3
4
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.formLogin();
}
OAuth 로그인을 사용하는 경우 UsernamePasswordAuthenticationFilter 대신 OAuth2LoginAuthenticationFilter가 호출된다.
두 필터의 공통 상위 클래스는 AbstractAuthenticationProcessingFilter이다.
Spring Security는 AbstractAuthenticationProcessingFilter를 기준으로 로그인 방식을 분기한다.
로그인 방식에 따라 UsernamePasswordAuthenticationFilter 또는 OAuth2LoginAuthenticationFilter가 동작한다.
OAuth2 Flow
OAuth2 로그인 흐름은 최초 인증 요청 URL 호출로 시작된다.
http://localhost:8080/oauth2/authorize/kakao
Spring Security OAuth2의 기본 요청 경로는 /oauth2/authorization/{registrationId} 이다.
1
<a href="/oauth2/authorization/kakao">kakao</a>
frontend와 backend의 도메인이 동일한 경우 상대 경로를 사용할 수 있다.
frontend와 backend의 도메인이 다른 경우 backend 주소를 명시해야 한다.
참고 글 : 배포 후 oauth2 수정
ex) <a href="https://backend.haedal.com/oauth2/authorization/kakao">kakao</a>
Resource Server는 client_id와 redirect_uri 정보를 검증한다.
검증이 성공하면 Resource Server는 Resource Owner에게 권한 허용 여부를 요청한다.
Resource Owner가 권한을 허용하면 Resource Server는 authorization code를 발급한다.
authorization code는 redirect_uri를 통해 Client에게 전달된다.
Resource Owner는 authorization code를 받았다는 사실을 인지하지 못한다.
redirect 예시는 다음과 같다.
http://localhost:8080/login/oauth2/code/kakao?code=xxxxxx
이 단계는 해당 사용자가 OAuth Provider에 존재하는 사용자임을 검증하는 과정이다.
이후 JwtAuthenticationFilter가 토큰 처리를 담당하게 된다.
Resource Server의 API를 호출하기 위해 Access Token이 필요하다.
grant_type, client_id, redirect_uri, code 값을 포함하여 Access Token을 요청한다.
1
2
3
4
5
6
curl -v -X POST "https://kauth.kakao.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=${REST_API_KEY}" \
--data-urlencode "redirect_uri=${REDIRECT_URI}" \
-d "code=${AUTHORIZE_CODE}"
Access Token을 발급받으면 OAuth2 인증 과정은 완료된다.
OAuth2 인증 성공은 로그인 성공을 의미한다.
이후 과정은 커스텀 인가 처리 단계이며 본 프로젝트에서는 OAuth2 + JWT 방식을 사용하였다.
CustomOAuthService는 인증된 사용자 정보를 확인하고 DB에 존재하지 않으면 신규 사용자로 저장한다.
CustomOAuthService가 성공하면 OAuth2SuccessHandler가 실행된다.
OAuth2SuccessHandler는 JWT 인증 토큰을 생성하고 redirect_uri로 이동시킨다.
JWT 토큰은 QueryString에 포함되어 전달된다.
CustomOAuthService에서 예외가 발생하면 OAuth2FailureHandler가 실행된다.
JWT
JWT는 Claim 기반 인증 방식을 사용한다.
여기서 Claim이란 사용자에 대한 속성 정보를 의미하며
JWT는 이러한 Claim을 포함하는 사용자 상태가 담긴 의미 있는 토큰이다.
이로 인해 매 요청마다 인증 서버에 검증 요청을 보낼 필요가 없고 각 서버에서 토큰을 직접 검증할 수 있다.
Stateless 아키텍처 구성이 가능해지며
서버 자원을 효율적으로 사용할 수 있어 전체적인 서버 비용 절감 효과를 기대할 수 있다.
클라이언트는 먼저 Auth Server에 로그인을 요청합니다.
인증이 성공하면 Auth Server는 JWT 토큰을 발급하고,
이후 클라이언트는 API 요청을 보낼 때 Authorization Header에 해당 JWT 토큰을 포함하여 전달합니다.
애플리케이션 서버는 별도의 인증 서버 호출 없이 전달받은 JWT 토큰의 유효성을 직접 검증한 뒤 요청을 처리합니다.
다만 JWT 방식에는 주의해야 할 점도 존재합니다.
JWT는 매 요청마다 헤더에 포함되기 때문에 네트워크 트래픽이 증가할 수 있으며,
토큰 자체에 사용자 정보를 포함하고 있어 보안 관리가 중요합니다.
특히 JWT가 만료되기 전에 탈취될 경우 서버 측에서 이를 즉시 제어하기 어렵기 때문에,
반드시 만료 시간을 설정하여 토큰의 유효 기간을 제한해야 합니다.
Code
본 프로젝트에서는 Kakao, Google OAuth 로그인을 적용하였다.
아래 예시는 Kakao OAuth 기준으로 작성되었다.
JwtTokenProvider에서 OAuth2 로그인 이후 JWT 생성 로직이 수행된다.
SecurityConfig에서 OAuth2 로그인 성공 시 CustomOAuthService가 실행된다.
OAuth2 Filter 단계에서 CustomOAuthService의 loadUser()가 호출된다.
로그인 성공 시 OAuth2SuccessHandler의 onAuthenticationSuccess()가 실행된다.
OAuth2SuccessHandler에서 최초 로그인 여부 확인과 JWT 생성이 이루어진다.
모든 과정은 Spring Security Filter 내부에서 처리된다.
별도의 Login Controller는 존재하지 않는다.
OAuth 2.0 설정
1
2
3
4
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-configuration-processor'
spring.profiles.include=oauth 설정을 통해 application-oauth.properties 파일을 분리하여 관리할 수 있다.
1
spring.profiles.include=oauth
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# kakao about uri
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri = https://kapi.kakao.com/v2/user/me
# kakao certification need application information
spring.security.oauth2.client.registration.kakao.client-name=kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.client-id =
# kakao certification uses method & userinfo scope
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST
spring.security.oauth2.client.registration.kakao.client-secret =
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.scope=profile_nickname, account_email
{baseUrl}/login/oauth2/code/{registrationId} 로 redirect-uri를 받고 있어
login/oauth2/code/kakao로 Redirection Endpoint를 작성했다.
Domain
User Entity
User 엔티티는 OAuth2 로그인과 JWT 인증 모두를 처리하기 위해 UserDetails 인터페이스를 구현한다.
Spring Security에서 인증 객체로 사용되기 위해 UserDetails 구현이 필요하다.
socialId는 OAuth Provider에서 제공하는 사용자 고유 식별 값이다.
social 필드는 kakao, naver, google 등 로그인 제공자를 구분하기 위해 사용된다.
enabled 값은 계정 활성화 여부를 판단하기 위한 flag이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
@Column(nullable = false)
private String nickname;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String email;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserRole role;
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false)
private String socialId;
private String social;
@Builder
public User(String socialId, String email, String password, UserRole role, String username, String nickname, String social) {
this.socialId = socialId;
this.email = email;
this.password = password;
this.username = username;
this.nickname = nickname;
this.role = role == null ? UserRole.USER : role;
this.social = social;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getUsername() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
}
getAuthorities()는 JWT 생성 시 권한 정보를 담기 위해 사용된다.
현재 코드에서는 null을 반환하고 있으나 실제 운영 환경에서는 반드시 권한 정보를 반환해야 한다.
UserRole Enum
UserRole은 사용자 권한을 Enum 형태로 관리한다.
Spring Security에서는 권한 비교 시 문자열 기반 비교를 수행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Getter
@AllArgsConstructor
public enum UserRole {
USER,
ADMIN;
public static UserRole of(String name) {
for (UserRole role : values()) {
if (role.name().contains(name)) return role;
}
throw new BadConstantException();
}
}
of 메소드는 문자열 기반 권한을 Enum 타입으로 변환하는 역할을 한다.
JWT Claim에서 추출한 권한 문자열을 Enum으로 변환할 때 사용된다.
UserMapper
UserMapper는 OAuth Provider에서 전달받은 사용자 정보를 User 엔티티로 변환하는 역할을 한다.
인증과 인가 단계에서 각각 다른 형태의 User 객체를 생성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Component
@RequiredArgsConstructor
@Slf4j
public class UserMapper {
public static User ofKakao(OAuth2User oAuth2User, String nickname) {
var attributes = oAuth2User.getAttributes();
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
return User.builder()
.socialId(String.valueOf(attributes.get("id")))
.email((String) kakaoAccount.get("email"))
.password("")
.username((String) properties.get("nickname"))
.nickname(nickname)
.social("kakao")
.build();
}
public static User of(OAuth2User oAuth2User, String nickname) {
var authority = oAuth2User.getAuthorities();
String auth = authority.toString().replace("[", "").replace("]", "");
return User.builder()
.password("")
.nickname(nickname)
.role(UserRole.of(auth))
.build();
}
}
OAuth2User.getAuthorities()를 통해 Provider로부터 전달된 권한 정보를 확인할 수 있다.
UserDetailService
UserDetailService는 Spring Security 인증 과정에서 사용자 정보를 로딩하기 위한 인터페이스이다.
1
2
3
public interface UserDetailService extends UserDetailsService {
UserDetails loadUserByUsername(String nickname) throws UserNotFoundException;
}
UserDetailServiceImpl
JwtTokenProvider에서 추출한 사용자 정보로 DB에서 사용자 정보를 조회한다.
조회된 사용자 정보를 기반으로 UserDetails 객체를 생성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
@RequiredArgsConstructor
@Slf4j
public class UserDetailServiceImpl implements UserDetailService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) {
log.info("[loadUserByUsername] email : {}", email);
return userRepository.findByEmail(email)
.orElseThrow(UserNotFoundException::new);
}
}
JWT Payload에 저장된 email 값을 기준으로 사용자 정보를 조회한다.
이를 위해 loadUserByUsername 메소드를 오버라이드하여 구현한다.
UserRepository
1
2
3
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
Config
SecurityConfig
FilterChain 구성과 인증 및 인가 정책을 정의한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final CustomOAuthService oAuthService;
private final OAuth2SuccessHandler successHandler;
private final Oauth2FailureHandler failureHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.authorizeRequests()
.antMatchers("/css/**", "/login/**", "/oauth2/**").permitAll()
.antMatchers("/admin/**").hasAuthority("ADMIN")
.anyRequest().authenticated();
http.oauth2Login() // OAuth2 로그인 설정 시작점
.userInfoEndpoint() // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당
.userService(oAuthService) // OAuth2 로그인 성공 시, 후작업을 진행할 UserService 인터페이스 구현체 등록
.and()
.successHandler(successHandler)
.failureHandler(failureHandler);
http.addFilterBefore(
new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
}
Spring Security는 여러 Filter를 순차적으로 실행한다.
JWT 인증을 위해 UsernamePasswordAuthenticationFilter보다 먼저 실행되도록 설정한다.
hasAuthority는 권한 기반 접근 제어를 수행한다.
hasRole은 내부적으로 ROLE_ 접두어를 추가한다.
hasRole을 사용할 경우 DB에도 ROLE_ADMIN 형태로 저장되어야 한다.
1
2
3
alter table user
change column role
role enum('ROLE_USER','ROLE_ADMIN');
PasswordEncoderConfiguration
PasswordEncoder를 SecurityConfig에서 분리하여 순환 참조를 방지한다.
1
2
3
4
5
6
7
8
@Configuration
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
OAuth 흐름
사용자가 소셜 로그인을 완료한다.
AbstractAuthenticationProcessingFilter에서 OAuth2 로그인 과정이 시작된다.
OAuth2LoginAuthenticationFilter가 인증을 수행한다.
attemptAuthentication 과정에서 OAuth2AuthenticationToken이 생성된다.
OAuth2LoginAuthenticationProvider가 authenticate를 수행한다.
OAuth2UserService의 loadUser가 호출된다.
기본 구현체는 DefaultOAuth2UserService이다.
커스텀 구현체인 CustomOAuthService가 호출된다.
CustomOAuthService
CustomOAuthService는 OAuth Provider에서 전달받은 사용자 정보를 가공한다.
DB에 사용자 정보가 없으면 신규 사용자로 저장한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate =
new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// OAuth2 서비스 id (구글, 카카오, 네이버)
String registrationId =
userRequest.getClientRegistration().getRegistrationId(); // kakao
// OAuth2 로그인 진행 시 키가 되는 필드 값(PK)
String userNameAttributeName =
userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName(); // kakao는 id
// SuccessHandler가 사용할 수 있도록 등록
OAuth2Attribute attribute =
OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = userRepository.findByEmail(attribute.getEmail())
.orElseGet(() -> userRepository.save(UserMapper.ofKakao(oAuth2User, attribute.getName())));
if (!user.isEnabled()) {
throw new OAuth2AuthenticationException(new OAuth2Error("Not Found"));
}
// 로그인한 user를 retur
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())),
attribute.convertToMap(), // {name=kakao에서 설정한 이름, id=xx, key=email, email=xx@kakao.com, picture=xx}
"email"
);
}
}
OAuth2Attribute
OAuth Provider별 사용자 정보를 통합 관리하기 위한 클래스이다.
Spring Boot는 Google과 Facebook만 기본 지원한다.
Kakao와 Naver는 직접 구현이 필요하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Builder(access = AccessLevel.PRIVATE)
@Getter
public class OAuth2Attribute {
private Map<String, Object> attributes; // OAuth2 반환하는 유저 정보 Map
private String attributeKey;
private String email;
private String name;
private String picture;
static OAuth2Attribute of(String provider, String attributeKey, Map<String, Object> attributes) {
switch (provider) {
case "google":
return ofGoogle(attributeKey, attributes);
case "kakao":
return ofKakao(attributeKey, attributes);
case "naver":
return ofNaver(attributeKey, attributes);
default:
throw new RuntimeException();
}
}
Map<String, Object> convertToMap() {
Map<String, Object> map = new HashMap<>();
map.put("key", attributeKey);
map.put("email", email);
map.put("name", name);
map.put("picture", picture);
return map;
}
}
OAuth2SuccessHandler
OAuth2 로그인 성공 후 JWT 생성과 Redirect 처리를 담당한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
private final JwtTokenProvider jwtProvider;
@Value("${oauth.redirection.url}")
private String REDIRECTION_URL;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = (String) oAuth2User.getAttributes().get("email");
String token = jwtProvider.createToken(User.builder().email(email).build());
response.sendRedirect(REDIRECTION_URL + "?token=" + token);
}
}
JwtAuthenticationFilter
JWT 토큰의 유효성을 검사하는 custom filter
UsernamePasswordAuthenticationFilter보다 먼저 실행되도록 설정해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final String BEARER = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
if (token != null && token.startsWith(BEARER)) {
token = token.substring(BEARER.length());
if (jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
JWT 인증이 필요한 요청에서만 동작하도록 조건을 분기한다.
검증된 Authentication 객체는 SecurityContext에 저장된다.
reference
Kakao developers REST API docs
Spring Security + JWT를 통해 프로젝트에 인증 구현하기
[Spring Boot] OAuth2 + JWT + React 적용해보리기
Spring Security 와 OAuth 2.0 와 JWT 의 콜라보
Spring Security 커스텀 필터를 이용한 인증 구현 - 스프링시큐리티 설정(2)
Spring Security OAuth 설정 및 이해하기
Spring Security OAuth2 Login Flow
Spring Security - UsernamePasswordAuthenticationFilter 란
(Spring Security) OAuth2 서비스 구현 정리


