[Spring Boot] Spring Boot, JWT, OAuth2 (2)

2021. 3. 6. 02:08Spring/Spring Boot

1. 환경 구축

- docker-compose.yml

version: "3.7"
services:
  db:
    image: mysql:latest
    restart: always
    command: --lower_case_table_names=1
    container_name: local_mysql
    ports:
      - "13306:3306"
    environment:
      - MYSQL_DATABASE=TEST
      - MYSQL_ROOT_PASSWORD=password
      - TZ=Asia/Seoul

    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
    volumes:
      - /Volumes/Data/Docker/share_mysql:/var/lib/mysql # 디렉토리 공유
docker-compose up -d

- 소셜 로그인을 위한 OAuth2 서비스 등록

 

예를 들어, 구글을 등록할 때, 승인된 리다이렉션 URI는 서비스에서 파라미터로 인증 정보를 주었을 때 인증을 성공하면 구글에서 리다이렉트할 URI를 뜻한다. Spring Boot 2 버전의 Security에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URI를 지원하고 있다. 즉, 이미 Security에서 구현이 되어있기 때문에 사용자가 별도로 리다이렉트 URI를 지원하는 컨트롤러를 만들 필요가 없다.

 

단, 서버에 배포할 경우, localhost로된 주소가 아닌 서버용 주소를 추가해주어야한다.

  • http://localhost:8080/login/oauth2/code/google

 

 

 

2. Spring Boot

1) build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    // JWT
    compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
}

2) application.yml

등록한 서비스의 클라이언트 ID, 비밀키, redirectUri를 등록해준다. 단, 클라이언트 ID와 비밀키의 정보는 보안상 중요한 파일이므로, 해당 파일은 .gitignore에 등록해주어야한다.

  • scope는 로그인 성공 후 제 도메인에서 구글에 요청할 사용자 정보이다. email, profile을 선언했으므로 이제 제 도메인에서 google 사용자의 email과 profile 정보를 사용할 수 있다.

  • redirectUri는 사용자가 구글에서 Authentication을 성공 후 authorization code를 전달할 제 도메인의 endPoint 이다.

  • Spring Security에서는 google의 default redirectUri로 /login/oauth2/code/google 를 제공하며, 네이버처럼 default로 제공해주지 않는다면 반드시 전부 명시해 준다.

 

spring:
    datasource:
        url: jdbc:mysql://localhost:13306/TEST?useSSL=false&serverTimezone=UTC&useLegacyDatetimeCode=false
        username: root
        password: password

    jpa:
        show-sql: true
        hibernate:
            ddl-auto: update
            naming-strategy: org.hibernate.cfg.ImprovedNamingStrategy
        properties:
            hibernate:
                dialect: org.hibernate.dialect.MySQL5InnoDBDialect
    security:
      oauth2:
        client:
          registration:
            google:
              clientId: 5014057553-8gm9um6vnli3cle5rgigcdjpdrid14m9.apps.googleusercontent.com
              clientSecret: tWZKVLxaD_ARWsriiiUFYoIk
#              redirectUri: "{baseUrl}/oauth2/callback/{registrationId}"
              scope:
                - email
                - profile
                
            facebook:
              clientId: 121189305185277
              clientSecret: 42ffe5aa7379e8326387e0fe16f34132
              redirectUri: "{baseUrl}/oauth2/callback/{registrationId}" # Note that facebook now mandates the use of https redirect URIs, so make sure your app supports https in production
              scope:
                - email
                - public_profile
                
            github:
              clientId: d3e47fc2ddd966fa4352
              clientSecret: 3bc0f6b8332f93076354c2a5bada2f5a05aea60d
              redirectUri: "{baseUrl}/oauth2/callback/{registrationId}"
              scope:
                - user:email
                - read:user
                
          provider:
            facebook:
              authorizationUri: https://www.facebook.com/v3.0/dialog/oauth
              tokenUri: https://graph.facebook.com/v3.0/oauth/access_token
              userInfoUri: https://graph.facebook.com/v3.0/me?fields=id,first_name,middle_name,last_name,name,email,verified,is_verified,picture.width(250).height(250)

app:
  auth:
    tokenSecret: 926D96C90030DD58429D2751AC1BDBBC
    tokenExpirationMsec: 864000000
  oauth2:
    # After successfully authenticating with the OAuth2 Provider,
    # we'll be generating an auth token for the user and sending the token to the
    # redirectUri mentioned by the frontend client in the /oauth2/authorize request.
    # We're not using cookies because they won't work well in mobile clients.
    authorizedRedirectUris:
      - http://localhost:8080/oauth2/redirect
#      - myandroidapp://oauth2/redirect
#      - myiosapp://oauth2/redirect

  • app.auth.tokenSecret은 JWT Token 을 Hash 할 때 사용하는 Secret Key이다.

  • app.auth.ExpirationMsec는 JWT Token의 유효기간을 설정한다. 유효기간이 만료된 Token으로 접근시 재발급 process를 거치게된다. (60 * 60 * 24 = 1 Day)

  • app.oauth2.authorizedRedirectURis는 생성된 JWT Token을 response 할 uri를 입력한다. 여기서는 localhost:8080으로 전달하였고 배열형식으로 여러개를 정의 할 수 있다. 이곳에 정의된 redirectUri외에는 JWT Token을 전달 받을 수 없다.

