[Spring Security] OAuth 2.0 + JWT 회원가입 및 로그인

HJ·2023년 9월 24일
5

Spring Security

목록 보기
8/9
post-thumbnail

OAuth2 로그인을 구현해보았습니다. 이전까지 구현했던 회원가입과 JWT 로그인도 포함되어 있는데 전체 코드는 GITHUB에서 확인하실 수 있습니다


Authentication


SecurityContext 에 들어갈 수 있는 객체는 Authentication 타입 뿐입니다. 또한 Authentication 의 Principal 에는 UserDetails 와 OAuth2User 타입만 저장할 수 있습니다.

회원가입 로직을 직접 구현한 경우에는 UserDetails 타입을, 소셜 로그인 구현 시에는 OAuth2User 타입을 저장하게 됩니다.

이전에는 UserpasswordAuthenticationToken 을 만들 때 UserDetailsService 의 loadUserByUsername() 에서 반환된 UserDetails 를 담아주었습니다.

이번에는 OAuth2 를 사용하기 때문에 OAuth2UserServiceloadUser() 에서 OAuth2User 를 구현한 OAuth2UserImpl 을 생성하여 반환하게 됩니다.




인증 프로세스


OAuth-Client 라이브러리가 Authorization Code 를 받고, 이를 통해 Access Token 을 받아 소셜에 정보를 요청하고 받는 것을 자동으로 처리해주기 때문에 OAuth2UserService 와 로그인 성공 핸들러만 구현하였습니다.

OAuth-Client 라이브러리가 자동적으로 수행하게 하려면 즉, OAuth2UserService 의 loadUser() 가 자동적으로 호출되게 하려면 redirect URL 을 /login/oauth2/code/소셜타입 으로 설정해주어야합니다.

그렇지 않으면 설정한 redirect URL 뒤에 ?code=XXXX 형식으로 Authorication Code 가 전달되며, OAuth2UserService 가 자동으로 호출되지 않습니다.



[ OAuth2UserService ]

OAuth2UserService 는 OAuth 2.0 인증을 통해 소셜로부터 정보를 가져오는 역할을 담당합니다.

loadUser(userRequest) 는 소셜 로그인 API 의 사용자 정보 제공 URI 로 요청을 보내서 사용자 정보를 얻은 후 OAuth2User 를 반환합니다.

즉, OAuth2User 는 OAuth2UserService 가 소셜에서 받은 여러 가지 정보를 담고 있게 되며, getAttributes() 메서드를 통해 소셜로부터 받은 모든 정보를 추출할 수 있습니다.



[ OAuth2UserRequest ]

public class OAuth2UserRequest {

	private final ClientRegistration clientRegistration;
	private final OAuth2AccessToken accessToken;
	private final Map<String, Object> additionalParameters;
    ...
}

OAuth2UserRequest 에는 내부적으로 AccessToken 을 가지고 있습니다.

Authorization Code 를 이용해 Access Token 받아 이를 통해 사용자에 대한 정보를 요청하고 받는 과정을 처리할 때 사용됩니다.

OAuth-Client 가 자동으로 처리해주기 때문에 직접적으로 사용하지는 않습니다.



[ OAuth2User ]

loadUser() 의 결과로 반환되는 객체가 OAuth2User 입니다. 즉, 소셜 사이트로부터 전달받은 정보를 가지고 있는 객체가 바로 OAuth2User 가 되는 것입니다.

getAttributes() 를 통해 Map 을 추출할 수있는데, 아래와 같은 내용을 가지고 있습니다.

각 소셜 사이트 별로 Map 의 형태와 사용자에 대한 정보가 담긴 key 가 달라서 소셜 별로 다르게 정보를 추출해야합니다.

< google >

{
   "sub": "구글에서 나의 식별값",
   "name": "name",
   "given_name": "given_name",
   ...
}
< naver >

{
  "resultcode": "00",
  "message": "success",
  "response": {
    "email": "email",
    ...
  }
}



OAuth 2.0 로직


[ OAuth2UserService ]


@Service
@RequiredArgsConstructor
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {

    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName();
        SocialType socialType = OAuth2Utils.getSocialType(registrationId);
        ...
    }
}

