하효닝
log(hahyun ^ B)
하효닝
전체 방문자
오늘
어제
  • 분류 전체보기 (140)
    • Diary (0)
    • Web (7)
    • Frontend (8)
    • Python (44)
      • Python (1)
      • Algorithm (13)
      • Coding Test (30)
    • Django (3)
      • Django (2)
      • Django Rest (1)
    • Java (14)
      • Java (10)
      • Java Tuning (4)
    • Spring (34)
      • Spring (7)
      • Spring MVC (5)
      • DB 접근기술 (1)
      • JPA (10)
      • Spring Security (3)
      • Rest API (8)
    • Computer Science (26)
      • Operating System (8)
      • Linux (2)
      • Network (2)
      • Database (9)
      • SQL Tuning (5)
    • AWS (2)
    • Git (0)
    • etc (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
하효닝

log(hahyun ^ B)

Spring/Spring Security

OAuth2 로그인 인증

2022. 1. 21. 22:40

OAuth2.0

  • 인증 및 권한 획득을 위한 업계 표준 프로토콜
  • 보안수준이 어느정도 검증된 플랫폼의 API를 이용하여 사용자 인증과 리소스에 대한 권한 획득(인가)을 할 수 있도록 해준다.
  • 즉, 구글, 카카오, 네이버 등과 같은 사이트에서 로그인을 하면 직접 구현한 사이트에서도 로그인 인증을 받을 수 있도록 하는 구조

 

구성요소

  • Resource Owner: 개인 정보의 소유자
  • Client: 리소스 서버에서 제공해주는 자원을 사용하는 외부 플랫폼
  • Authorization Server: 외부 플랫폼이 리소스 서버의 사용자 자원을 사용하기 위한 인증 서버
  • Resource Server: 사용자의 개인 정보(자원)을 저장하고 제공해주는 서버

 

인증 종류

1. 권한 코드 승인 방식

  • 일반적으로 서버 사이드에서 인증을 처리할 때 이용하는 방식으로, Resource Owner로부터 리소스 사용에 대한 허락을 의미하는 Authorization Code를 이용하여 Access Token을 요청하는 방식
  • Access Token이 바로 클라이언트로 전달되지 않기 때문에 다른 방식보다 보안에 좋은 특징

 

2. 암시적 승인 방식

  • 권한 코드 없이 바로 토큰을 발급하여 사용하는 방식으로, 브라우저나 앱과 같은 서버와의 연동이 없는 애플리케이션에 주로 사용
  • 권한 코드 검증이 들어가지 않기 때문에 보통 Read Only 서비스에서만 사용

 

3. 비밀번호 자격 증명 방식

  • Client에 Server Provider(구글, 네이버 등)의 아이디와 비밀번호를 저장해두고 사용하는 방식
  • 일반적으로 Client와 Server Provider의 관계가 아주 긴밀한 관계일 때만 사용

 

4. 클라이언트 자격 증명 방식

  • Client와 Resource Owner가 같을 때 사용하는 인증 방식
  • 추가적인 인증이 필요하지 않고, Authorization Server로부터 바로 토큰을 받을 수 있다.

 

Access Token

  • 소셜 플랫폼에서 로그인을 했다 하더라도 개발한 웹 사이트에 ID와 PW를 그대로 전달해주면 안되기 때문에 Access Token을 발급 받고, 그 토큰을 기반으로 원하는 기능을 구현해야 한다.
  • Access Token은 로그인 하지 않고도 인증을 할 수 있도록 해주는 인증 토큰으로, Access Token을 발급받기 위한 일련의 과정들을 인터페이스로 정의해둔 것이 OAuth
  • 스프링 시큐리티에서는 별도의 설정이 없다면 세션을 이용하여 서버 측에 사용자 정보만을 저장하며, Access Token은 사용자 정보에 직접 접근할 수 있도록 해주는 정보만 가지고 있다.

 

  • Refresh Token은 새로운 Access Token을 발급받기 위한 정보를 담고 있으며, 클라이언트가 Access Token이 없거나 만료된 상태라면, Refresh Token을 통해 Auth Server에 요청하여 새로운 Access Token을 발급받을 수 있다.
  • 서버는 클라이언트의 요청이 들어올 때마다 Access Token이 유효한지 매번 클라이언트 상태를 관리 및 공유할 추가적인 저장 공간과, 요청마다 Access Token의 검증 및 업데이트를 위한 DB 호출이 발생한다.
  • 마이크로 서비스 개발처럼 서버의 수가 많은 경우에는 각각의 서버가 Access Token의 유효성 및 권한 확인을 Auth Server에 요청하기 때문에 병목 현상 등이 발생해 서버의 부하로 이어질 수 있다.
  •  
  • 이러한 문제점을 해결하기 위해 JWT 기반 인증 도입 

 

로그인 기능 구현

의존성 추가

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

 

application-ouath.yml

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: {클라이언트 ID}
            client-secret: {클라이언트 보안 비밀}
            scope: profile, email
          naver:
            client-id: {클라이언트 ID}
            client-secret: {클라이언트 보안 비밀}
            redirectUri: http://localhost:8181/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            scope: name, email
            client-name: Naver
        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

 

  • 스프링 부트에서 yml이나 properties의 이름을 application-xxx 형식으로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다.
  • profile=xxx라는 식으로 호출하면 해당 yml이나 properties의 설정들을 가져올 수 있다.
spring:
  profiles:
    include: oauth

 

  • 스프링 부트 2.0 방식에서는 url 주소를 모두 명시할 필요없이, client 인증 정보만 입력하면 된다.
  • CommonOAuth2Provider라는 enum이 추가되어 구글, 깃허브, 페이스북, 옥타의 기본 설정값을 제공한다.
  • 네이버는 스프링 시큐리티를 공식 지원하지 않기 때문에 CommonOAuth2Provider에서 지원하는 설정값들을 수동으로 입력해줘야 한다.

 

승인된 리다이렉션 URI

  • 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 리다이렉트할 URL
  • 스프링 부트 2 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜 서비스코드} 로 리다이렉트 URL 지원
  • 사용자가 별도로 리다이렉트 URL을 지원하는 컨트롤러를 만들 필요가 없다.

 

