2023.01.09 - [Backend/Spring] - [Spring] Spring Security 기본 개념 (JWT / OAuth2.0 / 동작 방식 / 구성 요소)
개인 프로젝트를 진행하며 개념도 정리도 다시하고 있다.
스프링 시큐리티는 거의 3번째 공부하고 있지만 여전히 복잡하다.
이번에는 SSR에서 OAuth2.0 로그인하는 방법을 블로깅해보겠다.
SSR로는 Thymleaf를 쓰겠다.
프로젝트 구성은 Spring Boot + Thymleaf가 되겠다.
스프링 시큐리티와 OAuth를 쓰기 위해 라이브러리를 추가해줬다.
Maven
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
<version>5.7.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>3.0.1</version>
</dependency>
Gradle
implementation 'org.springframework.security:spring-security-oauth2-client:5.7.5'
compileOnly "org.springframework.boot:spring-boot-starter-security"
이 외에 Spring Boot Starter web, JPA와 lombok을 추가해줬다.
Google Login API 생성
https://console.cloud.google.com/projectselector2/apis/dashboard?supportedpurview=project
구글 개발자 콘솔 중 API 및 서비스에서 프로젝트를 하나 만들어준다.
사용자 인증정보를 만들어준다.
애플리케이션 유형을 '웹 애플리케이션'으로,
이름은 그냥 내 프로젝트 이름으로 했다.
redirection url은 http://localhost:8080/login/oauth2/code/google
메뉴의 OAuth 동의 화면을 외부로 만들어준다.
일단 필수 입력 *표시된 정보를 입력하고 넘어가준다.
그 다음 scope는 우리가 인증할때 받아오는 정보의 범위를 말한다. 미리 정해준다.
email, profile을 골랐다.
client-id, client-secret key 를 복사해둔다.
이따 application.yaml에 적어줄 것임
Naver Login API 생성
https://developers.naver.com/products/login/api/api.md
scope를 선택한다.
서비스 url은 http://localhost:8080/
redirection url은 http://localhost:8080/login/ouath2/code/naver
로 설정해줬다.
내 애플리케이션에서 client-id와 client secret을 복사해둔다.
Kakao Login API 생성
https://developers.kakao.com/product/kakaoLogin
카카오는 애플리케이션을 먼저 추가해주고 나머지를 설정해준다.
카카오 로그인을 사용할 것이니 켜주고 가져올 scope도 정해준다.
보안에서 카카오 로그인도 활성화해주고 코드도 복사해둔다. client secret으로 사용될 것이다.
client-id는 요약 정보의 rest-api 키가 될 것임
redirect url도 설정해준다.
이제 이 정보들을 application.yaml에 저장해준다.
application.yaml
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
|
spring:
security:
oauth2:
client:
registration:
google:
client-id: [client-id]
client-secret: [client-secret]
scope:
- profile
- email
naver:
client-id: [client-id]
client-secret: [client-secret]
client-name: Naver
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope:
- name
- email
kakao:
client-id: [client-id]
client-secret: [client-secret]
client-name: Kakao
client-authentication-method: POST
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope:
- profile_nickname
- account_email
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response
kakao:
authorizationUri: https://kauth.kakao.com/oauth/authorize
tokenUri: https://kauth.kakao.com/oauth/token
userInfoUri: https://kapi.kakao.com/v2/user/me
userNameAttribute: id
|
cs |
줄바꿈, 띄어쓰기에 주의한다.
kakao에서 authentication-method: POST 설정을 안해주면 에러가 난다.
[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: 401 Unauthorized: [no body]
이제 본격적으로 OAuth 설정에 들어간다.
기본 화면부터 만들어준다.
resource > templates에 index.html 생성해준다.
index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
|
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Hello Spring Boot</h1>
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
<a href="/oauth2/authorization/kakao" class="btn btn-secondary active" role="button">Kakao Login</a>
</body>
</html>
|
cs |
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
|
@Configuration
@EnableWebSecurity
public class SecurityConfig { //WebSecurityConfigurerAdapter was deprecated
private final CustomOAuth2UserService customOAuth2UserService;
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService) {
this.customOAuth2UserService = customOAuth2UserService;
}
@Bean
public BCryptPasswordEncoder encoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.authorizeRequests()
.antMatchers("/api/user").permitAll()
.and()
.oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);
return http.build();
}
}
|
cs |
oauth2Login 요청이 들어오면
userInfoEndpoint로 접근해 구현해낼 구현체 (CustomOAuth2UserService)를 등록해준다.
⭐️⭐️⭐️ 2023.12.16 추가
Spring Boot 3.x 에서는 설정이 바뀌었다. 아래 포스팅에서 확인하세요...
CustomOAuth2UserService
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
|
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
public CustomOAuth2UserService(UserRepository userRepository, HttpSession httpSession) {
this.userRepository = userRepository;
this.httpSession = httpSession;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
Users user = saveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user));
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey()
);
}
private Users saveOrUpdate(OAuthAttributes attributes) {
Users user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
|
cs |
여기에서는 요청에서 들어온 Provider을 구분해주고 유저 정보에 접근한다.
유저 정보는 OAuthAttributes라는 이름에서 Provider별로 Scope에 따라 정보를 담아 준다.
그리고 정보에 따라 유저를 생성해주고 세션도 설정해준다.
유저는 간단히 id, name, email, password, role만 가진다.
Users
@Entity
@Getter
@NoArgsConstructor
public class Users {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private String password;
@Enumerated(EnumType.STRING)
private Role role;
@Builder
public Users(Long id, String name, String email, String password, Role role) {
this.id = id;
this.name = name;
this.email = email;
this.password = password;
this.role = role;
}
public Users update(String name) {
this.name = name;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
UserRepository
public interface UserRepository extends JpaRepository<Users, Long> {
Optional<Users> findByEmail(String email);
}
OAuthAttributes
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
|
@Getter
@Builder
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
if("naver".equals(registrationId)) {
return ofNaver("id", attributes);
} else if ("kakao".equals(registrationId)) {
return ofKakao("id", attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributes.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> account = (Map<String, Object>) attributes.get("profile"); return OAuthAttributes.builder()
.name((String) account.get("nickname"))
.email((String) response.get("email"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
public Users toEntity() {
return Users.builder()
.name(name)
.email(email)
.role(Role.USER)
.build();
}
}
|
cs |
|
|
OAuth2User의 return이 map 형태이기 때문에 변환이 필요하다.
여기서 provider에 따라 scope 이름에 따라 뽑아준다.
이렇게 하고 서버를 실행해주면
구글 로그인도
네이버 로그인도
카카오 로그인도
잘 들어가진다.
로그인 역시 잘 되고 이후 리다이랙션도 원래 index로 잘 넘어가진다.
DB에도 저장이 잘 되는 것을 볼 수 있다.
view단에 넘겨주고 싶다면 login유저 dto를 만들어서 세션에 담긴 정보를 넘겨줘도 된다.
이로써 동작 과정은 이렇다.
1. 소셜 로그인 요청
2. SecurityConfig에서 oauth2Login 요청 들어온 걸 캐치 ( default path : /oauth2/authorization/{registrationId} )
3. application.yaml에 작성한 secreat-id, secreat-key, redirect-url 속성을 가지고 권한 부여 요청
4. 소셜 로그인 진행
5. 로그인 후 유저 정보 생성
내 프로젝트는 vue.js를 사용하므로 다음에는 SPA에서 구현해보겠다.
코드
https://github.com/recordbuffer/Custom-er
참고
스프링 부트와 AWS로 혼자 구현하는 웹 서비스 | 이동욱 - 교보문고 (kyobobook.co.kr)
Authorization Grant Support :: Spring Security
댓글