~☆~ 우하하!!~ 개발블로그

[SpringBoot][소셜로그인] 소셜 로그인 (Naver) 및 회원가입 절차 수정 본문

SpringBoot

[SpringBoot][소셜로그인] 소셜 로그인 (Naver) 및 회원가입 절차 수정

iwoohaha 2024. 11. 20. 08:20
반응형

[정리]

diary 프로젝트가 이메일 주소와 패스워드를 이용한 회원가입 절차만으로 구성되어 있다가,

구글 로그인 기능을 연동하면서 구글 계정을 이용한 회원가입 절차와 로그인 기능(https://iwoohaha.tistory.com/320)이 통합(https://iwoohaha.tistory.com/334)되는 구조로 변경되었다.

네이버 로그인 기능을 연동하려하니 이메일 주소를 제공하지 않는다는 점 때문에 소셜 로그인 기능을 사용하는 경우 회원정보 관리 테이블의 구조가 달라져야 하는 상황이 되었다.(https://iwoohaha.tistory.com/337)

그리하여 https://iwoohaha.tistory.com/338 에서 소셜 로그인 기능으로 가입되는 회원정보를 저장할 테이블을 신규로 생성하여 구성하였다.

이제 소셜 로그인 사용시 users 테이블에 인증성공한 데이터를 저장하는 로직으로 수정해보려고 한다.

[수정]

앞에서 정리한 것처럼 구글과 네이버가 제공하는 인증 성공한 사용자 정보의 구조는 다르다(https://iwoohaha.tistory.com/338). 이렇게 소셜 로그인 정보제공자에 따라 각각 상이한 구조를 처리할 수 있도록 공통 구조의 클래스를 작성해야 한다.

소셜 로그인 정보제공자가 전달해주는 주요 데이터로는 providerid, email, name, profile_image_url 등이 있다.

  • providerid : 소셜 로그인 정보제공자(Google, Naver 등) 에서 유지하는 사용자의 고유ID
  • email : 소셜 로그인 정보제공자가 관리하는 사용자의 이메일 주소
  • name : 소셜 로그인 정보제공자가 관리하는 사용자의 이름
  • profile_image_url : 소셜 로그인 정보제공자가 관리하는 사용자의 프로파일 이미지 URL

여기에 어떤 정보제공자인지를 구분할 수 있는 provider 가 추가되면 되겠다.

이 정도의 데이터를 다루기 위한 interface 를 OAuth2UserInfo 라는 이름으로 domain 패키지 아래에 생성을 하자.

package com.woohahaapps.study.diary.domain;

public interface OAuth2UserInfo {
    String getProvider();
    String getProviderId();
    String getEmail();
    String getName();
    String getProfileImageUrl();
}

OAuth2UserInfo 인터페이스는 앞으로 추가하는 각 OAuth2 를 이용한 인증정보 제공자별(예:Google, Naver 등) 클래스의 기반 인터페이스로 사용할 예정이다.

Google 계정으로 로그인시 Google 이 제공하는 인증된 사용자의 정보는 아래 스크린샷에서 볼 수 있듯이 OAuth2User 의 getAttributes로 얻어지는 Map<String, Object> 를 통해서 구할 수 있다.

따라서 Map<String, Object> 를 멤버로 갖는 GoogleOAuth2UserInfo 클래스를 아래와 같은 내용으로 구현할 수 있다. 앞에서 설명했듯이 GoogleOAuth2UserInfo 클래스는 OAuth2UserInfo 인터페이스를 구현하는 클래스가 되어야 한다.

package com.woohahaapps.study.diary.domain;

import java.util.Map;

public class GoogleOAuth2UserInfo implements OAuth2UserInfo {

    private final Map<String, Object> attributes;

    public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getProviderId() {
        return (String)attributes.get("sub");
    }

    @Override
    public String getEmail() {
        return (String)attributes.get("email");
    }

    @Override
    public String getName() {
        return (String)attributes.get("name");
    }

    @Override
    public String getProfileImageUrl() {
        return (String)attributes.get("picture");
    }
}

GoogleOAuth2UserInfo 클래스의 getProvider() 는 무조건 "google"을 리턴하도록 구현하였고, 다른 값들은 생성자 함수로 전달되는 Map<String, Object> 형의 attributes 로부터 각 key 값에 대한 값을 리턴하도록 구현하였다.

CustomOAuth2UserService 의 loadUser 함수에서 GoogleOAuth2UserInfo 를 사용하도록 코드를 수정해보자.

package com.woohahaapps.study.diary.service;

import com.woohahaapps.study.diary.domain.GoogleOAuth2UserInfo;
import com.woohahaapps.study.diary.domain.Member;
import com.woohahaapps.study.diary.domain.OAuth2UserInfo;
import com.woohahaapps.study.diary.domain.User;
import com.woohahaapps.study.diary.mapper.MemberMapper;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberMapper memberMapper;
    private final JavaMailSender mailSender;

    public CustomOAuth2UserService(MemberMapper memberMapper, JavaMailSender mailSender) {
        this.memberMapper = memberMapper;
        this.mailSender = mailSender;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String provider = userRequest.getClientRegistration().getRegistrationId();
        System.out.printf("provider = [%s]%n", provider);
//        String email = oAuth2User.getAttribute("email");
//        String name = oAuth2User.getAttribute("name");
        OAuth2UserInfo oAuth2UserInfo = null;
        if (provider.equals("google")) {
            oAuth2UserInfo = new GoogleOAuth2UserInfo(oAuth2User.getAttributes());
        }
        String email = oAuth2UserInfo.getEmail();
        String name = oAuth2UserInfo.getName();

        Optional<Member> member = memberMapper.GetMember(email);
        if (member.isEmpty()) {
//            throw new UsernameNotFoundException("해당 이름의 사용자가 존재하지 않습니다: " + email);
            Member newmember = new Member();
            newmember.setEmail(email);
            newmember.setName(name);
            newmember.setRole("ROLE_USER");
            newmember.setProvider(provider);
            memberMapper.CreateMember(newmember);

            // Mail 발송
            SimpleMailMessage message = new SimpleMailMessage();
            message.setTo(newmember.getEmail());
            message.setSubject("[signUp] diary 에 회원가입되었습니다");
            message.setText(String.format("email: %s\nname: %s", newmember.getEmail(), newmember.getName()));
            mailSender.send(message);

            return new User(newmember);
        }
        //return oAuth2User;
        return new User(member.get());
    }
}

기존 oAuth2User.getAttribute("email") 과 oAuth2User.getAttribute("name") 으로 가져오는 대신(일부러 코드상에 주석으로 처리해 두었다), GoogleOAuth2UserInfo 클래스형의 변수를 만들어서 email 주소와 name 변수값을 가져오는 방법으로 변경하여 Google 계정 로그인을 이용한 로그인 및 회원가입 절차를 통과하도록 수정하였다.

이번에는 Naver 로그인하는 경우에 대해서 코드를 마저 작성해보자. 아래 스크린샷에서 볼 수 있듯이 Naver 가 제공하는 사용자의 정보는 oAuth2User.getAttribute("response") 로 구해지는 Map<String, Object> 목록을 사용해야 한다.

위 정보를 참고하여 OAuth2UserInfo 인터페이스를 구현한 NaverOAuth2UserInfo 클래스를 아래와 같이 구현할 수 있다.

package com.woohahaapps.study.diary.domain;

import java.util.Map;

public class NaverOAuth2UserInfo implements OAuth2UserInfo {

    private final Map<String, Object> attributes;

    public NaverOAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getProviderId() {
        return (String)attributes.get("id");
    }

    @Override
    public String getEmail() {
        return "";
    }

    @Override
    public String getName() {
        return (String)attributes.get("name");
    }

    @Override
    public String getProfileImageUrl() {
        return (String)attributes.get("profile_image");
    }
}

email 주소는 Naver 가 제공하지 않으므로 "" 이 리턴되도록 해 두었다. 이런 경우를 대비해서 users 테이블에서도 email 컬럼은 Null 허용으로 구성되어 있다. NULL 허용 컬럼이지만 "" 을 리턴하므로 NULL 로 저장되지 않고 빈문자열로 저장될 것이다.

이제 CustomOAuth2UserService 의 loadUser 함수에서 NaverOAuth2UserInfo 를 사용하도록 수정해보자.

package com.woohahaapps.study.diary.service;

import com.woohahaapps.study.diary.domain.*;
import com.woohahaapps.study.diary.mapper.MemberMapper;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberMapper memberMapper;
    private final JavaMailSender mailSender;

    public CustomOAuth2UserService(MemberMapper memberMapper, JavaMailSender mailSender) {
        this.memberMapper = memberMapper;
        this.mailSender = mailSender;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String provider = userRequest.getClientRegistration().getRegistrationId();
        System.out.printf("provider = [%s]%n", provider);
//        String email = oAuth2User.getAttribute("email");
//        String name = oAuth2User.getAttribute("name");
        OAuth2UserInfo oAuth2UserInfo = null;
        if (provider.equals("google")) {
            oAuth2UserInfo = new GoogleOAuth2UserInfo(oAuth2User.getAttributes());
        } else if (provider.equals("naver")) {
            oAuth2UserInfo = new NaverOAuth2UserInfo(oAuth2User.getAttribute("response"));
        }
        String email = oAuth2UserInfo.getEmail();
        String name = oAuth2UserInfo.getName();

        Optional<Member> member = memberMapper.GetMember(email);
        if (member.isEmpty()) {
//            throw new UsernameNotFoundException("해당 이름의 사용자가 존재하지 않습니다: " + email);
            Member newmember = new Member();
            newmember.setEmail(email);
            newmember.setName(name);
            newmember.setRole("ROLE_USER");
            newmember.setProvider(provider);
            memberMapper.CreateMember(newmember);

            // Mail 발송
            SimpleMailMessage message = new SimpleMailMessage();
            message.setTo(newmember.getEmail());
            message.setSubject("[signUp] diary 에 회원가입되었습니다");
            message.setText(String.format("email: %s\nname: %s", newmember.getEmail(), newmember.getName()));
            mailSender.send(message);

            return new User(newmember);
        }
        //return oAuth2User;
        return new User(member.get());
    }
}

userRequest.getClientRegistration().getRegistrationId() 로 구한 provider 에 따라 분기처리할 때 "naver" 인 경우를 추가하였다.

이렇게 수정한 상태에서 실행시켰을 때 Naver 가 이메일 주소를 제공하지 않으므로 회원정보 테이블(member)에서 해당 회원에 대한 정보는 당연히 찾을 수가 없을 것이다.

이제 새로 생성한 users 테이블을 사용할 차례이다.

우선 users 테이블에 1개의 레코드 정보를 저장하기 위한 도메인 클래스로 Users 를 작성해보자.

package com.woohahaapps.study.diary.domain;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;

import java.sql.Timestamp;

@Data
public class Users {
    private Long id;
    private String provider_user_id;
    private String provider;
    private String email;
    private String name;
    private String profile_image_url;
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
    @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
    private Timestamp created_at;
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
    @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
    private Timestamp updated_at;
}

mapper 패키지 아래에 UsersMapper 인터페이스를 다음과 같이 추가한다.

package com.woohahaapps.study.diary.mapper;

import com.woohahaapps.study.diary.domain.Users;
import org.apache.ibatis.annotations.Mapper;

import java.util.Optional;

@Mapper
public interface UsersMapper {
    Integer CreateUsers(Users users);
    Optional<Users> GetUsers(String provider_user_id, String provider);
}

데이터베이스에서 users 테이블에 연결하여 쿼리를 수행하기 위한 UsersMapper.xml 파일을 작성한다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.woohahaapps.study.diary.mapper.UsersMapper">
    <insert id="CreateUsers" useGeneratedKeys="true" keyColumn="id" keyProperty="id" parameterType="Users">
        insert into users (provider_user_id, provider, email, name, profile_image_url)
        values (#{provider_user_id}, #{provider}, #{email}, #{name}, #{profile_image_url});
    </insert>
    <select id="GetUsers" resultType="Users">
        select
            *
        from users
        where
            provider_user_id = #{provider_user_id}
            and provider = #{provider}
    </select>
</mapper>

매퍼 xml 파일(UsersMapper.xml)이 신규로 추가되었으므로 mybatis-config.xml 에 이 항목을 추가하자.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <mappers>
        <mapper resource="mapper/DiaryMapper.xml"/>
        <mapper resource="mapper/MemberMapper.xml"/>
        <mapper resource="mapper/UsersMapper.xml"/>
    </mappers>
</configuration>

이번에는 CustomOAuth2UserService 의 loadUser 함수에서 users 테이블에 해당 인증정보가 존재하는지를 확인하는 로직으로 수정한다. CustomOAuth2UserService 에서는 소셜 로그인 인증정보만을 관리하는 기능으로 제한한다. 

package com.woohahaapps.study.diary.service;

import com.woohahaapps.study.diary.domain.*;
import com.woohahaapps.study.diary.mapper.MemberMapper;
import com.woohahaapps.study.diary.mapper.UsersMapper;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.*;

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberMapper memberMapper;
    private final JavaMailSender mailSender;
    private final UsersMapper usersMapper;

    public CustomOAuth2UserService(MemberMapper memberMapper, JavaMailSender mailSender, UsersMapper usersMapper) {
        this.memberMapper = memberMapper;
        this.mailSender = mailSender;
        this.usersMapper = usersMapper;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String provider = userRequest.getClientRegistration().getRegistrationId();
        System.out.printf("provider = [%s]%n", provider);
//        String email = oAuth2User.getAttribute("email");
//        String name = oAuth2User.getAttribute("name");
        OAuth2UserInfo oAuth2UserInfo = null;
        if (provider.equals("google")) {
            oAuth2UserInfo = new GoogleOAuth2UserInfo(oAuth2User.getAttributes());
        } else if (provider.equals("naver")) {
            oAuth2UserInfo = new NaverOAuth2UserInfo(oAuth2User.getAttribute("response"));
        }
        String email = oAuth2UserInfo.getEmail();
        String name = oAuth2UserInfo.getName();
        String provider_user_id = oAuth2UserInfo.getProviderId();
        String profile_image_url = oAuth2UserInfo.getProfileImageUrl();

        Optional<Users> users = usersMapper.GetUsers(provider_user_id, provider);
        if (users.isEmpty()) { // users 테이블에 OAuth2 인증정보가 존재하지 않음
            Users newUsers = new Users();
            newUsers.setProvider(provider);
            newUsers.setProvider_user_id(provider_user_id);
            newUsers.setEmail(email);
            newUsers.setName(name);
            newUsers.setProfile_image_url(profile_image_url);

            Integer affectedCount = usersMapper.CreateUsers(newUsers);
            if (affectedCount < 1) {
                throw new RuntimeException(String.format("""
                                소셜 로그인 인증정보 저장오류: 
                                provider=%s
                                , provider_user_id=%s
                                , email=%s
                                , name=%s
                                , profile_image_url=%s
                                """
                                , provider, provider_user_id, email, name, profile_image_url
                ));
            }
            // 소셜 로그인 인증정보 저장 성공
            users = usersMapper.GetUsers(provider_user_id, provider);
        }

        // 기존 attributes를 복사하여 새로운 HashMap 생성
        Map<String, Object> attributes = new HashMap<>(oAuth2User.getAttributes());

        // usersAttributes 추가
        Map<String, Object> usersAttributes = oAuth2UserInfo.convertToMap();
        usersAttributes.put("uid", users.get().getId());

        attributes.put("users", usersAttributes);

        Collection<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_USER"));
        // 각 Provider에 따라 nameAttributeKey 설정
        String nameAttributeKey = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        return new DefaultOAuth2User(authorities, attributes, nameAttributeKey);
    }
}

users 테이블에 저장된 소셜 로그인 인증정보를 획득한 후에는 DefaultOAuth2User 를 생성하여 리턴하도록 변경하였는데, 이 값은 뒤에서 살펴볼 CustomOAuth2LoginSuccessHandler 에서 다루어질 것이다. CustomOAuth2LoginSuccessHandler 의 onAuthenticationSuccess 함수에서 member 테이블의 회원정보를 다루도록 할 예정이다.

DefaultOAuth2User 를 생성할 때 전달하는 파라미터 중 attributes 에 users 테이블에서 구한 소셜 로그인 인증정보를 담아 전달하기 위해서 oAuth2User 의 getAttributes() 로 얻어진 Map<String, Object> 에 key 가 "users" 인 해시맵을 추가하고 있다. "users" 해시맵에는 uid 라는 key 로 users 테이블에 저장되어 있는 인증정보의 id 값이 추가된다. OAuth2UserInfo 에 convertToMap 이라는 인터페이스 함수를 추가해서 "users" 해시맵에 담을 인증정보를 구성하도록 한다.

package com.woohahaapps.study.diary.domain;

import java.util.Map;

public interface OAuth2UserInfo {
    String getProvider();
    String getProviderId();
    String getEmail();
    String getName();
    String getProfileImageUrl();

    Map<String, Object> convertToMap();
}

GoogleOAuth2UserInfo 클래스와 NaverOAuth2UserInfo 클래스에 convertToMap 함수를 각각 구현하고  내용은 다음과 같이 동일한 내용으로 작성하면 된다.

...
    @Override
    public Map<String, Object> convertToMap() {
        Map<String, Object> map = new HashMap<>();
        map.put("provider", getProvider());
        map.put("providerid", getProviderId());
        map.put("email", getEmail());
        map.put("name", getName());
        map.put("profileimageurl", getProfileImageUrl());
        return map;
    }
}

 