scope

  • scope의 기본값은 openId, profile, email
  • openId라는 scope가 있으면 OpneId Prpvider로 인식하기 때문에, OpenId Provider인 서비스와 그렇지 않은 서비스로 나눠서 각각 OAuth2Service를 만들어야 한다.
  • 하나의 OAuth2Service로 사용하기 위해 openId는 빼고 등록

 

user_name_attribute=response

  • 네이버 회원 조회 시 반환되는 JSON의 최상위 필드는 resultCode, message, response
  • 스프링 시큐리티에서는 하위 필드를 명시할 수 없기 때문에, 본문을 담고 있는 response를 user_name으로 지정하고, 이후 자바 코드로 response의 id를 user_name으로 지정

 

User 엔티티

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id @GeneratedValue
    @Column(name = "user_id")
    private Long id;

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private Role role;

    @Builder
    public User(String name, String email, Role role) {
        this.name = name;
        this.email = email;
        this.role = role;
    }

    /*
     * 회원 정보 수정
     */
    public User update(String name) {
        this.name = name;
        return this;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }
}

 

Role enum

@Getter
@RequiredArgsConstructor
public enum Role {

    // 스프링 시큐리티에서 권한 코드는 항상 ROLE_로 시작
    USER("ROLE_USER", "일반 사용자"),
    ADMIN("ROLE_ADMIN", "관리자");

    private final String key;
    private final String title;
}

 

SecurityConfig

@EnableWebSecurity // 스프링 시큐리티 설정들을 활성화
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
            .cors().and()
            .csrf().disable()
            // url 별 권한 관리
            .authorizeRequests()
            	// 권한 관리 대상을 지정하는 옵션으로, URL, HTTP 메소드별로 관리 가능
            	.antMatchers("/myPage").hasRole("USER")
            	.antMatchers("/**").permitAll()
            .and()
                .logout()
                    // 로그아웃 성공시 이동할 주소
                    .logoutSuccessUrl("/")
            .and()
                .oauth2Login()
                    // 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당
                    .userInfoEndpoint()
                        // 로그인 성공시 UserService 인터페이스의 구현체 등록
                        // 리소스 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시
                        .userService(customOAuth2UserService);

    }
}

 

