코드 그라데이션

스프링 시큐리티로 OAuth2 구현하고 적용하기 본문

SpringBoot [예제] 블로그 만들기/시큐리티, JWT 입히기

스프링 시큐리티로 OAuth2 구현하고 적용하기

완벽한 장면 2023. 10. 21. 22:20

1. 의존성 추가하기

build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}

 

2. 쿠키 관리 클래스 구현하기

- 앞으로 쿠키를 사용할 일이 자주 생기는데, 유틸리티로 사용할 쿠키 관리 클래스를 미리 구현해 둔다.

util.CookieUtil.java

public class CookieUtil {
    // 요청값(이름, 값, 만료 기간)을 바탕으로 쿠키 추가
    public static void addCookie(HttpServletResponse response,
                                 String name, String value,
                                 int maxAge) {
        // 새로운 쿠키 객체를 생성함. 이름과 값을 설정.
        Cookie cookie = new Cookie(name, value);

        // 쿠키의 경로를 설정한다.
        // "/"는 웹 애플리케이션 전체에서 사용 가능한 쿠키를 의미.
        cookie.setPath("/");

        // 쿠키의 만료 기간을 설정. maxAge는 초 단위.
        cookie.setMaxAge(maxAge);

        // HttpServletResponse를 사용하여 쿠키를 응답에 추가.
        response.addCookie(cookie);
    }

    // 쿠키의 이름을 입력받아 쿠키 삭제
    public static void deleteCookie(HttpServletRequest request,
                                    HttpServletResponse response,
                                    String name) {
        // 현재 요청에서 사용 가능한 모든 쿠키를 가져온다.
        Cookie[] cookies = request.getCookies();

        // 쿠키가 없는 경우 아무 작업도 하지 않고 함수를 종료.
        if (cookies == null) {
            return;
        }

        // 요청에서 가져온 쿠키 목록을 반복하면서
        // 입력된 이름과 일치하는 쿠키를 찾는다.
        for (Cookie cookie : cookies) {
            if (name.equals(cookie.getName())) {
                // 해당 쿠키의 값을 비운다.
                cookie.setValue("");

                // 쿠키의 경로를 설정.
                // 이는 추가 쿠키를 삭제하기 위한 경로와 일치해야 한다.
                cookie.setPath("/");

                // 쿠키의 만료 기간을 0으로 설정하여
                // 해당 쿠키를 즉시 만료시킨다.
                cookie.setMaxAge(0);

                // HttpServletResponse를 사용하여
                // 수정된 쿠키를 응답에 추가하여 삭제한다.
                response.addCookie(cookie);
            }
        }
    }

    // 객체를 직렬화해 쿠키의 값으로 변환
    public static String serialize(Object obj) {
        // 객체를 직렬화하고 Base64로 인코딩하여 문자열로 반환한다.
        return Base64
                .getUrlEncoder()
                .encodeToString(SerializationUtils.serialize(obj));
    }

    // 쿠키를 역직렬화해 객체로 변환
    public static <T> T deserialize(Cookie cookie, Class<T> tClass) {
        // 쿠키의 값을 Base64로 디코딩하고,
        // 역직렬화하여 지정된 클래스로 변환한다.
        return tClass.cast(SerializationUtils
                .deserialize(Base64.getUrlDecoder().decode(cookie.getValue()))
        );
    }
}

 

# addCookie

- 요청값(이름, 값, 만료 기간)을 바탕으로 HTTP 응답에 쿠키를 추가한다.

 

# deleteCookie

- 쿠키 이름을 입력받아 쿠키를 삭제한다.

- 실제 삭제 방법 x. 만료 타이머 설정

 

# serialize

- 객체를 직렬화해 쿠키의 값으로 들어갈 값 변환

 

# deserialize

- 쿠키를 역직렬화 객체로 변환한다.

 

 

3. OAuth2 서비스 구현하기

3-1. User.java에 OAuth 관련 키 저장하는 코드 추가

