2023. 1. 16. 14:52ㆍSpring
OAuth란?
OAuth는 유저가 따로 아이디나 비밀번호를 제공하지 않고 구글이나 네이버, 페이스북 같은 타사 인증을 대신 사용하는 표준화한 인증방식이다.
OAuth를 왜 쓸까?
- 과거 제공사별로 상이했던 인증 절차를 OAuth 프로토콜로 표준화함
- 유저 인증은 타사에 맡기고 AccessToken만 받아서 개인 정보를 받아서 사용함(개인 정보를 DB로 관리하지 않아도 됌)
- 유저가 별도의 아이디나 비밀번호를 기억할 필요 없음
- 유저의 가입 부담을 줄여줌(주로 스타트업 서비스에서 유저의 진입 장벽을 낮춰줌)
OAuth Flow
- 클라이언트가 인증 서버에 Authorization Code 요청
- Authorization Code 반환
- Authorization Code 로 토큰 서버에 Access Token 요청
- Access Token 반환
- Access Token 으로 리소스 서버 API 호출
- 타겟 리소스 반환
Spring Security + OAuth2 사용하기
- 소셜 로그인 제공사에서 오픈 API 등록 신청
- oauth 의존성 추가
- application.yml 작성
- security 설정 빈 생성
1. 소셜 로그인 제공사에서 오픈 API 등록 신청
이번 포스팅에선 네이버 오픈 API를 사용했다.
링크 : https://developers.naver.com/apps/#/register?api=nvlogin
등록이 완료후 client-id와 client-secret을 받을 수 있다.
추가적으로 Redirect URL을 등록해주어야 한다.
Redirect URL이란 OAuth 로그인 인증이 완료되면 다시 base URL로 돌아와야하는데 그 url 주소를 말한다.
스프링에서 제공하는 default redirect url template은 {baseUrl}/login/oauth2/code/{registrationId} 이다.
2. oauth 의존성 추가
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
3. application.yml 작성
네이버는 OAuth2.0 공식 지원대상이 아니라서 "naver"라는 id로 custom provider를 등록해줘야한다.
provider 등록을 안하면 "clientRegistrationRepository"에 "naver"라는 id로 등록된 provider가 없다고 에러가 발생한다.
security:
oauth2:
client:
registration:
naver: # 네이버 소셜 로그인 registration id
client-id: {client-id}
client-secret: {client-secret}
scope:
- name
- email
client-name: Naver
authorization-grant-type: authorization_code # 코드를 부여받는 방식
redirect-uri:
# 네이버는 OAuth2.0 공식 지원대상이 아니라서 별도로 "naver"라는 provider를 등록해줘야함
# provider 등록을 안하면 "clientRegistrationRepository"에 "naver"라는 id로 등록된 provider가 없다고 에러남
provider:
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 # 회원정보를 json의 response 키값으로 리턴해줌.
4. security 설정 빈 생성
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) // 선택적으로 권한 처리시 @Secured나 @PreAuthorize 사용
public class SecurityConfig {
private final PrincipalOauth2UserService principalOauth2UserService;
public SecurityConfig(PrincipalOauth2UserService principalOauth2UserService) {
this.principalOauth2UserService = principalOauth2UserService;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable() // csrf 보안 설정 여부
.authorizeRequests() // url에 대한 인증 설정
.antMatchers("/user/**").authenticated() // 인증되어야만 url 정상 처리
.antMatchers("/manager/**").access("hasRole('MANAGER') or hasRole('ADMIN')") // 특정 권한이 있어야만 url 정상 처리
.antMatchers("/admin/**").access("hasRole('ADMIN')")
.anyRequest().permitAll() // 그외는 인증을 요구하지 않음
.and()
.formLogin() // spring security에서 제공하는 로그인 form을 사용함
.and()
.oauth2Login() // OAuth2 인증을 사용함
.userInfoEndpoint() // OAuth login 성공 이후 동작 정의함
.userService(principalOauth2UserService); // principalOauth2UserService 커스텀 서비스에게 처리 위임
return http.build();
}
}
PrincipalOauth2UserService는 OAuth2 인증시 사용하는 커스텀 객체인데 아래 예제 코드에서 설명하겠다.
소셜 로그인 동작 Flow
- "http://{baseUrl}/oauth2/authorization/{registrationId}”을 호출하면 Spring Security Oauth2 Client는 타사에서 제공하는 로그인 페이지를 요청한다.
- 제공사 인증 서버는 로그인이 완료되면 authorization code를 애플리케이션 서버로 전송함
- Authorization Code 로 Access Token 을 발급
- Access Token으로 제공사 리소스 서버에 사용자 정보를 요청
- spring security는 서버 세션안의 스프링만 별도로 사용하는 영역(Security ContextHolder)에 Access Token과 User 정보를 저장한다
- User 정보는 Authentication 타입 객체안에 OAuth2User 타입 객체에 저장된다
- 클라이언트는 Access Token id 를 쿠키로 저장한다.
예제 코드
PrincipalDetails
- 자체 로그인시 UserDetails타입 객체를 사용하고 OAuth 로그인시 OAuth2User 타입 객체를 사용한다.
- 아래 코드에선 UserDetails와 OAuth2User 두 가지 타입 모두 다중 상속 받도록 작성했다.
@Getter
public class PrincipalDetails implements UserDetails, OAuth2User {
private final User user;
private Map<String, Object> attributes;
// 로컬 로그인시 동작
public PrincipalDetails(User user) {
this.user = user;
}
// oauth 로그인시 동작
public PrincipalDetails(User user, Map<String, Object> attributes) {
this.user = user;
this.attributes = attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collect;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public String getName() {
return null;
}
}
PrincipalOauth2UserService
제공사 API로부터 가져온 유저 정보를 핸들링하는 Service Layer 커스텀 객체이다.
유저 정보를 OAuth2User 타입 객체에 담아서 리턴하는 loadUser() 함수를 정의해준다.
최초 소셜 로그인시 유저 정보를 DB에 저장하는 코드를 추가했다.
@Service
@Slf4j
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
private final UserService userService;
// "/login/oauth2/code/{providerId}" 요청시 자동으로 동작
// 소셜 로그인시 결과가 OAuth2User 타입 객체에 담김
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
UserJoinRequest userJoinRequest = parseToUserJoinRequest(userRequest, oAuth2User);
User find = userService.findByUsername(userJoinRequest.getUsername()).orElseGet(() -> saveOAuthUser(userJoinRequest));
return new PrincipalDetails(find, oAuth2User.getAttributes());
}
private UserJoinRequest parseToUserJoinRequest(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
log.info("getClientRegistration : {}", userRequest.getClientRegistration());
log.info("getAccessToken : {}", userRequest.getAccessToken().getTokenValue());
log.info("getAdditionalParameters : {}", userRequest.getAdditionalParameters());
log.info("getAttributes : {}", oAuth2User.getAttributes());
log.info("getAuthorities : {}", oAuth2User.getAuthorities());
String providerId = userRequest.getClientRegistration().getRegistrationId();
Map<String, Object> response = oAuth2User.getAttribute("response");
String userId = (String) response.get("id");
String username = String.format("%s_%s", providerId, userId);
String password = "defaultPassword";
String email = (String) response.get("email");
String role = "USER";
return UserJoinRequest.builder()
.providerId(providerId)
.username(username)
.password(password)
.email(email)
.role(role)
.build();
}
private User saveOAuthUser(UserJoinRequest userJoinRequest) {
log.info("최초 소셜 로그인이 감지되어 회원가입합니다.");
return userService.saveUser(userJoinRequest);
}
}
아래는 소셜 로그인 성공시 로그에 찍히는 내용이다.
현재 코드는 네이버 API만 사용했지만 제공사마다 리소스 요청 API 응답 포맷이 다르기 때문에 추상화할 필요가 있어보인다.
getClientRegistration : ClientRegistration{registrationId='naver', clientId='clientId', clientSecret='clientSecret', clientAuthenticationMethod=org.springframework.security.oauth2.core.ClientAuthenticationMethod@4fcef9d3, authorizationGrantType=org.springframework.security.oauth2.core.AuthorizationGrantType@5da5e9f3, redirectUri='<http://localhost:8080/login/oauth2/code/naver>', scopes=[name, email], providerDetails=org.springframework.security.oauth2.client.registration.ClientRegistration$ProviderDetails@86135fc3, clientName='Naver'}
getAccessToken : {accessTokenValue}
getAdditionalParameters : {}
getAttributes : {resultcode=00, message=success, response={id=jO_Dgs23LK3fkkopR21-Asdfqw2341F, email=hong@naver.com, name=홍길동}}
getAuthorities : [ROLE_USER, SCOPE_email, SCOPE_name]
로그인 동작 테스트
Spring Session안에 Authentication 객체를 로그에 찍어보면 아래 처럼 정상적으로 저장된 것을 확인할 수 있다.
/test/login==========
authentication : User(id=9, username=naver_jO_Dgs23LK3fkkopR21-Asdfqw2341F, password=$2a$10$ZqmKiZ2RDDsjGPqw89yke.btVJNgd7HScimR7weoI.UqlseYLrVZ6, email=hong@naver.com, role=USER, providerId=naver, createDate=null)
정리
이번 포스팅에선 OAuth와 Spring Security를 이용한 소셜 로그인 구현에 대해서 알아보았다.
다음 포스팅에선 JWT에 대해서 알아보겠다.
코드 참조
https://github.com/KIM-KYOUNG-OH/Education-Lab
refer
'Spring' 카테고리의 다른 글
[Spring Security] JWT을 이용한 소셜 로그인 구현 (0) | 2023.02.01 |
---|---|
[Spring Security] 웹사이트 자체 로그인 구현하기 (0) | 2023.01.12 |