OAuth 완벽 이해하기

정다운·2025년 7월 10일

완벽 이해하기

목록 보기
2/5

멋쟁이 사자처럼 13기 강의를 진행하며 만든 자료를 블로그에 공유합니다.

OAuth(OpenID Authentication)란?

OAuth는 인증(Authentication)과 권한(Authorization) 부여를 위한 개방형 표준 프로토콜입니다!

사용자가 자신의 개인 정보를 직접 제공하지 않고, 다양한 외부 플랫폼(예: 구글, 깃허브, 네이버 등)의 서비스에 안전하게 접근할 수 있도록 돕습니다!


왜 필요할까요~?

  • 문제점 OAuth 이전에는 다른 애플리케이션에 로그인하거나 개인 정보를 제공할 때 사용자 이름과 비밀번호 등을 직접 입력하여 제공해야 했습니다! 이런 방식은 보안 위험이 크며, 사용자 계정 정보가 노출될 수 있는 구조입니다!
  • 해결 방식 하지만 OAuth는 사용자가 플랫폼에서 직접 로그인하고, 애플리케이션은 토큰(token)을 통해 일부 권한만 위임받아 사용합니다! 이 토큰은 사용자가 승인한 범위 내에서만 사용되며, 아이디와 비밀번호 노출 없이 안전하게 인증과 인가 처리를 할 수 있습니다!

용어 정리

클라이언트(Client) : 리소스에 접근하는 애플리케이션 혹은 서비스 (Spring boot 서버)

인증 서버(Authorization Server) : 인증, 인가를 수행하는 서버로 access token을 발급

리소스 서버(Resource Server) : 구글, 깃허브, 카카오, 네이버 등 사용자의 리소스를 가지고 있는 서버를 의미

인가 코드(Authorization Code) : 사용자가 로그인 성공 후 발급받는 코드로 access token 발급 시 필요


구글 소셜 로그인 흐름

  1. 구글 로그인 화면으로 이동
  2. 로그인 성공 → 코드 발급 : 사용자가 로그인에 성공하고 권한을 허용하면 구글은 인가 코드(Authorization Code)를 만들어서 서버주소(redirect URI)로 보내줌
  3. 인가 코드를 서버로 전달 : 이 코드는 주소창에 ?code=abcd1234 처럼 붙어서 서버로 전달됨
  4. 서버가 구글에 Access Token 요청 : 서버는 받은 code를 들고 구글에 인증해달라고 요청함
  5. 구글이 Access Token발급 : 구글은 요청을 확인 후 Access Token(일종의 열쇠처럼 작동)을 돌려줌
  6. 서버가 사용자 정보 요청 : 서버는 이 Access Token을 이용해 구글 사용자 정보 API에 요청
  7. 구글이 사용자 정보 전달 : 구글은 요청이 맞다면 "email": "[abc@gmail.com](mailto:abc@gmail.com)", "name": "홍길동" 같은 정보를 JSON으로 넘겨줌
  8. 서버가 사용자 DB 저장 및 로그인 처리 : 서버는 이 정보로 이미 가입돼 있으면 로그인 처리, 없으면 새로 회원가입 진행
  9. 서버에서 자체 JWT 토큰 생성 → 클라이언트에 응답 : 서버는 사용자의 로그인에 성공하면 자제적인 Access Token(예:JWT)을 만들어 클라리언트에 반환, 이 토큰은 이후부터 로그인 했다고 증명하는 열쇠가 되어 다른 API호출 시 함께 보내면 인증됨
  • 참고 사진


google cloud 에서 서비스 등록

<구글이 해당 서버가 신뢰할 수 있는 클라이언트인지 사전에 식별하고 인증하기 위해 서비스를 등록합니다!>

  1. 구글 클라우드 접속

  1. 프로젝트 선택 클릭

  1. 새 프로젝트 만들기

  1. Likelion13으로 생성

  1. API 및 서비스 클릭

  1. 사용자 인증 정보 클릭

  1. 동의 화면 구성 클릭

  1. 사진과 같이 입력

  1. 외부로 선택해 주세요~

  1. 사용하시는 이메일을 입력해 주세요~

  1. 동의 후 만들기를 클릭해 주세요~

  1. 데이터 액세스를 클릭 후 범위 추가 또는 삭제를 눌러 주세요~

  1. 기본적으로 많이 사용되는 email, profile를 선택 후 업데이트를 클릭해 주세요~

  1. 아래 사진처럼 뜨면 성공입니다!!

  1. 그 다음 대상으로 들어가 테스트 사용자의 Add users를 클릭해 주세요~

  1. 그 다음 본인 이메일을 입력해 주시면 됩니다~

  1. 다시 API 및 서비스를 들어가서 사용자 인증정보로 들어가 주세요~

  1. 사용자 인증 정보 만들기에서 OAuth 클라이언트 ID를 클릭해 주세요~

