[OAuth + Spring Boot + JWT] 4. 스프링 시큐리티없이 OAuth 로그인 구현하기

Junseo Kim·2021년 9월 23일
15
post-thumbnail

이전글

작성한 코드는 아래 저장소에 있습니다!
=> https://github.com/KJunseo/oauth-without-spring-security


스프링 시큐리티가 OAuth 로그인을 처리하는 구조를 참고하여 스프링 시큐리티 없이 OAuth 로그인을 구현해보려고 한다.

이전 글에 적었듯 OAuth 로그인을 처리하기 위해서는 Set up과정과 실제 로그인을 처리하는 과정 2가지를 구현해줘야한다.(각 OAuth 서버 등록은 [OAuth + Spring Boot + JWT] 2. 스프링 시큐리티로 OAuth 구현해보기를 참고하기. redirect url은 임의로 바꿔줘야한다!)

🪨 Set up

application.yml 파일에 각 OAuth 서버의 정보를 등록해준다. application.yml 파일에 적어준 형식대로 값을 바인딩할 객체를 생성해 줄 것이기 때문에 시큐리티를 사용할 때 처럼 굳이 spring.security.oauth2.client.registration, spring.security.oauth2.client.provider와 같은 형식을 지켜줄 필요는 없다. 원하는 대로 구조를 잡아주자. 나는 간단하게 oauth2.user(OAuth 서버 정보 등록시 발급받은 client-id, client-secret와 직접 설정해준 redirect-uri), oauth2.provider(OAuth 서버의 access token을 얻을 수 있는 uri, access token으로 유저 정보를 가져올 수 있는 uri)로 구조를 잡아서 진행하겠다.

이때 redirect-uri은 임의로 적어줘도 되지만, OAuth 서버 등록할 때 적어준 redirect-uri 값과 일치는 시켜줘야한다.

예시(github oauth 정보 등록 시 redirect-uri 적어주는 부분)

# application-oauth.yml

oauth2:
  user:
    github:
      client-id: 6c34d9a6903231c5a301
      client-secret: ${비밀키}
      redirect-uri: http://localhost:8080/redirect/oauth
    google:
      client-id: 54767115914-gcla0mork6h3156h4qcutjerm0mdf4fu.apps.googleusercontent.com
      client-secret: ${비밀키}
      redirect-uri: http://localhost:8080/redirect/oauth
    naver:
      client-id: sCfhQHgPVQFFf8RTGjVe
      client-secret: ${비밀키}
      redirect-uri: http://localhost:8080/redirect/oauth
  provider:
    github:
      token-uri: https://github.com/login/oauth/access_token
      user-info-uri: https://api.github.com/user
    google:
      token-uri: https://www.googleapis.com/oauth2/v4/token
      user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
    naver:
      token-uri: https://nid.naver.com/oauth2.0/token
      user-info-uri: https://openapi.naver.com/v1/nid/me

프로퍼티 파일에 적어준 정보들을 객체로 바인딩 해보자. 이전 글에서 스프링 시큐리티는 @ConfigurationProperties을 사용하여 프로퍼티 값을 객체로 바인딩 해주었다.

먼저 현재 구조를 바인딩 받을 수 있는 객체를 생성해준다. oauth2 하위에 크게 userprovider가 존재하고 각각 하위에 존재하는 값들을 아래와 같이 static class의 필드로 두면 값을 바인딩 받을 수 있는 상태가 된다.

@Getter
@ConfigurationProperties(prefix = "oauth2")
public class OauthProperties {
    private final Map<String, User> user = new HashMap<>();

    private final Map<String, Provider> provider = new HashMap<>();

    @Getter
    @Setter
    public static class User {
        private String clientId;
        private String clientSecret;
        private String redirectUri;
    }

    @Getter
    @Setter
    public static class Provider {
        private String tokenUri;
        private String userInfoUri;
        private String userNameAttribute;
    }
}

