Spring Security OAuth2 소셜 인증 데이터베이스 저장

데이터베이스에 회원 정보 저장

step-01: Google, Facebook 간단한 소셜 인증에서 소셜 기반으로 인증 처리를 진행했습니다. 이번 차례에서는 해당 정보를 데이터베이스에 영속화 시키는 간단한 예제로 Data JPA, H2를 이용하겠습니다. 전체코드는 Github에 공게되어 있습니다.

테이블

social-table

  • user_connection : 소셜에서 넘겨준 프로필에 대한 테이블
  • user : 해당 프로젝트의 user 테이블

전체적은 플로우는 다음과 같습니다.

  1. 소셜에서 회원 인증
  2. 인증된 회원정보 데이터베이스 조회

    • 조회되는 경우 -> 기존 유저로 판단, 조화된 정보기반으로 인증처리
    • 조회되지 않은 경우 -> 신규 유저로 판단, user_connection, user 테이블 저장 저장된 정보기반으로 인증처리

Security

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
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        ...
		http.antMatcher("/**").authorizeRequests().antMatchers("/", "/login**").permitAll().anyRequest()
                ...
				.addFilterBefore(ssoFilter(), BasicAuthenticationFilter.class);
		// @formatter:on
    }
    private Filter ssoFilter() {
        CompositeFilter filter = new CompositeFilter();
        List<Filter> filters = new ArrayList<>();
        filters.add(ssoFilter(google(), new GoogleOAuth2ClientAuthenticationProcessingFilter(socialService)));
        filters.add(ssoFilter(facebook(), new FacebookOAuth2ClientAuthenticationProcessingFilter(socialService)));
        filter.setFilters(filters);
        return filter;
    }
    private Filter ssoFilter(ClientResources client, OAuth2ClientAuthenticationProcessingFilter filter) {
        OAuth2RestTemplate restTemplate = new OAuth2RestTemplate(client.getClient(), oauth2ClientContext);
        filter.setRestTemplate(restTemplate);
        UserInfoTokenServices tokenServices = new UserInfoTokenServices(client.getResource().getUserInfoUri(), client.getClient().getClientId());
        filter.setTokenServices(tokenServices);
        tokenServices.setRestTemplate(restTemplate);
        return filter;
    }
}

step-01: Google, Facebook 간단한 소셜 인증의 SecurityConfig 에서 조금 변경한 내용입니다.

달라진 점은 크게 없고 GoogleOAuth2ClientAuthenticationProcessingFilter, FacebookOAuth2ClientAuthenticationProcessingFilter의 클래스를 기반으로 필터에 등록시켰습니다. 이때 소셜 가입 및 로그인 처리를 담당하는 SocialService를 생성자를 통해서 주입해주었습니다.

OAuth2ClientAuthenticationProcessingFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FacebookOAuth2ClientAuthenticationProcessingFilter extends OAuth2ClientAuthenticationProcessingFilter {
    private ObjectMapper mapper = new ObjectMapper();
    private SocialService socialService;
    public FacebookOAuth2ClientAuthenticationProcessingFilter(SocialService socialService) {
        super("/login/facebook");
        this.socialService = socialService;
        mapper.configure(DeserializationConfig.Feature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        final OAuth2AccessToken accessToken = restTemplate.getAccessToken(); // 토큰 정보 가져옴
        final OAuth2Authentication auth = (OAuth2Authentication) authResult;
        final Object details = auth.getUserAuthentication().getDetails(); // 소셜에서 넘겨 받은 정보를 details에 저장
        final FacebookUserDetails userDetails = mapper.convertValue(details, FacebookUserDetails.class); // Object mapper를 이용해서 객체 변환
        userDetails.setAccessToken(accessToken); // access token 정보도 저장
        final UserConnection userConnection = UserConnection.valueOf(userDetails); // UserConnection를 userDetails 기반으로 생성
        final UsernamePasswordAuthenticationToken authenticationToken = socialService.doAuthentication(userConnection); // SocialService를 이용해서 인증 절차 진행
        super.successfulAuthentication(request, response, chain, authenticationToken);
    }
}

OAuth2ClientAuthenticationProcessingFilter 클래스를 상속 받아서 구현한 클래스입니다. successfulAuthentication 메서드는 소셜 인증을 성공되면 호출되는 메서드입니다. 가장 중

restTemplate.getAccessToken() 메서드를 통해서 해당 유저의 access token 정보를 가져옵니다.

인자로 넘겨받은 authResult 객체에 소셜에서 넘겨받은 정보를 details 객체에 넘겨받습니다. 이때 넘겨받은 정보를 ObejctMapper를 이용해서 FacebookUserDetails로 변환 합니다.

step-01: Google, Facebook 간단한 소셜 인증에서 보셨듯 소셜에 넘겨주는 profile 객체는 LinkedHashMap의 형태입니다. 실제 런타임 전까지는 정확한 자료형을 확인하기가 어렵습니다. 그래서 FacebookUserDetails DTO 클래스 이용하는 것이 가독성 및 유지 보수하기 좋다고 생각합니다.

FacebookUserDetails 객체를 기반으로 UserConnection 객체를 만듭니다. UserConnection 객체는 아래에서 설명하겠습니다.

doAuthentication 메서드

1
2
3
4
5
6
7
8
9
10
11
public UsernamePasswordAuthenticationToken doAuthentication(UserConnection userConnection) {
    if (userService.isExistUser(userConnection)) {
        // 기존 회원일 경우에는 데이터베이스에서 조회해서 인증 처리
        final User user = userService.findBySocial(userConnection);
        return setAuthenticationToken(user);
    } else {
        // 새 회원일 경우 회원가입 이후 인증 처리
        final User user = userService.signUp(userConnection);
        return setAuthenticationToken(user);
    }
}

Data JPA에 대한 설명은 하지 않겠습니다. 데이터베이스에서 회원 존재 유무를 확인 후 기존 회원일 경우는 바로 인증 회원 정보를 리턴합니다. 그렇지 않고 신규 회원일 경우에는 회원 가입을 진행 시킵니다. 이때 user_conncection, user 테이블에 두 곳 모두 저장시킵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
    insert 
    into
        user_connection
        (access_token, display_name, email, expire_time, image_url, profile_url, provider, provider_id, id) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        user
        (email, nickname, provider_id, id) 
    values
        (?, ?, ?, ?)

신규 가입일 경우 쿼리입니다. user_connection, user 테이블에 각각 insert가 되는 것을 확인할 수 있습니다.

UserConnection

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
@Table(name = "user")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)private long id;
    ...
    @OneToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "provider_id", referencedColumnName = "provider_id", nullable = false, updatable = false, unique = true)
    private UserConnection social;
    @Builder
    private User(String email, String nickname, UserConnection social) {
        this.email = email;
        this.nickname = nickname;
        this.social = social;
    }
    public static User signUp(UserConnection userConnection) {
        return User.builder()
                .email(userConnection.getEmail())
                .nickname(userConnection.getDisplayName())
                .social(userConnection)
                .build();
    }
}
@Entity
@Table(name = "user_connection")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserConnection {
    @Id @GeneratedValue(strategy = GenerationType.AUTO) private long id;
    @Column(name = "email") private String email;
    @Column(name = "provider") @Enumerated(EnumType.STRING) private ProviderType provider;
    @Column(name = "provider_id", unique = true, nullable = false) private String providerId;
    @Column(name = "display_name") private String displayName;
    @Column(name = "profile_url") private String profileUrl;
    @Column(name = "image_url") private String imageUrl;
    @Column(name = "access_token") private String accessToken;
    @Column(name = "expire_time") private long expireTime;
    ...
    public static UserConnection valueOf(GoogleUserDetails userDetails) {
        return UserConnection.builder()
                .expireTime(userDetails.getExpiration())
                .accessToken(userDetails.getAccess_token())
                .providerId(userDetails.getSub())
                .email(userDetails.getEmail())
                .displayName(userDetails.getName())
                .imageUrl(userDetails.getPicture())
                .provider(ProviderType.GOOGLE)
                .profileUrl(userDetails.getProfile())
                .build();
    }
}

코드는 길지만 말하고자 하는 것은 단순합니다. 지금 프로젝트에서는 소셜 가입 외에는 회원 인증 절차(회원가입)가 없다는 가정입니다.

그렇다는 것은 UserConnection 객체가 만들어지는 이유는 단 한 가지입니다. Google, Facebook를 통한 회원 인증일 경우입니다. UserConnection 위의 코드를 보시면 알겠지만 UserConnection 객체를 만들 수 있는 것은 방법은 valueOf 객체뿐입니다. (JPA 프록시객체 때문에 protected 생성자는 만들어야 합니다.)

이 처럼 객체의 생성도 명확한 근거 외에는 생성을 제한하는 좋은 구조라고 생각합니다. 또 User 객체도 마찬가지입니다. 소셜 회원 가입 이외에는 회원가입이 없으므로 User 객체는 UserConnection를 기반으로 만드는 방법 말고 제공하지 않는 것도 마찬가지입니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.