애플리케이션 유형 → 웹 애플리케이션

이름 → LikeLion13

승인된 리디렉션 URI → http://localhost:8080/login/oauth2/code/google 을 정확히 입력해 주세요~ (메모장에 입력해 놓아주세요~)

다음 만들기 버튼까지 눌러주시면 됩니다~

  1. 그러면 클라이언트 ID랑 비밀번호가 뜨는데 이걸 꼭 URI랑 같이 메모장에 복붙해 주세요!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

테스트

https://accounts.google.com/o/oauth2/v2/auth?client_id=<CLIENT_ID>&redirect_uri=<REDIRECT_URI>&response_type=code&scope=email%20profile

위 링크에 메모장에 적어 놓은 클라이언트 ID와 리디렉션 URI로 변경하여 접속해 볼게요~

아래 사진과 같은 화면이 나온다면 성공입니다~

인가코드 (Authorization code) 출력해보기

우선 인가코드를 발급해보는 테스트를 진행해보겠습니다!

이는 위에서 구글 로그인 창으로 이동해, 로그인이 완료 되었다면 리디렉션 URI에 담겨 돌아오는 authorization_code 값을 눈으로 확인해보는 과정입니다!

토큰 강의에서 사용했던 프로젝트를 열어주세요~

그 다음 build.gradle에서 아래 의존성을 추가해주세요~

dependencies {
	...

    implementation 'com.google.code.gson:gson:2.10.1'

	...
}

추가 후 코끼리 눌러주세요~

application.yml

spring:
  profiles:
    active: prod

  datasource:
    url: ${spring.datasource.url}
    username: ${spring.datasource.username}
    password: ${spring.datasource.password}
    driver-class-name: ${spring.datasource.driver-class-name}

  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MySQL8Dialect
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: false
        use_sql_comments: true
        show_sql: true
    open-in-view: false

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.type.descriptor.sql: trace

jwt:
  secret: ${JWT_SECRET}
  access-token-validity-in-milliseconds: ${JWT_ACCESS_TOKEN_VALIDITY_IN_MILLISECONDS}
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}

application-prod.yml

데이터베이스 스키마 생성해 주세요~

spring:
  config:
    activate:
      on-profile: prod

  datasource:
    url: jdbc:mysql://localhost:3306/likelionoauth2
    username: root
    password: #각자 비밀번호로 변경
    driver-class-name: com.mysql.cj.jdbc.Driver

jwt:
  secret: ???????  # openssl rand -base64 64
  access-token-validity-in-milliseconds: 3600000
client-id: 클라이언트 ID
client-secret: 클라이언트 SECRET KEY

token:
  expire:
    time: 1800000

.gitignore

src/**/application-prod.yml


image.png

AuthLoginController

아래 코드는 OAuth2로그인 흐름을 테스트하기 위해 작성한 최소 구성의 컨트롤러입니다!

구글에서 받은 인가코드를 서버가 받아 AuthLoginServer에게 전달합니다!

package com.likelion.likelionjwt.oauth.api;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/login/oauth2")
public class AuthLoginController {
    private final AuthLoginService authLoginService;

    @GetMapping("/code/{registrationID}")
    public void googleLogin(@RequestParam String code, @PathVariable String registrationID){
        authLoginService.socialLogin(code, registrationID);
    }
}

AuthLoginService

이 코드도 테스트를 위한 코드로 실제로 토큰을 요청하거나 사용자 정보를 가져오지 않고 단순히 인가 코드와 소셜로그인의 id(즉 google)을 출력하는 구조입니다!

package com.likelion.likelionjwt.oauth.application;

import org.springframework.stereotype.Service;

@Service
public class AuthLoginService {

    public void socialLogin(String code, String registrationId){
        System.out.println("code = " + code);
        System.out.println("registrationId = " + registrationId);
    }
}
  • 컨트롤러에서 받은 authorization code와 소셜 로그인 id를 받아 콘솔에 출력

