[Security] JWT 로컬 + 소셜 로그인 튜토리얼

얄루얄루·2023년 2월 4일
5

Spring

목록 보기
13/14

Background

이번 프로젝트에 소셜 로그인을 도입하면서 별별 문제를 다 겪었는데, 생각보다 소셜로그인 튜토리얼이 없다는 걸 느꼈다.

사실 해놓고 보면 진짜 별 거 없어서 안 쓴 걸까? 하는 생각도 들기는 한다.

근데 내가 소셜 로그인을 처음 시도할 때에는 특정 라이브러리를 써야 하는 걸까? 어떤 식으로 필터를 구성해야 하지? 등등의 걱정을 많이 했었다. 구현에 들어가서도 많은 역경을 겪었고.

그런 면에서 단순히 따라하기만 하면 기본적인 구조는 잡히는 글이 딱히 없어서 내가 써 본다.

우선, 이 튜토리얼에서는 JWT를 활용한 로컬 로그인을 구현할 것이고, 그 다음 단계로 소셜 로그인을 도입해 받아온 사용자 정보를 토대로 JWT를 생성해 발급 할 것이다.

결국 유저는 로컬 로그인을 했던 소셜 로그인을 했던, api 서비스 이용을 위해서는 동일한 규격의 JWT를 가지고 Security filter를 통과해야 하게끔 만들 것이다.

관련 정책은 로컬 계정이던 소셜 계정이던 이메일 중복은 불가능하다는 것으로 갈 것이다. 즉, yaaloo@velog.io로 로컬 가입을 했으면 해당 이메일을 사용하는 소셜 계정으로는 가입하지 못한다.

JWT

기초이론

사용자 정보를 문자열화 해놓은 것이라고 보면 된다.

JWT 사용 전에는 HttpSession에 사용자의 sessionId를 키로 사용자 정보를 저장하곤 했다.

이 경우, csrf 공격에 대한 처리를 하지 않으면 해당 공격에 취약하다는 문제가 있고, 또 서버 내에 Session 정보를 저장해 둘 저장소가 추가로 필요해진다.

그런 문제점들을 보완하고자 사용되기 시작한 게 JWT이고, 토큰 문자열에 사용자 정보가 담겨있기 때문에 토큰 자체가 일종의 저장소 역할을 한다고도 할 수 있겠다.

JWT는 어쨌거나 프론트단으로 전달되는 데이터이기 때문에 비밀번호 등 민감한 정보를 저장하기는 곤란한다. 그렇기 때문에 서버에 전달된 후에 DB에서 전체 레코드를 불러올 수 있도록 해당 유저를 식별할 수 있는 유니크 값을 넣어 둘 필요가 있다. 추가로 인가 처리를 위한 authority도 넣어두면 좋다.

JwtIssuer

먼저, JWT를 발급하고 싶다면 이걸 만드는 친구가 있어야 할 것이다.

지정 암호화 알고리즘에 맞춰 토큰을 생성하는 메소드를 설계하는 것은 공수가 너무 많이 들기 때문에 잘 만들어진 라이브러리를 가져다 쓰자.

이보다 최신 버전도 있다. 필요하면 찾아서 쓰자.

implementation 'io.jsonwebtoken:jjwt:0.9.1'

그리고 JwtIssuer를 구현할 텐데, refresh token도 함께 구현을 해보자.

JwtDto, Aes256Util에 빨간불이 들어와도 정상이다.

@Component
@RequiredArgsConstructor
public class JwtIssuer {

    private static String SECRET_KEY = "secretKeyForJsonWebTokenTutorial";
    public static final long EXPIRE_TIME = 1000 * 60 * 5;
    public static final long REFRESH_EXPIRE_TIME = 1000 * 60 * 15;
    public static final String KEY_ROLES = "roles";

    @PostConstruct
    void init(){
        SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
    }

