본문 바로가기

개발/백엔드

Spring Security OAuth

OAuth2는 사용자가 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는 접근 위임을 위한 개방형 표준입니다.

 

OAuth(Open Authorization)

 

쉽게 얘기하면 = 신뢰할 수 있는 다른 서비스에 로그인을 맡기는 것

OAuth는 인증을 위한 개방형 표준 프로토콜입니다.

 

프로세스 요약

👤Resource Owner: 사용자
🖥️Client: OAuth를 사용하는 웹/앱
🔑Authorization Server: 인증 서버 (네이버/카카오/구글의 로그인 등 인증 처리)
📦Resource Server: 리소스 서버 (사용자 정보를 가진 서버, 프로필, 이메일

 👤 사용자       🖥️ 클라이언트     🔑 인증서버        📦 리소스서버
      |                 |                 |                    |
      |                 |                 |                    |
      | 1️⃣ 로그인 버튼  |                 |                    |
      | --------------> |                 |                    |
      |                 |                 |                    |
      |                 | 2️⃣ 인증 요청    |                    |
      |                 | --------------> |                    |
      |                 |                 |                    |
      | 3️⃣ 로그인 페이지|                 |                    |
      | <----------------------------------------              |
      |                 |                 |                    |
      | 4️⃣ 계정정보 입력|                 |                    |
      | ---------------------------------------->              |
      |                 |                 |                    |
      |                 | 5️⃣ 인증 코드    |                    |
      |                 | <-------------- |                    |
      |                 |                 |                    |
      |                 | 6️⃣ 토큰 요청    |                    |
      |                 | --------------> |                    |
      |                 |                 |                    |
      |                 | 7️⃣ 액세스 토큰  |                    |
      |                 | <-------------- |                    |
      |                 |                 |                    |
      |                 | 8️⃣사용자정보요청|                    |
      |                 | ---------------------------------->  |
      |                 |                 |                    |
      |                 | 9️⃣ 사용자 정보  |                    |
      |                 | <----------------------------------  |
      |                 |                 |                    |
      | 🔟 로그인 완료  |                 |                    |
      | <-------------- |                 |                    |

 

 

구현해야할 스프링 부트 프로젝트 요약

+-------------------------------------------------------------+
|                     Spring Boot Application                 |
+-------------------------------------------------------------+
|  +----------------+  +---------------+  +----------------+  |
|  |  SecurityConfig|  |  OAuth2Service|  |  JWTService    |  |
|  | - 보안 설정     |  | - OAuth2 처리 |  | - JWT 생성/검증|  |
|  +----------------+  +---------------+  +----------------+  |
|                                                             |
|  +----------------+  +---------------+  +----------------+  |
|  |  UserService   |  | TokenService  |  |RedisRepository|   |
|  | - 사용자 관리   |  | - 토큰 관리   |  | - 토큰 저장소  |  |
|  +----------------+  +---------------+  +----------------+  |
|                                                             |
|  +----------------+  +---------------+                      |
|  |ExceptionHandler|  |CustomFilters  |                      |
|  | - 예외 처리     |  | - 필터 체인   |                      |
|  +----------------+  +---------------+                      |
+-------------------------------------------------------------+

인증 프로세스 흐름:
====================
1. 사용자 인증 요청
   - 브라우저에서 /oauth2/authorization/{provider} 접근
   - 소셜 로그인 페이지로 리다이렉트

2. 권한 부여 코드 발급
   - 사용자가 권한 승인
   - Authorization Code 발급

3. Access Token 교환
   - Authorization Code로 Access Token 요청
   - OAuth2 제공자가 Access Token 발급

4. 사용자 정보 조회
   - Access Token으로 사용자 정보 요청
   - CustomOAuth2UserService에서 사용자 정보 처리

5. 토큰 관리
   - JWT 토큰 생성
   - Redis에 Refresh Token 저장
   - 클라이언트에 JWT 전달

주요 컴포넌트 설명:
===================
SecurityConfig: 보안 설정 관리
- URL 접근 권한 설정
- OAuth2 클라이언트 설정
- CSRF 보호 설정

OAuth2Service: OAuth2 인증 처리
- 소셜 로그인 처리
- 사용자 정보 매핑
- 인증 결과 처리

TokenService: 토큰 관리
- JWT 토큰 생성/검증
- Refresh Token 관리
- 토큰 저장소 연동

RedisRepository: 토큰 저장소
- Refresh Token 저장
- 토큰 조회/삭제
- 만료 시간 관리

 

 

1. client-id, client-secret 발급 받기

kakao: Kakao Developers

naver: 네이버 로그인 - INTRO

google: https://console.cloud.google.com/

 

2. 의존성 및 설정

// build.radle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

// application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: your-client-id
            client-secret: your-client-secret
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope:
              - email
              - profile
          naver:
            client-id: your-client-id
            client-secret: your-client-secret
            scope:
              - name
              - email
              - profile_image
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
          kakao:
            client-id: your-client-id
            client-secret: your-client-secret
            scope:
              - profile_nickname
              - profile_image
            client-name: Kakao
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"

 

3. 주요 코드

// SecurityConfig: 전체적인 보안 설정 관리
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    
    private final CustomOAuth2UserService oAuth2UserService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final OAuth2FailureHandler oAuth2FailureHandler;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
                .antMatchers("/api/v1/auth/**", "/oauth2/**").permitAll()
                .anyRequest().authenticated()
            .and()
            .oauth2Login()
                .authorizationEndpoint()
                    .baseUri("/oauth2/authorization")
                .and()
                .redirectionEndpoint()
                    .baseUri("/oauth2/callback/*")
                .and()
                .userInfoEndpoint()
                    .userService(oAuth2UserService)
                .and()
                .successHandler(oAuth2SuccessHandler)
                .failureHandler(oAuth2FailureHandler);
                
        return http.build();
    }
}