이제 CustomOAuth2LoginSuccessHandler 의 onAuthenticationSuccess 함수를 수정해보자.

이 함수는 CustomOAuth2UserService 의 loadUser 가 호출되어 성공적으로 OAuth2 프로토콜에 의해서 소셜 로그인 인증이 성공한 경우에 실행된다.

앞으로 작성해 볼 대략의 흐름을 정리하면 다음과 같다.

  • 소셜 로그인 인증 정보(CustomOAuth2UserService 의 loadUser 가 리턴한 값) 획득
  • users 테이블의 id 값이 member 테이블의 uid 컬럼값과 일치하는 member 테이블의 레코드 존재 여부 확인(소셜 로그인한 정보가 회원정보 테이블에 존재하는지 여부)
    • 존재한다면 member 테이블의 레코드로 jwt 토큰정보 생성하여 로그인 처리
    • 존재하지 않는다면
      • 소셜 로그인 인증정보에 email 주소가 존재한다면, member 테이블에 회원정보로 추가하고, jwt 토큰정보 생성하여 로그인 처리
      • 소셜 로그인 인증정보에 email 주소가 존재하지 않는다면, 사용자로부터 email 주소를 입력받기 위한 화면으로 이동

 

users 테이블과 member 테이블의 레코드간 관계를 연결하기 위해서 member 테이블에 uid 컬럼을 추가한다.