    public JwtDto createToken(String userEmail, String role) {
        String encryptedEmail = Aes256Util.encrypt(userEmail);

        Claims claims = Jwts.claims().setSubject(encryptedEmail);
        claims.put(KEY_ROLES, role);

        Date now = new Date();

        String accessToken = Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + EXPIRE_TIME))
            .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
            .compact();

        claims.setSubject(encryptedEmail);

        String refreshToken = Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + REFRESH_EXPIRE_TIME))
            .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
            .compact();

        return JwtDto.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();
    }

    public String getSubject(Claims claims) {
        return Aes256Util.decrypt(claims.getSubject());
    }

    public Claims getClaims(String token) {
        Claims claims;
        try {
            claims = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
        } catch (ExpiredJwtException e) {
            claims = e.getClaims();
        } catch (Exception e) {
            throw new BadCredentialsException("유효한 토큰이 아닙니다.");
        }
        return claims;
    }

}

Aes256Util

JWT는 그 header에 토큰 암호화에 사용한 알고리즘 정보 등이 들어가 있다. 그렇기 때문에 토큰을 입수하면 누구라도 해독이 가능하다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ5YWFsb29AdmVsb2cuaW8iLCJuYW1lIjoieWFhbG9vIiwiaWF0IjoxNTE2MjM5MDIyfQ.qSmCVJw60WmQbzWnC2OWc96zxOSsGbvWuZJx03cRFPA

위 JWT을 여기에 가서 넣어보자.

{
  "sub": "yaaloo@velog.io",
  "name": "yaaloo",
  "iat": 1516239022
}

이런 payload를 볼 수 있을 것이다.

토큰이 탈취당할 가능성이 있다는 것을 생각하면, 토큰 안에 들어갈 내용을 암호화 해둔다면 유사시에 보다 안전할 것이다.

그렇기 때문에 암호화 유틸을 만들 생각이다.

public class Aes256Util {

    public static String alg = "AES/CBC/PKCS5Padding";
    private static final String KEY = "YAALOOJSONWEBTOKENTUTORIALONVELOG".substring(0, 24);
    private static final String IV = KEY.substring(0, 16);

    public static String encrypt(String text){
        try{
            Cipher cipher = Cipher.getInstance(alg);
            SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(), "AES");
            IvParameterSpec ivParameterSpec = new IvParameterSpec(IV.getBytes(StandardCharsets.UTF_8));
            cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivParameterSpec);
            byte[] encrypted = cipher.doFinal(text.getBytes(StandardCharsets.UTF_8));
            return Base64.encodeBase64String(encrypted);
        } catch (Exception e) {
            return null;
        }
    }

    public static String decrypt(String cipherText){
        try{
            Cipher cipher = Cipher.getInstance(alg);
            SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(), "AES");
            IvParameterSpec ivParameterSpec = new IvParameterSpec(IV.getBytes(StandardCharsets.UTF_8));
            cipher.init(Cipher.DECRYPT_MODE, keySpec, ivParameterSpec);
            byte[] decodeBytes = Base64.decodeBase64(cipherText);
            byte[] decrypted = cipher.doFinal(decodeBytes);
            return new String(decrypted, StandardCharsets.UTF_8);
        } catch (Exception e){
            return null;
        }
    }

}

JwtDto

access token과 refresh token을 함께 발급해 줄 생각이므로 편의를 위해 Dto하나를 만들어 주자.

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class JwtDto {
    private String accessToken;
    private String refreshToken;
}

JwtAuthProvider

토큰 발급을 했으면 해당 토큰을 통해 인증 처리를 하는 친구도 필요할 것이다.

검증 로직은 정책따라 다를 수 있다. 예시 로직은 매우 부실한 편이다.

@Component
@RequiredArgsConstructor
public class JwtAuthProvider {

    private final UserDetailsService userDetailsService;
    private final JwtIssuer jwtIssuer;

	// 인증용
    public boolean validateToken(String token) {
        if (!StringUtils.hasText(token)) {
            return false;
        }
        Claims claims = jwtIssuer.getClaims(token);
        if (claims == null) {
            return false;
        }
        
        /*
        * 추가 검증 로직
        */
        
        return true;
    }

	// 재발급용
    public boolean validateToken(JwtDto jwtDto) {
        if (!StringUtils.hasText(jwtDto.getAccessToken())
            || !StringUtils.hasText(jwtDto.getRefreshToken())) {
            return false;
        }

        Claims accessClaims = jwtIssuer.getClaims(jwtDto.getAccessToken());
        Claims refreshClaims = jwtIssuer.getClaims(jwtDto.getRefreshToken());

		/*
        * 추가 검증 로직
        */
        
        return accessClaims != null && refreshClaims != null
            && jwtIssuer.getSubject(accessClaims).equals(jwtIssuer.getSubject(refreshClaims));
    }