여기까지는 값을 바인딩 할 수 있는 상태로 만든 것이고 실제로 사용하기 위해서는 설정 파일을 만들어주고 @EnableConfigurationProperties를 붙여줘야한다.

@Configuration
@EnableConfigurationProperties(OauthProperties.class)
public class OauthConfig {

    private final OauthProperties properties;

    public OauthConfig(OauthProperties properties) {
        this.properties = properties;
    }

}

이렇게 까지 하면 프로퍼티 파일에 적어준 정보가 하나의 OauthProperties 객체로 만들어진다. 이를 각 OAuth 서버 정보로 나눠서 InMemory 저장소에 저장해서 사용해야 한다.

저장소에 저장하기에 앞서 OauthProperties를 분해해야 한다. 스프링 시큐리티로 보면 ClientRegistration 객체를 만들어 주는 것이다.

// spring security의 ClientRegistration 역할

@Getter
public class OauthProvider {
    private final String clientId;
    private final String clientSecret;
    private final String redirectUrl;
    private final String tokenUrl;
    private final String userInfoUrl;

    public OauthProvider(OauthProperties.User user, OauthProperties.Provider provider) {
        this(user.getClientId(), user.getClientSecret(), user.getRedirectUri(), provider.getTokenUri(), provider.getUserInfoUri());
    }

    @Builder
    public OauthProvider(String clientId, String clientSecret, String redirectUrl, String tokenUrl, String userInfoUrl) {
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.redirectUrl = redirectUrl;
        this.tokenUrl = tokenUrl;
        this.userInfoUrl = userInfoUrl;
    }
}

스프링 시큐리티는 OAuth2ClientPropertiesRegistrationAdapter를 통해 OAuth2ClientProperties(우리는 OauthProperties)를 ClientRegistration(우리는 OauthProvider)로 바꿔준다. adapter를 만들어주자

public class OauthAdapter {
    
    private OauthAdapter() {}

    // OauthProperties를 OauthProvider로 변환해준다.
    public static Map<String, OauthProvider> getOauthProviders(OauthProperties properties) {
        Map<String, OauthProvider> oauthProvider = new HashMap<>();

        properties.getUser().forEach((key, value) -> oauthProvider.put(key,
                new OauthProvider(value, properties.getProvider().get(key))));
        return oauthProvider;
    }
}

이제 이 OauthProvider를 저장해 줄 InMemory 저장소를 만들자

public class InMemoryProviderRepository {
    private final Map<String, OauthProvider> providers;

    public InMemoryProviderRepository(Map<String, OauthProvider> providers) {
        this.providers = new HashMap<>(providers);
    }
    
    public OauthProvider findByProviderName(String name) {
        return providers.get(name);
    }
}

마지막으로 OauthConfig에서 빈으로 등록된 OauthProperties를 주입받아 OauthAdapter를 사용해 각 OAuth 서버 정보를 가진 OauthProvider로 분해하여 InMemoryProviderRepository에 저장해 주면 된다.

@Configuration
@EnableConfigurationProperties(OauthProperties.class)
public class OauthConfig {

    private final OauthProperties properties;

    public OauthConfig(OauthProperties properties) {
        this.properties = properties;
    }

    // 추가된 부분
    @Bean
    public InMemoryProviderRepository inMemoryProviderRepository() {
        Map<String, OauthProvider> providers = OauthAdapter.getOauthProviders(properties);
        return new InMemoryProviderRepository(providers);
    }
}

여기까지 진행한다면 애플리케이션이 실행될 때, OAuth 서버 정보들을 객체로 만들어 메모리에 저장된다.

⚙️ OAuth 로그인 요청 처리

이제 OAuth 로그인을 처리하는 컨트롤러를 만들 것이다.

그전에 제일 처음 포스트의 최종 설계 부분을 보자.

여기서 백엔드는 프론트에서 authorization code를 넘겨 받은 이후 과정을 진행하기로 하였다.