이 uid 컬럼은 users 테이블의 id 값을 저장하기 위한 용도이다.

ALTER TABLE public."member" ADD uid smallint NULL;
COMMENT ON COLUMN public."member".uid IS 'users 테이블의 id 컬럼값. 소셜 로그인에 의한 회원가입 정보';

만약 소셜 로그인 기능으로 users 테이블에 인증 정보가 저장될 경우 이를 근거로 diary 에서 사용 가능한 회원정보를 member 테이블에 추가할 때 users 테이블의 id 컬럼값을 사용하게 된다. 그러기 위해서 CustomOAuth2UserService 의 loadUser 함수에서 아래 코드를 추가했던 것이다.

...
        usersAttributes.put("uid", users.get().getId());
...

member 테이블에 새로운 컬럼(uid)이 추가되었으므로 관련된 수정을 이어나간다. 우선 domain 패키지 아래의 Member 클래스이다.

package com.woohahaapps.study.diary.domain;

import jakarta.validation.constraints.NotBlank;
import lombok.*;

@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
@Builder
public class Member {
    @NotBlank(message = "이메일 주소는 필수값입니다.")
    private String email;
//    @NotBlank(message = "패스워드는 필수값입니다.")
    private String password;
//    @NotBlank(message = "이름은 필수값입니다.")
    private String name;
    private String role;
    private String provider;// 외부인증정보 제공자
    private Long uid;
}