SecurityConfig

저번 토큰 강의에서 작성했던 코드에 이어서 작성할게요~

package com.likelion.likelionjwt.global.config;

import com.likelion.likelionjwt.global.jwt.JwtAuthorizationFilter;
import lombok.RequiredArgsConstructor;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration  // 하나 이상의 @Bean 메서드를 가지고 있어 spring 컨테이너가 해당 메서드들을 관리하고 빈으로 등록
@EnableWebSecurity  // spring security 활성화, 웹 보안 설정 제공
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthorizationFilter jwtAuthorizationFilter;

    @Bean // 보안 필터 체인을 정의하는 Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .httpBasic(AbstractHttpConfigurer::disable) // 기본 HTTP 인증 비활성화
                .formLogin(AbstractHttpConfigurer::disable) // 스프링 기본 폼 로그인 비활성화
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // 세션 정책을 stateless로 설정 -> 서버가 세션을 생성하지 않고 토큰 기반 인증을 사용하도록 설정 = jwt 방식
                .csrf(AbstractHttpConfigurer::disable)  // csrf 비활성화
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests
				                .requestMatchers("/login/oauth2/**").permitAll()
                        .requestMatchers("/members/**").permitAll() // members로 시작하는 경로는 허용
                        .requestMatchers("/posts/**").permitAll() // posts도 허용
                        .anyRequest().authenticated()   // 그 외 모든 요청은 인증 필요
                )
                .addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class)
                // 지정된 필터 앞에 커스텀 필터(jwtAuthorizationFilter) 추가
                .formLogin(AbstractHttpConfigurer::disable)
                .build();
    }
    @Bean
    public PasswordEncoder passwordEncoder() { // 패스워드 암호화
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
   }
💡

https://accounts.google.com/o/oauth2/v2/auth?client_id=<CLIENT_ID>&redirect_uri=<REDIRECT_URI>&response_type=code&scope=email%20profile

다시 아까 작성했던 링크로 들어가 로그인 해주세요!!

빈 화면과 콘솔에 인가코드, 구글이 출력되면 성공입니다~

구글 로그인과 회원가입, 토큰 발급

이제 구글로 로그인할 때 처음 방문한 사용자는 회원가입 후 로그인(토큰 발급), 이미 회원가입이 되어있다면 바로 로그인을 해주도록 만들어 보겠습니당~~

Domain

Member

지금까지 실습해오던 member 도메인에 이어서 작성해줄게요~

package com.likelion.likelionjwt.member.domain;

import com.likelion.likelionjwt.post.domain.Post;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long memberId;

    @Column(name = "member_email", nullable = false)
    private String email;
    private String password;

    @Column(name = "member_name", nullable = false)
    private String name;
    
    @Column(name = "member_picture_url")
    private String pictureUrl;

    @Enumerated(EnumType.STRING)
    @Column(name = "member_role", nullable = false)
    private Role role;

    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Post> posts = new ArrayList<>();

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

}
  • 이메일, 이름, 사용자 권한을 필수값으로 수정해주고
  • 구글 등의 소셜 로그인에서 제공하는 프로필 이미지 주소를 저장하도록 수정해 줄게요~

DTO

Token

구글로부터 받은 access_token값을 받아서 자바 객체로 변환하거나 서버에서 JWT를 만들어 클라이언트에서 반환할 때 사용하는 DTO 입니다~

package com.likelion.likelionjwt.oauth.api.dto;

import com.google.gson.annotations.SerializedName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;

@Data
@Builder
@AllArgsConstructor
public class Token {

    @SerializedName("access_token")
    private String accessToken;
}
  • SerializedName은 직렬화, 역직렬화 시 이름 매핑을 해줍니다!
  • 구글이 반환하는 JSON에서 access_token이라는 키를 이 클래스의 accessToken필드에 매핑되도록 지정합니다

UserInfo

MemberInfoResDto가 있는데 왜 또 만드는지 의문이 드시는 분들이 계실 수도 있을 것 같아용!

둘의 차이점은 MemberInfoResDto는 로그인 성공 후 클라이언트에 전달할 정보를 담고 있고, UserInfo는 구글 API로부터 응답받은 JSON을 파싱하는 역할을 합니다!!

즉, UserInfo는 구글로부터 받은 사용자 이름, 이메일, 프로필 사진 등을 담습니다!

package com.likelion.likelionjwt.oauth.api.dto;