OAuth2UserService 를 구현한 구현체의 loadUser() 메서드가 실행되면 부모인 DefaultOAuth2UserService 의 loadUser() 를 호출하여 소셜로부터 정보를 받아 OAuth2User 를 생성합니다.

OAuth2User 에서 추출한 registrationId 는 어떤 소셜에서 정보를 제공해주었는지, userNameAttributeName 은 OAuth2User 가 가진 전체 정보에서 사용자 정보가 담긴 key를 나타냅니다.

registrationId=naver
userNameAttributeName=response

Naver 로그인을 했기 때문에 registrationId 는 naver 가 출력되었고, Naver 의 경우 response 라는 key 안에 JSON 형식으로 사용자에 대한 정보가 들어있기 때문에 userNameAttributeName 는 response 를 출력하게 됩니다.

각 소셜을 구분하기 위해 SocialType 이라는 Enum 타입을 만들었는데, 전달받은 registrationId 를 통해 SocialType 을 반환받기 위해 OAuth2Utils 의 getSocialType() 을 호출합니다.



[ OAuth2Utils ]

public class OAuth2Utils {

    // registrationID 를 보고 어떤 소셜에서 인증을 했는지 반환
    public static SocialType getSocialType(String registrationId) {
        // Member 에서도 사용하기 위해 대문자로 변경
        if (registrationId != null) {
            registrationId = registrationId.toUpperCase();
        }

        if ("GOOGLE".equals(registrationId)) {
            return SocialType.GOOGLE;
        } else if ("KAKAO".equals(registrationId)) {
            return SocialType.KAKAO;
        } else if ("NAVER".equals(registrationId)) {
            return SocialType.NAVER;
        }
        return null;
    }
    ...
}

OAuth2Utils 는 각 소셜마다 분기 처리를 진행하는 메서드를 총 집합해놓은 클래스 입니다.

만약 각 클래스에서 직접 분기 처리를 진행한다고 했을 때 소셜 로그인이 추가되거나 필요한 정보가 바뀌면 각 클래스들을 돌아다니면서 수정해야합니다.

이를 방지하고자 분기 처리가 필요한 모든 메서드들을 하나의 클래스에서 처리해줄 수 있도록 위 클래스를 만들게 되었습니다.

getSocialType() 은 String 을 전달받아 SocialType 을 반환해주는 메서드입니다.

registrationId 를 통해 SocialType 을 구할 때와 OAuth2 최초 로그인 시 회원가입을 할 때 Member 객체를 DB 에 저장하는 과정에서 String 을 SocialType 으로 변환할 때 사용됩니다.



[ OAuth2UserService ]


@Service
@RequiredArgsConstructor
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        
        ...
        // 소셜에서 전달받은 정보를 가진 OAuth2User 에서 Map 을 추출하여 OAuth2Attribute 를 생성
        Map<String, Object> attributes = oAuth2User.getAttributes();

        // 내부에서 OAuth2UserInfo 생성과 함께 OAuth2Attributes 를 생성해서 반환
        OAuth2Attributes oauth2Attributes = OAuth2Attributes.of(socialType, userNameAttributeName, attributes);
        ...
    }
}

OAuth2User 의 getAttributes() 를 통해 Map 을 추출합니다.

이 Map 에는 유저 정보를 포함한 다른 모든 정보가 담겨 있고, 그 형태는 소셜마다 조금씩 다르며, 네이버의 경우 아래처럼 되어 있습니다.

{
  "resultcode": "00",
  "message": "success",
  "response": {
    "email": "email",
    ...
  }
}

그 후에 각 소셜로부터 받은 정보 중에서 사용자 정보 추출에 필요한 key 와 필요한 정보를 가진 객체인 OAuth2Attributes 를 생성합니다.



[ OAuth2Attributes ]

@Getter
public class OAuth2Attributes {

    private String nameAttributeKey;    // OAuth2 로그인 시 사용자 정보가 담긴 key 값 ( ex> 네이버 = response )
    private OAuth2UserInfo oauth2UserInfo;