3) AppProperties 클래스

application.yml의 app 설정을 바인딩한다.

package com.tistory.ozofweird.springboot_oauth2_jwt.config;

import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;

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

@Getter
@ConfigurationProperties(prefix = "app")
public class AppProperties {
    private final Auth auth = new Auth();
    private final OAuth2 oauth2 = new OAuth2();

    @Getter
    @RequiredArgsConstructor
    public static class Auth {
        private String tokenSecret;
        private long tokenExpirationMsec;

        @Builder
        public Auth(String tokenSecret, long tokenExpirationMsec) {
            this.tokenSecret = tokenSecret;
            this.tokenExpirationMsec = tokenExpirationMsec;
        }
    }

    @Getter
    @RequiredArgsConstructor
    public static final class OAuth2 {
        private List<String> authorizedRedirectUris = new ArrayList<>();

        public OAuth2 authorizedRedirectUris(List<String> authorizedRedirectUris) {
            this.authorizedRedirectUris = authorizedRedirectUris;
            return this;
        }
    }

}

4) MainApplication 클래스

프로젝트에서 AppProperties.java를 사용할 수 있도록 선언해준다.

package com.tistory.ozofweird.springboot_oauth2_jwt;

import com.tistory.ozofweird.springboot_oauth2_jwt.config.AppProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
public class SpringbootOauth2JwtApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringbootOauth2JwtApplication.class, args);
    }

}

5) CORS 설정

CORS(Cross-Origin Resource Sharing)은 동일한 출처가 아니여도 다른 출처에서의 자원을 요청하여 사용할 수 있도록 허용하는 구조이다. 오리진은 도메인과 비슷하지만 프로토콜과 포트번호 포함 여부의 차이가 있다.

  • 도메인 - naver.com

  • 오리진 - https://www.naver.com/PORT

 

CORS는 오리진 사이의 리소스 공유를 제한하여 XSS, CSRF와 같은 해킹 공격을 방지한다.

package com.tistory.ozofweird.springboot_oauth2_jwt.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final long MAX_AGE_SECS = 3600;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry
                // CORS 적용할 URL 패턴
                .addMapping("/**")
                // 자원을 공유할 오리진 지정
                .allowedOrigins("*")
                // 요청 허용 메서드
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                // 요청 허용 헤더
                .allowedHeaders("*")
                // 쿠키 허용
                .allowCredentials(true)
                .maxAge(MAX_AGE_SECS);
    }
}

6) AuthProvider 클래스

AuthProvider 클래스는 google, naver와 같은 OAuth 공급자를 의미한다.

package com.tistory.ozofweird.springboot_oauth2_jwt.domain.user;

public enum AuthProvider {
    local,
    facebook,
    google,
    github
}

7) User 클래스, Role 클래스, UserRepository 인터페이스

package com.tistory.ozofweird.springboot_oauth2_jwt.domain.user;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.sun.istack.NotNull;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
public class User {

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

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    @Column
    private String imageUrl;

    @Column
    private Role role;

    @Column(nullable = false)
    private Boolean emailVerified = false;

    @JsonIgnore
    @Column
    private String password;

    @NotNull
    @Enumerated(EnumType.STRING)
    private AuthProvider provider;

    @Column
    private String providerId;

    @Builder
    public User(String name, String email, String imageUrl, Role role, Boolean emailVerified, String password, AuthProvider provider, String providerId) {
        this.name = name;
        this.email = email;
        this.imageUrl = imageUrl;
        this.role = role;
        this.emailVerified = emailVerified;
        this.password = password;
        this.provider = provider;
        this.providerId = providerId;
    }

    public User update(String name, String imageUrl) {
        this.name = name;
        this.imageUrl = imageUrl;
        return this;
    }
}
package com.tistory.ozofweird.springboot_oauth2_jwt.domain.user;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST("ROLE_GUEST", "손님"), // 가입 전
    USER("ROLE_USER", "일반 사용자"), // 가입 후
    ADMIN("ROLE_ADMIN", "관리자");

    private final String key;
    private final String title;
}
package com.tistory.ozofweird.springboot_oauth2_jwt.domain.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);

    Boolean existsByEmail(String email);
}

 

8) SecurityConfig 클래스

Spring Security는 오버라이드된 configure(HttpSecurity http)에서 antMatchers를 이용해 ROLE을 확인할 수 있다. 하지만 관리 대상과요구사항이 많아지면 ROLE만으로 문제 해결이 어렵다.

 

- @EnableGlobalMethodSecurity

  • @Secured 사용하여 인가처리하는 옵션
  • @PreAuthorize, @PostAuthorize 사용하여 인가처리하는 옵션
  • @RoleAllowd 사용하여 인가처리하는 옵션

 

- CustomUserService

인증시 사용할 User Service이다.

 

- TokenAuthenticationFilter