User.java

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "email", nullable = false, unique = true)
    private String email;

    @Column(name = "password") // OAuth 쓰면서 nullable = false 속성 삭제
    private String password;


    // 사용자 이름
    @Column(name = "nickname", unique = true)
    private String nickname;


    @Builder
    public User(String email, String password, String nickname) {
        this.email = email;
        this.password = password;
        this.nickname = nickname; // 추가
    }


    @Override // 권한 반환
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("user"));
    }

    @Override // 고유한 값 - 사용자의 id를 반환
    public String getUsername() {
        return email;
    }

    @Override  // 사용자의 패스워드를 반환
    public String getPassword() {
        return password;
    }

    @Override // 계정 만료 여부 반환
    public boolean isAccountNonExpired() {  // 만료되었는지 확인하는 로직
        return true; // true -> 유효한 상태임
    }

    @Override // 계정 잠금 여부 반환
    public boolean isAccountNonLocked() { //계정이 잠겼는지 확인하는 로직
        return true; // 잠기지 않았음
    }

    @Override // 패스워드의 만료 여부 반환
    public boolean isCredentialsNonExpired() {  // 패스워드가 만료되었는지 확인하는 로직
        return true; // 만료되지 않았음(true)
    }

    @Override // 계정 사용 가능 여부 반환
    public boolean isEnabled() { // 계정이 사용 가능한지 확인하는 로직
        return true; // 사용 가능(true)
    }

	
	// 추가
    // 사용자 이름 변경
    public User update(String nickname) {
        this.nickname = nickname;

        return this;
    }
}

 

 

3-2. config에 oauth 패키지 만든 후 OAuth2UserCustomService.java 생성 후 메서드 두개 구현

OAuthUserCustomService

// 사용자 정보 조회 및 업데이트 또는 생성
@Service
@RequiredArgsConstructor
public class OAuth2UserCustomService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest)
        throws OAuth2AuthenticationException {
        // 1. 요청을 바탕으로 유저 정보를 담은 객체 반환
        OAuth2User oAuth2User = super.loadUser(userRequest);
        // OAuth2 유저 정보를 저장 또는 업데이트하고 해당 유저 정보를 반환
        saveOrUpdate(oAuth2User);
        return oAuth2User;
    }

    // 2 유저가 있으면 업데이트, 없으면 유저 생성
    private User saveOrUpdate(OAuth2User oAuth2User) {
        // OAuth2 유저로부터 속성(attribute) 맵을 가져온다.
        Map<String, Object> attributes = oAuth2User.getAttributes();

        // 이메일과 이름을 맵에서 추출
        String email = (String) attributes.get("email");
        String name = (String) attributes.get("name");

        // 이메일을 기반으로 사용자를 데이터베이스에서 찾거나 생성.
        User user = userRepository.findByEmail(email)
                .map(entity -> entity.update(name)) // 사용자가 이미 존재하는 경우, 이름을 업데이트
                .orElse(User.builder()
                        .email(email)
                        .nickname(name)
                        .build()); // 사용자가 없는 경우, 새로운 사용자를 생성.

        // 사용자 정보를 데이터베이스에 저장하고 업데이트한 사용자 객체를 반환
        return userRepository.save(user);
    }

}

 

 

4. OAuth2 설정 파일 작성하기

4-1. 시큐리티 컨피그 파일 대대적 수정

기존의 WebSecurityConfig -> WebOAuthSecurityConfig

@RequiredArgsConstructor
@Configuration
public class WebOAuthSecurityConfig {

    private final OAuth2UserCustomService oAuth2UserCustomService;
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final UserService userService;

    // 웹 보안을 구성하는 메서드
    @Bean
    public WebSecurityCustomizer configure() {
        return (web) -> web.ignoring()
                .requestMatchers(toH2Console())
                .requestMatchers("/img/**", "/css/**", "/js/**");
    }

    // Spring Security 필터 체인을 정의하는 빈을 생성
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 다양한 보안 설정을 비활성화
        http.csrf().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .logout().disable();

        // 세션 관리 정책 설정
        http.sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // 토큰 인증 필터 추가
        http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        // 요청에 대한 인가 규칙 설정
        http.authorizeRequests()
                .requestMatchers("/api/token").permitAll()
                .requestMatchers("/api/**").authenticated()
                .anyRequest().permitAll();

