단의 개발 블로그

소셜로그인 본문

Web/Spring

소셜로그인

danso 2024. 11. 7. 23:58

JWT

JSON Web Token의 약자로 인증을 위한 데이터 형식이다. JSON 형식으로 암호화된 문자열로 구성하여 데이터를 전송한다. 이 문자열은 서버와 클라이언트 간의 인증과 정보 전달에 사용된다. 즉, 클라이언트 - 서버 간 안전한 정보 전달을 위한 인증 권한 부여 메커니즘으로, 토큰 기반 인증 (Token-Based Authentication) 방식에 사용된다. 형식은 헤더, 페이로드, 서명으로 구성된다.

JWT 구조

빨간색이 헤더, 보라색이 페이로드, 하늘색이 서명이다. 

  • Header
    • JWT 토큰의 정보를 담고 있는 메타데이터다.
    • 사용되는 알고리즘과 토큰의 타입을 정의한다.
    • 위 예시에서는 HS256 알고리즘과 JWT 타입으로 지정되어 있다.
  • Payload
    • JWT 토큰에 실제 정보가 담긴 부분이다.
    • 사용자 정보를 여기에 지정하는데 해당 정보를 보통 Claim이라 한다.
    • sub는 토큰의 주체를 지정하며, 사용자 ID 혹은 email을 사용한다.
    • name은 사용자 명을 나타낸다.
    • iat는 토큰의 발급 시간을 지정한다.
  • Signature
    • JWT가 실제 서버에서 보낸 데이터 인지 검증하는 디지털 서명이다.
    • 비밀키를 사용하여 헤더와 페이로드를 알고리즘으로 서명하고, 이를 서명에 추가한다.

장단점

장점 단점
토큰 기반 인증 방식으로 서버에서 저장하지 않기 때문에 서버 자원을 효율적으로 사용하며 부하를 주지 않는다. 클라이언트 단에서 노출될 경우 보안 위험성이 존재한다. HTTPS 보안 프로토콜을 꼭 사용해야 한다.
JWT 자체에도 유저 정보가 담겨 있기 때문에 클라이언트와 서버 간의 상태를 저장하지 않기 때문에 Stateless 구조를 가진다. 토큰이 해독 될 경우 사용자 정보가 노출될 수도 있다.
서버 단에서 검증을 따로 하기 때문에 서버의 비밀키가 탈취되지 않는다면 무결성 검증에도 유용하다  

중복로그인, 로그아웃 처리 등이 필요할 경우 세션을 사용하고, 그렇지 않을 경우 JWT를 사용한다. 여기서는 JWT를 사용해서 소셜로그인을 구현한다.

 

AccessToken & RefreshToken

AccessToken은 짧은 만료 시간을 갖는 토큰이다. 해당 토큰으로 유저에 대한 정보, 인증 인가 처리를 한다. 해당 토큰이 만료되면 서버는 RefreshToken으로 다시 AccessToken을 만들어 전달한다. 토큰 응답 방식은 헤더와 쿠키로 응답할 수 있는데, 헤더의 경우 자바스크립트로 조작이 가능할 수 있기 때문에 쿠키를 사용해야 한다. 리프래시 토큰은 액세스 토큰이 만료될 경우 리프래시 토큰을 사용해 엑세스 토큰을 재발급 하게 된다.

 

스프링 환경에서 적용

아래 두가지를 사용하여 적용한다.

  1. 스프링 시큐리티
    • JWT 토큰을 생성하고 검증하며 이를 통해 인증과 권한을 부여한다.
    • 시큐리티 설정에 JWT 관련 설정 작업을 추가해야 한다.
  2. JWT 라이브러리
    • JWT를 생성하고 검증하기 위한 별도의 클래스를 정의한다.
    • 필터를 등록하여 사용자 인증과 권한이 필요한 요청에서 JWT 검증 로직을 실행한다.

구현과정

  1. JWT 라이브러리 추가 ('jjwt')
  2. JWT 필터 구현
    • 엑세스 토큰 검증 및 인증을 해당 필터가 검사한다.
    • 스프링 시큐리티에서 JWT 인증 필터인 JwtAuthenticationFilter와 JwtAuthorizationFilter를 제공한다.
    • JwtAuthenticationFilter는 클라이언트가 보낸 JWT 토큰을 검증하고 인증정보를 추출한다.
    • JwtAuthorizationFilter는 인증된 사용자의 권한을 검증하는 역할을 한다.
  3. 엑세스 토큰 발급
  4. 리프레시 토큰 발급
    • 엑세스 토큰이 만료된 경우 클라이언트는 리프레시 토큰을 사용하여 새로운 엑세스 토큰을 발급 받아서 사용한다.
    • 서버는 전송받은 리프레시 토큰을 검증하고 새로운 엑세스 토큰을 발급한다.
  5. 토큰 저장
    • 서버는 레디스에 토큰을 저장한다.
    • 클라이언트는 서버가 보낸 쿠키에 토큰을 조회하여 사용한다.

 