로그인시 JWT Token을 확인해 인가된 사용자 유무를 판별하고 내부 프로세스를 수행한다. 인가된 사용자를 확인할 때 DB를 조회하지 않고 JWT 토큰에 저장된 값들로만 확인할 수 있도록한다.

 

- HttpCookieOAuth2AuthorizationRequestRepository

Spring OAuth2는 기본적으로 HttpSessionOAuth2AuthorizationRequestRepository를 사용해 Authorization Request를 저장한다. 하지만 JWT의 경우 Session에 저장할 필요가 없으므로 HttpCookieOAuth2AuthorizationRequestRepository 클래스를 생성하여 Authorization Request를 Based64 encoded cookie에 저장한다.

 

package com.tistory.ozofweird.springboot_oauth2_jwt.config;

import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.Role;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.CustomUserDetailsService;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.TokenAuthenticationFilter;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.CustomOAuth2UserService;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.HttpCookieOAuth2AuthorizationRequestRepository;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.OAuth2AuthenticationFailureHandler;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.OAuth2AuthenticationSuccessHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity           // Spring Security 활성화
@EnableGlobalMethodSecurity( // SecurityMethod 활성화
        securedEnabled = true,
        jsr250Enabled = true,
        prePostEnabled = true
)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomUserDetailsService customUserDetailsService;

    private final CustomOAuth2UserService customOAuth2UserService;

    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;

    private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;

    private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;

    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter();
    }

    /*
      By default, Spring OAuth2 uses HttpSessionOAuth2AuthorizationRequestRepository to save
      the authorization request. But, since our service is stateless, we can't save it in
      the session. We'll save the request in a Base64 encoded cookie instead.

      HttpCookieOAuth2AuthorizationReqeustRepository
      - JWT를 사용하기 때문에 Session에 저장할 필요가 없어져, Authorization Request를 Based64 encoded cookie에 저장
    */

    @Bean
    public HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository() {
        return new HttpCookieOAuth2AuthorizationRequestRepository();
    }

    // Authorization에 사용할 userDetailService와 password Encoder 정의
    @Override
    public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        authenticationManagerBuilder
                .userDetailsService(customUserDetailsService)
                .passwordEncoder(passwordEncoder());
    }

    // SecurityConfig에서 사용할 password encoder를 BCryptPasswordEncoder로 정의
    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    /*
      AuthenticationManager를 외부에서 사용하기 위해 AuthenticationManager Bean을 통해
      @Autowired가 아닌 @Bean 설정으로 Spring Security 밖으로 Authentication 추출
     */
    @Bean(BeanIds.AUTHENTICATION_MANAGER)
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // CORS 허용
                .cors()
                .and()
                // 토큰을 사용하기 위해 sessionCreationPolicy를 STATELESS로 설정 (Session 비활성화)
                    .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // CSRF 비활성화
                    .csrf().disable()
                // 로그인폼 비활성화
                    .formLogin().disable()
                // 기본 로그인 창 비활성화
                    .httpBasic().disable()
                    .authorizeRequests()
                        .antMatchers("/").permitAll()
                        .antMatchers("/api/**").hasAnyRole(Role.GUEST.name() ,Role.USER.name(), Role.ADMIN.name())
                        .antMatchers("/auth/**", "/oauth2/**").permitAll()
                        .anyRequest().authenticated()
                .and()
                    .oauth2Login()
                        .authorizationEndpoint()
                // 클라이언트 처음 로그인 시도 URI
                        .baseUri("/oauth2/authorization")
                        .authorizationRequestRepository(cookieAuthorizationRequestRepository())
                .and()
                    .userInfoEndpoint()
                        .userService(customOAuth2UserService)
                .and()
                    .successHandler(oAuth2AuthenticationSuccessHandler)
                    .failureHandler(oAuth2AuthenticationFailureHandler);

        // Add our custom Token based authentication filter
        // UsernamePasswordAuthenticationFilter 앞에 custom 필터 추가!
        http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

9) UserPrincipal 클래스, CustomOAuth2UserService 클래스


- 사용자에 대한 인증에 대한 처리 (참고)

  • User 데이터를 저장할 User 클래스 생성
  • User를 데이터베이스에 저장
  • 생성한 User 클래스를 Spring Security의 내장 클래스와 연결 (UserDetails,UserDetailsService 인터페이스 사용)
  • SecurityConfig에 데이터베이스 Auth 정의

 

- UserDetails, UserDetailsService (참고)

  • UserDetails는 Spring Security에서 User 클래스 역할을 수행
  • UserDetailsService는 Spring Security에서 UserRepository 역할을 수행

Spring Security에는 default User가 정의되어 있기 때문에 직접 정의한 User 클래스를 Spring Security 내장 클래스와 연결을 하기 위해서 UserDetails와 UserDetailsService 인터페이스를 사용해야한다. 여기서 직접 정의한 클래스는 User, UserRepository, UserPrincipal 클래스이다.


- UserPrincipal 클래스

이 클래스는 User를 생성자로 전달받아 Spring Security에 User 정보를 전달한다. UserPrincipal 클래스는 인증 된 Spring Security 주체를 나타낸다. 인증 된 사용자의 세부 정보를 포함한다.

