Spring/Spring Security

스프링 시큐리티를 활용한 OAuth2 인증과정

다오__ 2024. 3. 9. 02:58

https://oauth.net/2/

 

OAuth 2.0 — OAuth

OAuth 2.0 OAuth 2.0 is the industry-standard protocol for authorization. OAuth 2.0 focuses on client developer simplicity while providing specific authorization flows for web applications, desktop applications, mobile phones, and living room devices. This

oauth.net

 

OAuth2 인증 프로세스

1. 클라이언트 등록
   - 애플리케이션 서버는 OAuth2 제공자 설정으로 클라이언트로 등록된다.
   - OAuth2 제공자의 인증/인가 서버 접근을 위한 클라이언트 정보가 발급된다. 


2. 리다이렉션 URI 설정
   - 인증이 된 사용자(소셜로그인한 유저)를 리다이렉션할 URI 설정한다.
   - 이 URI는 인증 과정 완료 후 사용자를 안내할 웹 페이지 주소이다.

3. 인증 요청
   - 사용자 로그인 시도 시, 스프링 시큐리티는 OAuth2 제공자의 인증 페이지로 리다이렉션한다.
   - 클라이언트 ID와 리다이렉션 URI 등의 정보가 요청에 포함된다.

4. 사용자 로그인 및 동의
   - 사용자는 OAuth2 제공자의 인증 페이지에서 로그인하고 애플리케이션 접근 권한을 동의한다.

5. 인증 코드 수신
   - 로그인 및 동의 후, OAuth2 제공자는 리다이렉션 URI로 사용자를 다시 보내며, 인증 코드가 URL에 포함되어 전송된다.

6. 액세스 토큰 요청
   - 스프링 시큐리티는 받은 인증 코드를 사용하여 OAuth2 제공자에게 액세스 토큰을 요청한다.
   - 클라이언트 ID와 클라이언트 비밀번호가 함께 제공된다.

7. 액세스 토큰 및 사용자 정보 수신
   - 스프링 시큐리티는 OAuth2 제공자로부터 액세스 토큰을 받는다.
   - 필요한 경우, 추가적으로 사용자 정보를 요청하고 수신한다.

8. 인증 완료 및 사용자 세션 생성
   - 받은 액세스 토큰 및 사용자 정보를 기반으로 사용자 세션을 생성한다.
   - 사용자는 이제 인증된 상태가 되어 보호된 자원에 접근할 수 있다.

9. 리프레시 토큰 사용
   - 액세스 토큰이 만료될 경우, 리프레시 토큰(발급된 경우)을 사용하여 새로운 액세스 토큰을 자동으로 요청하고 갱신할 수 있다.

 

 

스프링 시큐리티의 토큰 관리

Spring Security에서 OAuth2 로그인을 사용할 때, 세션에 저장되는 것은 OAuth2AuthenticationToken이다. 이 토큰에는 인증 결과에 대한 정보(사용자의 정보, 권한, access_token 등)가 포함되는데, 일반적으로, refresh_token은 OAuth2AuthorizedClientService에 의해 관리되며, 이 서비스는 인증된 클라이언트(사용자)의 access_token 및 refresh_token을 저장하는 데 사용된다.

 

Q. 시큐리티에서 자동으로 처리해주는데, 일반적으로는 JWT를 통해 access_token을 따로 처리하는 이유는 뭘까?

  • 스프링 시큐리티의 토큰은 세션에 저장을 한다. 서버에서 access_token을 관리하므로 stateful한 상태이다. 서버에서 엑세스 토큰을 관리하게 되면 유저가 많아질 수록 서버에 부담이 커질 것이다. 또한 서버가 여러개일 경우 모든 서버에 세션을 공유해야 한다. 배포 시 로드밸런서가 여러 서버로 배정해주게 되는데 , 이 경우 각 서버마다 같은 정보를 중복 저장 해놓아야하는 작업이 필요하게 된다. 매우 비효율적이다.

 

  • 보안적인 문제,
    • 세션 하이재킹, 공격자가 사용자의 세션 ID를 탈취하여 사용자로서 시스템에 접근할 수 있는 위험이 있다. 
    • CSRF 공격으로 사용자가 로그인한 상태에서 악의적인 요청이 이루어질 경우, 서버는 해당 요청을 정상적인 사용자의 요청으로 인식할 수 있다.

 

