[Spring] Flutter와 Spring Security를 활용한 OAuth2.0 인증 구현하기

김피자·2024년 11월 2일
0

Spring

목록 보기
30/30
post-thumbnail

팀 프로젝트를 진행하면서 Flutter와 Spring Boot로 OAuth2.0 인증을 구현하게 되었다.
웹과 모바일의 인증 처리 방식이 달라 처음에 조금 난관이 있었다.

주요 차이점과 어려웠던 점

로그인 플로우의 차이

웹: 브라우저에서 직접 OAuth 제공자의 로그인 페이지로 리다이렉트
모바일: 네이티브 앱에서 별도의 브라우저나 WebView를 통한 처리 필요

토큰 관리 방식

웹: localStorage나 sessionStorage를 주로 사용
모바일: secure storage를 사용하여 더 안전한 저장 필요

CORS 및 보안 설정

웹: 브라우저의 Same-Origin Policy 고려 필요
모바일: 네이티브 앱은 CORS 제약이 없지만, 다른 보안 고려사항 존재

이러한 차이점을 고려하여 두 플랫폼 모두에서 원활하게 작동하는 인증 시스템을 구축하는 과정을 정리해보았다.


1. 인증 프로세스 상세 설명

1.1 최초 로그인 (카카오 OAuth)

클라이언트에서 카카오 로그인 완료
카카오 액세스 토큰을 서버로 전송 (/login/kakao)
서버는 카카오 API로 사용자 정보 조회
JWT 액세스 토큰과 리프레시 토큰 발급
리프레시 토큰은 DB 저장, 둘 다 클라이언트로 반환

1.2 API 요청 인증

클라이언트는 모든 API 요청에 JWT 액세스 토큰 포함
JWT 필터에서 토큰 검증
유효한 경우 요청 처리, 만료된 경우 401 응답

1.3 토큰 갱신

액세스 토큰 만료 시 리프레시 토큰으로 갱신 요청
서버는 리프레시 토큰 검증 후 새 액세스 토큰 발급

2. 코드 설명

예제 프로젝트 구조

src/
├── main/
│   ├── java/
│   │   └── com/
│   │       └── example/
│   │           └── auth/
│   │               ├── config/                 
│   │               │   └── SecurityConfig.java # Spring Security 설정
│   │               │
│   │               ├── controller/            
│   │               │   └── AuthController.java # 인증 관련 컨트롤러
│   │               │
│   │               ├── domain/               
│   │               │   └── UserInfo.java    # 사용자 정보 엔티티
│   │               │
│   │               ├── dto/                 
│   │               │   ├── AuthResponse.java # 인증 응답 DTO
│   │               │   └── UserInfoDto.java  # 사용자 정보 DTO
│   │               │
│   │               ├── repository/          
│   │               │   └── UserInfoRepository.java
│   │               │
│   │               ├── service/            
│   │               │   └── UserService.java
│   │               │
│   │               └── security/           
│   │                   ├── JwtFilter.java  # JWT 필터
│   │                   └── JwtUtil.java    # JWT 유틸리티
│   │
│   └── resources/
       └── application.yml    # 애플리케이션 설정