package com.tistory.ozofweird.springboot_oauth2_jwt.security;

import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.User;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;

@Getter
public class UserPrincipal implements OAuth2User, UserDetails {

    private Long id;
    private String email;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;
    private Map<String, Object> attributes;

    public UserPrincipal(Long id, String email, String password, Collection<? extends GrantedAuthority> authorities) {
        this.id = id;
        this.email = email;
        this.password = password;
        this.authorities = authorities;
    }

    public static UserPrincipal create(User user) {
        List<GrantedAuthority> authorities = Collections.
                singletonList(new SimpleGrantedAuthority("ROLE_USER"));

        return new UserPrincipal(
                user.getId(),
                user.getEmail(),
                user.getPassword(),
                authorities
        );
    }

    public static UserPrincipal create(User user, Map<String, Object> attributes) {
        UserPrincipal userPrincipal = UserPrincipal.create(user);
        userPrincipal.setAttributes(attributes);
        return userPrincipal;
    }

    @Override
    public String getUsername() {
        return email;
    }

    @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 Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

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

    public void setAttributes(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getName() {
        return String.valueOf(id);
    }
}

 

 

- CustomOAuth2UserService 클래스

이 클래스는 Spring Security의 DefaultOAuth2UserService의 상속을 받는다. 이 클래스는 UserPrincipleDetailsService 클래스의 역할로 User 정보를 가져오는 역할을 수행한다. 가져온 User의 정보는 UserPrincipal 클래스로 변경해 Spring Security로 전달한다.

 

여기서 사용된 loadUser 메서드는 OAuth2 공급자로부터 Access Token을 받은 이후 호출이된다. 이 메서드에는 OAuth2 공급자로부터 사용자 정보를 가져온다. 만약 동일한 이메일이 DB에 존재하지 않을 경우 사용자 정보를 등록하지만, 존재할 경우에는 사용자 정보를 업데이트하도록 한다.

package com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2;

import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.AuthProvider;
import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.User;
import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.UserRepository;
import com.tistory.ozofweird.springboot_oauth2_jwt.exception.OAuth2AuthenticationProcessingException;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.UserPrincipal;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.user.OAuth2UserInfo;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.user.OAuth2UserInfoFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.Optional;

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest);

        try {
            return processOAuth2User(oAuth2UserRequest, oAuth2User);
        } catch (AuthenticationException ex) {
            throw ex;
        } catch (Exception ex) {
            // Throwing an instance of AuthenticationException will trigger the OAuth2AuthenticationFailureHandler
            throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
        }
    }

    // 시용자 정보 추출
    private OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) {
        OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(oAuth2UserRequest.getClientRegistration().getRegistrationId(), oAuth2User.getAttributes());
        if(StringUtils.isEmpty(oAuth2UserInfo.getEmail())) {
            throw new OAuth2AuthenticationProcessingException("OAuth2 공급자(구글, 네이버, ...) 에서 이메일을 찾을 수 없습니다.");
        }

        Optional<User> userOptional = userRepository.findByEmail(oAuth2UserInfo.getEmail());
        User user;
        if(userOptional.isPresent()) {
            user = userOptional.get();
            if(!user.getProvider().equals(AuthProvider.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId()))) {
                throw new OAuth2AuthenticationProcessingException(
                        user.getProvider() + "계정을 사용하기 위해서 로그인을 해야합니다.");
            }
            user = updateExistingUser(user, oAuth2UserInfo);
        } else {
            user = registerNewUser(oAuth2UserRequest, oAuth2UserInfo);
        }

        return UserPrincipal.create(user, oAuth2User.getAttributes());
    }

    // DB에 존재하지 않을 경우 새로 등록
    private User registerNewUser(OAuth2UserRequest oAuth2UserRequest, OAuth2UserInfo oAuth2UserInfo) {

        return userRepository.save(User.builder()
                .name(oAuth2UserInfo.getName())
                .email(oAuth2UserInfo.getEmail())
                .imageUrl(oAuth2UserInfo.getImageUrl())
                .provider(AuthProvider.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId()))
                .providerId(oAuth2UserInfo.getId())
                .build()
        );
    }

    // DB에 존재할 경우 정보 업데이트
    private User updateExistingUser(User existingUser, OAuth2UserInfo oAuth2UserInfo) {

        return userRepository.save(existingUser
                .update(
                        oAuth2UserInfo.getName(),
                        oAuth2UserInfo.getImageUrl()
                )
        );
    }
}

10) TokenProvider 클래스, TokenAuthenticationFilter 클래스

- TokenProvider 클래스

이 클래스는 유효한 JWT를 생성해준다. (JWT Properties 정보를 담고 있는 클래스 사용 - 3번 참고)

package com.tistory.ozofweird.springboot_oauth2_jwt.security;

import com.tistory.ozofweird.springboot_oauth2_jwt.config.AppProperties;
import io.jsonwebtoken.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

import java.util.Date;

@Service
public class TokenProvider {