OAuth2.0

OAuth2.0은 인증데 대한 표준 프로토콜로 3자 애플리케이션이 사용자를 대신해 본인의 서비스를 이용할 수 있는 권한을 부여하는 메커니즘을 의미한다. 말이 어렵지만 쉽게 말하자면 소셜로그인이라고 보면 된다. OAuth의 구성요소와 승인방식을 이해하면 된다. 인증서버는 OAuth2.0인증 서버를 의미한다. 이 말은 스프링 부트가 클라이언트로 부터 받은 인증 정보를 이용해 소셜 로그인 API를 호출하고, 그 결과를 클라이언트에 반환하는 역할을 하는 것을 말한다.

OAuth 구성요소

구분 설명
Resource Owner 웹 서비스를 이용하는 유저, 자원(개인정보)을 소유하는 사용자
Client 직접 구축한 애플리케이션 서버, 클라이언트 라는 이름은 Resource Server로 필요한 자원을 요청/응답 하는 관계이기 때문에 사용된다.
Authorization Server 권한을 부여 해주는 서버,
사용자는 해당 서버로 ID,PW를 넘기고 Authorization Code를 받는다
Client는 해당 서버로 Authorization Code를 넘겨 Token을 받는다.
Resource Server 사용자의 개인정보를 가지고 있는 애플리케이션(Google, Kakao, Naver), Client는 Token을 해당 서버로 넘겨 개인정보를 받는다.
Access Token 자원에 대한 접근 권한을 Resource Owner가 인가하였음을 나타내는 자격증명
Refresh Token Client는 Authorization Server로 부터 AccessToken과 Refresh Token을 함께 부여 받는다.
AccessToken은 보안상 짧은 만료기간을 갖고 사용자의 인증/인가를 실시한다. 만료되면 Refresh Token을 사용하여 AccessToken을 재 발급한다.

OAuth2 승인 방식

방식 설명
Authorization Code Grant Type 가장 일반적인 승인 방식이다.
서버 측 애플리케이션에서 사용한다.
클라이언트가 사용자를 인증하고 권한을  요청하는 요청을 만들고 인증서버에 전송한다.
인증 서버는 사용자에게 인증 페이지를 표시하고 사용자 인증을 수행한다.
인증이 성공하면 서버는 액세스 토큰과 함께 리다이렉션 URL을 클라이언트에 반환한다.
클라이언트는 액세스 토큰을 사용해 애플리케이션을 이용한다.
Implicit Grant Type 사용자 인증 및 권한 부여를 위한 프로토콜이다.
웹 브라우저 또는 JavaScript 기반 클라이언트에서 사용된다.
Authorization Code Grant와 달리 클라이언트에게 액세스 토큰이 직접 반환된다.
해당 방식은 보안에 취약하다. 이를 해결하려면 암호화를 수행하거나, 서버측에서 다시 한번 확인하는 작업을 해야한다.
Resource Owner Password 
Credentials Grant Type
사용자 이름과 암호를 사용하여 인증하고, 액세스 토큰을 반환한다.
사용자가 클라이언트에 직접 로그인하며, 비밀번호를 클라이언트에게 제공한다.
클라이언트는 이 정보를 사용해 인증 서버에 요청하고 액세스 토큰을 받는다.
해당 방식은 보안에 취약해서 권장하지 않는다.
Client Credentials Grant Type 클라이언트 자체가 리소스에 대한 액세스를 요청하고, 인증 서버로부터 액세스 토큰을 받는 프로토콜이다.
사용자가 아닌 클라이언트 자체가 API에 접근하여 사용한다.

 

각 플랫폼 설정 정보는 스킵했다.

 

스프링 서버 설정

yaml로 서버 설정을 하면 직접 Rest 요청을 하지 않아도 자동으로 알맞게 요청하게 해준다. 우리나라 뿐만 아니라 글로벌 서비스를 하는 Google, Twiter 등의 서버는 시큐리티에서 이미 provider를 구현해 뒀기 때문에 필요하지 않다.