MemberMapper.xml 도 수정해야 한다. insert 문과 update 문에서 수정이 필요하다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.woohahaapps.study.diary.mapper.MemberMapper">
    <insert id="CreateMember" parameterType="Member">
        insert into member (email, password, name, role, provider, uid)
        values (#{email}, #{password}, #{name}, #{role}, #{provider}, #{uid});
    </insert>
    <select id="GetMember" resultType="Member">
        select
            *
        from member
        where email=#{email};
    </select>
    <update id="UpdateMember" parameterType="Member">
        update member
        set
            password = #{password}
            , name = #{name}
            , role = #{role}
            , provider = #{provider}
            , uid = #{uid}
        where
            email = #{email};
    </update>
    <delete id="DeleteMember">
        delete from member
        where
            email = #{email};
    </delete>
    <select id="GetAllMembers" resultType="Member">
        select
            *
        from member
        order by
            email
    </select>
</mapper>

소셜 로그인에 성공한 경우 정보제공자로부터 전달받은 소셜 로그인 인증 정보를 users 테이블에 저장한 후 users 테이블의 id 값이 member 테이블의 uid 로 존재하는지를 확인해야 하므로 MemberMapper 인터페이스에 GetMemberByUserId 라는 함수를 추가하자.

package com.woohahaapps.study.diary.mapper;

import com.woohahaapps.study.diary.domain.Diary;
import com.woohahaapps.study.diary.domain.Member;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;
import java.util.Map;
import java.util.Optional;

@Mapper
public interface MemberMapper {
    //public void CreateMember(String email, String password, String name);
    void CreateMember(Member member);

    Optional<Member> GetMember(String email);
    
    Optional<Member> GetMemberByUserId(Long uid);

    //public void UpdateMember(String email, String password, String name);
    void UpdateMember(Member member);

    void DeleteMember(String email);

    //List<Map<String, Object>> GetAllMembers();
    List<Member> GetAllMembers();
}

MemberMapper.xml 에는 GetMemberByUserId 함수를 위한 쿼리문을 추가하자.

...
    <select id="GetMemberByUserId" resultType="Member">
        select
            *
        from member
        where 
        	uid = #{uid};
    </select>
...

 

CustomOAuth2LoginSuccessHandler 의 onAuthenticationSuccess 함수를 수정하기 위한 기반 준비가 완료되었으니, 위에 언급한 대략의 흐름대로 코드를 수정해보자.

member 테이블에 대해서 조회, 수정 등의 작업이 필요하니 MemberMapper 를 멤버로 선언한다.

...
@Component
public class CustomOAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;
    private final MemberMapper memberMapper;

    public CustomOAuth2LoginSuccessHandler(JwtUtil jwtUtil, MemberMapper memberMapper) {
        this.jwtUtil = jwtUtil;
        this.memberMapper = memberMapper;
    }
 ...

필요시 jwt 토큰을 생성해야 하니 createJwtCookieAndRedirect 라는 함수를 아래와 같이 추가한다.

...
    private void createJwtCookieAndRedirect(HttpServletResponse response, Authentication authentication) throws IOException {
        // JWT 생성
        String jwtToken = jwtUtil.createToken(authentication);
        System.out.println("Generated JWT Token: " + jwtToken);

        // 쿠키 설정
        Cookie cookie = new Cookie("Authorization", "Bearer=" + jwtToken);
        cookie.setMaxAge(24 * 60 * 60); // 1일 유효
        cookie.setPath("/");
        cookie.setHttpOnly(true);

        response.addCookie(cookie);

        // 홈으로 리디렉션 설정
        setDefaultTargetUrl("/");
    }
...

onAuthenticationSuccess 함수의 내용은 다음과 같이 작성해주면 되겠다.

...
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        System.out.println("authentication = [" + authentication + "]");
        DefaultOAuth2User user = (DefaultOAuth2User) authentication.getPrincipal();
        Map<String, Object> attributes = user.getAttributes();

        // 소셜 로그인 사용자 정보 확인
        Map<String, Object> usersAttributes = (Map<String, Object>) attributes.get("users");
        String provider = (String) usersAttributes.get("provider");
        String email = (String) usersAttributes.get("email");
        String name = (String) usersAttributes.get("name");
        String uidStr = String.valueOf(usersAttributes.get("uid"));
        Long uid = Long.valueOf(uidStr);

        // 최종적으로 구할 데이터는 member 테이블의 값이다.
        // member 테이블에서 uid 컬럼값을 조회
        Optional<Member> member = memberMapper.GetMemberByUserId(uid);
        if (member.isPresent()) {// 조회 성공. 인증정보 수정
            // JWT 생성 및 쿠키 설정
            createJwtCookieAndRedirect(response, jwtUtil.createAuthentication(member.get().getEmail()));
        } else {// member 테이블에 uid 값으로 등록된 레코드가 존재하지 않는다.
            if (email == null || email.isEmpty()) {// 이메일 주소 정보가 존재하지 않는다.
                // 이메일 입력 페이지로 리디렉션
                String targetUrl = String.format("/register-email?uid=%d&provider=%s&name=%s"
                        , uid, provider, URLEncoder.encode(name, StandardCharsets.UTF_8));
                setDefaultTargetUrl(targetUrl);
            } else {// 이메일 주소 정보가 존재하므로 레코드를 추가하고 인증 처리하면 된다.
                // 동일한 이메일 주소가 이미 존재하는가를 확인한다.
                member = memberMapper.GetMember(email);
                if (member.isPresent()) {// uid 를 업데이트한다.
                    member.get().setUid(uid);
                    member.get().setProvider(provider);
                    memberMapper.UpdateMember(member.get());
                } else {
                    // member 테이블에 신규 회원 추가
                    Member newMember = new Member();
                    newMember.setEmail(email);
                    newMember.setName(name);
                    newMember.setUid(uid);
                    newMember.setProvider(provider);
                    newMember.setRole("ROLE_USER");
                    memberMapper.CreateMember(newMember);
                }
                // 인증처리하여 JWT 발급 및 쿠키 설정
                createJwtCookieAndRedirect(response, jwtUtil.createAuthentication(email));
            }
        }

        super.onAuthenticationSuccess(request, response, authentication);
    }