    @Builder
    public OAuth2Attributes(String nameAttributeKey, OAuth2UserInfo oauth2UserInfo) {
        this.nameAttributeKey = nameAttributeKey;
        this.oauth2UserInfo = oauth2UserInfo;
    }

    // OAuth2Utils 를 통해 분기처리 없이 생성된 OAuth2UserInfo 를 반환
    public static OAuth2Attributes of(SocialType socialType, String userNameAttributeName,
                                      Map<String, Object> attributes) {
        return OAuth2Attributes.builder()
                .nameAttributeKey(userNameAttributeName)
                .oauth2UserInfo(OAuth2Utils.getOAuth2UserInfo(socialType, attributes))
                .build();
    }
}

각 소셜마다 정보를 주는 방법이 다르기 때문에 각 소셜마다 분기 처리를 하여 OAuth2Attribute 를 만드는 메서드를 다르게 정의하고, 그 내부에서 서로 다른 방식으로 각 정보들을 파싱해야합니다.

이를 위해 소셜로부터 받은 정보를 전달받고, 필요한 정보들을 추출하는 메서드를 가진 OAuth2UserInfo 라는 추상클래스를 만들고, 소셜마다 이를 구현한 구현체를 만들었습니다.

OAuth2Attribute 는 OAuth2UserInfo 부모 클래스에만 의존하기 때문에 분기 처리 없이 동일한 코드를 사용하게 되고, 필요한 정보나 소셜 로그인이 추가되어도 생겨도 코드를 수정하지 않아도 됩니다.



[ OAuth2Utils ]

public class OAuth2Utils {
    
    ...
    // 소셜로부터 받은 정보들을 받아 파싱하는 OAuth2UserInfo 를 생성하는 메서드
    public static OAuth2UserInfo getOAuth2UserInfo(SocialType socialType, Map<String, Object> attributes) {
        switch (socialType) {
            case GOOGLE:
                return new GoogleOAuth2UserInfo(attributes);
            case NAVER:
                return new NaverOAuth2UserInfo(attributes);
            case KAKAO:
                return new KakaoOAuth2UserInfo(attributes);
        }
        return null;
    }
}

이번에도 OAuth2Utils 에서 분기처리를 진행하여 각 소셜 타입에 맞게 OAuth2UserInfo 를 상속한 구현체들을 생성하여 반환하는 역할을 수행합니다.

각 구현체에서 필요한 정보를 추출하기 위해 소셜로부터 받은 attributes 를 함께 전달합니다.



[ OAuth2UserInfo ]

public abstract class OAuth2UserInfo {

    protected Map<String, Object> attributes;   // 각 소셜에서 전달된 전체 정보

    public OAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }
    
    // 소셜에서 제공받은 정보를 추출하기 위한 메서드 ( 필요한 정보 )
    public abstract String getSocialId();   // 소셜 식별 값 ( 소셜 내부에서 사용자 식별값 )
    public abstract String getEmail();  // 최초 로그인 시, 회원가입할 때 이메일 전달을 위해 사용
}

회원가입, 로그인을 위해 socialId 와 email 정보가 필요하기 때문에 이 두 가지 정보를 추출하는 추상 메서드를 정의하고, 각 소셜 별로 추상 클래스와 추상 메서드를 구현하게 됩니다.

생성자에서 전달받은 attributes 라는 Map 은 소셜로부터 받은 전체 정보를 의미하고, 거기서 유저에 대한 정보를 추출하여 Map 으로 만들게 됩니다.


public class NaverOAuth2UserInfo extends OAuth2UserInfo {

    public static Map<String, Object> responseMap;  // 소셜로부터 받은 정보( attributes ) 에서 유저 정보들이 담긴 Map

    public NaverOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
        responseMap = (Map<String, Object>) attributes.get("response"); // attributes 에서 유저에 대한 정보만 추출하여 Map 으로 생성
    }

    // Naver 는 정보가 attributes 내부에 response 라는 key 의 value 로, value 내부에 ( key, value ) 형식으로 담겨져서 온다
    @Override
    public String getSocialId() {
        return String.valueOf(responseMap.get("id"));
    }

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