spring:
 security:
   oauth2:
     client:
       registration:
         google:
           client-id: 소셜에서 발급 받은 값
           client-secret: 소셜에서 발급 받은 값
           scope:
             - email
             - profile
         kakao:
           client-id: 소셜에서 발급 받은 값
           redirect-uri: 소셜에서 설정한 값
           authorization-grant-type: authorization_code
           client-authentication-method: POST
           client-name: Kakao
           scope:
             - profile_nickname
             - account_email
        naver:
           client-id: 소셜에서 발급 받은 값
           client-secret: 소셜에서 발급 받은 값
           redirect-uri: 소셜에서 설정한 값
           authorization-grant-type: authorization_code
           client-name: Naver
       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

 

SpringBoot

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

  private final LoginService loginService;
  private final JwtService jwtService;
  private final UserRepository userRepository;
  private final ObjectMapper objectMapper;
  private final CustomOAuth2UserService customOAuth2UserService;
  private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
  private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .formLogin(AbstractHttpConfigurer::disable)
        .httpBasic(AbstractHttpConfigurer::disable)
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests((authorizeRequests) ->
            authorizeRequests
                .requestMatchers("/", "/home","/user/join", "/WEB-INF/**", "/js/**", "/css/**", "/image/**", "/favicon.ico").permitAll()
                .requestMatchers("/api/v1/user/**").hasRole(Role.USER.name())
                .anyRequest().authenticated())
        .oauth2Login(oauth -> {
          oauth.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(customOAuth2UserService))
          .successHandler(oAuth2AuthenticationSuccessHandler)
              .failureHandler(oAuth2AuthenticationFailureHandler)
              .defaultSuccessUrl("http://localhost:3000/");
        });
    return http.build();
  }
  @Bean
  public BCryptPasswordEncoder encode(){
    return new BCryptPasswordEncoder();
  }
}

PrincipalDetails

OAuth2User와 UserDetails 인터페이스의 구현체다. UserDetails 클래스의 경우 servie에서 구현할 때 필요하다.

OAuth2User는 Spring Security의 OAuth2 클라이언트 인증 과정을 통해 인증된 사용자 정보를 나타낸다. 인증 서버로 부터 전달받은 사용자 정보를 캡슐화 하여 제공한다. 해당 인터페이스를 사용하면 OAuth2 클라이언트를 통해 로그인한 사용자 정보를 쉽게 가져올 수 있다. (@AuthenticationPrincipal PrincipalDetails) 해당 인터페이스는 getName(), getAuthrities(), getAttributes() 등의 메소드를 포함하고 있다.

public class PrincipalDetails implements OAuth2User, UserDetails {

  private User user;

  private Map<String, Object> attributes;

  public PrincipalDetails(User user) {
    this.user = user;
  }

  public PrincipalDetails(User user, Map<String, Object> attributes) {
    this.user = user;
    this.attributes = attributes;
  }

  @Override
  public String getPassword() {
    return user.getPassword();
  }

  @Override
  public String getUsername() {
    return user.getName();
  }

  @Override
  public Map<String, Object> getAttributes() {
    return attributes;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    Collection<GrantedAuthority> authorities = new ArrayList<>();
    authorities.add(() -> "ROLE_" + user.getRole());
    return List.of();
  }

  @Override
  public String getName() {
    return "";
  }

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

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

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

  @Override
  public boolean isEnabled() {
    return true;
  }
}

CustomOAuth2UserService

loadUser 메소드는 OAuth2UserRequest 객체를 인자로 전달 받아 PricipalDetails 객체를 반환한다. 

saveOrUpdate 메소드는 OAuth2의 정보를 가져와서 우리 애플리케이션의 DB에 해당 이메일로 가입한 유저가 있는지 조회한다.

만약 없을 경우 Entity로 변경하고, 해당 값을 저장한다.

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

  private final UserRepository userRepository;

  @Override
  public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    OAuth2User oAuth2User = super.loadUser(userRequest);
    String registrationId = userRequest.getClientRegistration().getRegistrationId();
    String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
    OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
    User user = saveOrUpdate(attributes);
    return new PrincipalDetails(user, oAuth2User.getAttributes());
  }

  private User saveOrUpdate(OAuthAttributes attributes) {
    User user = userRepository.findByEmail(attributes.getEmail())
        .orElse(attributes.toEntity());
    return userRepository.save(user);
  }

}

OAuthAttributes

소셜 로그인 별로 데이터를 가공하여 유저와 매핑한다.

@Getter
public class OAuthAttributes {

  private Map<String, Object> attributes;
  private String nameAttributeKey;
  private String name;
  private String email;
  private String picture;
  private SocialType socialType;