이를 위한 access_token을 클라이언트, 즉 사용자에게 JWT방식으로 저장하게끔 함으로써 stateless, 인가 요청 시, JWT를 검증해 요청을 받아 주는 방식으로 진행한다. 

CSRF(Cross-Site Request Forgery, 사이트간 요청 위조) 공격은 웹 애플리케이션에서 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행동을 실행하도록 만드는 보안 취약점을 이용한 공격 방법입니다. 사용자가 웹 애플리케이션에 로그인한 상태에서, 공격자가 준비한 악의적인 웹 페이지나 링크를 클릭하게 만들어, 사용자가 의도하지 않은 요청(예: 비밀번호 변경, 이메일 주소 변경, 자금 이체 등)을 웹 애플리케이션에 대해 전송하게 합니다.

 

 

스프링 시큐리티에서 인증 설정하기

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Slf4j
public class WebSecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;


    /**
     * WebSecurityConfigrerAdapter는 더이상 사용되지 않는다 5.7~
     * SecurityFilterChain bean 방식을 사용한다.
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        log.info("시큐리티 필터 체인 시작");
        //csrf disable
        //세션방식에서는 세션이 항상 고정되기 때문에 csrf공격을 반드시 방어해주어야 한다.
        //jwt 방식은 세션을 stateless 상태로 관리하기 때문에 csrf에 대한 공격을 방어 할 필요는 없다.
        http
                .csrf((auth) -> auth.disable());

        //Form 로그인 방식 disable
        //jwt방식으로 로그인 하기 때문에 form로그인을 사용하지 않는다.
/*        http
                .formLogin((auth) -> auth.disable());*/

        //http basic 인증 방식 disable
        http
                .httpBasic((auth) -> auth.disable());

        //경로별 인가
        http
                .authorizeHttpRequests((auth) -> auth
                        //static
                        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                        //form
                        .requestMatchers("/", "/auth/**","/test","index.html").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        //api
                        /*.requestMatchers("/api/**").permitAll()*/

                        .anyRequest().authenticated()
                )
                .oauth2Login(oauth2Login -> oauth2Login
                        .loginPage("/auth/login")
                        .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
                                .userService(customOAuth2UserService))
                        //.successHandler(urlAuthenticationSuccessHandler) 안됨,,
                        .defaultSuccessUrl("/auth/login/success")
                        .failureUrl("/auth/login/fail")
                )
                .logout(logout -> logout
                                        .logoutUrl("/auth/logout") // 로그아웃 처리 URL
                                        .logoutSuccessUrl("/test") // 로그아웃 성공 후 리다이렉트할 URL
                                        .invalidateHttpSession(true) // 세션 무효화
                                        .deleteCookies("JSESSIONID") // 쿠키 삭제
                                        .clearAuthentication(true) // 인증 정보 클리어
                );

        //세션 설정 항상 stateless하게 관리

        http
                .sessionManagement((session) -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));

        return http.build();
    }

}

 

시큐리티 5.7버전 이후부터는 SecurityFilterChain Bean방식으로 설정한다.

https://dao-blog.tistory.com/90

 

1. oauth2Login(oauth2Login - > oauth2Login 메서드는 OAuth2 로그인을 활성화하며, 이후의 구성은 OAuth2 로그인 방식에 특화된 설정을 제공한다.

2. .loginPage("/login")
사용자가 인증되지 않은 상태에서 보호된 리소스에 접근을 시도할 때, 이 메서드는 사용자를 리다이렉션할 로그인 페이지의 경로를 지정한다. 기본적으로 스프링 시큐리티는 자체적인 로그인 페이지를 제공하지만, `.loginPage("/login")`을 사용하여 커스텀 로그인 페이지로 사용자를 안내할 수 있다.

3. .defaultSuccessUrl("/loginSuccess")**
이 메서드는 사용자가 로그인에 성공했을 때 리다이렉션될 기본 URL을 설정한다. 사용자 인증 후, 보통 사용자를 환영 페이지나 메인 페이지로 안내하는 데 사용된다.

4. .failureUrl("/loginFailure")
로그인 시도가 실패했을 때 사용자를 리다이렉션할 URL을 설정한다. 로그인 과정에서 발생한 오류(예: 잘못된 자격 증명, 계정 잠김 등)를 처리하기 위한 페이지로 안내할 수 있다.

 

그러면, 로그인이 성공했을 떄, JWT토큰을 점검하고, 발급해보자,

SimpleUrlAuthenticationSuccessHandler 를 상속받아 구현한다.

                .oauth2Login(oauth2Login -> oauth2Login
                        .loginPage("/auth/login")
                        .successHandler(urlAuthenticationSuccessHandler)
                        .defaultSuccessUrl("/auth/login/success")
                        .failureUrl("/auth/login/fail")

                );
SimpleUrlAuthenticationSuccessHandler:  인증 성공 후 실행될 사용자 정의 로직을 정의하기 위해 사용

 

public class UrlAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final TokenProvider tokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication){
        String userName = authentication.getName();
        Role role = getRole(authentication);

