OAuth2
stomp를 활용하면 header에 token값을 넣어 인증을 할 수 있다는 글을 많이 보았다.
실제로 적용하는 곳은 많지 않았는데 해당 기능을 적용할 수 있으니 한번 해봐야하지 않을까? 싶었다.
그러려면 security를 적용해야했고 기존 project에는 이미 security가 적용되어있어
다른 방법으로 로그인을 구현해봐야겠다 싶어서 OAuth를 활용했다.
*Authentication : 인증, Authorization : 인가
OAuth2란
Security flow
스프링 시큐리티에서 동작하는 기본적인 formLogin을 할 경우(별도 설정x)
client로 부터 요청을 받으면 servlet filter에서 SecurityFilterChain으로 작업이 위임되고
그 중 UsernamePasswordAuthenticationFilter(AuthenticationFilter
에 해당)에서 인증을 처리AuthenticationFilter
가 요청을 가로채서 해당 정보를 통해UsernamePasswordAuthenticationToken
객체
(사용자가 입력한 데이터를 기반으로 생성, 즉 현 상태는 미검증 Authentication) 생성AuthenticationFilter
는 요청 객체(HttpServletRequest)에서 username과 password를 추출해서 token을 생성AuthenticationManger
에게 token을 전달.
AuthenticationManager
는 인터페이스이며, 일반적으로 사용되는 구현체는ProviderManager
다.ProviderManager
는 인증을 위해AuthenticationProvider
로 token을 전달한다.
(UsernamePasswordAuthenticationToken 객체를 전달)AuthenticationProvider
는 token의 정보를UserDetailsService
에 전달한다.UserDetailsService
는 전달받은 정보를 통해 db에서 일치하는 사용자를 찾아UserDetails
객체를 생성한다.생성된
UserDetails
객체는AuthenticationProvider
로 전달되며, 해당 Provider에서 인증을 수행하고
성공하게 되면ProviderManager
로 권한을 담은 토큰을 전달한다.
→ 인증이 완료되면, 사용자 정보를 담은 Authentication 객체를 반환ProviderManager
는 검증된 token을AuthenticationFilter
로 전달한다.AuthenticationFilter
는 검증된 token(Authentication 객체)을SecurityContextHolder
에 있는SecurityContext
에 저장
☑️ UsernamePasswordAuthenticationFilter란
Form based Authentication 방식으로 인증을 진행할 때 아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임하는 필터이다.
유저가 로그인 창에서 Login을 시도할 때 보내지는 요청에서 아이디(username)와 패스워드(password) 데이터를 가져온 후
인증을 위한 토큰을 생성 후 인증을 다른 쪽에 위임하는 역할을 하는 필터이다.
Spring Boot 기반의 HttpSecurity를 설정하는 코드에서 http.formLogin();
을 사용하면
시큐리티에서는 기본적으로 UsernamePasswordAuthenticationFilter 을 사용하게 된다.
1
2
3
4
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)throws Exception {
http.formLogin();
}
OAuth 로그인을 하게 된다면 UsernamePasswordAuthenticationFilter 대신 OAuth2LoginAuthenticationFilter 가 호출된다.
두 필터의 상위 클래스는 AbstractAuthenticationProcessingFilter이다.
스프링 시큐리티는 AbstractAuthenticationProcessingFilter를 호출하고,
로그인 방식에 따라 구현체인 UsernamePasswordAuthenticationFilter 와 OAuth2LoginAuthenticationFilter 가 동작하는 방식이다.
OAuth2 flow
1. OAuth2 login flow는 맨처음 Request URL을 보내면서 시작된다.
http://localhost:8080/oauth2/authorize/kakao
Spring Security OAuth 2.0 기본 요청 경로는 /oauth2/authorization/{registrationId}
이다.
1
<a href="/oauth2/authorization/kakao">kakao</a>
위와 같은 주소를 사용하려면 back과 front의 주소가 같아야한다.
만약에 back과 front가 주소가 다를 경우 back의 주소를 작성해야한다.
ex) <a href="https://backend.haedal.com/oauth2/authorization/kakao">kakao</a>
참고 글 : 배포 후 oauth2 수정
2. resource server는 client의 id와 redirect_uri를 확인한다.
3. 해당 값이 같으면 Resource Server가 Resource Owner에게 Client에게 권한을 허용할 것인지 메세지를 띄워 권한을 확인한다.
4. 임시 비밀번호 같은 authroization code를 Resource Server가 Resource Owner에게 주면 Resource Owner는 Clinet에게 준다.
*여기서 Resource Owner는 authroization code를 받았는지도 모른다.
Location: ${REDIRECT_URI}?code=${AUTHORIZE_CODE}
http://localhost:8080/login/oauth2/code/kakao?code = skfjskdjfaei
이런 식으로 넘겨준다.
→ 카카오의 유저 정보를 모아 놓은 서버에서 해당 유저가 있기 때문에 있는 유저다 라고 알려주는 것
5. JwtAuthenticationFilter가 토큰을 처리한다.
6. Resource Owner의 정보에 접근하기 위해 Access Token이 필요하며
Resource Server에서 요구하는 정보 grant_type, client_id, redirect_uri, code를 보낸다.
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}"
7. AccessToken을 받는다.
= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
여기까지가 oauth2과정으로 인증이 통과되었다는 것은 로그인 성공을 의미한다.
이 다음은 custom하여 인가과정을 작성한 것인데 나는 OAuth2+JWT를 적용했다.
8. SecurityConfig에서 정의한 CustomOAuthService는 인증된 사용자의 세부사항을 확인해서 db에 없을 경우 값을 저장한다.
9. 만일 CustomOAuthService가 성공이라면 OAuth2SuccessHandler가 실행되어서
JWT authentication token(인증 토큰)을 만들고 redirect_uri로 간다.
(queryString에 jwtToken을 넣는다.)
10. 만일 CustomOAuthService에서 에러가 발생한다면
throw new OAuth2AuthenticationException(new OAuth2Error("error"), new RuntimeException("에러 발생!!"));
와 같이 작성하여 Oauth2FailureHandler가 실행되게 한다.
JWT
JWT는 Claim 기반 방식을 사용한다.
여기서 Claim이란 사용자에 대한 속성 값들을 가리킨다.
즉, JWT은 의미있는 토큰 (사용자의 상태를 포함) 으로 구성되어 있기 때문에,
Auth Server에 검증 요청을 보내야만 했던 과정을 생략하고
각 서버에서 수행할 수 있게 되어 비용 절감 및 Stateless 아키텍처를 구성할 수 있다.
클라이언트 (사용자) 는 Auth Server에 로그인을 한다.
Auth Server에서 인증을 완료한 사용자는 JWT 토큰을 전달 받는다.
클라이언트는 특정 애플리케이션 서버에 리소스 (서비스에 필요한 데이터) 를 요청할 때,
앞서 전달 받은 JWT 토큰을 Authorization Header에 넣어 전달한다.
애플리케이션 서버는 전달 받은 JWT 토큰의 유효성을 직접 검사하여 사용자 인증을 할 수 있다.
고려해야 할 점은, 사용자 인증 정보가 필요한 요청을 보낼 때 헤더에 JWT 토큰 값을 넣어 보내야 하기 때문에
데이터가 증가하여 네트워크 부하가 늘어날 수 있다.
또한 토큰 자체에 사용자 정보를 담고 있기 때문에 JWT가 만료되기 전에 탈취당하면 서버에서 처리할 수 있는 일이 없다.
JWT 방식은 한 번 만들어 클라이언트에게 전달하면 제어가 불가능하기 때문에 만료 시간을 필수적으로 넣어 주어야 한다.
code 작성
나는 kakao, naver, google 3개를 적용했는데 아래는 kakao만 작성했다.
[흐름 정리]
JwtTokenProvider에서 OAuth2 로그인 과정이 수행된다.
SecurityConfig에서 OAuth2로그인 성공시에 CustomOAuthService에서 처리를 한다.
OAuth2 Filter 단에서 직접 커스텀한 OAuth2 Service의 “loadUser()” 메소드가 실행된다.
로그인을 성공하게 되면 OAuth2SuccessHandler의 “onAuthenticationSuccess” 메소드가 실행된다.
OAuth2SuccessHandler에서 최초 로그인 확인 및 JWT 생성 및 응답 과정이 실행된다.
*모든 과정은 Spring Security Filter 과정에서 수행된다. → Login Controller는 존재하지 않는다.
OAuth 2.0 설정
1
2
3
4
5
6
7
8
9
// securtiy
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt:0.9.1' // jwt
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-configuration-processor'
// 소셜 로그인을 통한 인증과 권한 처리를 쉽게 할 수 있게 해준다.
application.properties
*application.properties에서 spring.profiles.includes = name
이라고 작성하면
application-name
.properties 부분을 작성할 수 있다.
1
spring.profiles.include=oauth
application.properties에서
spring.profiles.includes = oauth
를 작성한 후 application-oauth-properties를 작성했다.
application-oauth.properties
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
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
75
76
77
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class User implements UserDetails {
public static final String DEFAULT_PROFILE_IMG_PATH = "images/default-profile.png";
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
@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;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRole role;
@Column(nullable = false)
private boolean enabled = true; // 1
@Column(nullable = false)
private String socialId;
private String social;
private String profile = DEFAULT_PROFILE_IMG_PATH;
@Builder // UserMapper와 연결
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.profile = DEFAULT_PROFILE_IMG_PATH;
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;
}
}
UserRole
1
2
3
4
5
6
7
8
9
10
11
12
@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();
}
}
UserMapper
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
@Component
@RequiredArgsConstructor
@Slf4j
public class UserMapper {
// 인증
public static User ofKakao(OAuth2User oAuth2User, String nickname) {
var attributes = oAuth2User.getAttributes();
Map<String, Object> kakao_account = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> properties = (Map<String, Object>) oAuth2User.getAttributes().get("properties");
return User.builder()
.socialId(String.valueOf(attributes.get("id")))
.email((String) kakao_account.get("email"))
.password("")
.username( (String) properties.get("nickname"))
.nickname(nickname)
.social("kakao")
// .picture((String)attributes.get("picture"))
.build();
}
// 인가
public static User of(OAuth2User oAuth2User, String nickname) {// nickname과 role만 있으면 됨
var authority = oAuth2User.getAuthorities();
String auth = authority.toString().replace("[","").replace("]","");
return User.builder()
.password("")
.nickname(nickname)
.role(UserRole.of(auth))
.build();
}
}
사용자가 OAuth 2.0 Provider를 통해 성공적으로 인증되면 OAuth2User.getAuthorities()
를 사용하여 권한을 얻을 수 있다.
UserDetailService
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
15
16
17
@Service
@RequiredArgsConstructor
@Slf4j
public class UserDetailServiceImpl implements UserDetailService {// @AuthenticationPrincipal에서 값을 받아오기 위해서는 아래 코드를 작성해야 한다.
private final UserRepository userRepository;
// User entity의 id 값 가져오기 (인증)
@Override
public UserDetails loadUserByUsername(String email) {
log.info("[loadUserByUsername] loadUserByUsername 수행. email : {}", email);
return userRepository.findByEmail(email).orElseThrow(() -> {
throw new UserNotFoundException();
});
}
}
JwtTokenProvider에서 토큰의 payload에서 가져온 email 정보를 통해 Repository에서 유저 정보를 가져와야한다.
그러기 위해서 UserDetailSerivce를 구현하는 클래스를 생성하여 loadUserByUsername 오버라이드 해서 이를통해 가져오면 된다.
UserRepository
1
2
3
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
config
SecurityConfig
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
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity //spring security 활성화를 위한 annotation
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() // rest api 에서는 csrf 공격으로부터 안전하고 매번 api 요청으로부터 csrf 토큰을 받지 않아도 되어 disable로 설정
.sessionManagement(); // Rest Api 기반 애플리케이션 동작 방식 설정
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)
.userInfoEndpoint().userService(oAuthService);
http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
// JwtAuthenticationFilter UsernamePasswordAuthenticationFilter보다 앞으로 설정
return http.build();
}
}
Spring Security는 여러가지의 필터를 순차적으로 돌며 해당되는 필터를 실행한다.
그리고 인증에 관련된 책임은 AuthenticationManager에 의해 수행된다.
기본적으로 Filter로 수행되는 것은 Form기반의 아이디와 비밀번호로 진행되는 UsernamePasswordAuthenticationFilter가 수행된다.
하지만 JWT 인증을 위해서는 새로운 필터를 만들어 UsernamePasswordAuthenticationFilter보다 먼저 수행되게 설정해야 한다.
.antMatchers("/admin/**").hasAuthority("ADMIN")
Spring Security에서 제공하는 역할(Role)과 권한(Authority)이 있다.
A와 B는 둘 다 관리자의 역할을 가지고 있지만 원칙상 A 계정은 게시판의 글을 등록할 수만 있으며,
B 계정은 등록된 글을 삭제만 할 수 있다고 했을 때 역할은 같지만 권한은 다르게 설정해야 할 것이다.
hasRole 메소드에는 ‘ROLE_’ 접두어를 내부적으로 붙여준다.
따라서 hasRole을 사용할 떄는 “ROLE_ADMIN”과 같이 작성해야하며 DB에도 동일하게 저장되어있어야한다.
1
2
3
alter table user
change column role
role enum('ROLE_USER','ROLE_ADMIN');
(Spring Security) OAuth2 서비스 구현 정리
3 Spring Security - Authorization(권한) 설정(ROLE), TagLib authorize 추가
☑️ 참고
어느 곳에서는 addFilterBefore 대신 addFilterAfter를 사용했다.
http.addFilterAfter(jwtAuthenticationFilter, LogoutFilter.class);
인증을 처리하는 기본필터 UsernamePasswordAuthenticationFilter 대신
별도의 인증 로직을 가진 필터를 생성하고 사용하고 싶을 때 아래와 같이 필터를 등록한다.
addFilterBefore
지정된 필터 앞에 커스텀 필터를 추가 (UsernamePasswordAuthenticationFilter 보다 먼저 실행된다)addFilterAfter
지정된 필터 뒤에 커스텀 필터를 추가 (UsernamePasswordAuthenticationFilter 다음에 실행된다.)addFilterAt
지정된 필터의 순서에 커스텀 필터가 추가된다
PasswordEncoderConfiguration
config에 PasswordEncoder를 넣었다가 순환참조 error가 난 적이 있었는데 따로 빼서 등록을 해야 된다.
1
2
3
4
5
6
7
@Configuration
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
OAuth
사용자가 소셜 로그인을 정상적으로 완료
AbstractAuthenticationProcessingFilter에서 OAuth2 로그인 과정을 호출
Resource Server에서 넘겨주는 정보를 토대로 OAuth2LoginAuthenticationFilter의
attemptAuthentication()
에서 인증 과정을 수행attemptAuthentication
attemptAuthentication()
처리 과정에서 OAuth2AuthenticationToken을 생성하기 위해 OAuth2LoginAuthenticationProvider의authenticate()
를 호출
authenticate
authenticate()
처리 과정에서 OAuth2User를 생성하기 위해 OAuth2UserService의loadUser()
를 호출
OAuth2UserService의 기본 구현체는 DefaultOAuth2UserService이지만,
커스텀한 OAuth2User를 반환하도록 구현하고 싶었으므로 직접 구현한 CustomOAuth2UserService의loadUser()
가 호출된다.
CustomOAuthService
DefaultOAuth2UserService를 구현한 OAuth2UserService 작성
SecurityConfig에서 OAuth2로그인 성공시에 CustomOAuthService에서 처리를 한다.
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
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
private final PasswordEncoder passwordEncoder;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// DefaultOAuth2UserService 객체를 성공정보를 바탕으로 만든다.
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
// 생성된 Service 객체로 부터 User를 받는다.
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// OAuth2 서비스 id (구글, 카카오, 네이버)
String registrationId = userRequest.getClientRegistration().getRegistrationId(); // kakao
// OAuth2 로그인 진행 시 키가 되는 필드 값(PK)
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
.getUserNameAttributeName(); // kakao는 id
// OAuth2 로그인을 통해 가져온 OAuth2User의 attribute를 담아주는 of 메소드
// SuccessHandler가 사용할 수 있도록 등록해준다.
OAuth2Attribute oAuth2Attribute = OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = userRepository.findByEmail(oAuth2Attribute.getEmail()).orElseGet(() -> {
log.info("[db save] : user social login");
User saved = UserMapper.ofKakao(oAuth2User, nickname);
userRepository.save(saved);
return saved;
});
if (!user.isEnabled()) throw new OAuth2AuthenticationException(new OAuth2Error("Not Found"), new UserNotFoundException());
Map<String, Object> memberAttribute = oAuth2Attribute.convertToMap(); // {name=kakao에서 설정한 이름, id=email, key=email, email=test@kakao.com, picture=null}
memberAttribute.put("id", user.getId());
// 로그인한 user를 return한다.
return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(user.getRole()
.name())), memberAttribute, "email");
}
}
☑️ 참고
OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService();
로 적은 글이 있어서 찾아봤다.
→ OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
OAuth2Attribute
OAuth2 로그인을 통해서 가져온 OAuth2User의 정보를 담아주기 위한 OAuth2Attribute를 생성한다.
스프링 부트에서는 google 및 facebook에 대한 OAuth2정보를 기본적으로 제공한다.
하지만 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
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
@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("email", attributes);
case "naver":
return ofNaver("id", attributes);
default:
throw new RuntimeException();
}
}
private static OAuth2Attribute ofGoogle(String attributeKey,
Map<String, Object> attributes) {
return OAuth2Attribute.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String)attributes.get("picture"))
.attributes(attributes)
.attributeKey(attributeKey)
.build();
}
private static OAuth2Attribute ofKakao(String attributeKey,
Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
return OAuth2Attribute.builder()
.name((String) kakaoProfile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.picture((String)kakaoProfile.get("profile_image_url"))
.attributes(kakaoAccount)
.attributeKey(attributeKey)
.build();
}
private static OAuth2Attribute ofNaver(String attributeKey,
Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuth2Attribute.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.picture((String) response.get("profile_image"))
.attributes(response)
.attributeKey(attributeKey)
.build();
}
Map<String, Object> convertToMap() {
Map<String, Object> map = new HashMap<>();
map.put("id", attributeKey);
map.put("key", attributeKey);
map.put("name", name);
map.put("email", email);
map.put("picture", picture);
return map;
}
}
OAuth2SuccessHandler
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
@Slf4j
@RequiredArgsConstructor
@Component
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");
User userInfo = userRepository.findByEmail(email).orElseThrow(UserNotFoundException::new);
String nickname = userInfo.getNickname();
User user = UserMapper.of(oAuth2User, nickname); // kakao type으로 넣기
String token = jwtProvider.generateToken(user); // string 으로 받는다
response.sendRedirect(getRedirectionURI(token, user));
}
private String getRedirectionURI(String token, User user) {
if(user.getRole().name().equals(UserRole.USER.name())){
return UriComponentsBuilder.fromUriString(REDIRECTION_URL).queryParam("token", token).build().toUriString();
}else if (user.getRole().name().equals(UserRole.ADMIN.name())){
return UriComponentsBuilder.fromUriString(ADMIN_REDIRECTION_URL).queryParam("token", token).build().toUriString();
}else{
throw new RuntimeException("[error] 잘못된 권한입니다.");
}
};
}
Oauth2FailureHandler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
public class Oauth2FailureHandler implements AuthenticationFailureHandler {
@Value("${oauth.failure.url}")
private String FAILURE_URL;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws
IOException, ServletException {
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
PrintWriter printWriter = response.getWriter();
printWriter.println("<script>");
printWriter.println(String.format("alert('%s')", exception.getMessage()));
printWriter.println(String.format("window.location.href='%s'", FAILURE_URL));
printWriter.println("</script>");
}
}
jwt
JwtTokenProvider
Jwt Token을 생성, 인증, 권한 부여, 유효성 검사, PK 추출 등의 다양한 기능을 제공하는 클래스
JwtTokenProvider에 Token을 통해 사용자 정보를 조회할 수 있는 메서드를 작성한다.
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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtTokenProvider {
private final UserDetailService userDetailsService;
@Value("${springboot.jwt.secret}")
private String secretKey; // 토큰 생성에 필요한 key
private final long TOKEN_VALID_MILISECOND = 1000L * 60 * 60 * 10; // token 유효시간 : 10시간
long refreshPeriod = 1000L * 60L * 60L * 24L * 30L * 3L;
// SecretKey 초기화
@PostConstruct // Bean 객체로 주입된 후 수행
protected void init() {
log.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
// secretKey 를 base64 형식으로 인코딩
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
log.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}
public String createToken(User user) {
log.info("[createToken] 토큰 생성 시작");
// Claims 객체에 담아 Jwt Token 의 내용에 값 넣기, sub 속정에 값 추가(Uid 사용)
Claims claims = Jwts.claims().setSubject(user.getNickname());
claims.put("roles", user.getRole().name()); // 사용자 권한확인용 추가
Date now = new Date();
// Token 생성
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + TOKEN_VALID_MILISECOND))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.info("[createToken] 토큰 생성 완료");
return token;
}
// 필터에서 인증에 성공시 SecurityContextHolder 에 저장할 Authentication 생성
public Authentication getAuthentication(String token) {
log.info("[getAuthentication] 토큰 인증 정보 조회 시작");
Claims claims = getClaims(token).getBody();
String role = claims.get("roles").toString();
User user = User.builder()
.nickname(claims.getSubject())
.role(UserRole.of(role))
.build();
log.info("[getAuthentication] 토큰 인증 정보 조회 완료");
return new UsernamePasswordAuthenticationToken(user, token, user.getAuthorities());
}
public String getNickname(String token) {
log.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
// 토큰을 생성할때 넣었던 sub 값 추출
String info = getClaims(token).getBody().getSubject();
log.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료");
return info;
}
// Token 유효기간 체크
public boolean validateToken(String token) {
log.info("[validateToken] 토큰 유효 체크 시작");
try {
Jws<Claims> claims = Jwts.parser().
setSigningKey(secretKey)
.parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
log.info("[validateToken] 토큰 유효 체크 예외 발생");
return false;
}
}
}
private Jws<Claims> getClaims(String jwt){
try{
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt);
}catch (SignatureException e){
log.error("Invalid JWT signature");
throw e;
} catch (MalformedJwtException e){
log.error("Invalid JWT token");
throw e;
} catch (ExpiredJwtException e){
log.error("Expired JWT token");
throw e;
} catch (UnsupportedJwtException e){
log.error("Unsupported JWT token");
throw e;
} catch (IllegalArgumentException e){
log.error("JWT claims string is empty");
throw e;
}
}
}
JwtAuthenticationFilter
Jwt가 유효한 토큰인지 인증하기 위한 Filter
CustomFilter를 만들어 UsernamePasswordAuthenticationFilter보다 먼저 걸리도록 설정해야한다.
스프링 시큐리티에서는 기본적으로 토큰 처리를 위한 필터가 없으므로 구현해서 Filter Chain에 추가해야 한다.
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
@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);
request.setAttribute("existsToken", true); // 토큰 존재 여부 초기화
if (isEmptyToken(token)) request.setAttribute("existsToken", false); // 토큰이 없는 경우 false로 변경
if (token == null || !token.startsWith(BEARER)) {
filterChain.doFilter(request, response);
return;
}
token = parseBearer(token);
if (jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// JwtTokenProvider를 통해 Jwt 토큰을 검증 받는다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private boolean isEmptyToken(String token) {
return token == null || "".equals(token);
}
private String parseBearer(String token) {
return token.substring(BEARER.length());
}
}
JWT 토큰 검증이 필요한 경우에만 동작하도록 조건 처리를 한다.
클라이언트에서는 Authorization Header에 토큰을 담아서 보내므로,
HttpServletRequest에서 토큰을 추출한 후, 검증하여 Authentication을 SecurityContext에 저장한다.
Error
ClientRegistrationRepository
oauth를 적용하면서 가장많이 본 오류다.
1
2
3
4
5
6
7
8
9
10
11
12
***************************
APPLICATION FAILED TO START
***************************
Description:
Method springSecurityFilterChain in org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration required a bean of type 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository' that could not be found.
Action:
Consider defining a bean of type 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository' in your configuration.
위 문제는 아래와 같이 수정하다보니 해결되었다.
1. properties에 정보입력하기
[본문 내용 참고하기]
2. security extends없애기
spring 공식 blog 5.7.x 버전부터는 아래와 같이 변경
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
}
}
변경 후 추상화 객체가 빠지고 @Bean을 추가하고 함수 명이 바뀌고 리턴값이 생긴걸 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
return http.build();
}
}
OAuthService
SecurityConfig에서 OAuth2로그인 성공시에 OAuthService에서 처리를 한다.
그런데 code는 잘 받아오는데 OAuthService로 가지 않는 상황이 생겼다.
로그를 찍어봐도 애초에 OAuthService로 가지 않는 문제가 발생했다.
1
2
3
4
5
6
7
8
9
http.oauth2Login() // OAuth2 로그인 설정 시작점
.userInfoEndpoint() // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당
.userService(oAuth2UserService) // OAuth2 로그인 성공 시, 후작업을 진행할 UserService 인터페이스 구현체 등록
.and()
.successHandler(successHandler)
.failureHandler(failureHandler);
.authorizationEndpoint().baseUri("/oauth2/authorize") // 소셜 로그인 Url
.and()
.redirectionEndpoint().baseUri("/oauth2/callback/**");// 소셜 인증 후 Redirect Url
baseUri 같은 경우에는 successHandler에서 처리해주기 때문에 생략했다.
1
2
3
4
5
6
http.oauth2Login()
.userInfoEndpoint().userService(customOAuth2UserService)
.and()
.successHandler(successHandler)
.failureHandler(failureHandler)
.userInfoEndpoint().userService(customOAuth2UserService);
redirection
카카오 로그인을 하면 “리디렉션한 횟수가 너무 많습니다.” 라는 오류가 뜨면서 계속 reload 되었다.
리디렉션 에러는 session 문제였다.
security에 .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
를 작성했었는데
위 코드는 세션을 사용하지 않는다를 의미한다. 이부분을 제거하니 제대로 동작하였다.
1. 클라이언트가 OAuth2 서비스 제공자에게 인증 요청을 보낸다.
2. 사용자는 서비스 제공자에게 로그인 정보를 제공하고, 인증을 수행한다.
3. 서비스 제공자는 클라이언트에게 인증 코드 또는 Access Token을 발급한다.
4. 클라이언트는 발급 받은 인증 코드 또는 Access Token을 사용하여 서비스 제공자의 API에 요청을 보낸다.
5. 서비스 제공자는 인증 코드 또는 Access Token의 유효성을 검증하고, 인증 및 자원 접근을 허용한다.
OAuth2 로그인 과정에서 session을 사용하여 인증 정보를 저장하고 관리한다.
session은 server에서 상태를 유지하고 client는 sessionID를 통해 인증을 유지한다.
따라서 OAuth2를 사용하는 경우 session 기반의 인증방식을 활용해서 로그인을 처리한다.
RESTful API와 같은 stateless한 시스템을 구축하려면 다른 토큰 기반의 인증 방식을 고려해야한다.
reference
Kakao developers REST API docs
[JAVA] Spring Boot(스프링 부트) - Security(시큐리티) 설정
Spring Security + JWT를 통해 프로젝트에 인증 구현하기
발급받은 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 Boot] Spring Security (5) - 역할(hasRole)과 권한(hasAuthority)의 차이는 무엇일까?
(Spring Security) OAuth2 서비스 구현 정리