OAuth2Attributes 에서 사용자 정보가 담긴 key 값을 의미하는 nameAttributeKey 을 받아도 되지만 구글의 경우 이를 사용하지 않을 뿐만 아니라 카카오도 이메일을 추출하기 위해서 필요한 key 가 또 있기 때문에 직접 키를 입력하도록 구현하였습니다.



[ OAuth2UserService ]


@Service
@RequiredArgsConstructor
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        
        ...
        // Member 생성을 위한 정보를 가지고 있는 OAuth2UserInfo
        OAuth2UserInfo oauth2UserInfo = oauth2Attributes.getOauth2UserInfo();
        String socialId = oauth2UserInfo.getSocialId();
        String email = oauth2UserInfo.getEmail();

        // 소셜 타입과 소셜 ID 로 조회된다면 이전에 로그인을 한 유저
        // DB 에 조회되지 않는다면 Role 을 GUEST 로 설정하여 반환 => LoginSuccessHandler 에서 회원가입으로 리다이렉트 후 추가 정보를 받는다
        Member member = memberRepository.findBySocialTypeAndSocialId(socialType, socialId)
                .orElse(Member.builder().email(email).role(MemberRole.GUEST).socialType(socialType).socialId(socialId).build());

        return new OAuth2UserImpl(Collections.singleton(new SimpleGrantedAuthority(member.getRole().getValue())),
                attributes, oauth2Attributes.getNameAttributeKey(), member);
    }
}

Member 객체를 찾기 위해 OAuth2Attributes 가 가진 OAuth2UserInfo 를 가져오고, OAuth2UserInfo 에서 필요한 socialId 와 email 을 추출합니다.

SocialId 는 각 소셜 내부에서 사용자를 구분하는 값 정도로 생각하면 됩니다. 그렇기 때문에 SocialType 과 SocialId 로 사용자를 구분하면 절대 일치하지 않는 고유한 값이 됩니다.

socialId 와 socialType 으로 DB 에서 사용자를 조회하는데 만약 사용자가 없다면 회원가입을 위해 필요한 정보들만으로 이루어진 Member 객체를 생성해서 전달하게 됩니다.

회원가입 시 email, socialId, socialType 을 저장하기 위해 role 을 GUEST 로 하여 Member 객체를 만들고, 필요한 모든 정보들로 OAuth2UserImpl 객체를 만들어서 반환하게 됩니다.



[ OAuth2UserImpl ]

public class OAuth2UserImpl extends DefaultOAuth2User {

    Member member;  // 토큰 만들기 위해 Member 객체를 담아서 전달

    public OAuth2UserImpl(Collection<? extends GrantedAuthority> authorities,
                          Map<String, Object> attributes, String nameAttributeKey,
                          Member member) {
        super(authorities, attributes, nameAttributeKey);
        this.member = member;
    }
}

OAuth2UserService 의 loadUser() 에서 반환되는 OAuth2User 객체에서 추가해야 할 필드가 있기 때문에 이를 상속받은 OAuth2UserImpl 을 구현합니다.

저는 이전에 토큰을 만드는 JwtUtils 에서 Member 를 전달받고, member 내부에서 id 와 role 값을 꺼내 사용했기 때문에 OAuth2UserImpl 에 Member 를 넣었습니다.

OAuth2UserService 부모의 loadUser() 를 통해 OAuth2User 를 반환받고, OAuth2UserService 가 OAuth2AuthenticationProvider 에게 반환할 때는 OAuth2UserImpl 을 반환하게 됩니다.



[ OAuth2LoginSuccessHandler ]