CustomOAuth2UserService

로그인 이후 가져온 사용자의 정보들을 기반으로 가입 및 정보수정, 세션 저장등의 기능 지원

 

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService
        implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest)
            throws OAuth2AuthenticationException {

        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        // 현재 로그인 진행 중인 서비스를 구분하는 코드 (구글인지, 네이버인지)
        String registrationId = userRequest
                .getClientRegistration()
                .getRegistrationId();

        // OAuth2 로그인 진행 시 키가 되는 필드값
        // 구글의 경우 기본적으로 코드를 지원 (sub)
        // 네이버, 카카오 등은 기본 지원하지 않는다.
        String userNameAttributeName = userRequest
                .getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        // OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
        OAuthAttributes attributes = OAuthAttributes
                .of(registrationId,
                    userNameAttributeName,
                    oAuth2User.getAttributes());

        // user 저장
        User user = saveOrUpdate(attributes);
        // 세션 설정
        httpSession.setAttribute("user", new SessionUser(user));

        return new DefaultOAuth2User(Collections.singleton(
                new SimpleGrantedAuthority(member.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }

    private User saveOrUpdate(OAuthAttributes attributes) {
        User user = userRepository.findByEmail(attributes.getEmail())
                .map(entity -> entity.update(attributes.getName()))
                .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}

 

OAuthAttributes 

OAuth2UserService를 통해 가져온 OAuth2User의 attribute(이름, 이메일)를 담을 DTO 클래스

@Getter
@Builder
public class OAuthAttributes {

    private Map<String, Object> attributes;
    private String nameAttributeKey; // OAuth2 로그인 진행 시 키가 되는 필드값
    private String name;
    private String email;

    /*
     * 생성 메서드
     * registrationId를 통해 서비스에 맞춰 OAuthAttributes 객체 생성 후 반환
     */
    public static OAuthAttributes of(String registrationId,
                                     String userNameAttributeName,
                                     Map<String, Object> attributes) {

        if ("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }
        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        // OAuth2User에서 반환하는 사용자 정보는 map이기 때문에 값 하나하나를 변환
        return OAuthAttributes.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        // naver는 사용자 정보가 response를 키로 하는 map 형태로 넘어온다.
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .role(Role.USER) // 가입 시 기본권한은 USER
                .build();
    }
}

 

SessionUser

@Getter
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SessionUser implements Serializable {

    private Long id;
    private String name;
    private String email;

    public SessionUser() {
    }

    public SessionUser(User user) {
        this.id = user.getId();
        this.name = user.getName();
        this.email = user.getEmail();
    }
}

 

※ JWT

  • JWT는 사용자의 상태를 포함한 의미있는 토큰으로 구성되어 있기 때문에, Auth Server에 검증 요청을 보내야 했던 과정을 서버에서 수행할 수 있게 되어 비용 절감 및 Stateless 아키텍처를 구성할 수 있다.
  • 고려할 점은 사용자 인증 정보가 필요한 요청을 보낼 때 헤더에 JWT 토큰값을 넣어 보내야 하기 때문에 데이터가 증가하여 네트워크 부하가 늘어날 수 있다.
  • 또한 토큰 자체에 사용자 정보를 담고 있기 때문에 JWT가 만료되기 전에 탈취당하면 서버에서 처리할 수 있는 일이 없고, JWT를 만들어 클라이언트에게 전달하면 제어가 불가능하기 때문에 만료 시간을 필수로 넣어주어야 한다.
OAuth와 JWT를 이용한 인증과정

 

 

'Spring > Spring Security' 카테고리의 다른 글

스프링 시큐리티 설정과 로그인 구현  (0) 2022.01.25
JWT 인증 구현  (0) 2022.01.21
    'Spring/Spring Security' 카테고리의 다른 글
    • 스프링 시큐리티 설정과 로그인 구현
    • JWT 인증 구현
    하효닝
    하효닝

    티스토리툴바