오늘의 목표 

JWT를 이용해 인증 인가를 구현하던 api에 oauth2를 이용한 소셜 로그인 기능 추가하기

 

OAUTH2 인증&인가 진행 과정

기본 동작 과정은 저번 글에서 설명했듯이 다음과 같다.

(여기서 이야기하는 Client는 실제 사용자가 아닌 Oauth Provider에 등록한 우리의 서비스임을 주의하자)

  1. Resource Owner 가 Client에 로그인 요청
  2. Client는 Authorization Server에 다시 로그인 요청(Clitne Id, Redirect URI, Response Type, Scope 전달)
  3. Authorization Server에서 Client에 로그인 페이지 제공
  4. Resource Owner 로그인 성공시 Authrorization code 와 함께 redirect uri로 리다이렉트
  5. redirect uri에서 Client는 Autorization Server에게 Authroization code를 사용해 Access Token 발급 요청
  6. Client에서 필요할때마다 Access Token으로 Resource 서버의 사용자 정보에 접근

사실 권한부여(Grant Type) 유형은 위와같은 Authorization code 유형이 아닌 다른 유형도 존재하지만,

가장 많이 사용하는 Authorization code 유형을 기준으로 진행했다.

다른 유형도 궁금하다면 OAuth2 Grant Type에 대해 검색해보자.

 

https://velog.io/@crow/OAuth-2.0-%EA%B6%8C%ED%95%9C%EB%B6%80%EC%97%AC-%EC%9C%A0%ED%98%95Grant-Type

 

OAuth 2.0 권한부여 유형(Grant Type)

123

velog.io

 

 

프론트와 백엔드가 나눠진 인증 과정

 

프론트와 백 어디가 주가 될 것인가 - Authorization Server Redirect URI를 어디로 설정하는 것이 좋은가?

 

Authorization Server의 역할을 다시 한 번 짚어볼 필요가 있다.

 

Authorization Server는 사용자 인증을 진행한 후 인증 성공시 codestate

미리 설정해놓은 Redirect URI에 쿼리 파라미터 형식으로 전달한다.

 

그리고 이 codeClient Secret 등의 정보를 사용해 Autorization Server는 Access Token을 발급한다.

이 Access Token을 헤더로 사용해야 Resource Server에서 유저 정보를 가져올 수 있다.

 

저번 글에선 이 과정을 Spring Security의 필터에게 맡겼다.

그런데 프론트와 협업하는 상황에선 이 과정을 어떻게 처리해야할까?

 

이는 Authorization Server의 Redirect URI를 어떻게 설정하느냐와 깊은 연관이 있다.

1. 프론트가 모두 담당

프론트에서 Authorization Server에 접근해 code를 발급받고 Access Token을 발급받은 다음

Access Token을 백엔드로 넘겨주거나 혹은 유저 정보까지 조회한 후 필요한 유저 정보만 백엔드로 넘겨주는 방식이다.

그러면 백엔드에서 유저 정보를 사용해 서비스용 Access Token과 Refresh Token을 생성한 후 다시 프론트로 넘겨준다.

 

전자의 경우는 상당히 위험한데, 프론트에서 Access Token과 Refresh Token을 백엔드로 전송할때 탈취의 위험이 있고,

탈취시 심각한 개인정보 유출로 이어질 수 있다.

후자의 경우 OAuth2를 단순히 유저 인증용으로만 사용할 때(로그인 이후엔 유저 정보가 필요 없을 때) 사용할 것 같다.

 

2. 프론트로 RedirctURI를 설정하고 Authorization code만 다시 백엔드로 넘겨주기

구글링하면 가장 많이 나오는 방식이다.

프론트에서 Authorization Server에 접근해 code를 발급받은 후 그 코드만 백엔드에 넘겨주는 방식이다.

Authorization code는 유효기간도 매우 짧고 탈취당해도 client_secret을 알지 못한다면 소용이 없고

client_secret과 같은 정보는 모두 백에서 관리하므로 보안에 장점이 있는 방식이다.

 

문제는 Spring Security를 적용하기가 힘들다.

 

Spring Security의 oauth2 로그인 로직은

  • OAuth2AuthorizationRequestRedirectFilter에서 OAuth2AuthorizationRequest를 생성해 쿠키에 저장하고
  • OAuth2LoginAuthenticationFilter에서 생성해뒀던  OAuth2AuthorizationReques객체를 이용해
    Access Token을 발급받는다.

OAuth2AuthorizationRequestRedirectFilter를 반드시 거쳐가야 하는데 이 필터는