        log.info("인증 되었습니다. user {}, role {}",userName,role);

        String createdToken = tokenProvider.createToken(userName, role);

        tokenProvider.addJwtToCookie(createdToken,response);
        response.addHeader("Authorization", createdToken);
    }

    private Role getRole(Authentication authentication) {
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        String role = authorities.isEmpty() ? null : authorities.iterator().next().getAuthority();
        return Role.valueOf(role);
    }
}

 

SuccessHandler를 상속받아 커스텀하였다. 핵심 메서드는 onAuthenticationSuccess인데, 핸들러 작동 시 자동으로 호출되게 된다. Authentication에는 유저 정보가 들어있다. 유저의 이름과 권한을 꺼내 토큰을 만들어준다. 나는 임시로

쿠키랑 헤더에 둘다 담았다. 나중에 선택해서 한개는 지울려고 한다.

 

토큰 관리를 위한 TokenProvider를 만들었다. 

@Slf4j
@Component
public class TokenProvider {
    //JWT 토큰 제공자
    public static final String BEARER_PREFIX = "Bearer ";
    public static final String AUTHORIZATION_HEADER  = "Authorization";
    public final long TOKEN_TIME = 60 * 10 * 1000L; // 10분
    public static final String AUTHORIZATION_KEY = "auth"; // 사용자 권한 값의 KEY
    public static final int SUBSTRING_NUMBER = 7;

    @Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;


        @PostConstruct
        private void init() {
            byte[] bytes = Base64.getDecoder().decode(secretKey);
            key = Keys.hmacShaKeyFor(bytes);
        }


        // header 토큰을 가져오기 Keys.hmacShaKeyFor(bytes);
        public String resolveToken(HttpServletRequest request) {
            String bearerToken= request.getHeader(AUTHORIZATION_HEADER);
            if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)){
                return bearerToken.substring(SUBSTRING_NUMBER);
            }
            return null;
        }

        // 토큰 생성
        public String createToken(String username, Role role) {
            Date date = new Date();

            String createdToken = BEARER_PREFIX +
                    Jwts.builder()
                            .setSubject(username) // 사용자 식별자값(ID)
                            .claim(AUTHORIZATION_KEY, role) // 사용자 권한
                            .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
                            .setIssuedAt(date) // 발급일
                            .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                            .compact();
            log.info(Messages.CREATED_TOKEN);
            return createdToken;
        }

        // JWT Cookie 에 저장
        public void addJwtToCookie(String token, HttpServletResponse res) {
            try {
                token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행

                Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
                cookie.setPath("/");

                // Response 객체에 Cookie 추가
                res.addCookie(cookie);
            } catch (UnsupportedEncodingException e) {
                log.error(e.getMessage());
                throw new RuntimeException(e);
            }
        }

        // JWT 토큰 substring
        public String substringToken(String tokenValue) {
            if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
                return tokenValue.substring(7);
            }
            log.error(Messages.NOT_FOUND_TOKEN);
            throw new NullPointerException(Messages.NOT_FOUND_TOKEN);
        }

        // 토큰 검증
        public boolean validateToken(String token) {
            try {
                Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
                return true;
            } catch (SecurityException | MalformedJwtException | SignatureException e) {
                log.error(Messages.INVALID_TOKEN_SIGNATURE);
            } catch (ExpiredJwtException e) {
                log.error(Messages.EXPIRED_TOKEN);
            } catch (UnsupportedJwtException e) {
                log.error(Messages.UNSUPPORTED_TOKEN);
            } catch (IllegalArgumentException e) {
                log.error(Messages.INVALID_TOKEN);
            }
            return false;
        }

        // 토큰에서 사용자 정보 가져오기
        public Claims getUserInfoFromToken(String token) throws ExpiredJwtException {
            try {
                Claims body = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
                log.info("사용자 정보 확인되었습니다. {}",body.toString());
                return body;

            } catch (ExpiredJwtException e) {
                log.error(Messages.EXPIRED_TOKEN);
                return e.getClaims();
            }
        }
        // HttpServletRequest 에서 Cookie Value : JWT 가져오기
        public String getTokenFromRequest(HttpServletRequest req) {
            Cookie[] cookies = req.getCookies();
            log.error("Authorization 쿠키를 찾습니다.");

            if (cookies != null) {
                log.error(Messages.NOT_FOUND_COOKIES);
                return null;
            }

                for (Cookie cookie : cookies) {
                    if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
                        log.info(Messages.FOUNT_COOKIES,cookie.getName());
                        try {
                            String decode = URLDecoder.decode(cookie.getValue(), "UTF-8");
                            log.info("쿠키 복호화 되었습니다. {}", decode);
                            return decode;
                        } catch (UnsupportedEncodingException e) {
                            log.error("지원하지 않는 인코딩방식입니다.");
                            return null;
                        }
                    }
                }

            return null;
        }

    }

 