약식으로 프론트 역할은 간단한 html 페이지와 postman으로 대체하려고 한다. resources - static 하위에 index.html을 만들어주자.

<!--authoriazation code를 얻어오기 위한 간단한 코드-->
<a href="https://github.com/login/oauth/authorize?client_id=6c34d9a6903231c5a301&scope=id,name,email,avatar_url">Github
    Login</a><br>
<a href="https://accounts.google.com/o/oauth2/v2/auth?scope=https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email&client_id=54767115914-gcla0mork6h3156h4qcutjerm0mdf4fu.apps.googleusercontent.com&response_type=code&redirect_uri=http://localhost:8080/redirect/oauth&access_type=offline">Google
    Login</a><br>
<a href="https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=sCfhQHgPVQFFf8RTGjVe&redirect_uri=http://localhost:8080/redirect/oauth">Naver
    Login</a>

컨트롤러 만들기

먼저 컨트롤러 형태부터 만들자. provider 이름과, authorization code를 받아서 실제로 로그인을 실행할 것이다. 실제 로직은 service 단에서 처리하도록 할 것이다.

@RestController
public class OauthRestController {

    private final OauthService oauthService;

    public OauthRestController(OauthService oauthService) {
        this.oauthService = oauthService;
    }

    @GetMapping("/login/oauth/{provider}")
    public ResponseEntity<LoginResponse> login(@PathVariable String provider, @RequestParam String code) {
        LoginResponse loginResponse = oauthService.login(provider, code);
        return ResponseEntity.ok().body(loginResponse);
    }
}

서비스 만들기

OAuth 로그인 시 할 일은 크게 2가지이다. 프론트에서 받은 authorizatoin code를 통해 OAuth 서버의 access token을 얻어오는 것과, access token을 통해 실제 유저 정보를 얻어오는 것이다.

access token 얻어오기

access token을 얻어오기 위해서 우리는 만들어 둔 OauthProvider가 필요하다. 프론트에서 넘어온 provider값을 통해 InMemoryProviderRepository에서 OauthProvider를 가져오자.

@Service
public class OauthService {

    private final InMemoryProviderRepository inMemoryProviderRepository;

    public OauthService(InMemoryProviderRepository inMemoryProviderRepository) {
        this.inMemoryProviderRepository = inMemoryProviderRepository;
    }

    public LoginResponse login(String providerName, String code) {
        // 프론트에서 넘어온 provider 이름을 통해 InMemoryProviderRepository에서 OauthProvider 가져오기
        OauthProvider provider = inMemoryProviderRepository.findByProviderName(providerName);

        // TODO access token 가져오기
        // TODO 유저 정보 가져오기
        // TODO 유저 DB에 저장
        return null;
    }
}

access token을 가져오거나, 유저 정보를 가져올 때는 실제로 OAuth 서버와 통신을 해야한다. WebClient를 사용하여 통신을 하려고한다. 아래의 의존성을 추가해주자.

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

WebClient에 대한 내용은 [Spring] WebClient 참고하기

OAuth 서버와의 통신을 통해 access token을 받아올 dto를 만들어주자

@Getter
@NoArgsConstructor
public class OauthTokenResponse {
    @JsonProperty("access_token")
    private String accessToken;

    private String scope;

    @JsonProperty("token_type")
    private String tokenType;

    @Builder
    public OauthTokenResponse(String accessToken, String scope, String tokenType) {
        this.accessToken = accessToken;
        this.scope = scope;
        this.tokenType = tokenType;
    }
}

이제 WebClient를 사용해 OAuth 서버에 access token 요청을 하면 된다. 프로퍼티 파일에 적어줬던 access token을 요청할 수 있는 uri에 요청을 보내면 된다. 이 때 헤더에 client-id와 client-secret값으로 Basic Auth를 추가해주고, 컨텐츠 타입을 APPLICATION_FORM_URLENCODED로 설정해준다. 요청 바디에는 authorization code, redirect_uri 등을 넘겨주면 된다.