    private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class);

    private AppProperties appProperties;

    public TokenProvider(AppProperties appProperties) {
        this.appProperties = appProperties;
    }

    public String createToken(Authentication authentication) {
        UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();

        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + appProperties.getAuth().getTokenExpirationMsec());

        return Jwts.builder()
                .setSubject(Long.toString(userPrincipal.getId()))
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS512, appProperties.getAuth().getTokenSecret())
                .compact();
    }

    public Long getUserIdFromToken(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(appProperties.getAuth().getTokenSecret())
                .parseClaimsJws(token)
                .getBody();

        return Long.parseLong(claims.getSubject());
    }

    public boolean validateToken(String authToken) {
        try {
            Jwts.parser().setSigningKey(appProperties.getAuth().getTokenSecret()).parseClaimsJws(authToken);
            return true;
        } catch (SignatureException ex) {
            logger.error("유효하지 않은 JWT 서명");
        } catch (MalformedJwtException ex) {
            logger.error("유효하지 않은 JWT 토큰");
        } catch (ExpiredJwtException ex) {
            logger.error("만료된 JWT 토큰");
        } catch (UnsupportedJwtException ex) {
            logger.error("지원하지 않는 JWT 토큰");
        } catch (IllegalArgumentException ex) {
            logger.error("비어있는 JWT");
        }
        return false;
    }
}

 

- TokenAuthenticationFilter 클래스

이 클래스는 요청으로부터 전달된 JWT 토큰 검증하는데 사용된다.

 

전달된 Request에서 JWT 토큰을 가져오고, 가져온 토큰의 유효성 검사 후, 토큰에 있는 사용자 Id를 가져온다. 가져온 사용자 Id로 사용자 정보를 가져오고, 이 정보로 UsernamePasswordAuthenticationToken을 만들어 인증하는 과정을 거친다.


- UsernamePasswordAuthenticationToken 클래스 (참고)

UsernamePasswordAuthenticationToken은 username, password 조합으로 토큰 객체를 생성해준다. 이 클래스에는 두 개의 생성자가 존재하는데, 첫 번째 생성자의 경우 username, password로 받아 만들어진 인증 전 객체이기 때문에 isAuthenticated는 false가 된다.

 

두 번째 생성자의 경우 username, password, authorites를 받아 생성된 토큰은 인증이 완료된 인증 후 객체이다.

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    private final Object principal;
	private Object credentials;
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
		super(null);
		this.principal = principal;
		this.credentials = credentials;
		setAuthenticated(false);
	}
	public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
			Collection<? extends GrantedAuthority> authorities) {
		super(authorities);
		this.principal = principal;
		this.credentials = credentials;
		super.setAuthenticated(true); // must use super, as we override
	}
}

package com.tistory.ozofweird.springboot_oauth2_jwt.security;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class TokenAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private TokenProvider tokenProvider;

    @Autowired
    private CustomUserDetailsService customUserDetailsService;

    private static final Logger logger = LoggerFactory.getLogger(TokenAuthenticationFilter.class);

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            String jwt = getJwtFromRequest(request);

            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                Long userId = tokenProvider.getUserIdFromToken(jwt);

                UserDetails userDetails = customUserDetailsService.loadUserById(userId);
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            logger.error("Security Context에서 사용자 인증을 설정할 수 없습니다", ex);
        }

        filterChain.doFilter(request, response);
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7, bearerToken.length());
        }
        return null;
    }
}

11) HttpCookieOAuth2AuthorizationRequestRepository 클래스

OAuth2 프로토콜은 CSRF 공격을 방지하기 위해 STATE 매개 변수를 사용하는 것을 권장한다.

 

인증 중에 애플리케이션은 인증 요청(Authorization Request)에 이 매개 변수를 전송하고 OAuth2 공급자는 OAuth2 콜백에서 변경되지 않은 이 매개 변수를 반환한다. 애플리케이션은 OAuth2 공급자로부터 반환된 상태 매개 변수의 값을 초기에 보낸 값과 비교를 한다. 만약 일치하지 않을 경우 인증 요청을 거부한다.

 

즉, 애플리케이션이 상태 매개 변수를 저장하여 추후에 OAuth2 공급자에서 반환 된 상태와 비교할 수 있어야한다. 이를 위해 쿠키에 상태와 redirect_uri를 저장하도록 설정해야 한다.

 

이 클래스는 인증 요청(Authorization Request)을 쿠키에 저장하고 검색하는 기능을 제공한다.

package com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2;

import com.nimbusds.oauth2.sdk.util.StringUtils;
import com.tistory.ozofweird.springboot_oauth2_jwt.util.CookieUtils;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Component
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
    public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
    private static final int cookieExpireSeconds = 180;

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
                .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
                .orElse(null);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if (authorizationRequest == null) {
            CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
            CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
            return;
        }

        CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
        if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
            CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
        }
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
        return this.loadAuthorizationRequest(request);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
        CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
        CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
    }
}

12) CookieUtils 클래스

package com.tistory.ozofweird.springboot_oauth2_jwt.util;

import org.springframework.util.SerializationUtils;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Base64;
import java.util.Optional;