@RequiredArgsConstructor
public class CustomOAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtService jwtService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        
        Member member = ((OAuth2UserImpl) authentication.getPrincipal()).getMember();
        String accessToken = JwtUtils.generateAccessToken(member);

        // 최초 로그인인 경우 추가 정보 입력을 위한 회원가입 페이지로 리다이렉트
        if (member.getRole().equals(MemberRole.GUEST)) {
            response.addHeader(JwtConstants.ACCESS, JwtConstants.JWT_TYPE + accessToken);
            String redirectURL = UriComponentsBuilder.fromUriString("http://localhost:8080/oauth2/signUp")
                    .queryParam("email", member.getEmail())
                    .queryParam("socialType", member.getSocialType())
                    .queryParam("socialId", member.getSocialId())
                    .build()
                    .encode(StandardCharsets.UTF_8)
                    .toUriString();
            getRedirectStrategy().sendRedirect(request, response, redirectURL);
        } else {
            String refreshToken = JwtUtils.generateRefreshToken(member);
            jwtService.save(new RefreshToken(refreshToken, member.getId()));

            response.addHeader(JwtConstants.ACCESS, JwtConstants.JWT_TYPE + accessToken);
            response.addHeader(JwtConstants.REFRESH, JwtConstants.JWT_TYPE + refreshToken);

            // 최초 로그인이 아닌 경우 로그인 성공 페이지로 이동
            String redirectURL = UriComponentsBuilder.fromUriString("http://localhost:8080/loginSuccess")
                    .build()
                    .encode(StandardCharsets.UTF_8)
                    .toUriString();
            getRedirectStrategy().sendRedirect(request, response, redirectURL);
        }
    }
}

로그인이 성공하면 실행되는 성공 핸들러입니다. Authentication 에서 OAuth2UserImpl 를 꺼내고, 그 내부에서 Member 객체를 꺼내서 진행합니다.

최초 로그인인 경우 memebr 의 role 을 확인하고, member 에 담긴 email, socialId, socialType 을 쿼리 파라미터로 하여 회원가입 페이지로 리다이렉트 시킵니다.

만약 최초 로그인이 아니라면 로그인 성공 페이지로 이동하게 됩니다.



[ OAuth2Controller ]

@Controller
public class OAuth2Controller {

    // OAuth2 로그인 시 최초 로그인인 경우 회원가입 진행, 필요한 정보를 쿼리 파라미터로 받는다
    @GetMapping("/oauth2/signUp")
    public String loadOAuthSignUp(@RequestParam String email, @RequestParam String socialType, @RequestParam String socialId, Model model) {
        model.addAttribute("email", email);
        model.addAttribute("socialType", socialType);
        model.addAttribute("socialId", socialId);
        return "member/signUp";
    }
}

리다이렉트 시 호출되는 컨트롤러입니다. 쿼리 파라미터로 전달된 값들을 @RequestParam 을 통해 받게 되고 이를 Model 에 담아 회원가입 페이지로 전달하게 됩니다.



[ signUp.html ]

<div>
    <label for="name">이메일</label>
    <!-- model 을 통해 email 이 전달되면 input 에 표시 및 readOnly 설정 -->
    <!-- 단> 카카오는 email 이 전달되지 않아서 빈값도 확인 -->
    <input type="text" id="email" class="form-control" th:value="${email}"
            th:readonly="${email != null && email != '' ? 'readonly': false}">
    <p id="error-email" style="color: red"/>
</div>

<!-- OAuth2 최초 로그인 후 회원가입 시, socialType, socialId 정보를 DB 에 기록해야함 -->
<div>
    <input type="text" id="socialType" class="form-control" th:value="${socialType}" hidden="hidden">
    <input type="text" id="socialId" class="form-control" th:value="${socialId}" hidden="hidden">
</div>

화면에서 주의할 점은 위의 두 가지 입니다. 회원가입 시 form 의 데이터를 json 으로 만들어 controller 에게 넘겨주기 때문에 socialId 와 socialType 을 받아야 하며, 사용자가 입력하지 않도록 hidden 처리를 합니다.

또한 이메일의 경우, 소셜 로그인을 통해 이메일이 전달된 경우라면 입력 폼을 readOnly 로 설정하였습니다. 다만 카카오의 경우 null 은 아니지만 빈 값이 오기 때문에 이에 대한 처리도 진행해주었습니다.

참고로 readOnly 가 아닌 disabled 로 설정하게 되면 서버로 데이터가 넘어가지 않는다고 합니다.



[ Member ]