  @Builder
  public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture, SocialType socialType) {
    this.attributes = attributes;
    this.nameAttributeKey = nameAttributeKey;
    this.name = name;
    this.email = email;
    this.picture = picture;
    this.socialType = socialType;
  }

  public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
    if (registrationId.equals("naver")) {
      return ofNaver("id", attributes);
    } else if(registrationId.equals("kakao")) {
      return ofKakao("id", attributes);
    } else {
      return ofGoogle(userNameAttributeName, attributes);
    }
  }

  private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
    Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
    Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
    return OAuthAttributes.builder()
        .name((String) properties.get("nickname"))
        .email((String) kakaoAccount.get("email"))
        .picture((String) properties.get("profile_image"))
        .socialType(SocialType.KAKAO)
        .attributes(attributes)
        .nameAttributeKey(userNameAttributeName)
        .build();
  }
  private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
    Map<String, Object> response = (Map<String, Object>) attributes.get("response");
    return OAuthAttributes.builder()
        .name((String) response.get("name"))
        .email((String) response.get("email"))
        .picture((String) response.get("profile_image"))
        .socialType(SocialType.NAVER)
        .attributes(response)
        .nameAttributeKey(userNameAttributeName)
        .build();
  }

  private static OAuthAttributes ofGoogle (String userNameAttributeName, Map<String, Object> attributes){
    return OAuthAttributes.builder()
        .name((String) attributes.get("name"))
        .email((String) attributes.get("email"))
        .picture((String) attributes.get("picture"))
        .socialType(SocialType.GOOGLE)
        .attributes(attributes)
        .nameAttributeKey(userNameAttributeName)
        .build();
  }

  public User toEntity(){
    return User.builder()
        .name(name)
        .email(email)
        .picture(picture)
        .role(Role.USER)
        .socialType(socialType)
        .build();
  }
}

OAuth2SuccessHandler

스프링 시큐리티 OAuth 로그인이 문제가 없을 경우 해당 핸들러가 실행된다. 해당 핸들러에서 토큰을 발급하고, 쿠키에 담아 클라이언트에게 여기서 설정한 redirect url로 전달한다.

@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

  private final JwtProcess jwtProcess;

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication auth) throws IOException, ServletException {
    PrincipalDetails principal = (PrincipalDetails) auth.getPrincipal();

    User user = principal.getUser();
    String additionalInputUri = "";
    String accessToken = jwtProcess.createAccessToken(principal);
    String refreshToken = saveRefreshToken(user);

    if(user.getName() == null) {
      additionalInputUri = "login";
    }
    log.info("accessToken={}", accessToken);
    log.info("refreshToken={}", refreshToken);
    addCookie(response,"accessToken", accessToken);
    addCookie(response, "refreshToken", refreshToken);

    getRedirectStrategy().sendRedirect(request, response, "http://localhost:3000");

  }
  private static void addCookie(HttpServletResponse response, String name, String value, boolean httpOnly) {
    value = URLEncoder.encode(value);
    Cookie cookie = new Cookie(name, value);
    cookie.setHttpOnly(httpOnly);
    cookie.setPath("/");
    response.addCookie(cookie);
  }
  private static void addCookie(HttpServletResponse response, String name, String value) {
    addCookie(response, name, value, true);
  }
  private String saveRefreshToken(User user) {
    String email = user.getEmail();
    String role = user.getRoleKey();
    return jwtProcess.createRefreshToken(email, role);
  }

}

프론트 코드

'use client'

export default function Login() {
  return (
    <>
      <span>Login</span>
      <br />
      <a href="http://localhost:8080/oauth2/authorization/naver?redirect_uri=http://localhost:3000/">
        네이버로그인
      </a>
      <br />
      <a href="http://localhost:8080/oauth2/authorization/kakao?redirect_uri=http://localhost:3000/">
        카카오로그인
      </a>
      <br />
      <a href="http://localhost:8080/oauth2/authorization/google?redirect_uri=http://localhost:3000/">
        구글 로그인
      </a>
    </>
  )
}
import Login from '@/component/auth/login'
import NaverLogin from '@/app/(noLogin)/login/NaverLogin'

export default function Page() {
  return (
    <div>
      <Login />
      <NaverLogin />
    </div>
  )
}

확인하기

메인 페이지에서 login 버튼을 눌러 로그인 화면으로 이동한다.

소셜 로그인을 클릭하면 아래와 같이 redirect url로 이동하면서 쿠키에 토큰이 생성되어 있는걸 확인할 수 있다.

 

이 이후엔 화면 먼저 그리고 스프링 기능 구현을 해야겠다. 

 

참고

https://yenjjun187.tistory.com/794

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-OAuth-20-%EA%B0%9C%EB%85%90-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC

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

Spring 구성속성  (0) 2024.11.01
Security 사용하기  (3) 2024.10.08
Security  (0) 2024.10.08
JPA  (1) 2024.09.05
데이터 베이스  (0) 2024.09.04