public class CookieUtils {

    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();

        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(name)) {
                    return Optional.of(cookie);
                }
            }
        }

        return Optional.empty();
    }

    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(maxAge);
        response.addCookie(cookie);
    }

    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie: cookies) {
                if (cookie.getName().equals(name)) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                }
            }
        }
    }

    public static String serialize(Object object) {
        return Base64.getUrlEncoder()
                .encodeToString(SerializationUtils.serialize(object));
    }

    public static <T> T deserialize(Cookie cookie, Class<T> cls) {
        return cls.cast(SerializationUtils.deserialize(
                Base64.getUrlDecoder().decode(cookie.getValue())));
    }
}

13) OAuth2UserInfo (Facebook, Google, Github) 클래스

OAuth2UserInfo 추상 클래스를 각각의 서비스에 맞게 구현한다.

package com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.user;

import java.util.Map;

public abstract  class OAuth2UserInfo {

    protected Map<String, Object> attributes;

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

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

    public abstract String getId();

    public abstract String getName();

    public abstract String getEmail();

    public abstract String getImageUrl();
}
package com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.user;

import java.util.Map;

public class FacebookOAuth2UserInfo extends OAuth2UserInfo {

    public FacebookOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getId() {
        return (String) attributes.get("id");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getImageUrl() {
        if(attributes.containsKey("picture")) {
            Map<String, Object> pictureObj = (Map<String, Object>) attributes.get("picture");
            if(pictureObj.containsKey("data")) {
                Map<String, Object>  dataObj = (Map<String, Object>) pictureObj.get("data");
                if(dataObj.containsKey("url")) {
                    return (String) dataObj.get("url");
                }
            }
        }
        return null;
    }
}
package com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.user;

import java.util.Map;

public class GoogleOAuth2UserInfo extends OAuth2UserInfo {

    public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getId() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getImageUrl() {
        return (String) attributes.get("picture");
    }
}
package com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.user;

import java.util.Map;

public class GithubOAuth2UserInfo extends OAuth2UserInfo {

    public GithubOAuth2UserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getId() {
        return ((Integer) attributes.get("id")).toString();
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getImageUrl() {
        return (String) attributes.get("avatar_url");
    }
}

14) OAuth2UserInfoFactory 클래스

package com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.user;

import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.AuthProvider;
import com.tistory.ozofweird.springboot_oauth2_jwt.exception.OAuth2AuthenticationProcessingException;

import java.util.Map;

public class OAuth2UserInfoFactory {

    public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
        if(registrationId.equalsIgnoreCase(AuthProvider.google.toString())) {
            return new GoogleOAuth2UserInfo(attributes);
        } else if (registrationId.equalsIgnoreCase(AuthProvider.facebook.toString())) {
            return new FacebookOAuth2UserInfo(attributes);
        } else if (registrationId.equalsIgnoreCase(AuthProvider.github.toString())) {
            return new GithubOAuth2UserInfo(attributes);
        } else {
            throw new OAuth2AuthenticationProcessingException(registrationId + " 로그인은 지원하지 않습니다.");
        }
    }
}

15) OAuth2AuthenticationSuccessHandler 클래스

성공적인 인증이 일어나면 Spring Security는 SecurityConfig에 설정된 OAuth2AuthenticationSuccessHandler의  onAuthenticationSuccess() 메서드를 호출한다.

package com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2;

import com.tistory.ozofweird.springboot_oauth2_jwt.config.AppProperties;
import com.tistory.ozofweird.springboot_oauth2_jwt.exception.BadRequestException;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.TokenProvider;
import com.tistory.ozofweird.springboot_oauth2_jwt.util.CookieUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.Optional;

import static com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME;

@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private TokenProvider tokenProvider;

    private AppProperties appProperties;

    private HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;


    @Autowired
    OAuth2AuthenticationSuccessHandler(TokenProvider tokenProvider, AppProperties appProperties,
                                       HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository) {
        this.tokenProvider = tokenProvider;
        this.appProperties = appProperties;
        this.httpCookieOAuth2AuthorizationRequestRepository = httpCookieOAuth2AuthorizationRequestRepository;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String targetUrl = determineTargetUrl(request, response, authentication);

        if (response.isCommitted()) {
            logger.debug("응답이 이미 커밋되었습니다. " + targetUrl + "로 리다이렉션을 할 수 없습니다");
            return;
        }

        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Optional<String> redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        if(redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
            throw new BadRequestException("승인되지 않은 리디렉션 URI가 있어 인증을 진행할 수 없습니다.");
        }

        String targetUrl = redirectUri.orElse(getDefaultTargetUrl());

        String token = tokenProvider.createToken(authentication);

        return UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("token", token)
                .build().toUriString();
    }

    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }

    private boolean isAuthorizedRedirectUri(String uri) {
        URI clientRedirectUri = URI.create(uri);

        return appProperties.getOauth2().getAuthorizedRedirectUris()
                .stream()
                .anyMatch(authorizedRedirectUri -> {
                    // Only validate host and port. Let the clients use different paths if they want to
                    URI authorizedURI = URI.create(authorizedRedirectUri);
                    if(authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
                            && authorizedURI.getPort() == clientRedirectUri.getPort()) {
                        return true;
                    }
                    return false;
                });
    }
}

16) OAuth2AuthenticationFailureHandler

