[GDGoC] OAuth 2.0을 통한 구글 로그인

wuuwls·2024년 11월 17일
1

GDGoC

목록 보기
2/2
post-thumbnail

GDG on Campus SKHU의 Server 파트를 위한 강의자료입니다.

🌱 개요

지난 시간 우리는 JWT를 이용한 간단한 로그인을 구현해봤어요
이번 시간은 OAuth2.0 을 활용하여 소셜(구글) 로그인을 배워볼게요


☘️ OAuth란?

여러분들이 서비스를 이용하기 전 아래와 같은 로그인 화면들을 마주쳤던 기억이 있죠?

해당 서비스 즉 애플리케이션을 이용하기 위해서 Third Party Service 제 3의 서비스 계정 정보를 활용하여 우리가 사용하고자 하는 애플리케이션을 로그인 할 수 있는 기능을 OAuth라는 인증 표준을 통해 대부분을 구현해요.

그래서 OAuth가 뭔데? - Open Authorization

애플리케이션이 특정 시스템의 보호된 리소스에 접근하기 위해, Third Party 사용자 인증(Authentication)을 통해 사용하고자 하는 리소스 접근 권한을 부여하는 개방형 표준 인증 프레임워크

OAuth의 핵심!

OAuth를 이해하기 위해서 3가지 핵심 주체를 알아볼게요
설명을 돕기위해 우리는 구글을 통해서 소셜 로그인을 사용하는 상황을 가정해볼게요

1) Resource Owner

  • 리소스를 소유한 사용자라는 뜻이에요, 애플리케이션의 리소스 공유 요청을 허가 할 수 있어요.

2) Client

  • 사용자의 허가를 받아 사용자의 리소스에 접근할 수 있도록 요청하는 응용 애플리케이션이에요

3) Server

  • 사용자의 허가를 검증하고, 클라이언트에 응용 애플리케이션에 보유한 사용자의 리소스를 공유하는 서버에요.
  • 보편적으로 허가를 담당하는 검증 서버(Authorization Server), 실제 사용자 리소스를 보유한 리소스 서버 (Resource Server or API Server)로 분리 되어 있어요.

OAuth의 동작 과정


1. 리소스 소유자(이하 사용자)가 클라이언트 서비스를 이용하고자 이용 요청
2. 클라이언트는 검증 서버에 AccessToken 전송
3. 검증 서버는 사용자에게 인가 동의를 요청
4. 사용자의 인가 동의 응답
5. 검증 서버는 리소스에 접근할 수 있는 엑세스 토큰을 생성해 클라이언트로 전송
6. 엑세스 토큰 저장
7. 클라이언트는 리소스에 접근 가능한 엑세스 토큰을 통해 리소스 서버에 요청
8. 리소스 서버는 엑세스 토큰의 유효성을 검증하고, 검증에 통과하면 요청한 리소스를 응답
9. 클라이언트는 유저에게 서비스 이용을 담당

Google AccessToken의 구조

갱신

엑세스 토큰은 갱신 주기가 짧죠? 때문에 만료 시마다 다시 자격을 증명하기 위해 서버에 요청해야하는 번거로움이 있어요. 마찬가지로 RefreshToken을 추가로 발급하여 엑세스 토큰을 재발급해 줄 수 있습니다.


🌵 GCP 프로젝트 만들기

먼저 Google Cloud Platform에서 프로젝트를 만들어볼게요


프로젝트 이름은 본인이 하고싶은걸로~



이렇게 만들어진 걸 확인할 수 있어요


왼쪽 탭 메뉴 -> API 및 서비스 -> 사용자 인증 정보 -> 사용자 인증 정보 만들기 -> OAuth 클라이언트 ID 클릭


동의화면 구성 -> User Type : 외부 -> 만들기 -> 저장후 계속


앱 정보 입력(앱 이름, 사용자 지원 이메일) -> 개발자 연락처 정보 입력 -> 저장 후 계속


범위 추가 또는 삭제 -> email, profile, openid 선택 -> 저장 후 계속

테스트 사용자는 그대로 넘어가도록 할게요 (추가하지 않고 저장 후 계속)


다시 여기로 돌아와서 OAuth 클라이언트 ID 만들어요


애플리케이션 유형(웹 애플리케이션) 선택 -> 이름 입력 -> 승인된 리다이렉트 URI 입력 -> 만들기
http://localhost:8080/api/callback/google


클라이언트 ID, 클라이언트 Secret을 발급받아요~


🍀 프로젝트 만들기

application.yml

spring:
  datasource:
    url: ${DB_JDBC_URL}
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MySQL8Dialect
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: false
        use_sql_comments: true

jwt:
  secret: ${JWT_SECRET}
  access-token-validity-in-milliseconds: ${ACCESS_TOKEN_VALIDITY_IN_MILLISECONDS}
  • 저번 시간과 동일해요