import com.google.gson.annotations.SerializedName;
import lombok.Data;

@Data
public class UserInfo {
    private String id;
    
    private String email;

    @SerializedName("verified_email")
    private Boolean verifiedEmail;
    
    private String name;

    @SerializedName("given_name")
    private String givenName;

    @SerializedName("family_name")
    private String familyName;

    @SerializedName("picture")
    private String pictureUrl;
    
    private String locale;
}

Google OAuth2 로그인 후 사용자 정보는 아래와 같은 형태의 JSON으로 응답됩니다~

{
  "id": "112345678901234567890",
  "email": "user@gmail.com",
  "verified_email": true,
  "name": "홍길동",
  "given_name": "길동",
  "family_name": "홍",
  "picture": "https://lh3.googleusercontent.com/a-/AOh14GgNEXAMPLE",
  "locale": "ko"
}

repository

MemberRepository는 토큰 강의에서 만들었기에 그대로 갑니다~

이는 사용자가 이미 가입된 회원인지 확인하거나 새로 가입시키기 위해 사용됩니다!

  • MemberRepository
    package com.likelion.likelionjwt.member.domain.repository;
    
    import com.likelion.likelionjwt.member.domain.Member;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    import java.util.Optional;
    
    public interface MemberRepository extends JpaRepository<Member, Long> {
        Optional<Member> findByEmail(String email);
        boolean existsByEmail(String email);
    }

global

SecurityConfig

작성하던 코드에 이어서 작성해 주세요~

이 클래스는 spring boot 애플리케이션에서 폼로그인 없이 JWT만으로 인증 처리가 가능하도록 만들어 줍니다!

package com.likelion.likelionjwt.global.config;