@PostConstruct 어노테이션이 붙은 init 메서드는 클래스의 의존성 주입이 완료된 후 초기화를 위해 자동으로 실행된다.

  • init: 비밀 키를 Base64 디코딩하고, 디코드된 비밀 키를 사용하여 HMAC SHA 키를 생성하여 key 변수에 할당한다.
  • resolveToken: HTTP 요청 헤더에서 "Authorization" 값을 추출하고, 이 값이 "Bearer "로 시작하는 경우, 접두사를 제거한 나머지 토큰 문자열을 반환한다.
  • createToken: 사용자 이름과 역할을 입력으로 받아, JWT 토큰을 생성한다. 이 토큰에는 사용자 이름, 역할, 만료 시간, 발급 시간이 포함되고, 지정된 알고리즘과 키를 사용하여 서명된다.
  • addJwtToCookie: 생성된 JWT 토큰을 URL 인코딩하고, "Authorization" 이름으로 쿠키를 생성하여 응답에 추가한다.
  • substringToken: 입력받은 토큰에서 "Bearer " 접두사를 제거하고 순수 토큰 문자열만 반환한다.
  • validateToken: 주어진 토큰의 서명을 검증하고, 토큰의 유효성(예: 만료 여부, 형식의 정확성)을 검사한다.
  • getUserInfoFromToken: 토큰을 파싱하여 사용자 정보(Claims)를 추출하고 반환한다. 토큰이 만료된 경우 예외를 던지고, 만료된 토큰의 정보도 접근할 수 있다.
  • getTokenFromRequest: HTTP 요청에서 "Authorization" 쿠키를 찾고, 해당 쿠키에서 JWT 토큰을 추출하여 반환한다.

 

시큐리티설정 파일에서 직접 커스텀한 UrlAuthenticationSuccessHandler를 주입받아, oauth2Login.successHandler에 넣어주면 로그인이 성공했을 시, 핸들러가 실행된다. 

private final UrlAuthenticationSuccessHandler urlAuthenticationSuccessHandler;  
  ...
  	.oauth2Login(oauth2Login ->
                oauth2Login
                    .loginPage("/login")
                    .successHandler(urlAuthenticationSuccessHandler)
                    .defaultSuccessUrl("/loginSuccess")
                    .failureUrl("/loginFailure")
            );

그러면 JWT토큰을 발급해 사용자에게 전달하게 된다. UrlAuthenticationSuccessHandler에서 리프레시 토큰을 만들어서 엑세스토큰 - 리프레시토큰방식을 구현할 수도 있다.

 

인가 과정은 다음글에 작성하겠다.