의존성 추가

dependencies {
	...
    
	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
    implementation 'com.google.code.gson:gson:2.10.1'

	...
}

Gson은 json 구조를 띄는 직렬화 데이터를 JAVA 객체로 역직렬화, 직렬화 해주는 자바 라이브러리 입니다
ex) JSON Object <-> JAVA Object 행위를 돕는 라이브러리라고 생각하시면 됩니다.

domain

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String profileUrl;

    @Enumerated(EnumType.STRING)
    private Role role;

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

    USER("ROLE_USER"),
    ADMIN("ROEL_ADMIN");

    private final String key;

    Role(String key) {
        this.key = key;
    }
}

dto

@Getter
@Builder
@AllArgsConstructor
public class TokenDto {

    @SerializedName("access_token")
    private String accessToken;
}
@Getter
@AllArgsConstructor
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;
}
  • @SerializeName("access_token")은 아까 말한 GSON에서 제공하는 JSON으로 직렬화 하거나 역직렬화할 때 필요한 필드 네이밍을 지정하는 역할을 해요

jwt

JwtFilter, TokenProvider는 저번 시간과 동일합니다.

config

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final TokenProvider tokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .formLogin(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests
                        .requestMatchers("/api/**").permitAll()
                        .requestMatchers("/gdg/**").authenticated()
                        .anyRequest().authenticated()
                )
                .cors(cors -> cors.configurationSource(configurationSource()))
                .addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
                .build();
    }

    @Bean
    public CorsConfigurationSource configurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.setAllowedOriginPatterns(List.of("*"));
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setExposedHeaders(List.of("Access-Control-Allow-Credentials", "Authorization", "Set-Cookie"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }
}

저번 시간 config에서 cors 설정이 추가되었어요
cors가 어떤 역할을 하는지 한 번 공부해봤으면 좋겠어요~

service

@Service
@RequiredArgsConstructor
public class GoogleLoginService {

    private final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
    private final String GOOGLE_CLIENT_ID = <발급받은 Client_Id>;
    private final String GOOGLE_CLIENT_SECRET = <발급받은 Client_Secret>;
    private final String GOOGLE_REDIRECT_URI = "http://localhost:8080/api/callback/google";

    private final UserRepository userRepository;
    private final TokenProvider tokenProvider;

    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, TokenDto.class)
                    .getAccessToken();
        }

        throw new RuntimeException("구글 엑세스 토큰을 가져오는데 실패했습니다.");
    }

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

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

        User user = userRepository.findByEmail(userInfo.getEmail())
                .orElseGet(() -> userRepository.save(User.builder()
                        .email(userInfo.getEmail())
                        .name(userInfo.getName())
                        .profileUrl(userInfo.getPictureUrl())
                        .role(Role.USER)
                        .build())
        );

        return TokenDto.builder()
                .accessToken(tokenProvider.createAccessToken(user))
                .build();
    }

    private 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("유저 정보를 가져오는데 실패했습니다.");
    }

    public User test(Principal principal) {
        Long id = Long.parseLong(principal.getName());

        return userRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("유저를 찾을 수 없습니다."));
    }
}
  • getGoogleAccessToken : 이 메서드는 Google OAuth2 인증 서버에 엑세스 토큰을 요청하는 역할을 해요
  • loginOrSignUp : 이 메서드는 Google에서 주어진 accessToken을 이용해서 사용자의 정보를 얻어요 이를 통해서 로그인 or 회원가입을 진행하고 이에 따라서 JWT를 반환해요
  • getUserInfo : Google에서 주어진 엑세스 토큰을 통해서 사용자의 정보를 얻는 역할을 해요 엑세스 토큰으로 사용자 정보를 응답받고 이를 UserInfo 객체로 변환하여 반환해요

controller

@RestController
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
@RequestMapping("/api")
public class AuthController {

    private final GoogleLoginService googleLoginService;

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

    private TokenDto loginOrSignup(String googleAccessToken) {
        return googleLoginService.loginOrSignUp(googleAccessToken);
    }
}
@RestController
@RequiredArgsConstructor
@RequestMapping("gdg")
public class UserController {

    private final GoogleLoginService googleLoginService;

    @GetMapping("/test")
    public User getUser(Principal principal) {
        return googleLoginService.test(principal);
    }
}

테스트 해봐요

1) 서버 실행시키고 해당 URL로 진입

https://accounts.google.com/o/oauth2/v2/auth?client_id=(본인_클라이언트_ID)&redirect_uri=http://localhost:8080/api/callback/google&response_type=code&scope=https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email

2) 구글 로그인

3) accessToken 발급

4) test api를 호출하여 정상적으로 동작하는지 확인!

참고 자료

참고 자료1
참고 자료2

profile
신우일신하는 개발자되기

0개의 댓글