@Service
public class OauthService {

    private final InMemoryProviderRepository inMemoryProviderRepository;

    public OauthService(InMemoryProviderRepository inMemoryProviderRepository) {
        this.inMemoryProviderRepository = inMemoryProviderRepository;
    }

    public LoginResponse login(String providerName, String code) {
        // 프론트에서 넘어온 provider 이름을 통해 InMemoryProviderRepository에서 OauthProvider 가져오기
        OauthProvider provider = inMemoryProviderRepository.findByProviderName(providerName);

        // access token 가져오기
        OauthTokenResponse tokenResponse = getToken(code, provider);

        // TODO 유저 정보 가져오기
        // TODO 유저 DB에 저장
        return null;
    }

    private OauthTokenResponse getToken(String code, OauthProvider provider) {
        return WebClient.create()
                        .post()
                        .uri(provider.getTokenUrl())
                        .headers(header -> {
                            header.setBasicAuth(provider.getClientId(), provider.getClientSecret());
                            header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
                            header.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
                            header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
                        })
                        .bodyValue(tokenRequest(code, provider))
                        .retrieve()
                        .bodyToMono(OauthTokenResponse.class)
                        .block();
    }

    private MultiValueMap<String, String> tokenRequest(String code, OauthProvider provider) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("code", code);
        formData.add("grant_type", "authorization_code");
        formData.add("redirect_uri", provider.getRedirectUrl());
        return formData;
    }
}

유저 정보 가져오기

OAuth 서버 별로 가져올 수 있는 유저 정보가 다르다. 이번에는 oauthId, email, name, imageUrl 정도만 가져와 보겠다. token과 마찬가지로 이를 받을 수 있는 dto를 만들어준다.

@Getter
public class UserProfile {
    private final String oauthId;
    private final String email;
    private final String name;
    private final String imageUrl;

    @Builder
    public UserProfile(String oauthId, String email, String name, String imageUrl) {
        this.oauthId = oauthId;
        this.email = email;
        this.name = name;
        this.imageUrl = imageUrl;
    }
    
    public Member toMember() {
        return Member.builder()
                     .oauthId(oauthId)
                     .email(email)
                     .name(name)
                     .imageUrl(imageUrl)
                     .role(Role.GUEST)
                     .build();
    }
}

OAuth 서버에 WebClient를 통해 유저 정보를 요청하고 map으로 받아온다. Bearer 타입으로 Auth 헤더에 access token 값을 담아주면 된다.

    public LoginResponse login(String providerName, String code) {
        // 프론트에서 넘어온 provider 이름을 통해 InMemoryProviderRepository에서 OauthProvider 가져오기
        OauthProvider provider = inMemoryProviderRepository.findByProviderName(providerName);

        // access token 가져오기
        OauthTokenResponse tokenResponse = getToken(code, provider);

        // 유저 정보 가져오기
        UserProfile userProfile = getUserProfile(providerName, tokenResponse, provider);
        
        // TODO 유저 DB에 저장
        return null;
    }

    private UserProfile getUserProfile(String providerName, OauthTokenResponse tokenResponse, OauthProvider provider) {
        Map<String, Object> userAttributes = getUserAttributes(provider, tokenResponse);
        // TODO 유저 정보(map)를 통해 UserProfile 만들기
        return OauthAttributes.extract(providerName, userAttributes);
    }

    // OAuth 서버에서 유저 정보 map으로 가져오기
    private Map<String, Object> getUserAttributes(OauthProvider provider, OauthTokenResponse tokenResponse) {
        return WebClient.create()
                        .get()
                        .uri(provider.getUserInfoUrl())
                        .headers(header -> header.setBearerAuth(tokenResponse.getAccessToken()))
                        .retrieve()
                        .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
                        .block();
    }