인증 중에 에러가 발생하는 경우 SecurityConfig에 설정된 OAuth2AuthenticationFailureHandler의 onAuthenticationFailure() 메서드를 호출한다.

package com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2;

import com.tistory.ozofweird.springboot_oauth2_jwt.util.CookieUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

import static com.tistory.ozofweird.springboot_oauth2_jwt.security.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME;

@Component
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Autowired
    HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue)
                .orElse(("/"));

        targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("error", exception.getLocalizedMessage())
                .build().toUriString();

        httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

17) AuthController 클래스

외부 도메인인 클라이어트가 어플리케이션 서버에 자원을 요청하기 위해서는 CORS를 허용해야한다. (5번 참고)

package com.tistory.ozofweird.springboot_oauth2_jwt.controller;

import com.tistory.ozofweird.springboot_oauth2_jwt.controller.dto.ApiResponse;
import com.tistory.ozofweird.springboot_oauth2_jwt.controller.dto.AuthResponse;
import com.tistory.ozofweird.springboot_oauth2_jwt.controller.dto.LoginRequest;
import com.tistory.ozofweird.springboot_oauth2_jwt.controller.dto.SignUpRequest;
import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.AuthProvider;
import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.User;
import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.UserRepository;
import com.tistory.ozofweird.springboot_oauth2_jwt.exception.BadRequestException;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import java.net.URI;

@RequiredArgsConstructor
@RequestMapping("/auth")
@RestController
public class AuthController {

    private final AuthenticationManager authenticationManager;

    private final UserRepository userRepository;

    private final PasswordEncoder passwordEncoder;

    private final TokenProvider tokenProvider;

    @PostMapping("/login")
    public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) {

        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getEmail(),
                        loginRequest.getPassword()
                )
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);

        String token = tokenProvider.createToken(authentication);
        return ResponseEntity.ok(new AuthResponse(token));
    }

    @PostMapping("/signup")
    public ResponseEntity<?> registerUser(@RequestBody SignUpRequest signUpRequest) {
        if(userRepository.existsByEmail(signUpRequest.getEmail())) {
            throw new BadRequestException("이미 해당 이메일을 사용하고 있습니다.");
        }

        // 계정 생성
        User result = userRepository.save(User.builder()
                .name(signUpRequest.getName())
                .email(signUpRequest.getEmail())
                .password(passwordEncoder.encode(signUpRequest.getPassword()))
                .provider(AuthProvider.local)
                .build()
        );

        URI location = ServletUriComponentsBuilder
                .fromCurrentContextPath().path("/user/me")
                .buildAndExpand(result.getId()).toUri();

        return ResponseEntity.created(location)
                .body(new ApiResponse(true, "성공적으로 계정 생성이 되었습니다."));
    }

}

18) CurrentUser 메타 어노테이션, UserController 클래스

- CurrentUser 메타 어노테이션

이 어노테이션은 인증 된 사용자 주체를 컨트롤러에 삽입하는 데 사용된다.

 

  • @Target - 자신이 만든 어노테이션이 사용되기될 자바 요소를 지정할 수 있다.
  • @Retention - 어노테이션 정보 유지에 대한 설정을 할 수 있다.
  • @Document - 해당 어노테이션이 지정된 대상의 JavaDoc에 이 어노테이션의 존재를 표기
  • @AuthenticationPrincipal - 로그인한 사용자의 정보를 파라미터로 받을 때 사용

 

package com.tistory.ozofweird.springboot_oauth2_jwt.security;

import org.springframework.security.core.annotation.AuthenticationPrincipal;

import java.lang.annotation.*;

@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal
public @interface CurrentUser {

}

 

- UserController 클래스

package com.tistory.ozofweird.springboot_oauth2_jwt.controller;

import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.User;
import com.tistory.ozofweird.springboot_oauth2_jwt.domain.user.UserRepository;
import com.tistory.ozofweird.springboot_oauth2_jwt.exception.ResourceNotFoundException;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.CurrentUser;
import com.tistory.ozofweird.springboot_oauth2_jwt.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class UserController {

    private final UserRepository userRepository;

    @GetMapping("/user/me")
    @PreAuthorize("hasRole('USER')")
    public User getCurrentUser(@CurrentUser UserPrincipal userPrincipal) {
        return userRepository.findById(userPrincipal.getId())
                .orElseThrow(() -> new ResourceNotFoundException("User", "id", userPrincipal.getId()));
    }
}

19) LoginRequest 클래스

package com.tistory.ozofweird.springboot_oauth2_jwt.controller.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
public class LoginRequest {

    private String email;
    private String password;

    @Builder
    public LoginRequest(String email, String password) {
        this.email = email;
        this.password = password;
    }
}

20) SignUpRequest 클래스

package com.tistory.ozofweird.springboot_oauth2_jwt.controller.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
public class SignUpRequest {

    private String name;
    private String email;
    private String password;

    @Builder
    public SignUpRequest(String name, String email, String password) {
        this.name = name;
        this.email = email;
        this.password = password;
    }
}

21) AuthResponse 클래스

package com.tistory.ozofweird.springboot_oauth2_jwt.controller.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
public class AuthResponse {