    public Authentication getAuthentication(String token) {
        Claims claims = jwtIssuer.getClaims(token);
        String email = jwtIssuer.getSubject(claims);
        UserDetails userDetails = userDetailsService.loadUserByUsername(email);

        return new UsernamePasswordAuthenticationToken(userDetails, null,
            userDetails.getAuthorities());
    }

}

JwtFilter

만든 AuthProvider를 호출해 검증을 실행시킬 filter를 추가하자.

@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtAuthProvider jwtAuthProvider;
    public static final String JWT_HEADER_KEY = "Authorization";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        String token = resolveTokenFromRequest(request);

        if (!StringUtils.hasText(token)) {
            filterChain.doFilter(request, response);
            return;
        }

        if (jwtAuthProvider.validateToken(token)) {
            Authentication auth = jwtAuthProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(request, response);
    }

    private String resolveTokenFromRequest(HttpServletRequest request) {
        String token = request.getHeader(JWT_HEADER_KEY);

        if (!ObjectUtils.isEmpty(token)) {
            return token;
        }

        return null;
    }
}

Spring Security

인증 관련 각종 설정을 편하게 관리하기 위해 Spring Security를 의존성에 추가하자.

implementation 'org.springframework.boot:spring-boot-starter-security'

그리고 SecurityConfig를 작성해 기본적인 설정을 하고, security filter chain에 위에서 작성했던 filter를 추가하자.

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtFilter jwtFilter;
    
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/h2-console/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .formLogin().disable()
            .httpBasic().disable()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/", "/login/**", "/error").permitAll()
            .antMatchers("/users/**").hasRole(MemberRole.USER.name())
            .anyRequest().authenticated()
            .and()
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

Member

저장할 유저 정보가 있어야겠으므로 Member entity를 만들자. 클래스명으로 User는 DB에 따라 예약어인 경우가 많아 피하는 것이 좋다.

인증용 객체를 따로 생성하기가 귀찮기 때문에 UserDetails를 구현하는 방식으로 가자.

멤버로는 심플하게 id, email, password, name, role, provider 정도만 가져간다.

provider는 당장은 쓸모가 없지만 소셜로그인 파트에서 일할 친구다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class Member implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    private String password;

    private String name;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private MemberRole memberRole;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private MemberProvider provider;

    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singleton(new SimpleGrantedAuthority(this.memberRole.getAuthority()));
    }

    @Override
    @JsonIgnore
    public String getPassword() {
        return password;
    }

    @Override
    @JsonIgnore
    public String getUsername() {
        return email;
    }

    @Override
    @JsonIgnore
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    @JsonIgnore
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    @JsonIgnore
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    @JsonIgnore
    public boolean isEnabled() {
        return true;
    }
}
@Getter
@RequiredArgsConstructor
public enum MemberRole {
    GUEST,
    USER;
    private static final String PREFIX = "ROLE_";
    public String getAuthority(){
        return PREFIX + this.name();
    }
}
public enum MemberProvider {
    LOCAL,
    KAKAO
}

Member Repository & Service & Controller & etc

주제와 큰 연관 없지만 있어야 하는 것이므로 몰아서 넣었다.

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
    boolean existsByEmail(String email);
}
@Service
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtIssuer jwtIssuer;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return getMemberByEmail(email);
    }

    public Member signUp(SignUpForm form) {
    	if(memberRepository.existsByEmail(form.getEmail())){
            throw new RuntimeException("사용중인 이메일입니다.");
        }
        return memberRepository.save(Member.builder()
            .email(form.getEmail())
            .password(passwordEncoder.encode(form.getPassword()))
            .name(form.getName())
            .memberRole(MemberRole.USER)
            .provider(MemberProvider.LOCAL)
            .build());
    }

    public JwtDto signIn(SignUpForm form) {
        Member member = getMemberByEmail(form.getEmail());

        if (!passwordEncoder.matches(form.getPassword(), member.getPassword())) {
            throw new BadCredentialsException("일치하는 정보가 없습니다.");
        }

        return jwtIssuer.createToken(member.getEmail(), member.getMemberRole().name());
    }

    private Member getMemberByEmail(String email) {
        return memberRepository.findByEmail(email)
            .orElseThrow(() -> new UsernameNotFoundException("일치하는 정보가 없습니다."));
    }
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/login")
public class LoginController {

    private final MemberService memberService;
    
    @PostMapping("/local/signup")
    public ResponseEntity<Member> signUp(@RequestBody SignUpForm form){
        return ResponseEntity.ok(memberService.signUp(form));
    }

    @PostMapping("/local/signin")
    public ResponseEntity<JwtDto> signIn(@RequestBody SignUpForm form){
        return ResponseEntity.ok(memberService.signIn(form));
    }

}
@RestController
@RequestMapping("/users")
public class UserController {

    @GetMapping("/info")
    public ResponseEntity<Member> info(@AuthenticationPrincipal Member member){
        return ResponseEntity.ok(member);
    }
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class SignUpForm {
    private String email;
    private String password;
    private String name;
}
@Configuration
public class AppConfig {
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

테스트

간단히 테스트를 해보자.

DB는 H2를 사용 할 예정이다.

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
spring:
  datasource:
    username: sa
    password:
    url: jdbc:h2:mem:test;MODE=MySQL;DB_CLOSE_DELAY=1
    driverClassName: org.h2.Driver
  jpa:
    defer-datasource-initialization: true
    database-platform: H2
    hibernate:
      ddl-auto: create-drop
  h2:
    console:
      enabled: true

Postman을 사용해 테스트를 해보자.

  • 회원가입
  • 로그인
  • 정보 받기
  • 정보 받기 (토큰 없이 접근)

잘 구현이 되었다.

OAuth2

OAuth2 기초이론

간단하게 핵심만 짚고 가보자.

OAuth2는 프로토콜이다. 즉, 약속이다.

내가 ~한 정보를 보낼테니 ~한 정보를 주세요 하고 약속한 방식이라는 소리다.

어떤 정보들이 필요한 가에 대해서는 어느 정도 정형화 되어 있기는 한데, 각 provider의 개발자 페이지에서 대개 확인이 가능하다.

provider란 구글, 깃헙, 네이버, 카카오 등 소셜 로그인을 지원하는 대상을 말한다.

딱 잘라 말해서 REST api 요청으로만 생각보다 쉽게 구현이 가능하다.

그럼에도 라이브러리가 존재하는데, 이는 해당 요청의 엔드포인트 경로, 요청에 첨부될 값 등을 편리하게 관리하기 위해서 쓰인다.

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

해당 라이브러리는 자체적인 filter를 이용해 request url기준으로 검사를 해서 OAuth2 인증 요청인 경우, .yml파일 등에 정의된 정보를 이용해 자동으로 요청을 보내고, 토큰을 받고, 사용자 정보까지 받을 수 있다.

문제는 이렇게 설정을 할 경우 SSR 방식에 가깝게 되어서 익명 사용자에 대해 자동으로 소셜 로그인 페이지로 리디렉트 시키는 짓을 하기도 한다.

또한 JwtFilter에서 보내는 예외가 제대로 전달되지 않고 사라지는 문제도 발생하기 때문에 꽤 많은 부분을 커스텀해야 한다.

위에서 말했지만 특정 라이브러리 없이도 REST api 요청으로만 쉽게 구현이 가능한 것이 소셜 로그인이기 때문에 여기서는 해당 라이브러리를 .yml 파일에 정의된 설정을 자동 매핑해 InMemoryClientRegistrationRepository에 담아주는 기능만을 이용할 것이다.

해당 기능을 하는 클래스를 직접 보고 싶다면 OAuth2ClientRegistrationRepositoryConfiguration OAuth2ClientProperties 2클래스를 찾아보면 좋다.

SocialLoginService

@Service
@RequiredArgsConstructor
public class SocialLoginService {
    private final InMemoryClientRegistrationRepository clientRegistrationRepository;

    public SignUpForm signIn(String providerName, String code){
        ClientRegistration provider = clientRegistrationRepository.findByRegistrationId(providerName);
        KakaoToken tokens = getTokens(provider, code);
        return getFormFromUserProfile(provider, tokens.getAccess_token());
    }

    private SignUpForm getFormFromUserProfile(ClientRegistration provider, String token) {
        Map<String, Object> map = (Map<String, Object>) getUserAttributes(provider, token);
        OAuth2Attributes attributes = OAuth2Attributes.of(provider.getRegistrationId(), map);
        return new SignUpForm(attributes.getEmail(), null, attributes.getName());
    }

    private Map<?, ?> getUserAttributes(ClientRegistration provider, String token) {
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(token);
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> response = restTemplate.exchange(provider.getProviderDetails().getUserInfoEndpoint().getUri(),
            HttpMethod.GET, request, String.class);
        try {
            return new ObjectMapper().readValue(response.getBody(), Map.class);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private KakaoToken getTokens(ClientRegistration provider, String code) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(tokenRequest(provider, code), headers);
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> response = restTemplate.postForEntity(provider.getProviderDetails().getTokenUri(),
            request, String.class);
        try {
            return new ObjectMapper().readValue(response.getBody(), KakaoToken.class);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private MultiValueMap<String, String> tokenRequest(ClientRegistration provider, String code) {
        MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
        map.add("code", code);
        map.add("grant_type", provider.getAuthorizationGrantType().getValue());
        map.add("redirect_uri", provider.getRedirectUri());
        map.add("client_id", provider.getClientId());
        map.add("client_secret", provider.getClientSecret());
        return map;
    }
}

KakaoToken

토큰을 보내줄 때 기타 정보도 다 보내주므로, 일단은 받아주자.

@Getter
public class KakaoToken {
    private String access_token;
    private String token_type;
    private String refresh_token;
    private String expires_in;
    private String scope;
    private String refresh_token_expires_in;
}

OAuth2Attributes

토큰을 얻고난 뒤 사용자 정보 요청을 하고, 그 응답으로 온 사용자 정보로부터 필요한 정보만을 뽑아내야 한다.

provider마다 응답의 형식이 다르기 때문에 확장성을 고려하여 별개의 클래스로 설계하자.

@Getter
public class OAuth2Attributes {

    private final String name;
    private final String email;

    @Builder
    public OAuth2Attributes(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public static OAuth2Attributes of(String registrationId, Map<String, Object> attributes) {
        if ("kakao".equals(registrationId)) {
            return ofKakao(attributes);
        }

        throw new RuntimeException("잘못된 OAuth2 provider입니다.");
    }

    private static OAuth2Attributes ofKakao(Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");

        return OAuth2Attributes.builder()
            .name((String) kakaoProfile.get("nickname"))
            .email((String) kakaoAccount.get("email"))
            .build();
    }

}

LoginController

소셜 로그인을 처리하는 부분을 추가하자.

	private final SocialLoginService socialLoginService;
    
    @PostMapping("/social/{provider}")
    public ResponseEntity<JwtDto> socialSignIn(@PathVariable String provider, String code) {
        SignUpForm signUpForm = socialLoginService.signIn(provider, code);
        return ResponseEntity.ok(memberService.socialSignIn(signUpForm));
	}

MemberService

마찬가지로 소셜 로그인을 처리하는 부분을 추가하자.

	public JwtDto socialSignIn(SignUpForm form) {
        Member member;
        try {
            member = getMemberByEmail(form.getEmail());
        }catch (UsernameNotFoundException e) {
            member = memberRepository.save(Member.builder()
                .email(form.getEmail())
                .name(form.getName())
                .memberRole(MemberRole.USER)
                .provider(MemberProvider.KAKAO)
                .build());
        }

        return jwtIssuer.createToken(member.getEmail(), member.getMemberRole().name());
    }

의문점

아마 Controller에 있는 code는 무엇인가에 대해 의문이 생길 수 있다.

간단히 설명하자면,

  1. 인증 요청 (get) -> 인증 -> 권한 허가 -> redirect_url을 이용해 code 발급
  2. code 이용해 access token 요청 (post) -> 토큰 발급
  3. 토큰 이용해 사용자 정보 요청 (get) -> 사용자 정보 발급

3단계로 소셜 로그인은 설명할 수 있다.

이때, 프론트에서 axios 내지는 ajax 함수를 호출하는 방식으로 진행하면 토큰 발급 시 CORS 이슈가 발생한다.

a 태그를 이용해 해당 이슈를 회피할 수 있지만, 응답을 받을 마땅한 방법이 없다(고 한다).

그래서 응답을 받기 위해서는 결국 비동기 호출 함수를 실행시켜야 하는데, 그렇게 진행하면 말했듯이 CORS 이슈가 발생한다. 단, 다른 provider는 모르겠는데 카카오의 경우 카카오에서 제작한 Kakao login SDK인가 뭔가 하는 걸 사용하면 CORS 이슈 없이 프론트에서 진행이 가능하다고 한다.

그렇기 때문에 프론트/백으로 나뉘어진 RESTful Service 구조에서는 프론트에서 access token 같은 민감한 정보가 들어오지 않는 1번까지를 진행, 백에서 나머지를 진행하는 것이 일반적이다.

그런고로, 여기까지 구현했으면 백엔드에서 해야 하는 것은 끝났다 라고 볼 수 있다.

하지만 이러면 테스트가 조금 곤란하기 때문에 인증 요청을 통해 코드를 받는 부분도 추가 구현을 해보자.

LoginController 테스트용 추가 구현

	@GetMapping("/oauth2/{provider}")
    public void tryOAuth2(@PathVariable String provider, HttpServletResponse response)
        throws IOException {
        String url = socialLoginService.tryOAuth2(provider);
        response.sendRedirect(url);
    }
    @GetMapping("/oauth2/code/{provider}")
    public ResponseEntity<JwtDto> authorized(@PathVariable String provider, @RequestParam String code) {
        return socialLoginService.connectToSocialSignIn(provider, code);
    }

SocialLoginService 테스트용 추가 구현

	public String tryOAuth2(String providerName) {
        ClientRegistration provider = clientRegistrationRepository.findByRegistrationId(providerName);
        return provider.getProviderDetails().getAuthorizationUri()
            + "?client_id=" + provider.getClientId()
            + "&response_type=code"
            + "&redirect_uri=" + provider.getRedirectUri()
            + "&scope=" + String.join(",", provider.getScopes());
    }

    public ResponseEntity<JwtDto> connectToSocialSignIn(String providerName, String code) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.TEXT_PLAIN);
        HttpEntity<String> request = new HttpEntity<>(code, headers);
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<JwtDto> response = restTemplate.postForEntity(
            "http://localhost:8080/login/social/" + providerName,
            request, JwtDto.class);
        return response;
    }

tryOAuth2에서 response_type만 code라는 고정값으로 되어있는 이유는, 카카오는 웃긴 게 인증 요청을 할 때는 code로 받고, 토큰 요청 시에는 authorization_code로 받는다.

토큰 요청을 만드는 게 더 귀찮기 때문에 .yml 파일에는 authorization_code로 넣고, 인증 요청을 고정값으로 했다.

테스트

인증 요청시 권한 허용 등을 처리해야 하고 redirect_url로 code가 날아오므로 Postman으로 테스트하기는 곤란하다.

어차피 get요청이니까 브라우저를 통해 localhost:8080/login/oauth2/kakao에 접속하자.

흐름은 다음과 같다.

(get) tryOAuth2 -> (redirect) connectToSocialSignIn -> (post) socialSignIn

받은 token으로 /users/info에 접근해보자.

카카오 유저로 정상 가입되었고, 발급된 JWT로 서비스를 이용할 수 있음을 확인할 수 있다.

주의사항

프론트/백의 협업 시에 1/23으로 소셜 로그인을 나눠서 한다면 redirect_url에는 code가 포함된 redirect_url을 받을 프론트엔드 주소를 넣어줘야 한다. 그래야 거기서 받고 axios 등으로 쏜다.

토큰 요청 시에도 redirect_url이 필요한데, 이때는 redirect용이 아니라 검증용이다. 그러니 프론트에서 code 받을 때 사용했던 것과 동일한 url을 제공해야 한다.

발급되는 code는 1회용이다. 1번 요청하면 동일한 code로 추가요청 했을 시에 에러가 발생한다.

카카오 디벨로퍼 사이트에 등록한 redirect_url과 요청 시 보내는 redirect_url은 동일해야 한다.

Github

https://github.com/Syl8n/oauth2practice2

profile
시간아 늘어라 하루 48시간으로!

0개의 댓글