얻어온 유저 정보(map)를 UserProfile로 만들어 줘야하는데 enum을 사용해보겠다. 각 OAuth 서버 별로 데이터의 key값이 다르다. 예를 들어 github의 프로필 이미지는 avatar_url이지만 google의 경우는 picture이다. 따라서 OAuth 서버가 어떤 형식으로 데이터를 리턴하는지 확인해보고 추가해줘야한다.

public enum OauthAttributes {
    GITHUB("github") {
        @Override
        public UserProfile of(Map<String, Object> attributes) {
            return UserProfile.builder()
                              .oauthId(String.valueOf(attributes.get("id")))
                              .email((String) attributes.get("email"))
                              .name((String) attributes.get("name"))
                              .imageUrl((String) attributes.get("avatar_url"))
                              .build();
        }
    },
    NAVER("naver") {
        @Override
        public UserProfile of(Map<String, Object> attributes) {
            Map<String, Object> response = (Map<String, Object>) attributes.get("response");
            return UserProfile.builder()
                              .oauthId((String) response.get("id"))
                              .email((String) response.get("email"))
                              .name((String) response.get("name"))
                              .imageUrl((String) response.get("profile_image"))
                              .build();
        }
    },
    GOOGLE("google") {
        @Override
        public UserProfile of(Map<String, Object> attributes) {
            return UserProfile.builder()
                              .oauthId(String.valueOf(attributes.get("sub")))
                              .email((String) attributes.get("email"))
                              .name((String) attributes.get("name"))
                              .imageUrl((String) attributes.get("picture"))
                              .build();
        }
    };

    private final String providerName;

    OauthAttributes(String name) {
        this.providerName = name;
    }

    public static UserProfile extract(String providerName, Map<String, Object> attributes) {
        return Arrays.stream(values())
                     .filter(provider -> providerName.equals(provider.providerName))
                     .findFirst()
                     .orElseThrow(IllegalArgumentException::new)
                     .of(attributes);
    }

    public abstract UserProfile of(Map<String, Object> attributes);
}

DB에 저장하기

이렇게 만들어진 UserProfile을 DB에 저장해주면 된다.(Member는 이전 포스팅 참고)

그 후 프론트로 보낼 LoginResponse Dto를 만들어준다.

@Getter
@NoArgsConstructor
public class LoginResponse {
    private Long id;
    private String name;
    private String email;
    private String imageUrl;
    private Role role;
    private String tokenType;
    private String accessToken;
    private String refreshToken;

    @Builder
    public LoginResponse(Long id, String name, String email, String imageUrl, Role role, String tokenType, String accessToken, String refreshToken) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.imageUrl = imageUrl;
        this.role = role;
        this.tokenType = tokenType;
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }
}

우리 애플리케이션에서 쓸 access token과 refresh token도 생성해서 같이 LoginResponse에 담아주면 된다.(이 부분은 github 코드를 참고해보기)

     public LoginResponse login(String providerName, String code) {
        // 프론트에서 넘어온 provider 이름을 통해 InMemoryProviderRepository에서 OauthProvider 가져오기
        OauthProvider provider = inMemoryProviderRepository.findByProviderName(providerName);

        // access token 가져오기
        OauthTokenResponse tokenResponse = getToken(code, provider);

        // 유저 정보 가져오기
        UserProfile userProfile = getUserProfile(providerName, tokenResponse, provider);

        // 유저 DB에 저장
        Member member = saveOrUpdate(userProfile);

        // 우리 애플리케이션의 JWT 토큰 만들기
        String accessToken = jwtTokenProvider.createAccessToken(String.valueOf(member.getId()));
        String refreshToken = jwtTokenProvider.createRefreshToken();

        // TODO 레디스에 refresh token 저장
        // redisUtil.setData(String.valueOf(member.getId()), refreshToken);

        return LoginResponse.builder()
                            .id(member.getId())
                            .name(member.getName())
                            .email(member.getEmail())
                            .imageUrl(member.getImageUrl())
                            .role(member.getRole())
                            .tokenType("Bearer")
                            .accessToken(accessToken)
                            .refreshToken(refreshToken)
                            .build();
    }

    private Member saveOrUpdate(UserProfile userProfile) {
        Member member = memberRepository.findByOauthId(userProfile.getOauthId())
                                        .map(entity -> entity.update(
                                                userProfile.getEmail(), userProfile.getName(), userProfile.getImageUrl()))
                                        .orElseGet(userProfile::toMember);
        return memberRepository.save(member);
    }