    private String accessToken;
    private String tokenType = "Bearer"; // 인증 방식

    @Builder
    public AuthResponse(String accessToken) {
        this.accessToken = accessToken;
    }
}

22) ApiResponse 클래스

package com.tistory.ozofweird.springboot_oauth2_jwt.controller.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
public class ApiResponse {
    private boolean success;
    private String message;

    @Builder
    public ApiResponse(boolean success, String message) {
        this.success = success;
        this.message = message;
    }

}

23) BadRequestException 클래스

package com.tistory.ozofweird.springboot_oauth2_jwt.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
    public BadRequestException(String message) {
        super(message);
    }

    public BadRequestException(String message, Throwable cause) {
        super(message, cause);
    }
}

24) ResourceNotFoundException 클래스

package com.tistory.ozofweird.springboot_oauth2_jwt.exception;

import lombok.Getter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@Getter
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {

    private String resourceName;
    private String fieldName;
    private Object fieldValue;

    public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
        super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue));
        this.resourceName = resourceName;
        this.fieldName = fieldName;
        this.fieldValue = fieldValue;
    }

}

25) OAuth2AuthenticationProcessingException 클래스

package com.tistory.ozofweird.springboot_oauth2_jwt.exception;

import org.springframework.security.core.AuthenticationException;

public class OAuth2AuthenticationProcessingException extends AuthenticationException {

    public OAuth2AuthenticationProcessingException(String msg, Throwable t) {
        super(msg, t);
    }

    public OAuth2AuthenticationProcessingException(String msg) {
        super(msg);
    }
}

26) 전체 구조

27) OAuth2 로그인 과정

- 클라이언트에서 http://localhost:8080/oauth2/authorize/{provider{?redirect_url={로그인 인증 후 JWT 보낼 URI}로 request

28) 참고

- Docker MySQL

MySQL 8.0 이후 접속 시 allowPublicRetrieval 설정을 추가해주어야 한다.

allowPublicKeyRetrieval=true
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:13306/TEST?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=UTC&useLegacyDatetimeCode=false
    username: root
    password: password
    hikari:
      initialization-fail-timeout: 0

  jpa:
    database-platform: org.hibernate.dialect.MySQL5Dialect
    generate-ddl: true
    show-sql: true
    hibernate:
      ddl-auto: create

- application-oauth.yml

네이버 등록 설정 추가

spring:
    security:
      oauth2:
        client:
          registration:

            naver:
              clientId: [Client ID]
              client-secret: [Secret key]
              redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
              authorization-grant-type: authorization_code
              scope:
                - email
                - profile_image
              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

#            google:
#              clientId: 5014057553-8gm9um6vnli3cle5rgigcdjpdrid14m9.apps.googleusercontent.com
#              clientSecret: tWZKVLxaD_ARWsriiiUFYoIk
#              redirectUri: "{baseUrl}/oauth2/callback/{registrationId}"
#              scope:
#                - email
#                - profile
#            facebook:
#              clientId: 121189305185277
#              clientSecret: 42ffe5aa7379e8326387e0fe16f34132
#              redirectUri: "{baseUrl}/oauth2/callback/{registrationId}" # Note that facebook now mandates the use of https redirect URIs, so make sure your app supports https in production
#              scope:
#                - email
#                - public_profile
#            github:
#              clientId: d3e47fc2ddd966fa4352
#              clientSecret: 3bc0f6b8332f93076354c2a5bada2f5a05aea60d
#              redirectUri: "{baseUrl}/oauth2/callback/{registrationId}"
#              scope:
#                - user:email
#                - read:user
#          provider:
#            facebook:
#              authorizationUri: https://www.facebook.com/v3.0/dialog/oauth
#              tokenUri: https://graph.facebook.com/v3.0/oauth/access_token
#              userInfoUri: https://graph.facebook.com/v3.0/me?fields=id,first_name,middle_name,last_name,name,email,verified,is_verified,picture.width(250).height(250)

app:
  auth:
    tokenSecret: [Secret Token]
    tokenExpirationMsec: 864000000
  oauth2:
    # After successfully authenticating with the OAuth2 Provider,
    # we'll be generating an auth token for the user and sending the token to the
    # redirectUri mentioned by the frontend client in the /oauth2/authorize request.
    # We're not using cookies because they won't work well in mobile clients.
    authorizedRedirectUris:
      - http://localhost:8080/oauth2/redirect
#      - myandroidapp://oauth2/redirect
#      - myiosapp://oauth2/redirect

- Postman

포스트맨에서 OAuth 사용을 확인하려면, 다음과 같이 설정해주면 된다. 단, 네이버 개발자 센터에서도 '네이버아이디로로그인 Callback URL' 설정에 포스트맨 주소를 넣어주어야 동작을 확인할 수 있다.

29) 결과


[참고] github.com/ozofweird/SpringBoot_OAuth2_Jwt

[참고] wonyong-jang.github.io/posts/spring/

[참고] github.com/callicoder/spring-boot-react-oauth2-social-login-demo

[참고] www.callicoder.com/spring-boot-security-oauth2-social-login-part-2/

728x90