public class Member {
    ...
    // Member 가 생성되기 이전 DTO 로 User 를 생성할 때 사용
    // 비밀번호 암호화까지 동시에 수행
    public static Member createUser(MemberDTO dto, PasswordEncoder passwordEncoder) {
        Member member = Member.builder()
                .id(dto.getId())
                .name(dto.getName())
                .email(dto.getEmail())
                .password(passwordEncoder.encode(dto.getPassword()))    // 암호화해서 User 생성
                .role(MemberRole.USER)    // 역할 지정
                .socialType(OAuth2Utils.getSocialType(dto.getSocialType())) // String 형태의 SocialType 을 Enum 타입으로 변경
                .socialId(dto.getSocialId())
                .build();
        return member;
    }
}

화면에서 전달받은 JSON 을 DTO 에 담아서 받은 후 이를 Entity 로 변환하는 메서드입니다.

여기서 새롭게 Member 객체를 만들고 DB 에 저장해주기 때문에 OAuth2UserServiceImpl 에서는 Member 객체를 생성만 하고 저장하지 않았습니다.

Member 의 SocialType 은 Enum 인데 DTO 에서 SocialType 이 String 으로 넘어오기 때문에 OAuth2Utils 를 통해 이를 Enum 타입으로 변환합니다.




회원가입 및 로그인 전체 동작

[ 회원가입 ]

Animation_회원가입

회원가입 시 ID, 비밀번호, 이름, 이메일을 입력합니다.



[ 로그인 ]

Animation_로그인

로그인 시 ID 와 비밀번호를 입력하고, 로그인이 성공한다면 alert 창과 함께 로그인 성공 페이지로 이동합니다.

로그인 성공을 확인하기 위해 페이지 접근 권한을 체크하지 않았습니다.



[ OAUth 2.0 최초 로그인 ]

Animation_oauth회원가입

OAuth 2.0 을 통한 최초 로그인 시 회원가입 페이지로 이동합니다.

이때 소셜로부터 받은 이메일 정보는 자동으로 form 에 들어가게 되고, readOnly 로 설정되어 수정할 수 없습니다.



[ OAuth 2.0 로그인 ]

Animation_oauth로그인

OAuth 2.0 로그인 시 로그인이 성공한다면 로그인 성공 페이지로 이동합니다.



[ Postman 로그인 ]

Animation_postman 로그인

Postman 을 이용한 로그인 시도입니다. 로그인이 성공하면 Response Header 에 Access Token 과 Refresh Token 을 전달받게 됩니다.



[ Postman User 가 User 요청 ]

Animation_postman _user 요청

전달받은 Access Token 으로 /user 요청을 하게 되면 user 입니다 라는 메시지가 출력됩니다.



[ Postman User 가 Admin 요청 ]

Animation_postman _admin 요청

User 가 /admin 요청을 하게 되면 접근 권한이 없다는 메세지가 출력됩니다.



[ Postman Admin 이 Admin 요청 ]

Animation_postman _admin

admin 권한을 가진 아이디로 로그인 후 /admin 요청을 하게 되면 정상적으로 접근됩니다.



[ Postman Access Token 재발급 ]

Animation_postman _renew

Access Token 이 만료되면 /renew 를 통해 Refresh Token 을 전달하여 새롭게 Access Token 을 받을 수 있습니다.

6개의 댓글

comment-user-thumbnail
2024년 9월 4일

안녕하세요. 상세한 설명으로 덕분에 초보자인 저도 잘 이해했습니다! 감사합니다:)
글을 읽던 도중에 궁금한 점이 생겨서 댓글 남깁니다.
OAuth 2.0 최초 로그인에 성공하고 회원가입 페이지로 넘어가서 비밀번호를 입력하는 이유가 있을까요?

2개의 답글
comment-user-thumbnail
2024년 9월 15일

Line 로그인을 구현하고 있는데 loadUser가 자동으로 불러오기 전 콘솔에 token null이 찍히더라고요
액세스 토큰이 자동으로 잡히지 않는 것 같습니다
분명 콜백 주소도 /line으로 잘 했는데
?code=XXXX 형식으로 Authorication Code 가 전달되는걸 확인했습니다...

혹시 어떻게 해결해야 하는지 궁금합니다...아직 많이 부족한 실력인 나머지 몇 일째 문제를 해결하고 있지 못하고 있습니다 ㅠㅠ

1개의 답글