GitHub - da9dac/blank
Contribute to da9dac/blank development by creating an account on GitHub.
github.com
전체 코드는 해당 깃허브 레포지토리에서 볼 수 있습니다.
환경 설정
가장 많이 사용되는 OAuth2.0 서비스 제공업체인
구글, 카카오, 네이버를 이용한 인증을 구현하기 전에
yml / properties 파일에 설정을 추가해주겠다.
각 API 사용을 위한 사전 설정은 구글링 혹은 아래의 사이트를 참고하길 바란다.
OAuth 2.0을 사용하여 Google API에 액세스하기 | Authorization | Google for Developers
OAuth 2.0을 사용하여 Google API에 액세스하기 | Authorization | Google for Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English 의견 보내기 OAuth 2.0을 사용하여 Google API에 액세스하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하
developers.google.com
REST API | Kakao Developers 문서
Kakao Developers
카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.
developers.kakao.com
네이버 로그인 API 명세 - LOGIN (naver.com)
네이버 로그인 API 명세 - LOGIN
네이버 로그인 API 명세 네이버 로그인 API는 네이버 로그인 인증 요청 API, 접근 토큰 발급/갱신/삭제 요청API로 구성되어 있습니다. 네이버 로그인 인증 요청 API는 여러분의 웹 또는 앱에 네이버
developers.naver.com
spring:
security:
oauth2:
client:
registration:
google:
client-id: your-client-id
client-secret: your-client-secret
scope: email
redirect-uri: http://localhost:8080/auth/oauth/code
kakao:
client-id: your-client-id
client-secret: your-client-secret
authorization-grant-type: authorization_code
scope: account_email
client-name: kakao
client-authentication-method: POST
redirect-uri: http://localhost:8080/auth/oauth/code
naver:
client-id: your-client-id
client-secret: your-client-secret
authorization-grant-type: authorization_code
client-name: Naver
redirect-uri: http://localhost:8080/auth/oauth/code
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response
사전 설정을 마친 후 각 API의
클라이언트 ID와 시크릿키를 다음과 같이 설정 파일에 추가해준다.
scope는 자신이 가져오고자 하는 정보들을 적어주면 되는데
간단하게 이메일 정도만 가져오도록 했다.
구글, 페이스북, 깃허브 같은 기업은 이미 등록이 되어있지만
국내 기업인 카카오와 네이버는 별도로 provider를 설정해줘야 한다.
리다이렉트 uri의 경우에는 본인이 설정해둔 곳으로 바꿔주면 되는데
로컬에서만 테스트 할거라 그냥 대충 적어놨다.
@Bean
public DefaultOAuth2UserService defaultOAuth2UserService() {
return new DefaultOAuth2UserService();
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
위와 같이 설정을 마친 후에 Config 클래스에
OAuth를 사용하기 위한 빈 등록들을 해준다.
OAuth 요청 처리 흐름
원래는 클라이언트가 로그인 요청을 보내면 서버에서 리다이렉트 URL을 넘겨준 후에
클라이언트가 다시 이동하여 로그인을 수행한 후에 인가 코드를 넘겨주지만
미리 리다이렉트 URL을 전달하여 통신 횟수를 줄이겠다.
- 클라이언트(프론트) 측에 미리 OAuth 리다이렉트 URL을 가르쳐준다.
- 클라이언트 측이 로그인 후 인가 코드를 얻어 서버에 전달한다.
- 서버는 인가 코드로 해당 서비스 제공자의 액세스 토큰을 얻는다.
- 액세스 토큰으로 사용자 정보를 획득한다.
- 사용자 정보로 로그인 혹은 회원가입을 진행한다.
- 실제 본인 서버의 액세스 및 리프래시 토큰을 발급한다.
OAuth 서비스 구현 사전 작업
@Getter
@Builder
public class MemberProfile {
private String email;
}
scope에서 설정한 이메일을 담을 클래스를 하나 만든다.
(닉네임이나 다른 정보들도 더 가져오는 경우에는 필드를 추가하면 된다)
public enum OAuthProvider {
private final String registrationId;
private final Function<Map<String, Object>, MemberProfile> of;
OAuthProvider(String registrationId, Function<Map<String, Object>, MemberProfile> of) {
this.registrationId = registrationId;
this.of = of;
}
}
그 후 위와 같은 enum 클래스를 하나 생성하여
인증 서비스 제공자와 사용자의 정보를 가져오는 함수를 선언해준다.
GOOGLE("google", (attributes) ->
MemberProfile.builder()
.email((String) attributes.get("email"))
.build()),
KAKAO("kakao", (attributes) -> {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
return MemberProfile.builder()
.email((String) kakaoAccount.get("email"))
.build();
}),
NAVER("naver", (attributes) -> {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return MemberProfile.builder()
.email((String) response.get("email"))
.build();
});
위와 같이 3개의 enum 객체를 만들어준다.
각 서비스마다 정보를 가져오는 방법이 다른데
구글, 페이스북, 깃허브 같은 경우는 속성명만으로 간편하게 가져올 수 있고,
카카오, 네이버의 경우에는 각각 kakao_account, response로
Map 형태의 속성 컬렉션을 가져온 후에
키를 사용해 속성 값을 가져올 수 있다.
public String getRegistrationId() {
return this.registrationId;
}
위와 같은 메서드도 나중에 사용하기 위해 하나 만들어 둔다.
서비스 로직 구현
이제 실제 인증 로직을 처리할 서비스 로직을 구현하겠다.
구현할 로직들은 간단하게 아래와 같다.
- Authoriztion Code를 통해 OAuth 서비스에 액세스 한다.
- 사용자 정보를 획득한다.
- 회원가입 혹은 로그인을 진행한다.
- 액세스 토큰 및 리프래시 토큰을 발급한다.
@Service
@Transactional(readOnly = true)
public class OAuthService {
private final InMemoryClientRegistrationRepository inMemoryRepository;
private final MemberRepository memberRepository;
private final JwtProvider jwtProvider;
private final DefaultOAuth2UserService defaultOAuth2UserService;
private final RestTemplate restTemplate;
public OAuthService(InMemoryClientRegistrationRepository inMemoryRepository, MemberRepository memberRepository,
JwtProvider jwtProvider, DefaultOAuth2UserService defaultOAuth2UserService, RestTemplate restTemplate) {
this.inMemoryRepository = inMemoryRepository;
this.memberRepository = memberRepository;
this.jwtProvider = jwtProvider;
this.defaultOAuth2UserService = defaultOAuth2UserService;
this.restTemplate = restTemplate;
}
}
위와 같은 서비스 클래스를 하나 만들어 주고 의존성을 주입 받는다.
각 클래스의 역할은 아래와 같다.
- InMemoryClientRegistrationRepository : YML 파일에 설정했던 OAuth Provider 정보가 들어있는 저장소
- MemberRepository : 기존 회원이면 로그인 정보를 확인하고 아닐 시 신규 회원 정보를 저장
- JwtProvider : 로그인 성공 시 토큰을 발급해주는 역할
- DefaultOAuth2UserService : OAuth 사용자 정보를 얻어오는 역할로 UserDetailsService와 같다 볼 수 있다.
- RestTemplate : 인가 코드를 사용해 요청을 보내 액세스 토큰을 발급 받기 위해 사용
@Transactional
public OAuthResult login(OAuthProvider provider, String code) {
String registrationId = provider.getDescription(); // 1
ClientRegistration clientRegistration = inMemoryRepository.findByRegistrationId(registrationId); // 2
String token = getToken(code, clientRegistration); // 3
OAuth2User oAuth2User = getOAuth2User(token, clientRegistration); // 4
Map<String, Object> attributes = new HashMap<>(oAuth2User.getAttributes()); // 5
MemberProfile memberProfile = OAuthProvider.extract(registrationId, attributes); // 6
Member member = getOrSaveMember(memberProfile); // 7
return createToken(member); // 8
}
전체적인 로직의 흐름은 위의 코드와 같은데 간단하게 살펴보겠다.
0. 컨트롤러에서 클라이언트로부터 받아온 Provider 정보와 인가 코드를 받아온다
1. Provider 정보에서 어떤 OAuth 서비스를 사용해 로그인할지 정보를 가져온다.
2. InMemoryClientRegistrationRepository에서 Provider의 상세 정보를 가져온다.
3. 구글, 네이버, 카카오 등의 서비스의 액세스 토큰을 얻어 온다.
4. 얻어온 토큰으로 사용자 정보를 얻어 온다. (UserDetails와 같다고 볼 수 있다)
5. 필요한 사용자의 정보만 Map 형태로 가져온다.
6. 사용자 정보에서 이메일을 가져와 MemberProfile 객체를 만든다.
7. 이미 존재하는 이메일이면 기존 회원 정보를, 아니라면 새로운 회원을 만든다.
8. 실제 자신의 백엔드 서버에서 사용될 액세스 및 리프래시 토큰을 생성해 리턴한다.
@Getter
@Builder
public class OAuthResult {
private String accessToken;
private String refreshToken;
}
우선 로그인 요청의 리턴값인 액세스 토큰과 리프래시 토큰을
저장할 클래스를 하나 생성해준다.
private String getToken(String code, ClientRegistration clientRegistration) {
// 액세스 토큰 획들을 위해 요청을 보낼 URI
String uri = clientRegistration.getProviderDetails().getTokenUri();
// 요청 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(APPLICATION_FORM_URLENCODED);
headers.setAcceptCharset(List.of(UTF_8));
// 요청값 설정
HttpEntity<MultiValueMap<String, String>> entity =
new HttpEntity<>(tokenRequest(code, clientRegistration), headers);
// 요청 전송
try {
ResponseEntity<Map<String, String>> responseEntity = restTemplate.exchange(
uri,
HttpMethod.POST,
entity,
new ParameterizedTypeReference<>() {}
);
// 응답 바디에서 액세스 토큰 획득
return responseEntity.getBody().get("access_token");
} catch (HttpClientErrorException.BadRequest e) {
throw new RuntimeException();
}
}
인가 코드로 액세스 토큰을 얻어 오는 메서드로
실제 백엔드 서버의 액세스 토큰이 아닌 OAuth 서비스를 이용하기 위한
액세스 토큰에 해당한다.
private MultiValueMap<String, String> tokenRequest(String code, ClientRegistration provider) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("code", code);
formData.add("grant_type", "authorization_code");
formData.add("redirect_uri", provider.getRedirectUri());
formData.add("client_secret", provider.getClientSecret());
formData.add("client_id",provider.getClientId());
return formData;
}
인가 코드와 서비스 제공자의 정보를 토대로 요청에 사용될
데이터들을 설정해 준 후에
RestTemplate을 사용해 요청을 보내 액세스 토큰을 얻어 온다.
private OAuth2User getOAuth2User(String token, ClientRegistration clientRegistration) {
OAuth2AccessTokenResponse tokenResponse = OAuth2AccessTokenResponse.withToken(token)
.tokenType(OAuth2AccessToken.TokenType.BEARER)
.expiresIn(3600L)
.build();
OAuth2UserRequest userRequest = new OAuth2UserRequest(clientRegistration, tokenResponse.getAccessToken());
return defaultOAuth2UserService.loadUser(userRequest);
}
얻어온 액세스 토큰으로 OAuth2User 객체를 얻어오기 위한
응답 객체를 만든 후에 요청을 보내 객체를 얻어온다.
loadUser 메서드나 OAuth2User 객체명을 보면 알 수 있듯이
UserDetails를 UserDetailsService의 loadUserByUsername 메서드로
가져오는 것과 비슷하다.
private Member getOrSaveMember(MemberProfile memberProfile) {
Member member = getMember(memberProfile);
if (member == null) {
member = saveMember(memberProfile);
}
return member;
}
OAuth2User 객체에서 얻어온 이메일 값으로
이미 해당 이메일로 가입된 회원이 있으면
굳이 회원가입을 할 필요가 없으니 그대로 기존 회원 정보를 리턴하고
가입된 회원이 아니라면 새로 가입 시킨 후 리턴한다.
private Member getMember(MemberProfile memberProfile) {
return memberRepository.findByEmail(memberProfile.getEmail())
.orElse(null);
}
private Member saveMember(MemberProfile memberProfile) {
Member member = Member.createMember(
memberProfile.getEmail(),
memberProfile.getEmail().split("@")[0],
generateRandomNickname());
return memberRepository.save(member);
}
회원 조회 및 회원 가입 메서드는 대충 위와 같이 짤 수 있다.
(랜덤 닉네임을 부여하였는데 이 부분들은 각자 편한대로 바꿔도 상관 없다)
private OAuthResult createToken(Member member) {
CustomUserDetails userDetails = new CustomUserDetails(member);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
String accessToken = jwtProvider.createAccessToken(authentication, AuthConstant.ACCESS_TOKEN_EXPIRE_TIME);
String refreshToken = jwtProvider.createRefreshToken(authentication, AuthConstant.ACCESS_TOKEN_EXPIRE_TIME);
return OAuthResult.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
기존에 JWT 인증 필터에서 토큰을 생성하던 방식처럼 그대로
인증 객체를 하나 만든 후에 이를 토대로 토큰을 생성해준다.
OAuth 컨트롤러 구현
@RestController
@RequestMapping("/auth")
public class AuthController {
private MemberRepository memberRepository;
private PasswordEncoder passwordEncoder;
private OAuthService oAuthService;
public AuthController(MemberRepository memberRepository, PasswordEncoder passwordEncoder,
OAuthService oAuthService) {
this.memberRepository = memberRepository;
this.passwordEncoder = passwordEncoder;
this.oAuthService = oAuthService;
}
}
기존 컨트롤러에 OAuthService 클래스 의존성을 추가해준다.
@Getter
public class OAuthRequest {
private OAuthProvider provider;
private String code;
}
Provider와 인가 코드를 받아올 DTO 클래스도 위와 같이 생성해준다.
@PostMapping("/oauth")
public ResponseEntity<Void> oauth(@RequestBody @Valid OAuthRequest request) {
OAuthResult token = oAuthService.login(request.getProvider(), request.getCode());
HttpHeaders headers = new HttpHeaders();
headers.add(AUTHORIZATION, BEARER + token.getAccessToken());
headers.add(REFRESH, BEARER + token.getRefreshToken());
return ResponseEntity.ok().headers(headers).build();
}
구현해두었던 login 메서드로 Provider와 인가 코드를 넘겨준 후에
액세스 토큰과 리프래시 토큰을 받아온다.
이를 응답의 헤더에 추가하여 클라이언트에 전달해주면 끝이다.
(응답의 바디로 주든 헤더로 주든 편한 방식대로 해도 괜찮다)
인가 코드 얻기 및 테스트
구글
카카오
https://kauth.kakao.com/oauth/authorize?client_id={클라이언트ID}&scope={가져올 스코프}&response_type=code&redirect_uri={리다이렉트 URI}
네이버
https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id={클라이언트ID}&redirect_uri={리다이렉트 URI}&state=true
위와 같은 로그인 주소를 클라이언트 측에 미리 전달해주어
인가 코드와 Provider만 전달 받으면 된다.
각 URI 형식에 맞게 값을 넣은 후에 로그인을 하여 인가 코드를 얻어보겠다.
@GetMapping("/oauth/code")
public String code() {
return "get code";
}
카카오 같은 경우는 존재하지 않는 페이지를 리다이렉트 URI로 지정하면
응답으로 전달한 코드를 만료시키기 때문에 아무 API나 임시로 만들어 두는 것이 좋다.
구글의 경우에는 이렇게 리다이렉트된 URI를 확인해보면 주소창에 인가 코드가 적힌 것을 확인할 수 있다.
네이버와 카카오도 마찬가지로 이렇게 인가 코드를 확인할 수 있고
이 코드를 oauth 로그인 api로 요청을 보내면
이렇게 로그인에 성공한 후에 액세스 및 리프래시 토큰을 발급해주는 것을 확인할 수 있다.
마치며
JWT 및 OAuth 인증 방식의 경우에는
항상 돌아가는 방식이 비슷하기 때문에
어떤 순서로 인증이 진행되는지에 대해서만
확실하게 알아두면 언제든 구현할 수 있다.
로직이 바뀔 일이 거의 없기 때문에
암기 과목에 가까운 느낌이 들기도...
여담으로 깃허브 인증의 경우에는 사용자 정보를 가져오는
방식이 조금 다른 것으로 알고 있다.
이메일 속성만 가져오는 과정이 달랐는지
다른 속성들도 모두 달랐는지 햇갈리는데
만약 깃허브 인증을 사용한다면 참고하길 바란다.
'Back-End > Security' 카테고리의 다른 글
JWT 로컬 로그인 구현하기 - 2편 (0) | 2023.09.23 |
---|---|
JWT 로컬 로그인 구현하기 - 1편 (0) | 2023.09.21 |
[스프링 시큐리티] 공식문서로 배워보기 : Username/Password - 2 (0) | 2023.08.15 |
[스프링 시큐리티] 공식문서로 배워보기 : Username/Password - 1 (0) | 2023.08.14 |
[스프링 시큐리티] 공식문서로 배워보기 : 인증 아키텍처 (0) | 2023.08.14 |