import com.likelion.likelionjwt.global.jwt.JwtAuthorizationFilter;
import com.likelion.likelionjwt.global.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration  // 하나 이상의 @Bean 메서드를 가지고 있어 spring 컨테이너가 해당 메서드들을 관리하고 빈으로 등록
@EnableWebSecurity  // spring security 활성화, 웹 보안 설정 제공
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthorizationFilter jwtAuthorizationFilter;
    private final JwtTokenProvider jwtTokenProvider;

    @Bean // 보안 필터 체인을 정의하는 Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .httpBasic(AbstractHttpConfigurer::disable) // 기본 HTTP 인증 비활성화
                .formLogin(AbstractHttpConfigurer::disable) // 스프링 기본 폼 로그인 비활성화
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // 세션 정책을 stateless로 설정 -> 서버가 세션을 생성하지 않고 토큰 기반 인증을 사용하도록 설정 = jwt 방식
                .csrf(AbstractHttpConfigurer::disable)  // csrf 비활성화
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests
                        .requestMatchers("/login/oauth2/**").permitAll()
                        .requestMatchers("/members/**").permitAll() // members로 시작하는 경로는 허용
                        .requestMatchers("/posts/**").permitAll() // posts도 허용
                        .anyRequest().authenticated()   // 그 외 모든 요청은 인증 필요
                )
                .addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class)
                // 지정된 필터 앞에 커스텀 필터(jwtAuthorizationFilter) 추가
                .formLogin(AbstractHttpConfigurer::disable)
                .addFilterBefore(new JwtAuthorizationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
                .build();
    }
    @Bean
    public PasswordEncoder passwordEncoder() { // 패스워드 암호화
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

추가 후

Cannot resolve symbol 'TokenProvider’

Could not autowire. No beans of 'TokenProvider' type found.

Cannot resolve symbol 'JwtFilter’

위와 같은 에러가 뜨는 건 당연하답니다~

TokenProvider

이 코드도 작성하던 곳에 이어서 작성해 주세요~

이 클래스는 OAuth2 로그인 후 사용자 정보를 바탕으로 JWT를 생성하고 클라이언트가 보낸 JWT를 검증해 인증 객체를 만들어줍니다!

package com.likelion.likelionjwt.global.jwt;

import com.likelion.likelionjwt.common.error.ErrorCode;
import com.likelion.likelionjwt.common.exception.BusinessException;
import com.likelion.likelionjwt.member.domain.Member;
import com.likelion.likelionjwt.member.domain.repository.MemberRepository;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.crypto.SecretKey;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;

@Slf4j  // 로그 작성
@Component  // spring bean에 등록
public class JwtTokenProvider {

    private static final String AUTHORITIES_KEY = "auth"; // 권한 키 상수 추가 
    private final MemberRepository memberRepository;

    @Value("${token.expire.time}") // factory annotation 임포트
    private String tokenExpireTime; // 토큰 만료 시간

    @Value("${jwt.secret}")
    private String secret;  // 비밀키

    private SecretKey key;  // 객체 key

    public JwtTokenProvider(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @PostConstruct  // Bean이 초기화 된 후에 실행
    public void init() {    // 필터 객체를 초기화하고 서비스에 추가하기 위한 메소드 init
        byte[] keyBytes = Decoders.BASE64.decode(secret);   // 시크릿 키 디코딩 후
        this.key = Keys.hmacShaKeyFor(keyBytes); // 키 암호화
    }

    // 토큰 생성
    public String generateToken(Member member) {
        // 만료 시간 설정 util로 임포트
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + Long.parseLong(tokenExpireTime));

        return Jwts.builder()
                .subject(member.getMemberId().toString())   // 토큰 주체를 id로 설정
                .claim(AUTHORITIES_KEY, member.getRole().toString()) // 추가된 권한 Claim 
                .issuedAt(now)  // 발행 시간
                .expiration(expireDate) // 만료 시간
                .signWith(key, Jwts.SIG.HS256)  // ㅏ토큰 암호화
                .compact(); // 압축, 서명 후 토큰 생성
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token);  // 토큰 파싱, 검증
            return true;    // 검증 완료 -> 유효한 토큰
            // 검증 실패 시 반환하는 예외에 따라 다르게 실행
        } catch (UnsupportedJwtException | MalformedJwtException e) {
            throw new BusinessException(ErrorCode.NO_AUTHORIZATION_EXCEPTION, "JWT 가 유효하지 않습니다.");
        } catch (SignatureException e) {
            throw new BusinessException(ErrorCode.NO_AUTHORIZATION_EXCEPTION, "JWT 서명 검증에 실패했습니다.");
        } catch (ExpiredJwtException e) {
            throw new BusinessException(ErrorCode.NO_AUTHORIZATION_EXCEPTION, "JWT 가 만료되었습니다.");
        } catch (IllegalArgumentException e) {
            throw new BusinessException(ErrorCode.NO_AUTHORIZATION_EXCEPTION, "JWT 가 null 이거나 비어있거나 공백만 있습니다.");
        } catch (Exception e) {
            throw new BusinessException(ErrorCode.NO_AUTHORIZATION_EXCEPTION, "JWT 검증에 실패했습니다.");
        }
    }

    // 인증 객체 반환 core 로 임포트
    public Authentication getAuthentication(String token) {
        Claims claims = parseClaims(token);

        String authority = claims.get(AUTHORITIES_KEY, String.class);
        if(authority == null) {
            throw new BusinessException(ErrorCode.NO_AUTHORIZATION_EXCEPTION, "권한 정보가 없는 토큰입니다.");
        }

        List<GrantedAuthority> authorities = Arrays.stream(authority.split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        // memberId, 공백, authorities 반환
        return new UsernamePasswordAuthenticationToken(claims.getSubject(), "", authorities);
    }

    // Claims 파싱 
    private Claims parseClaims(String accessToken) {
        try{
            JwtParser parser = Jwts.parser()
                    .verifyWith(key)
                    .build();

            return parser.parseSignedClaims(accessToken).getPayload();
        }catch (ExpiredJwtException e){
            return e.getClaims();
        }
    }
}
  • getAuthentication : JWT 토큰에서 사용자 정보를 추출해 spring security가 사용하는 인증 객체를 생성합니다
  • parseClaims : JWT을 파싱하여 Claim(식별자, 권한 등)을 추출합니다

JwtAuthorizationFilter

이 부분도 토큰 강의에서 했던 코드 그대로 갑니다!!