📝 테스트 해보기

애플리케이션을 실행시킨 후 http://localhost:8080/에 접속한다.

원하는 로그인을 클릭한다. 그럼 authorization code가 나온다.

postman을 이용하여 이 코드를 백엔드에 보내준다.

참고로 구글 같은 경우는 authorization code를 한 번 decoding 해줘야한다

📝 완성본

작성한 코드는 아래 저장소에 있습니다!
=> https://github.com/KJunseo/oauth-without-spring-security

8개의 댓글

comment-user-thumbnail
2022년 2월 3일

정말 감사합니다... 보면서 많이 배우고 느끼고 갑니다..
죄송한데 질문드려도 될까요!
그전 게시물에서 시큐리티 동작과정을 직접 디버깅 하시면서 뜯어 보시는 것을 보며 저도 그런 공부 습관을 가져야겠다고 생각했습니다 혹시 추천해 주실만한 디버깅 습관이 있으신가요?!

두번째로는, 저도 지금 사이드 프로젝트로 Junseo Kim 님과 비슷한 구조로 프론트와 백을 나누어 진행하고 있습니다. 프론트는 ios앱이고, 백엔드로는 스프링부트를 사용하고 있습니다. 서버는 aws를 통해 웹페이지로 배포해 둔 상태입니다! 이와 같은 환경에서도 Junseo Kim 님께서 작성 해주신 방식으로 code를 앱에서 부터 get api로 받아와 서버에서 outh2 서버에 접근하는 방식이 유효한가요?!

긴글 읽어주셔서 감사합니다~ ㅎㅎ

1개의 답글
comment-user-thumbnail
2022년 4월 26일

안녕하세요, 보고 많이 배우네요. 정말 감사합니다.

하나 여쭤보고싶은게 프론트에서 리소스서버에서 authorization_code 를 가져오는부분은 따로 구현을 하신걸까요?

1개의 답글
comment-user-thumbnail
2022년 8월 31일

안녕하세요 잘보았습니다! 혹시 Google 의 Authorization code의 경우 디코딩을 해주어야 한다는데 어떤 걸로 디코딩 해주어야할까요???

답글 달기
comment-user-thumbnail
2022년 9월 14일

혹시 application-oauth.yml에 있는 jwt token secret-key 부분엔 어떤걸 적어줘야 하나요?? 전 열심히 따라해봤는데 401에러가 나와서요 ㅠㅠ

답글 달기
comment-user-thumbnail
2023년 2월 21일

감사합니다. google 공식문서에는 uri가 옛날꺼라 계속 에러 났는데 uri v3, v4로 변경해주니 바로 되네요ㅎㅎ

답글 달기
comment-user-thumbnail
2023년 5월 28일

감사합니다!! 잘 보고 배웠습니다.!!

혹시 제가 실습을 하면서 깃허브만 사용하면 되어서 코드들 다 작성했는데
https://github.com/login/oauth/authorize?response_type=code&client_id=@@@@@@@@@@@@@&scope=read:user&state=@@@@@@@@@@@%3D&redirect_uri=http://localhost:8080/callback
이런식으로 리다이렉트가 무한 반복되는데 혹시 조언을 좀 구할 수 있을까요..?

답글 달기