// CustomOAuth2UserService: OAuth2 사용자 정보 처리
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    
    private final UserRepository userRepository;
    
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oauth2User = super.loadUser(userRequest);
        
        try {
            return processOAuth2User(userRequest, oauth2User);
        } catch (Exception ex) {
            throw new OAuth2AuthenticationProcessingException("Failed to process OAuth2 user");
        }
    }
    
    private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oauth2User) {
        OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(
            userRequest.getClientRegistration().getRegistrationId(),
            oauth2User.getAttributes()
        );
        
        Optional<User> userOptional = userRepository.findByEmail(userInfo.getEmail());
        User user;
        
        if (userOptional.isPresent()) {
            user = userOptional.get();
            user = updateExistingUser(user, userInfo);
        } else {
            user = registerNewUser(userRequest, userInfo);
        }
        
        return UserPrincipal.create(user, oauth2User.getAttributes());
    }
}


// OAuth2SuccessHandler: 인증 성공 시 토큰 발급
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
    
    private final TokenProvider tokenProvider;
    private final RedisTemplate<String, String> redisTemplate;
    private final ObjectMapper objectMapper;
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException {
        
        UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
        
        String accessToken = tokenProvider.createAccessToken(userPrincipal);
        String refreshToken = tokenProvider.createRefreshToken(userPrincipal);
        
        // Redis에 Refresh Token 저장
        redisTemplate.opsForValue().set(
            "RT:" + userPrincipal.getId(),
            refreshToken,
            tokenProvider.getRefreshTokenExpiration(),
            TimeUnit.MILLISECONDS
        );
        
        TokenResponse tokenResponse = new TokenResponse(accessToken, refreshToken);
        
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(tokenResponse));
    }
}


// TokenProvider: JWT 토큰 생성 및 검증
@Component
@RequiredArgsConstructor
public class TokenProvider {
    
    @Value("${app.auth.tokenSecret}")
    private String tokenSecret;
    
    @Value("${app.auth.tokenExpirationMsec}")
    private long tokenExpirationMsec;
    
    @Value("${app.auth.refreshTokenExpirationMsec}")
    private long refreshTokenExpirationMsec;
    
    public String createAccessToken(UserPrincipal userPrincipal) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + tokenExpirationMsec);
        
        return Jwts.builder()
                .setSubject(Long.toString(userPrincipal.getId()))
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS512, tokenSecret)
                .compact();
    }
    
    public String createRefreshToken(UserPrincipal userPrincipal) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + refreshTokenExpirationMsec);
        
        return Jwts.builder()
                .setSubject(Long.toString(userPrincipal.getId()))
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS512, tokenSecret)
                .compact();
    }
}


// UserPrincipal: OAuth2 사용자 정보 캡슐화
public class UserPrincipal implements OAuth2User {
    private Long id;
    private String email;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;
    private Map<String, Object> attributes;
    
    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }
    
    @Override
    public String getName() {
        return String.valueOf(id);
    }
    
    public static UserPrincipal create(User user, Map<String, Object> attributes) {
        List<GrantedAuthority> authorities = Collections.singletonList(
            new SimpleGrantedAuthority("ROLE_USER")
        );
        
        UserPrincipal userPrincipal = new UserPrincipal(
            user.getId(),
            user.getEmail(),
            user.getPassword(),
            authorities
        );
        userPrincipal.setAttributes(attributes);
        return userPrincipal;
    }
}


// OAuth2UserInfoFactory: 소셜 로그인 제공자별 사용자 정보 처리
public class OAuth2UserInfoFactory {
    
    public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
        switch(registrationId.toLowerCase()) {
            case "google":
                return new GoogleOAuth2UserInfo(attributes);
            case "github":
                return new GithubOAuth2UserInfo(attributes);
            default:
                throw new OAuth2AuthenticationProcessingException(
                    "Sorry! Login with " + registrationId + " is not supported yet.");
        }
    }
}


public abstract class OAuth2UserInfo {
    protected Map<String, Object> attributes;
    
    public OAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }
    
    public abstract String getId();
    public abstract String getName();
    public abstract String getEmail();
    public abstract String getImageUrl();
}