        // OAuth2 로그인 구성
        http.oauth2Login()
                .loginPage("/login")
                .authorizationEndpoint()
                .authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
                .and()
                .successHandler(oAuth2SuccessHandler())
                .userInfoEndpoint()
                .userService(oAuth2UserCustomService);

        // 로그아웃 설정
        http.logout()
                .logoutSuccessUrl("/login");

        // 예외 처리 설정
        http.exceptionHandling()
                .defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                        new AntPathRequestMatcher("/api/**"));


        return http.build();
    }


    // OAuth2 로그인 성공 시 호출할 핸들러 빈 생성
    @Bean
    public OAuth2SuccessHandler oAuth2SuccessHandler() {
        return new OAuth2SuccessHandler(tokenProvider,
                refreshTokenRepository,
                oAuth2AuthorizationRequestBasedOnCookieRepository(),
                userService
        );
    }

    // 토큰 인증 필터 빈 생성
    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter(tokenProvider);
    }

    // OAuth2 인증 요청 쿠키 레포지토리 빈 생성
    @Bean
    public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository() {
        return new OAuth2AuthorizationRequestBasedOnCookieRepository();
    }

    // BCryptPasswordEncoder 빈 생성
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

filterChain() 메서드

토큰 방식으로 인증을 하므로 기존 폼 로그인, 세션 기능을 비활성화한다.

 

addFilterBefore() 헤더값 확인용 커스텀 필터 추가

헤더값을 확인할 커스텀 필터 추가

 

authorizeRequests() 메서드 URL 인증 설정

토큰 재발급 URL은 인증 없이 접근하도록 설정하고, 나머지 API들은 모두 인증을 해야 접근하도록 설정함

 

➍, ➎ oauth2Login() 메서드 이후 체인 메서드 수정

OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소를 설정한다. 인증 성공 시 실행할 핸들러도 설정한다. 해당 클래스는 바로 구현할 예정

 

exceptionHandling() 메서드 예외 처리 설정

/api로 시작하는 url인 경우 인증 실패 시 401 상태 코드를 반환한다.

 

 

4-2. OAuth2에 필요한 정보를 세션x, 쿠키에 저장해서 쓸 수 있도록 저장소 구현

OAuth2AuthorizationRequestBasedOnCookieRepository.java

public class OAuth2AuthorizationRequestBasedOnCookieRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    // OAuth2 Authorization Request를 저장할 쿠키의 이름
    public final static String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";

    // 쿠키 만료 시간 (초)
    private final static int COOKIE_EXPIRE_SECONDS = 18000;

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
        // Authorization Request를 삭제하고 반환
        return this.loadAuthorizationRequest(request);
    }

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        // 저장된 쿠키에서 Authorization Request를 불러옴
        Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
        return CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if (authorizationRequest == null) {
            // Authorization Request가 없으면 쿠키 삭제
            removeAuthorizationRequestCookies(request, response);
            return;
        }

        // Authorization Request를 쿠키에 저장
        CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
        // 저장된 Authorization Request 쿠키 삭제
        CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
    }
}

 

 

4-3. 생성자 사용하여 직접 생성해서 BCryptPasswordEncoder를 패스워드 암호화 할 수 있게 수정

UserService.java - findByEmail() 추가

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;

    public Long save(AddUserRequest dto) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

        // UserRepository를 통해 새 사용자를 저장.
        // - AddUserRequest에서 전달된 이메일과 비밀번호를 사용하여 새 사용자를 생성.
        // - 비밀번호는 BCryptPasswordEncoder를 사용하여 안전하게 해싱됨
        return userRepository.save(User.builder()
                .email(dto.getEmail())
                .password(encoder.encode(dto.getPassword()))
                .build()).getId(); // 저장된 사용자의 ID를 반환
    }
    /*
    이걸 리펙토링하면
    public Long save(AddUserRequest dto) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

        User user = userRepository.save(User.builder()
            .email(dto.getEmail())
            .password(encoder.encode(dto.getPassword()))  // 비밀번호 해싱
            .build());

        return user.getId();
    } 이렇게도 가능
     */

    public User findById(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
    }
    
    
    
    //추가

    public User findByEmail(String email) {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
    }
}

 