이 필터는 사용자의 요청마다 헤더에서 JWT를 추츨해 검증하고 유효한 경우 해당 사용자를 spring security 인증 객체로 등록합니다!

  • JwtAuthorizationFilter
    package com.likelion.likelionjwt.global.jwt;
    
    import jakarta.servlet.FilterChain;
    import jakarta.servlet.ServletException;
    import jakarta.servlet.ServletRequest;
    import jakarta.servlet.ServletResponse;
    import jakarta.servlet.http.HttpServletRequest;
    import lombok.RequiredArgsConstructor;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.stereotype.Component;
    import org.springframework.util.StringUtils;
    import org.springframework.web.filter.GenericFilterBean;
    
    import java.io.IOException;
    
    @Component
    @RequiredArgsConstructor
    public class JwtAuthorizationFilter extends GenericFilterBean {
    
        private final JwtTokenProvider jwtTokenProvider;
    
        // 모든 요청이 들어올 때 필터가 가로채 인증 로직 실행
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws
                IOException, ServletException {
            String token = resolveToken((HttpServletRequest) request); // 요청에서 토큰 resolve(추출)
    
            if (token != null && jwtTokenProvider.validateToken(token)) {   // 토큰이 유효한지 확인
                Authentication authentication = jwtTokenProvider.getAuthentication(token);  // 유효하다면 토큰으로부터 인증 정보 가져옴
                SecurityContextHolder.getContext().setAuthentication(authentication);
                // SecurityContext에 인증 정보 저장 -> 이후 요청 처리에서 인증된 사용자 정보에 접근할 수 있음
            }
            chain.doFilter(request, response); // 필터 체인의 다음 필터로 요청 넘기기
        }
    
        // 요청에서 토큰 추출 메소드
        private String resolveToken(HttpServletRequest request) {
            String bearerToken = request.getHeader("Authorization");    // 헤더에서 Authorization 값 꺼내기
            if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
                return bearerToken.substring(7);    // "Bearer " 부분을 잘라내고 토큰만 리턴
            }
            return null;
        }
    }

AuthLoginController

아까 만들었던 googleLogin부분을 주석처리 후 작성해 주세요!!

구글에서 받은 인가 코드를 받아 액세스 토큰 → 사용자 정보 → JWT 토큰 생성의 과정을 수행하고 클라이언트에게 JWT 액세스 토큰을 응답으로 전달합니다

package com.likelion.likelionjwt.oauth.api;

import com.likelion.likelionjwt.oauth.api.dto.Token;
import com.likelion.likelionjwt.oauth.application.AuthLoginService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/login/oauth2")
public class AuthLoginController {
    private final AuthLoginService authLoginService;

//    // code : 인증 서버에서 받은 authorization code
//    // registrationId : 사용자가 로그인한 소셜 로그인의 id
//    @GetMapping("/code/{registrationID}")
//    public void googleLogin(@RequestParam String code, @PathVariable String registrationID){
//        authLoginService.socialLogin(code, registrationID);
//    }

    @GetMapping("/code/google")
    public Token googleCallback(@RequestParam(name = "code") String code){
        String googleAccessToken = authLoginService.getGoogleAccessToken(code);
        return loginOrSignup(googleAccessToken);
    }

    public Token loginOrSignup(String googleAccessToken){
        return authLoginService.loginOrSignUp(googleAccessToken);
    }
}
  • googleCallback은
    • 인가 코드를 통해 access token을 요청하고,
    • 사용자 정보 조회 → JWT 발급 또는 회원가입 처리를 하고, (loginOrSignup)
    • Token객체를 JSON 형태로 응답합니다!

Cannot resolve method 'getGoogleAccessToken' in 'AuthLoginService’

Cannot resolve method 'loginOrSignUp' in 'AuthLoginService’

와 같은 오류가 뜨는 것은 정상이랍니다~

AuthLoginService

이것도 기존에 작성한 코드를 주석처리하고 작성해 주세요~

이 클래스는 OAuth 로그인 후 인가 코드를 통해 access token을 발급받고 사용자 정보를 조회해 회원가입 또는 로그인 처리 후 JWT를 생성해 반환하는 비즈니스 로직을 담당합니다!

package com.likelion.likelionjwt.oauth.application;

import com.google.gson.Gson;
import com.likelion.likelionjwt.global.jwt.JwtTokenProvider;
import com.likelion.likelionjwt.member.domain.Member;
import com.likelion.likelionjwt.member.domain.Role;
import com.likelion.likelionjwt.member.domain.repository.MemberRepository;
import com.likelion.likelionjwt.oauth.api.dto.Token;
import com.likelion.likelionjwt.oauth.api.dto.UserInfo;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.net.URI;
import java.util.Map;

@Service
@RequiredArgsConstructor
public class AuthLoginService {

//    // 컨트롤러에서 받은 authorization code와 소셜 로그인 id를 받아 콘솔에 출력
//    public void socialLogin(String code, String registrationId){
//        System.out.println("code = " + code);
//        System.out.println("registrationId = " + registrationId);
//    }