2.1 Security 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/login/**",
                    "/refresh-token"
                ).permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
}

코드 설명:

csrf().disable(): REST API 서버이므로 CSRF 보호 비활성화
authorizeHttpRequests(): URL 별 접근 권한 설정
sessionManagement(): 세션 없는 상태 관리 설정
addFilterBefore(): JWT 필터를 Spring Security 필터 체인에 추가

2.2 JWT 필터 구현

@Component
public class JwtFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
            HttpServletResponse response, FilterChain filterChain) {
        String token = request.getHeader("Authorization");
        
        if (token != null && token.startsWith("Bearer ")) {
            token = token.substring(7);
            try {
                String email = jwtUtil.getEmailFromToken(token);
                // 사용자 인증 처리
                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(email, null, new ArrayList<>());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } catch (ExpiredJwtException e) {
                handleExpiredToken(response);
                return;
            }
        }
        
        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getRequestURI();
        return path.startsWith("/login/") ||
               path.startsWith("/refresh-token");
    }
}

코드 설명:

shouldNotFilter(): 특정 경로는 필터 제외
doFilterInternal(): 실제 토큰 검증 로직
토큰 추출 → 검증 → SecurityContext에 인증 정보 설정
예외 상황(만료, 유효하지 않은 토큰 등) 처리

jwt 토큰 추출부에 대해 더 자세히 설명하자면

// HTTP 요청 헤더에서 'Authorization' 값을 가져옴
String token = request.getHeader("Authorization");

// Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
if (token != null && token.startsWith("Bearer ")) {
    // "Bearer " 부분(앞 7글자)을 제거하고 실제 JWT 토큰만 추출
    token = token.substring(7);
    ...
}

Bearer 토큰 인증 방식

Bearer 스키마

HTTP Authorization 헤더의 인증 스키마 중 하나
"Bearer"는 토큰 소지자(bearer)가 토큰을 소유하고 있다는 의미

형식: Authorization: Bearer <token>

예시)

Authorization: Bearer {엑세스토큰}

구조

"Bearer " (공백 포함 7글자)
실제 JWT 토큰 (Base64로 인코딩된 문자열)
따라서 substring(7)로 실제 토큰만 추출

참고로 Bearer 인증은 RESTful API에서 가장 일반적으로 사용되는 토큰 기반 인증 방식이다.

2.3 카카오 로그인 프로세스

@Service
public class UserService {
    public AuthResponse processKakaoLogin(String accessToken) {
        // 1. 카카오 API로 사용자 정보 조회
        Map<String, Object> userInfo = getUserInfoFromKakao(accessToken);
        
        // 2. 사용자 정보 저장/업데이트
        UserInfo user = findByEmail(userInfo.get("email"));
        if (user == null) {
            user = createNewUser(userInfo);
        }
        updateUserInfo(user, userInfo);
        
        // 3. JWT 토큰 발급
        String jwtAccessToken = jwtUtil.generateAccessToken(user);
        String jwtRefreshToken = jwtUtil.generateRefreshToken(user);
        
        // 4. 리프레시 토큰 DB 저장
        user.setRefreshToken(jwtRefreshToken);
        userRepository.save(user);
        
        return new AuthResponse(jwtAccessToken, jwtRefreshToken, 
            new UserInfoDto(user));
    }
}

코드 설명:

카카오 API 호출 및 사용자 정보 조회
사용자 정보 DB 저장/업데이트 로직
JWT 토큰 생성 및 리프레시 토큰 DB 저장
응답 객체 생성 및 반환

2.4 토큰 갱신 프로세스

@Service
public class UserService {
    public AuthResponse refreshToken(String refreshToken) {
        // 1. 리프레시 토큰 검증
        if (!jwtUtil.validateToken(refreshToken)) {
            throw new InvalidTokenException();
        }

        // 2. DB의 리프레시 토큰과 비교
        String email = jwtUtil.getEmailFromToken(refreshToken);
        UserInfo user = userRepository.findByEmail(email);
        if (!refreshToken.equals(user.getRefreshToken())) {
            throw new InvalidTokenException();
        }

        // 3. 새로운 액세스 토큰 발급
        String newAccessToken = jwtUtil.generateAccessToken(user);
        
        return new AuthResponse(newAccessToken, refreshToken, 
            new UserInfoDto(user));
    }
}

코드 설명:

리프레시 토큰 유효성 검증
DB에 저장된 토큰과 비교 검증
새로운 액세스 토큰 발급
기존 리프레시 토큰 유지

마치며

지금까지 Spring Security와 JWT를 활용한 OAuth2.0 인증 구현 방법을 알아보았다. 실제 환경에서는 추가적인 보안 설정과 에러 처리가 필요할 수 있다. 흑흑ㅠ

profile
제로부터시작하는코딩생활

0개의 댓글