[개발] - Spring/[개발예제] 시큐리티, 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