    @Value("${client-id}") // import 시 lombok으로 하면 안됨
    private String GOOGLE_CLIENT_ID;

    @Value("${client-secret}")
    private String GOOGLE_CLIENT_SECRET;

    // 구글 인증 코드를 엑세스 토큰으로 교환하는 API 주소
    private final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
    // OAuth 인증 후 구글이 리디렉션할 URI
    private final String GOOGLE_REDIRECT_URI = "http://localhost:8080/login/oauth2/code/google";

    private final MemberRepository memberRepository;
    private final JwtTokenProvider jwtTokenProvider;

    public String getGoogleAccessToken(String code) {
        RestTemplate restTemplate = new RestTemplate();
        Map<String, String> params = Map.of(
                "code", code,
                "scope", "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
                "client_id", GOOGLE_CLIENT_ID,
                "client_secret", GOOGLE_CLIENT_SECRET,
                "redirect_uri", GOOGLE_REDIRECT_URI,
                "grant_type", "authorization_code"
        );

        ResponseEntity<String> responseEntity = restTemplate.postForEntity(GOOGLE_TOKEN_URL, params, String.class);

        if(responseEntity.getStatusCode().is2xxSuccessful()) {
            String json = responseEntity.getBody();
            Gson gson = new Gson();

            return gson.fromJson(json, Token.class)
                    .getAccessToken();
        }
        throw new RuntimeException("구글 엑세스 토큰을 가져오는데 실패했습니다.");
    }

    public Token loginOrSignUp(String googleAccessToken){
        UserInfo userInfo = getUserInfo(googleAccessToken);

        if(!userInfo.getVerifiedEmail()){
            throw new RuntimeException("이메일 인증이 되지 않은 유저입니다.");
        }

        Member member = memberRepository.findByEmail(userInfo.getEmail()).orElseGet(() ->
                memberRepository.save(Member.builder()
                        .email(userInfo.getEmail())
                        .name(userInfo.getName())
                        .pictureUrl(userInfo.getPictureUrl())
                        .role(Role.ROLE_USER)
                        .build())
        );

        String jwt = jwtTokenProvider.generateToken(member);
        return new Token(jwt);
    }

    public UserInfo getUserInfo(String accessToken){
        RestTemplate restTemplate = new RestTemplate();
        String url = "https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + accessToken;

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + accessToken);
        headers.setContentType(MediaType.APPLICATION_JSON);

        RequestEntity<Void> requestEntity = new RequestEntity<>(headers, HttpMethod.GET, URI.create(url));
        ResponseEntity<String> responseEntity = restTemplate.exchange(requestEntity, String.class);

        if(responseEntity.getStatusCode().is2xxSuccessful()) {
            String json = responseEntity.getBody();
            Gson gson = new Gson();
            return gson.fromJson(json, UserInfo.class);
        }

        throw new RuntimeException("유저 정보를 가져오는데 실패했습니다.");
    }
}
  • getGoogleAccessToken에서는
    • Google OAuth 토큰 서버에 Post 요청을 보내 access_token을 받아옵니다!
    • RestTemplate를 사용해 파라미터를 전송하고
    • 응답 성공 시 JSON을 파싱해 Token객체를 만들고 access_token을 반환합니다!
  • getUserInfo에서는
  • loginOrSignUp에서는
    • getUserInfo로 사용자 정보를 가져온 뒤 이메일 인증 여부를 확인하고
    • DB에 사용자가 없으면 회원가입 후 저장, 있으면 그대로 로그인 합니다!
    • JwtTokenProvider를 통해 JWT토큰을 생성해여 Token으로 반환합니다!

이제 발급받은 Authorization code를 통해 Access Token을 발급받아보겠습니당!

💡

https://accounts.google.com/o/oauth2/v2/auth?client_id=<CLIENT_ID>&redirect_uri=<REDIRECT_URI>&response_type=code&scope=email%20profile

다시 아까 작성했던 링크로 들어가 로그인 해주세요!!

그러면 이렇게 Access Token이 뜰겁니당!!

이제 mysql로 들어가서 조회해볼건데용!

위 사진처럼 회원가입이 되면서 이름, 이메일, 프로필 등 잘 뜨면 성공입니당~~

오늘도 너무 수고 많으셨습니다~

0개의 댓글