...

이 상태에서 실행시켜보면 email 주소가 제공되지 않는 naver 에 로그인한 경우 다시 로그인 화면으로 돌아오는 현상이 발생하는데, setDefaultTargetUrl 로 지정된 register-email 페이지가 존재하지 않기 때문이다.

register-email.html 파일을 아래와 같이 작성한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.tyhmeleaf.org">
<head>
    <meta charset="UTF-8">

    <!-- 모바일에서의 적절한 반응형 동작을 위해 -->
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- -->
    <title>Register Email</title>
    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
          rel="stylesheet"
          integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
          crossorigin="anonymous"
    >
    <!-- -->
    <!-- head 태그에 다음의 코드를 삽입 -->
    <style type="text/css">

        html,
        body {
            height: 100%;
        }

        .form-signup {
            max-width: 330px;
            padding: 1rem;
        }

        .form-signup .form-floating:focus-within {
            z-index: 2;
        }

        .form-signup input[type="email"] {
            margin-bottom: -1px;
            border-bottom-right-radius: 0;
            border-bottom-left-radius: 0;
        }

        .form-signup input[type="password"] {
            margin-bottom: -1px;
            border-top-left-radius: 0;
            border-top-right-radius: 0;
            border-bottom-right-radius: 0;
            border-bottom-left-radius: 0;
        }

        .form-signup input[type="text"] {
            margin-bottom: 10px;
            border-top-left-radius: 0;
            border-top-right-radius: 0;
        }
        .border-box {
            border: 1px solid #ccc; /* 사각형 테두리 */
            border-radius: 5px; /* 모서리 둥글게 */
            padding: 10px; /* 내부 여백 */
            margin-bottom: 15px; /* 아래 간격 */
            text-align: center; /* 텍스트 가운데 정렬 */
        }
    </style>