4-4. 인증 성공 시 실행할 핸들러 구현

OAuth2SuccessHandler.java

@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    // ❶ 리프레시 토큰과 액세스 토큰의 이름과 유효 기간을 정의합니다.
    public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
    public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14);
    public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1);
    public static final String REDIRECT_PATH = "/articles";

    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
    private final UserService userService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        // ❷ OAuth2 인증에 성공한 사용자의 정보를 가져옵니다.
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email"));

        // ❸ 새 리프레시 토큰을 생성하고 저장합니다.
        String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
        saveRefreshToken(user.getId(), refreshToken);

        // ❹ 리프레시 토큰을 쿠키에 추가합니다.
        addRefreshTokenToCookie(request, response, refreshToken);

        // ❺ 액세스 토큰을 생성하고 리다이렉션 URL을 가져옵니다.
        String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
        String targetUrl = getTargetUrl(accessToken);

        // ❻ 인증에 사용된 정보를 지우고 사용자를 리다이렉션합니다.
        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    // ❸ 리프레시 토큰을 저장합니다.
    private void saveRefreshToken(Long userId, String newRefreshToken) {
        RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
                .map(entity -> entity.update(newRefreshToken))
                .orElse(new RefreshToken(userId, newRefreshToken));

        refreshTokenRepository.save(refreshToken);
    }

    // ❹ 리프레시 토큰을 쿠키에 추가합니다.
    private void addRefreshTokenToCookie(HttpServletRequest request, HttpServletResponse response, String refreshToken) {
        int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds();

        // 기존 리프레시 토큰 쿠키를 삭제하고 새로운 리프레시 토큰 쿠키를 추가합니다.
        CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME);
        CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToken, cookieMaxAge);
    }

    // ❻ 인증 정보와 OAuth2 권한 요청 관련 쿠키를 지웁니다.
    private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }

    // ❺ 액세스 토큰을 포함한 리다이렉션 URL을 생성합니다.
    private String getTargetUrl(String token) {
        return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
                .queryParam("token", token)
                .build()
                .toUriString();
    }
}

 

- 스프링 시큐리티의 기본 로직에서는 별도의 authenticationSuccessHandler를 지정하지 않으면 로그인 성공 이후 

  SimpleUrlAuthenticationSuccessHandler를 사용한다.

- 일반적인 로직은 동일하게 사용하고, 토큰과 관련된 작업만 추가로 처리하기 위해

  SimpleUrlAuthenticationSuccessHandler을 상속받은 후에 onAuthenticationSuccess() 메서드를 오버라이드 한다.

 

실행 흐름

리프레시 토큰 생성, 저장, 쿠키에 저장

- 토큰 제공자를 사용해 리프레시 토큰을 만든 뒤에,

  saveRefreshToken() 메서드를 호출해 해당 리프레시 토큰을 데이터베이스에 유저 아이디와 함께 저장한다.

- 그 이후에는 클라이언트에서 액세스 토큰이 만료되면 재발급 요청하도록 addRefreshTokenToCookie() 메서드를 호출해

  쿠키에 리프레시 토큰을 저장한다.

 

액세스 토큰 생성, 패스에 액세스 토큰 추가

- 토큰 제공자를 사용해 액세스 토큰을 만든 뒤에 쿠키에서 리다이렉트 경로가 담긴 값을 가져와 쿼리 파라미터에 액세스 토큰을 추가한다.

 

인증 관련 설정값, 쿠키 제거

- 인증 프로세스를 진행하면서 세션과 쿠키에 임시로 저장해둔 인증 관련 데이터를 제거한다.

- 기본적으로 제공하는 메서드인 clearAuthenticationAttributes()은 그대로 호출하고, 

   removeAuthorizationRequestCookies()를 추가로 호출해 OAuth 인증을 위해 저장된 정보도 삭제한다.

 

리다이렉트

- 에서 만든 URL로 리다이렉트한다.

 

 

 

 

728x90
Comments