스프링부트 Naver Login 구현하기
출처:
사용한 방식
저번에 카카오 로그인을 구현하였을 때는 restTemplate을 사용했었다.
그런데 이번에는 spring-security-oauth2
부분을 알아보고 사용해보고자 했다.
라이브러리 중에 spring-security-auth2-autoconfigure
이 있는데,
스프링부트2에서 기존 설정을 그대로 사용할 수 있어 많은 개발자가 이 방식을 사용했다고 한다.
하지만 책에서는 spring-boot-starter-oauth2-client
라이브러리를 사용했다.
그 이유는 아래와 같다.
- 스프링 팀에서 신규 기능은 oauth2 라이브러리에서만 지원하겠다고 선언
- 스프링부트용 라이브러리가 출시
- 기존에 사용되던 방식은 확장 포인트가 적절하게 open되어 있지 않아 직접 상속하거나,
오버라이딩 해야하고, 신규 라이브러리의 경우 확장 포인트를 고려해서 설계된 상태
직접 구현할 때 관련 자료를 찾아보면,
- application.properties 혹은 application.yml
- spring-security-oauth2-autoconfigure 라이브러리를 사용했는지
에 대한 의문을 가질 수 있다.
의문점 해결하기
-
application.properties 혹은 application.yml
1) application.properties
application.properties의 구조는 아래와 같다.
spring 안에 datasource가 있을 경우 '.'을 사용하여 계층적 데이터를 표시함을 알 수 있다.
또, 아래처럼 '#---'을 사용하여 여러 문서로 분할할 수 있다.
1
2
3
4
5
6
|
#---
spring.config.activate.on-profile=dev
spring.datasource.password=password
#---
spring.config.activate.on-profile=prod
spring.datasource.password=password
|
2) application.yml
application.yml에서는 계층 구조가 아래처럼 나타남을 알 수 있고,
‘—‘을 사용하여 여러 문서 파일을 지원함을 알 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
spring:
datasource:
password: password
url: jdbc:h2:dev
username: SA
---
spring:
config:
activate:
on-profile: staging
datasource:
password: 'password'
url: jdbc:h2:staging
username: SA
|
실습 시작!
우선 build.gradle로 가서 의존성 하나를 추가해준다.
1
2
|
# build.gradle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
해당 의존성은 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성
그럼 소셜 로그인 코드를 작성하기 앞서,
config/auth
패키지를 생성해준다.
SecurityConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception{
http
.csrf().disable().headers().frameOptions().disable().and()
.authorizeRequests()
.andMatchers("/","/css/**","/images/**","/js/**","/h2-console/**", "/profile").permitAll()
.andMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(customOAuth2UserService);
}
}
|
- @EnableWebSecurity:
Spring Security 설정들을 활성화시켜줍니다.
- .csrf().disable().headers().frameOptions().disable().and():
h2-console 화면을 사용하기 위해 해당 옵션들을 disable하는 부분
- .authorizeRequests():
URL 별 권한 관리를 설정하는 옵션의 시작점
이 부분을 넣어주어야 andMatchers() 옵션 사용 가능
- .andMatchers("/","/css/**", …).permitAll():
matchers 안에 있는 “/” 같은 부분들은 permitAll() 옵션으로 전체 열람 권한 줄 수 O
- anyRequest().authenticated():
설정된 값 이외 나머지 URL은 인증된 사용자들에게만 허용
- .logout().logoutSuccessUrl("/")
로그아웃 기능에 대한 여러 설정의 진입점
- .oauth2Login()
oauth2 로그인 기능에 대한 여러 설정 진입점
- .userInfoEndPoint()
oAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정 담당
- .userService
소셜 로그인 성공 시 후속 조치 진행. 추가로 진행하고자 하는 기능 명시 가능
OAuth2UserService 인터페이스의 추상메서드인 loadUser를 사용
resources/application-oauth.yml
resources에 application.yml 파일이 있을텐데,
resources/application-oauth.yml 파일 또한 만들어준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
# resources/application-oauth.yml
spring:
security:
oauth2:
client:
registration:
naver:
client-id: l4Z5n7Dr8puo1xal22dq
client-secret: fpoi6aZ8SO
redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope: name,email,profile_image
client-name: Naver
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
|
네이버는 Spring Security를 공식 지원하지 않기 때문에,
위에처럼 provider 값들을 수동으로 입력한다.
그래서 예를 들어 http://localhost:8080/oauth2/authorization/naver URL 값을 넣어주면
그럼 해당 oauth.yml 파일을 사용할 것이기 때문에
아래 profiles 부분을 spring 아래 아무데에나 넣어준다.
1
2
3
4
5
6
7
|
# resources/application.yml
spring:
...
profiles:
include: oauth
...
|
user/entity
아래처럼 userEntity가 있다고 가정한다.
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
|
/**
* user/entity/User.java
*/
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column
private String picture;
// Enumerated: Enum 값을 어떤 형태로 저장할지에 대한 설정(Default = INT)
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Builder
public User(String name, String email, String picture, Role role){
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
}
public User update(String name, String picture){
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey(){
return this.role.getKey();
}
}
|
위에서 Role을 넣어주었기 때문에
Role.java 파일을 만들어준다.
1
2
3
4
5
6
7
8
9
10
11
12
|
/**
* user/entity/Role을.java
*/
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
|
스프링 security에서는 권한 코드에 항상 ‘ROLE_‘이 앞에 있어야 한다.
user/repository/UserRepository
1
2
3
4
5
6
|
/**
* user/repository/UserRepository.java
*/
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
|
원하는 걸 사용할 수 있는데,
책의 예제에서는 findByEmail을 하였음을 알 수 있다.
config/auth/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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
/**
* config/auth/CustomOAuth2UserService.java
*/
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
/**
* DefaultOAuth2UserService는 OAuth2UserService의 구현체
* - 해당 클래스를 이용해서 userRequest에 있는 정보를 빼낼 수 있습니다.
* - objectMapper를 이용해서 json 형식으로 출력 가능(new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(userRequest))
*/
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
/**
* 변수에 대한 설명
* registrationId: 로그인 진행중인 서비스를 구분하는 코드 (구글, 네이버)
* userNameAttributeName: OAuth2 로그인 진행시 키가 되는 필드값 (PK 같은 역할) - 구글은 제공하지만 네이버, 카카오는 제공 X
* attributes: OAuth2UserService 를 통해 가져온 OAuth2User 클래스의 attribute를 담을 클래스
* - oAuth2User: OAuth2UserService 로 만들어진 OAuth2User 객체를 참조하는 변수
* - custom 정의 클래스
*/
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
// 세션에 자용자 정보를 저장하기 위한 DTO 클래스. User 클래스를 사용하면 안되기에 SessionUser
// 왜 사용하면 안 될까?? 직렬화 관련 에러 + User 클래스가 데이터베이스와 직접 연결되는 엔티티여서
// 렬화 기능을 가진 DTO를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 많은 도움
httpSession.setAttribute("user", new SessionUser(user));
return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes){
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
|
- RequiredArgsConstructor: 초기화 되지않은 final 필드나, @NonNull 이 붙은 필드에 대해 생성자를 생성
- ‘CustomOAuth2UserService 클래스까지 생성되었다면 OAuthAttributes 클래스를 생성
config/auth/dto/OAuthAttributes
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
|
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
@Builder
public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture){
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
/* OAuth2User에서 반환하는 사용자 정보는 Map 자료구조 형태이기에 값 하나하나를 변환 */
public static OAuthAttributes of(String registrationId, String userNameAttributeName,
Map<String ,Object> attributes) {
return ofNaver("id", attributes);
}
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"))
.picture((String) response.get("profile_image"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
/* 가입 기본 권한: GUEST. User 엔티티 생성 */
public User toEntity(){
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
|
config/auth/dto/SessionUser
1
2
3
4
5
6
7
8
9
10
11
12
|
@Getter
public class SessionUser implements Serializable {
private String name;
private String email;
private String picture;
public SessionUser(User user) {
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
}
}
|
인증된 사용자 정보만 필요합니다.