</head>
<body>


<script src="https://code.jquery.com/jquery-3.6.0.min.js" charset="UTF-8"></script>
<script th:inline="javascript">
    function convertUrlEncodedToJson(str) {
        let jsonOutput = "";
        let formRequest = decodeURIComponent(str).split("&");
        formRequest.forEach(function(keyValuePair) {
            let nameAndValue = keyValuePair.split('=');
            jsonOutput += '"' + nameAndValue[0] + '"' + ':' + '"' +
                nameAndValue[1]
                    .replace(new RegExp(String.fromCharCode(13), 'g'), '\\r')
                    .replace(new RegExp(String.fromCharCode(10), 'g'), '\\n')
                    .replace(/\\n/g, "\\n")
                    .replace(/\\'/g, "\\'")
                    .replace(/\\"/g, '\\"')
                    .replace(/\\&/g, "\\&")
                    .replace(/\\r/g, "\\r")
                    .replace(/\\t/g, "\\t")
                    .replace(/\\b/g, "\\b")
                    .replace(/\\f/g, "\\f")
                + '"' + ',';
        });
        return '{' + jsonOutput.substring(0, jsonOutput.length -1) + '}';
    }

    function createMember() {
        let form = $("#register-email-form");

        let formData = new FormData(form.get(0));// get Form element
        let email = formData.get("email");
        let name = formData.get("name");

        // 입력값 유효성 확인
        if (!email || !name) {
            alert("빈값일 수 없습니다.");
            return;
        }

        let url = `/api/register-email`;
        let data = form.serialize();
        console.log("data = " + data);

        data = convertUrlEncodedToJson(data);
        console.log(data);

        data = JSON.parse(data);
        console.log(data);
        //////////////////////////////////////////

        $.ajax({
            url: url,
            type: 'POST',
            data: JSON.stringify(data),
            contentType: 'application/json',
            error: function(xhr, status, error) {
                alert("error");
            },
            success: function() {
                alert("succeeded");
                window.location.href = '/';
            }
        });
    }
</script>

<main class="form-signup w-100 m-auto">
    <th:block th:if="${error}">
        <div th:if="${message}" class="alert alert-danger" role="alert" th:text="${message}"></div>
    </th:block>
    <form th:id="register-email-form" th:action="@{/api/register-email}" th:object="${member}" method="POST">
        <input type="hidden" th:field="*{uid}">
        <input type="hidden" th:field="*{provider}">
        <h1 class="h3 mb-3 fw-normal">Please register Email</h1>

        <div class="form-floating border-box">
            <!-- 읽기 전용 provider 값 표시 -->
            <p class="form-control-plaintext" th:text="*{provider}"></p>
            <label>Provider</label>
        </div>
        <div class="form-floating">
            <input type="email" th:field="*{email}" class="form-control" th:class="${#fields.hasErrors('email')} ? 'form-control fieldError' : 'form-control'" placeholder="name@example.com" />
            <label th:for="email">Email address</label>
            <p th:text="${#fields.hasErrors('email')}" th:errors="*{email}">Incorrect data</p>
        </div>
        <div class="form-floating">
            <input type="text" th:field="*{name}" class="form-control" th:class="${#fields.hasErrors('name')} ? 'form-control fieldError' : 'form-control'" placeholder="Username">
            <label th:for="name">Username</label>
            <p th:text="${#fields.hasErrors('name')}" th:errors="*{name}">Incorrect data</p>
        </div>

        <button class="btn btn-primary w-100 py-2" type="submit">Register</button>
    </form>
</main>


<!-- Popper -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
        integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
        crossorigin="anonymous"></script>

<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"
        integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"
        crossorigin="anonymous"></script>

</body>

</html>

이 파일은 로그인상태가 아닌 경우에도 접근이 가능해야 하므로 SecurityConfig 에서 항상 허용 목록에 추가한다.

...
                        .requestMatchers("/login", "/process_login", "/signin"
                                , "/signup"
                                , "/api/**"
                                , "/images/**"
                                , "/register-email"
                        ).permitAll()
...

register-email.html 은 아래와 같이 표시된다.

LoginController 에는 GET 방식의 /register-email 을 처리할 수 있는 함수와 POST 방식의 /api/register-email 을 처리할 수 있는 함수를 아래와 같이 작성한다.

...
    @GetMapping("/register-email")
    public String registerEmail(@RequestParam(value="error", required = false) String error
                                , @RequestParam(value="message", required = false) String message
                                , @RequestParam("uid") String uid
                                , @RequestParam("provider") String provider
                                , @RequestParam("name") String name
                                , Model model
    ) {
        // uid, provider, name 을 모델에 추가하여 뷰에 전달
        Member member = new Member();
        member.setProvider(provider);
        member.setUid(Long.parseLong(uid));
        member.setName(URLDecoder.decode(name, StandardCharsets.UTF_8));
        model.addAttribute("member", member);

        model.addAttribute("error", error);
        model.addAttribute("message", message);

        return "register-email"; // templates/register-email.html 반환
    }

    @PostMapping("/api/register-email")
    public String registerEmail(@Valid Member member, BindingResult result, HttpServletResponse response, Model model) {

        if (result.hasErrors()) {
            return "register-email"; // templates/register-email.html 반환
        }

        // 이메일 저장 로직
        try {
            member.setRole("ROLE_USER");
            memberMapper.CreateMember(member);

            // 인증처리하여 jwt 토큰을 발급
            return createJwtCookieAndRedirect(response, jwtUtil.createAuthentication(member.getEmail()));
        } catch (MemberException ex) {
            String errorMessage = ex.getMessage();
            errorMessage = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8); /* 한글 인코딩 깨진 문제 방지 */
            String targetUrl = String.format("/register-email?uid=%d&provider=%s&name=%s&error=true&message=%s"
                    , member.getUid(), member.getProvider(), URLEncoder.encode(member.getName(), StandardCharsets.UTF_8)
                    , errorMessage);
            return "redirect:" + targetUrl;
        } catch (IOException e) {
            throw new RuntimeException(e);
        } catch (Exception e) {
            System.out.println(e.getClass().getName());// org.springframework.dao.DuplicateKeyException
            if (e.getClass().getName().toLowerCase().contains("duplicatekey")){
                String errorMessage = "이메일주소가 이미 등록되어 있습니다: " + member.getEmail();
                errorMessage = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8); /* 한글 인코딩 깨진 문제 방지 */
                String targetUrl = String.format("/register-email?uid=%d&provider=%s&name=%s&error=true&message=%s"
                        , member.getUid(), member.getProvider(), URLEncoder.encode(member.getName(), StandardCharsets.UTF_8)
                        , errorMessage);
                return "redirect:" + targetUrl;
            } else {
                throw new MemberException(e.getMessage());
            }
        }
    }

    private String createJwtCookieAndRedirect(HttpServletResponse response, Authentication authentication) throws IOException {
        // JWT 생성
        String jwtToken = jwtUtil.createToken(authentication);
        System.out.println("Generated JWT Token: " + jwtToken);

        // 쿠키 설정
        Cookie cookie = new Cookie("Authorization", "Bearer=" + jwtToken);
        cookie.setMaxAge(24 * 60 * 60); // 1일 유효
        cookie.setPath("/");
        cookie.setHttpOnly(true);

        response.addCookie(cookie);

        // 홈으로 리디렉션 설정
        return "redirect:/";
    }
}

이제 이메일 주소가 제공되지 않는 OAuth2 프로토콜 인증 제공자를 이용하는 경우, 이메일 주소를 등록하는 과정을 거쳐서 사용할 수 있게 되었다.

 

반응형