/oauth2/authorization/** 와 같은 요청을 처리한다.

 

OAuth2LoginAuthenticationFilter가 처리하는 요청 url은 /login/oauth2/code/** 이다.

 

프론트에서 두 개의 url에 순서대로 요청하고 쿠키에 저장된 값을 꺼내 쓰고 하는게 너무 복잡하고 불편해보인다.

 

그렇다고 Security 안쓰고 직접 구현하는건 쉽냐? 그거도 아니다.

백엔드 서버에서 직접 Auhtorization Server랑 Resource Server에 접근해야하며,

결과 값 파싱까지 귀찮은게 한두가지가 아니다.

 

3. 백이 모두 담당

프론트에선 백의 인증 url만 호출하고 백엔드에서 인증 로직을 모두 전담하는 방식이다.

백엔드에서 모든 인증을 끝낸 후 프론트와 미리 약속한 redirect url에 (Authorization Server의 redirect URI와 다르다)

서비스용 access token, refresh token만 전달하면 된다.

 

이렇게하면 Authorization Server의 Redirect URI는 localhost:8080/login/oauth2/code/**

같은 백엔드 api 주소로 설정해 Spring Security를 사용하기 쉬워지며

Client Secret과 같은 보안 정보를 모두 백엔드에서 관리해 보안성도 높아진다.

 

나는 3번 방식을 사용하기로 결정했다.

 

우리가 선택한 방식의 인증 인가 진행 과정

  1. 프론트에서 백엔드에 로그인 페이지를 요청하는 것으로 시작
  2. 백엔드의 /oauth2/authorization/** 로 바로 리다이렉트 -> 로그인 페이지 제공
  3. 로그인 성공시 /login/oauth2/code/** 로 리다이렉트 -> 시큐리티 필터 동작
  4. 지난번에 만든 유저 서비스가 Authentication 객체 만들어 Security Context에 넣어둠
  5. SuccessHandler가 Authentication 객체에서 principal 꺼내서 JWT 제작
  6. 마지막에 프론트 리다이렉트 url로 토큰 반환

SecurityConfig 수정

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    private final CustomOAuth2UserService customOAuth2UserService;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
    private final JwtProvider jwtProvider;
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        http.csrf().disable();
        
        http
            	.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            	.formLogin().disable()
            	.httpBasic().disable()
            	.addFilter(new JwtAuthorizationFilter(authenticationManager(), jwtProvider))
            	.authorizeRequests()
            		.antMatchers("/v1/login").permitAll()
            		.antMatchers("/v1/refresh").permitAll()
            		.antMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**").permitAll()
            		.antMatchers("/admin/**", "/css/**", "/*.ico", "/error").permitAll()
            		.anyRequest().authenticated()
            .and()
            	.oauth2Login()
            	    .userInfoEndpoint()
                	.userService(customOAuth2UserService)
            		.and()
            		.successHandler(oAuth2AuthenticationSuccessHandler);
    }
}

 

Session을 이용해 유저 정보를 저장하지 않기 때문에 sessionCreationPolicy를 STATELESS로 설정했다.

 

기존의 JWT 인증,인가 방식을 그대로 사용하며 OAuth2 소셜 로그인을 추가해야하므로

기존 JWT 설정 (JwtAuthorizationFilter, JwtProvider) 를 그대로 사용하기로 했다.

 

그리고 지난 글에서 사용했던 customOAuth2UserService, UserInfo, OAuth2Attributes를 그대로 사용하기로 했다.

 

기존 로직은 OAuth2 로그인 성공 후 SecurityContext에 Authentication 객체를 저장하기만 하면

SecurityContext가 세션에 저장되어 인증 인가를 처리할 수 있었다.

 

그러나 이젠 Session에 저장하는 것이 아닌 JWT를 만들어 발급해야 하므로 로그인 성공 후 토큰을 만들어야 하는데

이를 CustomSuccessHandler 를 구현해 처리할 예정이다.

 

CustomSuccessHandler 구현

@RequiredArgsConstructor
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtProvider jwtProvider;
    
    private final String REDIRECT_URL = "http://localhost:3000/login/redirect";
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        UserInfo userInfo = (UserInfo) authentication.getPrincipal();
        User loginUser =  userInfo.getUser();
        
        String accessToken = jwtProvider.createAccessToken(loginUser);
        
        String refreshToken = jwtProvider.createRefreshToken(loginUser);
        
        getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(accessToken, refreshToken));
    }
    
    private String getRedirectUrl(String accessToken, String refreshToken){
        return UriComponentsBuilder.fromUriString(REDIRECT_URL)
                .queryParam("access_token", accessToken)
                .queryParam("refresh_token", refreshToken)
                .build().toUriString();
    }
}

 

SimpleUrlAuthenticationSuccessHandler를 상속받아 구현했다.

 

인증 성공 후 생성된 Authentication에서 UserInfo를 다시 꺼낸 후 access token과 refresh token을 생성했다,

그리고 생성된 refresh token과 access token을 프론트와 미리 약속한 redirect url에 전송하는 로직을 처리한다.

 

인증, 인가는 공부를 할 수록 의문점만 생기는 부분인 것 같다.

프론트에서 백으로 access token을 전송하는건 위험하다는데 백에서 프론트로 전송하는 건 괜찮은건가?

프론트에서 보관하고 있는 access token을 어떻게 탈취한다는 걸까?

redirect url을 백엔드 api로 설정하면 유저 브라우저로 백엔드 api를 직접 호출하는 것인데 문제는 없는가?

 

협업 프로젝트를 진행해보며 더 공부해봐야할 내용인 것 같다.

인증 인가를 구현하는 방식으로 session과 jwt 방식을 알아봤다.

이번엔 Spring Security에서 OAuth2를 이용해 인증, 인가를 구현하는 방법에 대해 공부해봤다.

OAuth2란?

구글, 페이스북, 카카오와 같은 다양한 플랫폼의 사용자 데이터에 대해 접근하기 위해

제 3자가 사용자 데이터에 대한 접근 권한을 위임받는 표준 프로토콜이다.

 

https://hudi.blog/oauth-2.0/

 

OAuth 2.0 개념과 동작원리

2022년 07월 13일에 작성한 글을 보충하여 새로 포스팅한 글이다. OAuth 등장 배경 우리의 서비스가 사용자를 대신하여 구글의 캘린더에 일정을 추가하거나, 페이스북, 트위터에 글을 남기는 기능을

hudi.blog

 

OAuth2를 이용해 인증, 인가를 구현하려면 어떻게 해야 하나요?

기존의 session, jwt 방식이 함께 사용된다고 생각하면 된다.

다만 session에 넣는 유저 정보를 OAuth2를 통해 가져온다.

 

OAuth2를 사용해 인증, 인가를 구현하면 얻을 수 있는 장점?

  1. 개발자는 로그인 폼, 회원가입, 아이디, 비밀번호 찾기 등과 같은 귀찮은 과정을 구현하지 않아도 된다.
  2. 사용자의 중요 정보를 DB에 저장하지 않아도 됨 -> 개인정보 유출 위험성을 낮출 수 있다.
  3. 사용자 또한 어플리케이션마다 계정을 만들고 관리할 필요가 없어 편리한 로그인이 가능해진다.

기본 용어

Resource Owner : 회원 정보의 소유자, 즉 실제 사용자

Client : 회원 정보를 사용하는 사용자 , 즉 우리가 만든 어플리케이션

Authorization Server : 인증용 서버

Resource Server : 회원 정보 제공 서버

 

기본 동작 방식

  1. Resource Owner 가 Client에 로그인 요청
  2. Client는 Authorization Server에 다시 로그인 요청(Clitne Id, Redirect URI, Response Type, Scope 전달)
  3. Authorization Server에서 Client에 로그인 페이지 제공
  4. Resource Owner 로그인 성공시 Authrorization code 와 함께 redirect uri로 리다이렉트
  5. redirect uri에서 Client는 Autorization Server에게 Authroization code를 사용해 Access Token 발급 요청
  6. Client에서 필요할때마다 Access Token으로 Resource 서버의 사용자 정보에 접근

이때 Spring Security를 사용하면

  • Autorization Server에 접근하는 uri 제공 (google, github, facebook)
  • Resource Server에 접근하는 uri,  Autorization code 값 저장 방법 등 제공
  • Access Token 관리 및 가져온 유저 정보 저장

등의 기능을 편리하게 사용할 수 있다.

 

이번 글의 목표

기존의 타임리프를 사용한 로그인 페이지에서 oauth2를 이용한 로그인 방식을 함께 구현해보자.

 

기존 프로젝트에 타임리프와 spring security의 formLogin 기능을 이용해서 어드민 페이지를 만들었다.

여기서 기존 로그인 방식을 유지하면서 oauth2를 이용한 소셜 로그인 기능을 추가해보자.

 

OAuth2를 사용하기 위한 설정 변경

1. 어플리케이션 등록

우리는 네이버와 구글을 이용해서 구현할 예정이다.

네이버와 구글로 소셜 로그인 기능을 사용하기 위해선 우리 서비스를 클라이언트로 등록해야 한다.

아래 링크를 참고해서 등록했다.

https://velog.io/@leeeeeyeon/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0%EC%99%80-OAuth-2.0%EC%9C%BC%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기

구글 로그인, 로그아웃, 회원가입, 권한관리 기능을 구현해보자

velog.io

 

2. application.yml 추가

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ...
            client-secret: ...
            scope: profile, email
            redirect-uri: https://localhost:8080/login/oauth2/code/google
          naver:
            client-id: ...
            client-secret: ...
            scope: name, email
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: https://localhost:8080/login/oauth2/code/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은 스프링 시큐리티에서 기본으로 Provider를 제공해주므로 위의 4가지 정보만 설정하면 된다.

그러나 네이버는 시큐리티에서 제공하지 않으므로 Provider 설정도 직접 해줘야한다.

 

여기서 중요한 설정은 redirect-uri로 우리는 반드시 .../login/oauth2/code/google 와 같은 형식으로 uri를 등록해야한다.

그리고 여기 등록한 uri는 어플리케이션 등록시 설정하는 redirect-uri와 동일해야한다.

그 이유는 아래에 후술한다.

 

3. SecurityConfig 파일 변경

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class OAuth2SecurityConfig {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    private final CustomFailureHandler customFailureHandler;
    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests((authorizeRequests) -> authorizeRequests
                        .requestMatchers(
                                new AntPathRequestMatcher("/admin/login")
                        ).permitAll()
                        .anyRequest().authenticated()
                    )
                .formLogin()
                    .loginPage("/admin/login")
                    .loginProcessingUrl("/admin/login")
                    .usernameParameter("accountId")
                    .defaultSuccessUrl("/admin")
                .and()
                .logout((logoutConfig) -> logoutConfig
                    .logoutUrl("/admin/logout")
                    .logoutSuccessUrl("/admin")
                ) 
                //OAuth2를 위한 설정 추가
                .oauth2Login((oauth2) -> oauth2
                    .loginPage("/admin/login") 
                    .defaultSuccessUrl("/admin")
                    .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
                        .userService(customOAuth2UserService))); //사용자 user service 추가

        return http.build();
    }    
}

 

기존의 Spring Security 설정 파일에 oauth2Login 설정을 추가하자.

formLogin 설정과 얼추 비슷해보인다.

 

복습 - 스프링 시큐리티의 기본 동작 방식

 

기존의 formLogin을 이용한 인증 구조는 위와 같았다.

 

설정을 통해 미리 만들어놓은 Authentication Filter에서 설정해놓은 URL로 들어오는 요청을 확인하면

AuthenticationManger가 인증을 처리할 Provider를 찾고

formLogin은 UserDetailsService를 통해 유저 정보를 가져와 Authentication 객체를 만들어

SecurityContext에 저장해놓고 사용했다.

 

OAuth2를 이용한 인증/인가시 몇가지 구현 객체만 바뀔 뿐 기본적인 구조는 위의 방식과 동일하다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http //OAuth2 설정 추가
       .oauth2Login((oauth2) -> oauth2
           .loginPage("/admin/login") 
           .defaultSuccessUrl("/admin")
           .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
           .userService(customOAuth2UserService)));
}

 

위와 같이 oauth2Login 로그인 설정을 추가하면 아래와 같은 필터 체인이 추가된다.

  • OAuth2AuthorizationRequestRedirectFilter
  • OAuth2LoginAuthenticationFilter
  • (사실 몇개 더 있지만 이 두 가지만 짚고 가자)

 

OAuth2AuthorizationRequestRedirectFilter

GET http://localhost:8080/oauth2/authorization/google

다음과 같은 양식의 요청이 들어오면 해당 요청을 처리하는 필터다.

 

요청 url과 파라미터를 검증해 조건에 맞으면 OAuth2AuthorizationRequest 객체를 생성하여 리턴한다.

 OAuth2AuthorizationRequest 객체의 authorizationRequestUri 주소로 사용자를 리다이렉트 한다.

이 주소에는 각 제공자에 맞는 OAuth2 로그인 페이지 URL이 담겨있다.

 

OAuth2LoginAuthenticationFilter

/login/oauth2/code/* 와 같은 양식의 요청이 들어오면 해당 요청을 처리하는 필터다.

 

그런데 이 양식 어디서 많이 봤다. 바로 redirect Uri 에서 설정한 uri 양식이다.

사용자가 로그인에 성공하면 redirect URI로 이동한다.

이때 우리는 redirect URI를 OAuth2LoginAuthenticationFilter가 처리하는 uri로 설정해 둔 것이다.

 

OAuth2LoginAuthenticationFilter는 Authentication Server에서 AccessToken을 발급받고,

AccessToken을 이용해 사용자 정보를 받아와 인증 객체(Authentication)를 생성해 리턴하는 중요 로직을 담당한다.

 

이때 실제 로직은 AuthenticationProvider에게 위임한다.

 

OAuth2LoginAuthenticationProvider

OAuth2LoginAuthenticationProvider 객체는 인가 코드로 OAuth2 제공자 인증 서버에서 액세스 토큰을 발급 받고,

액세스 토큰으로 사용자 정보를 받아 인증 객체를 생성하고 리턴하는 로직을 처리합니다.

이 때 액세스 토큰 발급과 사용자 정보를 받아오는 부분은 각각 

OAuth2AuthorizationCodeAuthenticationProvider 객체와 UserService 객체에게 위임합니다.

 

우리가 구현해야하는 부분

1. UserService 구현

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    
    private final UserRepository userRepository;
    
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
        //attributes 얻기 위한 코드
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Map<String, Object> attributes = OAuthAttributes.of(registrationId, oAuth2User);

        User user = saveOrUpdate(attributes);
        return UserInfo.ofOAuth2(user, attributes);
    }
    
    //유저 생성 서비스 로직
    private User saveOrUpdate(Map<String, Object> attributes){

        Optional<User> findUser = userRepository.findByEmail((String) attributes.get("email"));
        
        if(findUser.isPresent()){
            return findUser.get();
        }
        else{
            User user = User.fromOAuth2Attributes(attributes);
            userRepository.save(user);
            
            return user;
        }
    }
}

사용자가 로그인에 성공한 후 Access Token을 받는데 까지 성공하면 해당 Access Token을 이용해 유저 정보를 얻어올 수 있다. 이 과정은 DafultOAuth2UserService의 loadUser() 메서드가 구현하고 있으므로 사용하면 된다.

 

이렇게 가져온 유저 정보를 OAuth2User 객체 인스턴스로 반환하면

AbstractAuthenticationProcessingFilter 에서 Authentication 객체를 만들어 SecurityContext에 저장한다.

 

그런데 코드를 잘보면 주목할 점이 몇가지 있다.

DafultOAuth2UserService에서 반환한 OAuth2User 인스턴스를 그냥 반환하면 되는데

 

  1. Map<String, Object> 형태의 attributes를 뽑아내서
  2. 일치하는 User를 찾거나 없으면 새로 생성해내고
  3. 유저 인스턴스를 다시 UserInfo 객체로 변환해 반환한다.

왜 이렇게 쓸데없는 고생을 할까?

그 이유는 우리가 기존 formLogin 방식을 같이 사용하기 때문이다.

 

기존 formLogin 방식의 UserService를 보자.

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService, UserDetailsService{
    
    private final UserRepository userRepository;
    ...
    
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
        UserDetails findUser = userRepository.findByAccountId(username)
            .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 회원입니다"));
        
        return findUser;
    }
    
}

UserDetailsService를 상속받아 loadUserByUsername() 메서드를 오버라이딩했다.

유저 식별자(username)이 들어오면 DB에서 해당하는 유저 정보를 꺼내와 UserDetails 형태로 반환한다.

그 후 UserDetails 인스턴스를 이용해 Authentication 객체를 만들어 SecurityContext에 저장한다.

 

여기서 생기는 문제는 우리는 유저가 OAuth2 로그인을 할지, formLogin을 사용할지 알 수 없다!

 

그렇다면 유저 정보를 확인할 필요가 있을 때 SecurityContext에 저장된 Authentication 객체의 유저 정보가

OAuth2User 일지, UserDetails일지 꺼내서 확인하기 전까진 알 수 없다는 것이다!

 

따라서 Spring Security의 가장 핵심 기능인 Security Context에 유저 정보를 저장하고

필요한 상황이 생기면 전역적으로 사용하는 기능을 사용하기 힘들어졌다.

 

로그인한 유저 정보를 확인해야하는 곳마다 타입체크와 형변환을 하는 코드를 사용하는 것은

엄청나게 번거롭고 비효율적인 짓이기 때문이다.

 

이를 해결하기 위해 UserInfo 클래스를 만들었다.

 

2. UserInfo 클래스 구현

@Getter
public class UserInfo implements UserDetails, OAuth2User{

    private User user;
    private Map<String, Object> attributes;

    private UserInfo(User user, Map<String, Object> attributes){
        this.user = user;
        this.attributes = attributes;
    }
    
    public static UserInfo ofOAuth2(User user, Map<String, Object> attributes){
        return new UserInfo(user, attributes);
    }
    
    public static UserInfo from(User user){
        return new UserInfo(user, null);
    }
    
    //<== UserDetails ==>//
    public Collection<? extends GrantedAuthority> getAuthorities(){
        return Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey()));
    }
	...
    //<== Oauth2User ==>
    public String getName(){
        return user.getName();
    }
	...
}

UserInfo 클래스는 UserDails와 OAuth2User를 모두 구현하는 클래스이다.

이는 UserDails와 OAuth2User 모두 인터페이스이기 때문에 가능하다.

 

이로서 우리는 OAuth2Login에서든 FormLogin에서든 UserInfo 클래스를 반환하도록 구현하면 된다.

유저 정보가 필요할때마다 번거롭게 타입체크와 형변환을 할 필요 없이 주요 정보들을 가져올 수 있다.

 

그리고 OAuth2 방식과 formLogin 방식이 UserInfo를 생성하기 위해 필요한 매개변수가 다르기 때문에

정적 팩토리 메서드를 사용해봤다.

 

3. OAuthAttributes 구현

public class OAuthAttributes{
    
    private OAuthAttributes() {
        throw new UnsupportedOperationException();
    }
    
    public static Map<String, Object> of(String registrationId, OAuth2User oauth2User){
        
        switch(registrationId){
            case "google":
                return ofGoogle(oauth2User);
                
            case "naver":
                return ofNaver(oauth2User);
            
            default:
                throw new IllegalStateException("unvalid oauth2 login");
        }
    }
    
    private static Map<String, Object> ofGoogle(OAuth2User oauth2User){
        return oauth2User.getAttributes();
    }

    private static Map<String, Object> ofNaver(OAuth2User oauth2User){
        
        return (Map<String, Object>) oauth2User.getAttributes().get("response");
    }
}

OAuth2의 제공자마다 유저 정보 응답이 조금씩 다르다.

따라서 제공자마다 유저 정보로 넘어온 응답값을 추출해내는 과정이 필요한데 이를 하나의 클래스로 분리해봤다.

 

사실 UserService의 메서드로 넣어도 되는 메서드들이긴 하다.

근데 가만히 보니까 서비스 로직들 사이에 들어가있는게 불편해서 한 번 분리해봤다.

 

처음 판단은 이랬다.

  • OAuth2의 제공자가 지금은 구글과 네이버 뿐이지만 언제 더 늘어날지 모른다.(변경이 자주 일어날 것으로 예상)
  • 그렇다면 제공자가 늘어날때마다 UserService의 로직을 수정해야하만 한다.
  • 그러나 UserService에서 OAuth2 제공자가 추가될때 변경되어야 하는 로직은 응답값 추출과 관련된 메서드들 뿐이다.
  • 그렇다면 응답값 추출 메서드를 별도의 클래스로 분리해보면 어떨까?

그런데 분리를 하고 보니까 스태틱 메서드만 포함하고 있는 유틸리티 클래스가 생겨버렸다.

유틸리티 클래스에 대해선 논쟁이 많은 편이다. 특히 객체지향 프로그래밍과 맞지 않는다는 이야기를 많이 봤다.

 

유틸 클래스의 문제점으로 꼽히는 점은 사용하는 클래스와의 강한 결합이다.

유틸 클래스는 스태틱 메서드로만 만들어져 인스턴스를 생성하지 않고 사용한다.

그래서 유틸 클래스를 사용하는 클래스와 강하게 결합되는데

이는 유틸 클래스가 변경될 시 사용하는 모든 클래스의 변경을 유발한다.

 

그러나 UserService의 메서드를 분리해 만든 OAuth2Attributes 클래스는 UserService가 아닌 곳에서 사용될 일이 없다.

그래서 그냥 쓰기로 했다.

 

4. LoginPage 변경

<!DOCTYPE HTML>
...
<body>
    <div class="container">
        <form role="form" th:action="@{/admin/login}" method="post" th:object="${signInRequest}">
            ...
        </form>
        <div>
            <a href="/oauth2/authorization/google">Google Login</a> <br/>
            <a href="/oauth2/authorization/naver">Naver Login</a> <br/>    
        </div>
    </div> <!-- /container -->
</body>
</html>

로그인 페이지에 구글 로그인 네이버 로그인용 태그를 추가해줘야한다.

이때 링크 클릭시 url은 위의 OAuth2AuthorizationRequestRedirectFilter가 처리할 수 있도록

/oauth2/authorization/* 양식으로 해줘야한다.

5. AdminController 구현

    @GetMapping("/admin") //홈 화면
    public String newHome(@AuthenticationPrincipal UserInfo findUser, Model model){
        
        if (findUser == null){
            return "admin/home";
        }
        
        model.addAttribute("userName", findUser.getName());    
        return "admin/menu";
    }

위의 방식대로 구현하면 컨트롤러 단에선 UserInfo 만 받아온 후 형변환 없이 그대로 사용할 수 있게 된다.

 

 

기존 formLogin 방식에 oauth2Login을 사용해 소셜 로그인 기능을 추가해봤다.

기존 formLogin과 함께 사용하기 위해 Session 방식으로 구현했다.

다음으론 JWT를 이용한 OAuth2 로그인도 공부해봐야겠다.

인증 로직

  1. 아이디 & 비밀번호 담아서 서버에 요청
  2. 서버에서 검증
    • 올바르다면 AccessToken, Refresh Token 생성 후 유저에게 반환
    • Refresh Token은 Redis에 따로 저장
    • 올바르지 않다면 에러 메세지 반환

기존 Spring Security의 인증로직이다.

 

기존 form Login 방식의 인증용 필터인 UsernameAuthenticationFilter는 

유저 아이디와 비밀번호를 요청으로 받아서 확인한 후

Authentication 객체를 만들어서 securityContext에 넣어 사용한다.

그 후 응답시에 HttpSession에 Authentication 객체를 넣은 후 세션 ID를 쿠키로 설정해둔다.

 

이 인증로직을 재활용하려면 인증용 필터를 새롭게 만들어 줘야 하는데

우리가 해야할 일은 HttpBody로 유저 아이디, 비밀번호 받아서

Access Token, Refresh Token을 만든 후 HttpBody에 넣어 반환하는 로직이다.

 

Form 방식이 아니므로 필터를 상속받는것 보다 RestController를 새로 만드는 것이 더 간단해보인다.

LoginController를 만들자.

@Slf4j
@RestController
@RequiredArgsConstructor
public class LoginController{
    
    private final UserServiceImpl userService;
    private final JwtProvider jwtProvider;
    
    //인증 로직
    @PostMapping("/v1/login")
    public ResponseEntity<LoginResponse> loginUser(@Valid @RequestBody LoginRequest request){ 
        User loginUser = userService.signIn(request.getAccountId(), request.getPassword());

        String accessToken = jwtProvider.createAccessToken(loginUser);
        
        String refreshToken = jwtProvider.createRefreshToken(loginUser);
        
        LoginResponse response = new LoginResponse(accessToken, refreshToken);

        return ResponseEntity.ok(response);
    }
	
    //토큰 재발급 로직
    @PostMapping("/v1/refresh")
    public ResponseEntity<String> refreshToken(@RequestHeader("Refresh-Token") String refreshToken){
        
        if(jwtProvider.validateRefreshToken(refreshToken)){
            
            String accountId = getUserAccounId(refreshToken);
            
            User user = userService.findByAccountId(accountId);
            
            String accessToken = jwtProvider.createAccessToken(user);
            
            return ResponseEntity.ok(accessToken);
        }
        return ResponseEntity.ok("Unvalid Refresh-Token");
    }
    
    private String getUserAccounId(String refreshToken){
        refreshToken = refreshToken.replace("Bearer ", "");
        
        return jwtProvider.parseClaims(refreshToken)
                .get("accountId")
                .toString();
    }
}

 

그리고 JWT 관련된 로직을 수행할 JwtProvider를 만들자.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtProvider {
    //만료시간 : 30분
    private final Long ACCESS_EXP_TIME = 1000L * 60 * 30;
    
    //만료 시간 : 하루
    private final Long REFRESH_EXP_TIME = 1000L * 60 * 60 * 24;
    
    private final RedisService redisService;
    
    @Value("${jwt.secret}")
    private String salt;
    
    private Key secretKey;
    
    @PostConstruct
    protected void init(){
        secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
    }
    
    //Access Token 생성
    public String createAccessToken(User user){
        
        String accountId = user.getAccountId();

        String authorities = user.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));
        
        Date expiration = new Date(System.currentTimeMillis() + ACCESS_EXP_TIME);

        return Jwts.builder()
                .claim("accountId", accountId)
                .claim("authorities", authorities)
                .setExpiration(expiration)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }
    
    //Refresh Token 생성
    public String createRefreshToken(User user){
        String accountId = user.getAccountId();
        
        Date expiration = new Date(System.currentTimeMillis() + REFRESH_EXP_TIME);
        
        String refreshToken = Jwts.builder()
                                .claim("accountId", accountId)
                                .setExpiration(expiration)
                                .signWith(SignatureAlgorithm.HS256, secretKey)
                                .compact();
        
        //redis 유효기간 설정
        redisService.setValues(accountId, refreshToken);
        redisService.setExpiration(accountId, REFRESH_EXP_TIME);
        
        return refreshToken;
    }
    
    public Claims parseClaims(String tokenString){
        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(tokenString)
            .getBody();
    }
   
}

 

 

RefreshToken은 Redis에 저장할 것이므로, RedisService를 만들어서 연결하자.

@Service
@RequiredArgsConstructor
public class RedisService{
    
    private final RedisTemplate redisTemplate;
    
    public String getValues(String key){
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        return values.get(key);
    }
    
    public void setValues(String key, String value){
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key,value);
    }
    
    public void setExpiration(String key, Long time){
        int second = time.intValue();
        
        redisTemplate.expire(key, second, TimeUnit.SECONDS);
    }
    
    public void deleteByKey(String key){
        redisTemplate.delete(key);
    }
}

 

RefreshToken을 Redis에 저장하는 이유와 저장하는 방법?

Access Token의 탈취를 대비하기 위해서 Refresh 토큰을 만들어 사용하지만

Refresh Token 역시 탈취당할 가능성이 존재한다.

 

Refresh Token은 유효기간이 상대적으로 긴 편이므로

탈취당했을 경우, 악성 사용자가 긴 시간동안 Access Token을 재발급하며 사용할 수 있다.

 

따라서 Refresh Token 발급시에 토큰에 대한 정보를 저장소에 저장해 놓은 후

Refresh 토큰이 탈취된 사실을 알거나, 이상한 접근이 감지될 경우 저장소에서 삭제하는 등 대처가 가능하도록 한다.

 

이번 프로젝트에선 접근 속도가 빠른 redis를 사용하기로 했다.

 

유저 아이디(식별자)를 키로, 토큰을 값으로 설정하여 저장하기로 했는데

이렇게 한다면 만약 secret key가 유출되어 리프레쉬 토큰이 위조될 경우

  1. 토큰 검증
  2. 이상 없을 경우 토큰에서 유저 아이디 확인
  3. redis에서 유저 아이디로 기존 토큰과 비교 -> 위조 여부 한번 확인 가능

다음과 같은 로직으로 토큰 위조 여부를 확인하기 쉬워진다.

 

또한 보안이 중요한 서비스라면 사용자의 최근 요청 IP등을 같이 저장해

다른 IP에서 요청이 들어올 경우 메일을 보내거나,

Refresh Token을 삭제하는 등의 대처가 가능하도록 할 수 있겠다.

 

인가 로직

  1. 자원 접근 요청시 Autorization 헤더에 Access Token 담아서 요청
  2. 서버에서 Access Token 검증
    • 유효한 토큰이면 정상 응답 반환
    • 올바르지 않다면 에러 메세지 반환
  3. Access Token 만료시, Refresh Token을 헤더에 담아 서버에 재발급 요청
  4. Redis에 저장된 Refresh 토큰과 비교 후, 올바른 Refresh Token일시 Access Token 재발급

 

JWT 방식은 인증이 필요한 매 요청마다 Autorization 헤더에서 AccessToken을 찾아 확인해야한다.

이 과정은 Filter를 새로 만들어서 적용한다.

@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter{

    private final JwtProvider jwtProvider;
    
    @Autowired
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager,
                                 JwtProvider jwtProvider){
        super(authenticationManager);
        this.jwtProvider = jwtProvider;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {
        
        String accessToken = request.getHeader("Authorization");
        
        if (accessToken == null){
            chain.doFilter(request, response);
            return;
        }
        
        if (jwtProvider.validateAccessToken(accessToken)){
            Authentication authentication = jwtProvider.getAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);    
        }
        
        chain.doFilter(request, response);
    }
}

JwtProvider에 인증 관련 로직을 추가한다.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtProvider {
    ...
    
    // Access Token 검증
    public boolean validateAccessToken(String accessToken){
        
        if (!accessToken.startsWith("Bearer")){
            log.info("Token not start with Bearer");
            return false;
        }
        
        accessToken = accessToken.replace("Bearer ", "");
        
        try{
            Claims claims = parseClaims(accessToken);
            
            if (claims.get("accountId") != null && claims.get("authorities") != null){
                return true;    
            }
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }
    
    //Access Token -> Authentication 
    public Authentication getAuthentication(String accessToken){
        
        accessToken = accessToken.replace("Bearer ", "");
        
        Claims claims = parseClaims(accessToken);
        
        Collection<? extends GrantedAuthority> authorities = getAuthorityList(claims);
        
        UserDetails principal = new User(claims.get("accountId").toString(), "", "");
        
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }
    
    // Refresh Token 검증
    public boolean validateRefreshToken(String refreshToken){
        
        if (!refreshToken.startsWith("Bearer")){
            log.info("Token not start with Bearer");
            return false;
        }
        
        refreshToken = refreshToken.replace("Bearer ", "");
        
        try{
            Claims claims = parseClaims(refreshToken);
            
            String accountId = claims.get("accountId").toString();
            
            if (accountId != null && redisService.getValues(accountId) != null){
                return true;    
            }
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }
    
    public Claims parseClaims(String tokenString){
        return Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(tokenString)
            .getBody();
    }
    
    private List<GrantedAuthority> getAuthorityList(Claims claims){
        return Arrays.stream(claims.get("authorities").toString().split(","))
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
    }
}

 

필터 제작 후 Security Config에 FIlter를 적용할 범위를 설정한다.

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Autowired
    JwtProvider jwtProvider;

    //JWT
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        http.csrf().disable();
        
        http
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .formLogin().disable()
            .httpBasic().disable()
            .addFilter(new JwtAuthorizationFilter(authenticationManager(), jwtProvider))
            .authorizeRequests()
            .antMatchers("/v1/login").permitAll()
            .antMatchers("/v1/refresh").permitAll()
            .antMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**").permitAll()
            .anyRequest().authenticated();
    }

}

 

 

여기서 Refresh 요청마다 Refresh Token도 재발급하는

Refresh Token Rotation 방식을 통해 보안성을 더 높일 수도 있다.

 

https://junior-datalist.tistory.com/352

 

Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응

I. 서론 JWT와 Session 비교 및 JWT의 장점 소개 II. 본론 Access Token과 Refresh Token의 도입 이유 Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까? Refresh Token Rotation Redis 저장 방식 변경 III. 결론

junior-datalist.tistory.com

 

다음 글에선 악성 사용자들의 공격이 어떤식으로 이루어지는지,

Spring Security에선 어떤 식으로 방어하는 기능을 제공하는지 더 자세히 알아본 후 정리해봐야겠다.

JWT란?

JWT(Json Web Token) 은 웹에서 사용되는 JSON 형식의 토큰에 대한 표준 규격

사용자의 인증, 인가 정보를 주고 받기 위해 사용한다.

 

주로 Authorization 헤더에 Bearer + token 형태로 사용된다.

Base64로 표현 되어 있고 인코딩, 디코딩이 쉽다.

 

Base64란?

64진법 이라는 뜻.

8비트 이진 데이터를 ASCII 영역의 문자로 바꾸는 인코딩 방식.

 

구성 방식

토큰의 구조는 다음과 같다.

  1. Header : 토큰의 타입이나 어떤 알고리즘으로 생성되었는지 저장한다. 
  2. Payload : 사용자, 토큰에 대한 정보를 저장한다. (표준은 키를 3글자로 지정한다.)
  3. Signature : Header와 Payload 값을 서버에 있는 개인 키를 활용해 암호화해 저장한다. (위, 변조 방지용)

 

작동 방식

  1. 최초 로그인 시 서버에서 토큰을 사용자에게 발급
  2. 사용자는 다음 요청부터 Authorization 헤더에 발급받은 토큰을 넣어 요청을 보냄
  3. 서버는 해당 토큰을 검증(사용자 정보, 권한, 만료기간 등)을 확인하여 토큰이 유효하면 접근을 허가함

 

왜 사용하는가?

세션&쿠키 방식은 이용자가 늘어나면 메모리 사용량이 그만큼 늘어나게 되며,

매 요청마다 세션 저장소를 조회해야한다는 단점이 있었다.

 

JWT는 토큰 자체에 사용자 정보를 저장한다.

따라서 서버는 요청이 들어오면 토큰을 확인하는 로직만 수행하면 되므로

이용자가 늘어나도 session 방식과 다르게 서버에 부하가 적다.

 

그러나 단점도 존재하는데

  1. 쿠키&세션 방식보다 데이터 전달량이 많다
  2. Payload는 암호화되어있지 않으므로, 디코딩이 매우 쉽다.
  3. 발급한 토큰을 서버에서 관리할 수 없으므로 토큰 탈취시 대처가 어렵다.

따라서 토큰 생성시 민감한 정보는 절대로 넣으면 안되며

Secure, Http Only 등의 설정 잘 해줘야한다.

 

또한 발급한 토큰은 더 이상 서버에서 관리 못한다는 단점 때문에 로그아웃, 접근 제한 등이 어렵다.

이러한 문제를 해결하기 위해

  • 블랙리스트 방식
  • 리프레쉬 토큰 방식

을 주로 사용한다.

 

블랙리스트 방식

db, 혹은 서버 메모리에 허용하지 않을 토큰 명단을 생성후

해당 토큰이 들어오면 허가를 하지 않는 방식.

 

그러나 JWT의 장점인 stateless 하다, 서버 자원 사용이 적다는 장점이 사라지게 된다.

 

리프레쉬 토큰 방식

토큰의 탈취 가능성을 생각해 토큰의 유효기간을 매우 짧게 설정한 후 (엑세스 토큰)

유효기간이 긴 리프레쉬 토큰을 같이 발급해, 리프레쉬 토큰이 유효할 경우 토큰을 재발급 하는 방식.

 

리프레쉬 토큰은 발급 후 redis 같은 곳에 저장해서 관리한다.

(리프레쉬 토큰이 탈취당했을 경우 db에서 삭제 등으로 대응 가능)

 

액세스 토큰이 들어올 경우 확인만 하면 되므로 기존 JWT 장점 살리며

토큰이 탈취돼어도 길게 사용하지 못하도록 해 보안성을 높인다.

 

리프레쉬 토큰을 저장하고 사용하면 세션방식과 다를게 뭔가? 라는 생각이 들었지만

세션 방식은 매 요청마다 저장소를 확인하지만, 리프레쉬 토큰은 액세스 토큰이 만료되었을때만 확인하면 된다.

 

보통은 리프레쉬 토큰 방식을 사용하여 관리하며

보안이 중요한 경우 블랙리스트 방식을 같이 사용한다.

 

JWT 방식을 반드시 사용해야할까?

공부하면서 느낀점은 사실 JWT를 사용할 필요가 있을까 싶다.

요청이 몰리는 경우가 많은 서비스 등의 경우가 아닌 이상

그냥 세션 방식 써도 될 것 같다.

 

기존 세션 방식의 문제점인 서버 자원 사용량 증가 등의 문제는

Redis를 이용한 세션 클러스터링등으로 해결이 가능해진 것 같다.

 

은탄환은 없다라는 유명한 말이 있다.

상황에 맞춰서 적절한 방식을 사용하자.

 

Spring Security를 사용해 JWT 방식을 적용한 코드는 다음 글에서 써보겠다.

기존 프로젝트는 유저 이름과 비밀번호를 DB에 저장한 후,

스프링 인터셉터와 httpSession을 이용해 인증과 인가를 구현했다.

 

DB에 저장한 아이디, 비밀번호와 세션을 사용하는 기존 프로젝트의 인증, 인가 방식을 그대로 유지하며

Spring Security를 적용해보았다.

 

1. build.gradle 에 Spring Security 추가

dependencies {
    .
    .
    .
    
    //Spring Security 추가
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.security:spring-security-test'
}

2. 보안 설정 -  SecurityFilterChain 설정하기

저번 글에서 설명했던 SecurityFilterChain 이라는 스프링 빈을 통해서 인증, 인가, 보안과 관련된 설정들을 해줘야한다.

스프링부트 3.0.0 이하 버전은 WebSecurityConfigurerAdapter를 상속받는 설정 클래스를 만들면 된다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        
        // http.csrf().disable(); // 1. csrf 설정 끄기 (api일 경우에 사용)
        
        http.authorizeRequests() // 2. url 매핑 설정
                .antMatchers("/admin").permitAll() // 기존 초기화면 url
                .antMatchers("/admin/users/create-users").permitAll() // 기존 회원가입 url
                .antMatchers("/error/**").permitAll() // 기존 에러화면 url
                .anyRequest().authenticated()
            .and()
                .formLogin() // 3. 로그인 폼 설정 
                .defaultSuccessUrl("/admin") //로그인 성공 시 url
                .failureUrl("/admin/login"); //로그인 실패 시 url
        
        http.logout()
            .logoutUrl("/admin/logout");   // 4. 로그아웃 처리 URL
    }
}

설정 크래스를 만든 후 HttpSecurity를 파라미터로 받는 configure 메소드를 오버라이딩 해서 설정을 진행하면 된다.

 

1. csrf 설정

csrf 공격을 막는 설정이다.

rest api 등 form을 지원하지 않는 상황일 경우 꺼두면 된다.

 

2. 적용 url 설정

permitAll(), authenticated() 등을 이용해서 url에 따른 인증 조건을 설정할 수 있다.

기존 프로젝트의 url을 최대한 재활용하도록 설정했다.

 

3. 로그인 폼 설정

로그인 폼 방식으로 인증을 진행할 것이라는 설정을 formLogin()을 이용해 설정했다.

기본 설정값으로 /login url에 미리 만들어진 로그인 폼을 제공한다.

로그인 폼을 직접 만든 html으로 설정하거나,

로그인 폼의 파라미터 값을 변경하는 등의 설정을 바꿀 수 있다.

 

일단은 로그인 성공, 실패시 이동 url만 따로 지정줬다.

 

4. 로그아웃 처리 설정

로그아웃 처리 시 사용할 url을 직접 지정할 수 있다.

 

더 다양한 설정을 할 수 있으니 궁금하면 더 찾아보자

3. UserDetails, UserDetailsService 상속받아 구현하기

우리는 username/password 인증 방식을 사용하고, DB에 저장되어있는 ID, 비밀번호를 이용해 인증을 진행해야한다.

Spring Security는 username/password 방식 인증을 위해 UserDetailsService와 UserDetails를 이용한다.

 

UserDetails는 사용자의 정보를 담는 인터페이스다.

UserDetailsService는 인증을 위해 유저 정보(UserDetails)를 가져오는 인터페이스이다.

 

기존 프로젝트의 DB와 코드를 재활용하며 사용하기 위해

기존 domain 엔티티였던 User 객체가 UserDetails를 상속 받도록,

기존 회원가입을 관련 로직을 수행하던 userService 객체가 UserDetailsService를 상속받도록 구현했다.

 

UserDetails를 상속받기 위해 구현해야할 메서드는 다음과 같다.

메소드 이름 리턴 타입 설명
getAuthorities() Collection<? extends GrantedAuthority> 계정의 권한 목록 반환
getUsername() String 계정의 식별자(아이디, 이메일) 반환
getPassword() String 계정 비밀번호 반환
isAccountNonExpired() boolean 계정 만료 여부 반환
isAccountNonLocked() boolean 계정 잠김 여부 반환
isCredentialNonExpired() boolean 계정 비밀번호 만료 여부 반환
isEnabled() boolean 계정 사용 가능 여부 반환

 

@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor
public class User implements UserDetails{

    @Column(unique = true)
    private String accountId;
    ...
    
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserRole> userRoles = new ArrayList<>();
    
    ...
    
    //<==security==>//
    public Collection<? extends GrantedAuthority> getAuthorities(){
        return this.userRoles.stream()
            .map(ur -> new SimpleGrantedAuthority(ur.getRoleName()))
            .collect(toList());
    }

    public String getUsername(){
        return this.accountId; // 식별자 반환
    }

    public boolean isAccountNonExpired(){ // 나머지 설정은 기본적으로 true로 설정
        return true;
    }
    ...
    ...
}
@Entity
@Table(name = "user_roles")
@Getter
@NoArgsConstructor
public class UserRole {
    private String roleName;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
    
    public UserRole(String roleName){
        this.roleName = roleName;
    }
}

유저의 접근 권한 설정을 위해 userRole라는 객체를 만든 후 다대일 관계로 설정해줬다.

또한 유저 권한을 받는 getAuthorities()는 유저의 접근 권한 이름을 리스트로 반환하도록 구현했다.

 

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService, UserDetailsService{
    
    private final UserRepository userRepository;
	...
    ...
    
    //<== security 설정 ==> //
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
        
        User findUser = userRepository.findByAccountId(username)
            .orElseThrow(NoSuchUserException::new); 
            
        return findUser;
    }
}

UserDetailsService는 식별자(String username)을 파라미터로 받아

UserDetails를 반환하는 loadUserByUsername 메소드를 구현하면 된다. 

 

4. PasswordEncoder 및 UserDetailsService 설정하기

기존 프로젝트의 큰 문제점 중 하나는 DB에 사용자 비밀번호를 그대로 사용한다는 것이었다.

DB에 사용자 비밀번호와 이메일 등 민감한 정보를 그대로 사용하면,

DB가 털릴 경우 민감한 정보가 모두 유출될 수 있다는 위험성이 존재한다.

 

그래서 Spring Security는 문자열 암호화 기능을 제공하는 PasswordEncoder 인터페이스를 제공한다.

PasswordEncoder를 구현하는 인코더를 스프링 빈으로 설정하면

AuthentcationProvider에서 사용하도록 설정할 수 있다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    	...
        ...
	}
}

UserDetailsService를 구현한 UserService는 @Service 어노테이션이 있기 때문에

자동으로 컴포넌트 스캔 대상이 되므로 따로 등록할 필요는 없다.

 

구현 과정에서 겪은 오류와 이슈들

1. OSIV 이슈

org.hibernate.LazyInitializationException: 
failed to lazily initialize a collection of role:
project.domain.User.userRoles, could not initialize proxy - no Session

구현 후 서버를 돌렸더니 Lazy Loding 관련 에러가 발생했다.

UserDetailsService에서 꺼내온 UserDetails 객체의 권한 정보를 LazyLoding하는 과정에서 오류가 발생했다.

 

정확히는 UserDetailsService에서 UserDatails를 가져와 AuthenticationProvider에 넘겨주면

AuthenticationProvider는 UserDatails 객체를 이용해 Authentication 객체를 만드는데

권한 정보를 확인하기 위해 UserDatails 객채의 getAuthorities() 메소드를 실행할때

UserDetailsService에서 만든 트랜잭션 밖에서 조회했으므로 LazyLoding이 발생했다.

    //<== security 설정 ==> //
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
        
        User findUser = userRepository.findByAccountId(username)
            .orElseThrow(NoSuchUserException::new); 
        
        //userRole lazy loding
        findUser.getUserRoles().size();
        
        return findUser;
    }

간단하게 서비스 트랜잭션 안에서 userRoles 인증 객체들을 초기화해줌으로서 해결했다.

 

그런데 왜 OSIV를 true 설정해줬는데도 서비스 로직 밖까지 트랜잭션이 유지되지 않은걸까?

그 이유는 OSIV를 관리하는 설정이 스프링 인터셉터 기반이기 때문이다.

public class OpenEntityManagerInViewInterceptor extends ... {
    ...
    @Override
    public void preHandle(WebRequest request) throws DataAccessException {
        ...
        logger.debug("Opening JPA EntityManager in OpenEntityManagerInViewInterceptor");
        try {
            EntityManager em = createEntityManager();
            EntityManagerHolder emHolder = new EntityManagerHolder(em);
            TransactionSynchronizationManager.bindResource(emf, emHolder);
        ...
    }

	@Override
	public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException {
        ...
        logger.debug("Closing JPA EntityManager in OpenEntityManagerInViewInterceptor");
	EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager());
    }
    ...
}

스프링 부트에서 OSIV를 설정해주는 OpenEntityManagerInViewInterceptor 인터셉터의 구현 코드이다.

preHandle에서 EntityManager를 생성하고, afterCompletion에서 EntityManager를 닫는다.

 

그러나 스프링 시큐리티의 보안 로직은 서블릿 필터를 기반으로 동작한다.

그러므로 스프링 인터셉터로 EntityManager를 열어두기 전에 이미 필터에서 로직을 처리중이었다.

 

https://tecoble.techcourse.co.kr/post/2020-09-20-entity-lifecycle-2/

 

Entity Lifecycle을 고려해 코드를 작성하자 2편

이번 편에서는 전편에서 해결하지 못한 부분이었던 “Spring Boot에서는 기본적으로 OSIV의 설정 값이 true인데도 불구하고 LazyInitializationException…

tecoble.techcourse.co.kr

인터셉터의 실행 순서를 바꿔서 해결하는 방법도 존재하는 것 같아서 가져와봤다.

2. CSRF 토큰 에러와 th:action

설정에서 csrf.disable()을 제거 (csrf 보안 설정을 활성화) 했더니 로그인과 로그아웃 로직에서 에러가 발생했다.

spring security는 csrf 공격을 csrf 토큰을 자동으로 생성해 발급해주는 방식으로 예방한다.

 

내 html form에 csrf 토큰 전달 input이 존재하지 않아서 발생하는 오류 인듯해서

만들어둔 post 메소드를 진행해봤더니 유저 수정 post 메소드만 정상 동작해서 뇌정지가 왔다.

 

같은 post메소드인데 왜 하나는 되고 나머지는 안될까?

그 이유는 th:action 태그에 있었다.

<!-- create form -->
<form role="form" action="..." method="post" ...> 

<!-- update form -->
<form role="form" th:action="..." method="post" ...>

update form은 타임리프의 th:action 태그를 사용했는데

타임리프의 th:action 태그는 폼 태그에 자동으로 다음과 같은 scrf 토큰값을 hidden 태그로 추가해 전달해준다고 한다.

<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">

나머지 로직에 히든 태그를 추가하거나, th:action 태그를 사용해 해결할 수 있었다..

 

3. UserDetails 직접 구현하는 방식이 맞는가

이번에 적용한 코드에선 기존에 사용하던 도메인 객체인 User가 UserDetails를 구현하도록 수정했다.

그런데 도메인 객체가 스프링 시큐리티에 의존해도 되는가? 라는 의문이 발생했다.

 

나중에 spring security가 아니라 직접 만든 보안 로직을 적용해야한다면?

도메인 객체를 변경하는 비용은 상상을 초월할텐데.. 라는 생각이 들었다.

 

그런데 중간 레이어를 구현하기엔 코드와 클래스 양이 너무 많고 복잡해진다는 생각도 들었다.

그런데 생각해보니까 기존 도메인 객체도 JPA에 의존하고 있었다.

 

이 부분은 클린 아키텍처에 대해 조금 더 공부해봐야 할 것 같다.

 

구현해보면서 느낀 점 - Spring Security를 꼭 사용해야만 할까?

 

작은 프로젝트는 스프링 인터셉터를 이용해 직접 구현하는 것이 더 편할지도 모르겠다는 생각이 들었다.

일단 기본적인 구조를 이해하고 적용해보는데만 5일이 넘게 걸렸다.

그리고 아직도 username/password 방식이 아닌 jwt, oauth 방식등에 대한 공부는 더 해봐야한다.

 

그러나 Spring security는 csrf 토큰 방식 등 직접 구현하기 귀찮은 다양한 보안 기능들을 편하게 제공한다.

또 인증 인가 메커니즘을 jwt, ouath로 변경해야하는 상황이 생긴다면 직접 구현한 코드는 처음부터 다시 구현해야하지만,

스프링 시큐리티는 설정만 조금 변경하면 될 것 같다.

 

프로젝트 규모에 따라 사용할지 말지를 잘 결정해야할 것 같은데

잘 공부해서 사용하면 굉장히 유용할 것 같다.

스프링 시큐리티란?

인증, 인가 그리고 보편적인 공격들을 방어해주는 웹 보안 관련 스프링 하위 프레임워크이다.

 

작동 방식

스프링 시큐리티는 Servlet Filter기반으로 작동한다.

그리고 SecurityFilterChain 라는 이름의 스프링 빈으로 Security 설정을 관리한다.

 

그러나 Spring Bean은 스프링 컨테이너(Application Context)에서 관리되는데

Servlet Filter는 서블릿 컨테이너(톰캣)에서 관리하므로 서로의 생성주기가 다르다.

즉 스프링 빈으로 설정한 내용을 Servlet Filter에서 사용할 수 없다.

 

그래서 스프링 시큐리티는

서블릿 필터에서 서블릿 필터를 구현한 스프링 빈에게 요청을 위임하는 DelegatingFilterProxy를 사용해서 실제로 동작한다.

 

스프링 시큐리티는 SecurityFilterChain의 설정에 맞춰 필터들을 구현한 후

서블릿 필터체인 진행 중 FIlterChainProxy(DelegatingFilterProxy)가 호출되면

SecurityFilterChain이 만들어낸 스프링 시큐리티의 필터 체인을 호출해 보안 로직을 수행한다. 

 

여러개의 SecurityFilterChain을 만들어 보안 설정을 여러개 만들고 동시에 사용할 수 있으며

과거(스프링부트 3.0.0 이전)에는 WebSecurityConfigurerAdapter를 상속하는 Config class를 만들어 설정했지만

최근에는 직접 SecurityFilterChain을 빈으로 등록하며 설정한다.

 

  1. 사용자 요청
  2. ServletFilter 차례대로 동작
  3. DelegatingFilterProxy 동작
  4. FilterChainProxy 동작(요청에 맞는 SecurityFilterChain 선택)
  5. SecurityFilterChain에서 자신이 가진 각각의 필터를 차례대로 수행하며 보안 처리 수행
  6. 다음 로직 수행.

 

정확하게 어떻게 구현되었는지 코드 레벨로 뜯어보는 것은 너무 깊게 들어가는 것 같고,

  1. Spring Security는 서블릿 필터 기반으로 동작한다.
  2. 설정 파일에 SecurityFilterChain이라는 빈을 등록해 설정한다. (여러개 설정 가능)
  3. WebSecurityConfigureAdapter를 이용해 SecurityFilterChain을 생성한다. (과거의 설정 방식)

정도만 알고 넘어가면 될 것 같다.

 

SecurityFilterChain가 만들어내는 Security 필터들

SecurityFilterChain은 사용자의 설정에 따라 Security 필터들을 만들어낸다.

SecurityContextPersistenceFilter SecurityContext가 있는지 확인 (인증 전 요청인지 인증 후 요청인지 확인)하는 필터
LogoutFilter 로그아웃 요청인지 확인한 후 로그아웃 요청을 처리하는 필터
UsernamePasswordAuthenticationFilter 전달받은 Username, password를 이용해 인증 객체를 만들고 인증을 진행하는 필터
ConcurrentSessionFilter 현재 사용자의 세션 만료 여부를 체크하는 필터
RememberMeAuthenticationFilter 세션이 만료되거나 무효화 되었을 시에 동작하는 필터
AnonymousAuthenticationFilter 인증 시도나 권한 없이 특정 자원에 접근 시도시에 동작하는 필터
SessionManagementFilter 세션정보의 등록, 조회, 삭제 등을 관리하는 필터
ExceptionTranslationFilter 필터체인 동작중 발생하는 예외를 처리하는 필터
FilterSecurityInterceptor 인증이 완료된 유저의 인가 로직을 처리하는 필터

Username/password를 사용해 로그인 하고, session을 사용해 로그인을 유지하는 방식으로 설정한다면

다음과 같은 필터들이 만들어진다. 

 

이외에도 수많은 필터들이 존재하며 인증/인가 전략을 어떻게 세우느냐,

SecurityFilterChain을 어떻게 설정하느냐에 따라 만들어지는 필터들이 달라진다.

 

SpringSecurity의 핵심 요소 - Authentication, SecurityContext

1. Authenticaion이란?

스프링 시큐리티에서 사용하는 인증 인터페이스

인증 방법(username/password, oauth 등등) 에 따라 실제로 구현되고 사용되는 객체가 다르지만

이번 글에선 편의상 모두 Authentication 객체라고 부르겠다.

 

인증 전, 인증 후에 모두 사용된다.

 

인증 전 : 인증 요청시에 들어온 사용자의 정보를 담아 인증 검증을 위해 사용된다.

인증 후 : 최종 인증 결과를 담아 SecurityContext에 저장되어 전역적으로 사용 가능해진다.

  • principal: 사용자 아이디 (인증 전) /  UserDetails 객체를 저장 (인증 후)
  • credentials: 사용자 비밀번호 저장 (인증 전) / 값 없음 (인증 후)
  • authorities:  값 없음 (인증 전) / 인증된 사용자의 권한 목록 저장 (인증 후)
  • details: 인증 부가 정보
  • Authenticated: false (인증 전) / true (인증 후)

이러한 정보들을 담고 있는데 뒤의 인증 로직에서 어떤 값들이 들어가 어떻게 사용되는지

예시와 함께 다시 보면 이해가 더 쉬울 것 같다.

2. SecurityContext란?

Authentication 객체를 담고 있는 저장소로 필요할때 Authentcation 객체를 꺼내 쓸 수 있도록 제공되는 클래스이다.

ex) 사용자 인증 완료 후, 사용자 정보를 화면에 보여주고 싶음 -> 컨트롤러에서 인증한 사용자의 정보가 필요함

 

기본적으론 ThreadLocal에 저장되어 다른 Thread로부터 안전하며,

로직 아무곳에서나 참조가 가능하도록 설계되었다.

 

또 인증이 완료되고 응답을 반환했다면, HttpSession에 저장되어,

동일한 유저에게 다시 요청이 왔을때 재사용할 수 있다.

 

SecurityContextHolder는 SecurityContext 객체를 감싸는 wrapper 클래스이며,

SecurityContext를 어떻게 저장하는지 설정할 수 있다. (스레드 당 할당, 자식 스레드와 공유, 전체 공유 등등)

 

AuthenticationFilter - 인증 로직

AuthenticationFIlter은 인증을 담당하는 필터 인터페이스이다.

 

SecurityFilterChain 설정에 따라 구현되는 필터가 달라지는데

username/password를 사용해 로그인 하는 설정의 경우엔

UsernamePasswordAuthenticationFilter로 구현된다.

 

1. AuthenticationFilter는 사용자 요청으로부터 인증에 필요한 정보를 가지고 Authentication 객체를 만들어 AuthenticationManager에게 전달한다.

 

UsernamePasswordAuthenticationFilter는 사용자 요청에서

  • pricipal : username(로그인 아이디, 이메일 등)
  • credentials : 비밀번호

다음과 같은 값을 넣어 Authentication의 객체를 생성한 후 AuthenticationManager에게 전달한다.

 

 

2. AuthenticationManager는 받은 Authentication 객체를 사용해 인증을 진행한다.

 

AuthenticationManager 역시 인터페이스로 주로 ProviderManager로 구현한다.

 

ProviderManager는 Authentication 인증 객체를 받은 후 여러개의 AuthenticationProvider 중에서

해당 인증객체를 사용해 인증을 진행할 수 있는 AuthenticationProvider를 찾아 인증 처리를 위임한다. 

(username/password 인증 방식의 경우 DaoAuthenticationProvider에 넘긴다)

 

2-1. DaoAuthenticationProvider의 인증 처리 로직

 

DaoAuthenticationProvider는 UserDetailsService 객체와 PasswordEncoder 객체를 사용해 인증을 진행한다.

AuthenticationManager가 받아온 Authentication 객체엔 username(사용자 아이디, 이메일)과 password가 저장되어 있다.

 

  1. UserDetails 객체에선 Authenticaion 객체에 저장된 username의 값을 이용해 유저가 존재하는지 여부를 판단한다.
  2. 유저가 존재하지 않을 경우 UsernameNotFoundException을 발생시키고, 유저가 존재할 시 UserDetails 객체에 유저 정보(비밀번호, 사용자 권한 목록 등)을 담아 반환한다.
  3. PasswordEncoder를 사용해 Authentcaion 객체에 저장된 비밀번호와 UserDetails 객체의 비밀번호를 비교한다.
  4. 일치할 경우 인증이 성공했으므로 Authentcaion에 성공한 유저 정보 (UserDetails) 객체를 담아 반환한다.

 

3. AuthenticationManager는 인증 성공시 Authentication 객체를 SpringContext에 저장한다

 

최종적으로 만들어진 Authentication 객체는 SecurityContextHolder에 저장되어 필터가 종료된 이후에도 컨트롤러 로직 등에서 전역적으로 사용할 수 있도록 한다. (인증에 성공한 유저 정보를 컨트롤러에서 사용하도록)

 

또한 컨트롤러 로직이 모두 종료되고 요청 처리가 끝난 후 최종적으로 응답하기 전에

SecurityContextHolder의 SecurityContext 정보를 httpSession에 저장해놓은 후 응답하고,

SecurityContextHolder는 비워버린다.

 

 

FilterSecurityIntercepter - 인가 로직

1. SecurityContextPersistenceFilter에서 인증 전인지, 인증 후인지 판단한다.

세션을 이용하도록 SecurityFilterChain을 설정했다면

httpSession을 사용해 SecurityContext가 저장되어있는지 유무를 확인한다.

 

SecurityContext가 httpSession에 저장되어 있다면

세션에서 SecurityContext를 꺼내 SecurityContextHolder에 집어넣는다.

 

2. FilterSecurityIntercepter에서 인증 여부를 확인한다.

FilterSecurityIntercepter는 SecurityContext가 인증 객체(Authentication)를 가지고 있는지 확인한다.

 

3. 인증된 요청일 경우 사용자가 요청한 자원에 필요한 권한 정보를 조회한다.

인증 객체가 있는 경우 SecurityMetadataSource가 사용자가 요청한 정보의 권한 정보를 전달해준다.

요청한 정보에 접근 권한 설정이 없는 경우 바로 접근 허용한다.

 

4. AccessDecisionManager에게 권한 정보와, 사용자 정보를 보내 접근 승인 판단을 위임한다.

AccessDecisionManager에게 권한 정보 위임 후 접근 승인 판단을 위임한다.

AccessDecisionManager는 받은 권한 정보와 사용자 권한 정보를 이용해 사용자의 자원 접근을 허용/거부한다.

 

AccessDecisionManager는 AccessDecisionVoter 객체들을 사용해 승인, 거부 여부를 받은 후 최종적인 접근 결정을 내린다.

 

 

https://catsbi.oopy.io/f9b0d83c-4775-47da-9c81-2261851fe0d0

 

스프링 시큐리티 주요 아키텍처 이해

목차

catsbi.oopy.io

 

다음 글에선 세션과 username/password 방식을 이용해서 로그인 했던 기존 프로젝트에

spring security를 적용하는 방법에 대해 써보겠다.

+ Recent posts