티스토리 뷰

스프링 시큐리티 로고

들어가기 전 설명

 

현재 구현한 코드들은 GitHub 에 있습니다. 글을 보다가 헷갈리시면 소스코드로 참고해 주시기 바랍니다.

 

 

 

 

 

개발 환경

 

JAVA17
Spring Boot 3.1.8
Spring Security 6.1.6
Spring JPA 3.1.8
Mac OS
JAVA JWT 4.4.0
IntelliJ 2024.01.04v

 

 

 

 

 

작업 순서

 

  1. 로그인 인증 필터 (AuthenticationFilter) 구현
  2. 실제 인증 로직 (AuthenticationProvider) 구현 
  3. UserDetailsService DB 로직 구현
  4. SecurityConfig 설정
  5. JWT 모듈인 JwtHelper 제작
  6. 인증 성공 시 실행하는 핸들러 (AuthenticationSuccessHandler) 구현
  7. 인증 실패 시 실행하는 핸들러 (AuthenticationFailureHandler) 구현
  8. 인증 성공 후 JWT 발급과 응답값 생성 및 결과

 

 

만약에 시큐리티 흐름을 잘 모른다면 아래 글을 같이 보면서

해당 객체는 무슨 일을 하는지, 흐름은 어떻게 흘러가는지 같이 보면 더 좋을 것입니다.

[Spring Security] 누가 봐도 쉽게 이해하는 구조와 흐름

 

[Spring Security] 누가 봐도 쉽게 이해하는 구조와 흐름

이해 목적 실무에서 사용하다가 요구사항으로 인한 변경, 갑자기 터지는 이슈를 내부 구조를 알아야 대응하기 쉬워집니다.그래서 옵셔널 한 이 시큐리티를 깊게를 할 필요는 없지만 내부 구조

coasis.tistory.com

 

 

 

 

 

1. 로그인 인증 필터 (AuthenticationFilter) 구현

 

인증필터 상속 구조
그림1 인증필터 상속구조

 

 AbstractAuthenticationProcessingFilter를 상속받은 UsernamePasswordAuthenticationFilter입니다.


우리는 이제 UsernamePasswordAuthenticationFilter를 상속받아서 구현하면 됩니다.

물론 추상클래스인 AbstractAuthenticationProcessomgFilter 상속받아서 구현해도 상관없습니다!!!!!!!!

 

상상도 못한 정체
그림2 상상도 못한 정체

 

그래서 저는 UsernamePasswordAuthenticationFilter를 상속받아서 LoginAuthenticationFilter를 구현하였습니다.

 

 

LoginAuthenticationFilter

 

  • Authentication attemptAuthentication()
    → 사용자가 로그인 요청을 보냈어서 인증을 시도하기 위해 실행되는 메서드입니다.
    → json으로 보내든, formdata로 보내든 상관없이 분기처리하였습니다.
    → 사용자가 로그인 하려는 데이터를 가지고 내부적으로 인증 안된 인증 토큰을 만들어서 인증 관리자(AuthenticationManager)에게 전달하여 인증 위임을 합니다.

  • void successfulAuthentication()
    → 인증에 성공하였을 때 실행되는 메서드입니다.
    여기서 JWT 발급하거나 successHandler에 실행할 로직을 넣어도 상관없습니다. 하지만 저는 OAuth 로그인도 나중을 생각하여 해당 메서드에 구현 말고 핸들러를 구현하였습니다.

  • void unsuccessfulAuthentication()
    → 인증에 실패하였을 때 실행되는 메서드입니다.
@Slf4j
public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    // [로그인 실행 1] API 데이터 (email, password) 로그인을 시도한다.
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        String contentType = request.getContentType();
        String username = "";
        String password = "";

        // JSON으로 로그인 할 때
        if(contentType.equals(MediaType.APPLICATION_JSON_VALUE)){
            try {
                LoginRequest loginReqeust =  new ObjectMapper().readValue(request.getReader(), LoginRequest.class);
                username = loginReqeust.getEmail();
                password = loginReqeust.getPassword();
            } catch (IOException e) {
                throw new AuthenticationServiceException("잘못된 key, name으로 요청했습니다.", e);
            }
        } else if(contentType.equals(MediaType.APPLICATION_FORM_URLENCODED_VALUE)){
            username = request.getParameter("email"); // username이 아닌 email로 받기 때문에 파라미터 꺼내기
            password = this.obtainPassword(request);
        }

        // 아직 인증되기전의 인증 객체 생성
        UsernamePasswordAuthenticationToken unauthenticated = new UsernamePasswordAuthenticationToken(username , password);

        // token에 인증되지 않은 정보 검증 위해 AuthenticationManager로 전달
        return super.getAuthenticationManager().authenticate(unauthenticated);

    }

    // [로그인 실행 4] security 인증 성공 시 실행하는 메서드
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        log.info("Security Login >> 인증 성공");

        // 로그인 성공 핸들러 호출
        AuthenticationSuccessHandler handler = this.getSuccessHandler();
        handler.onAuthenticationSuccess(request, response, authResult);
    }

    // [로그인 실행 4] security 인증 실패 시 실행하는 메서드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        log.info("Security Login >> 인증 실패");

        // 로그인 실패 핸들러 호출
        AuthenticationFailureHandler handler = this.getFailureHandler();
        handler.onAuthenticationFailure(request, response, failed);
    }


}

 

 

 

 

 

 

2. 실제 인증 로직 (AuthenticationProvider) 구현

 

이제 여기서 내 DB에 있는 사람이야? 아니야? 검사를 하면 됩니다. 

 

LoginAuthenticationProvider

 

  • Authentication authenticate()
    → 사용자가 보낸 데이터를 실제 인증 로직을 실행하는 메서드입니다. 
    → Security에 있는 UserDetailsService 메서드를 실행하여 UserDetails를 조회해 오고, 비밀번호도 일치하는지 체크합니다.
    → 인증에 성공하면 인증 된 토큰으로 새롭게 만들어증 관리자(AuthenticationManager)에게 보내고 인증 관리자는 인증 필터(AuthenticationFilter)로 보냅니다.

  • boolean supports()
    → 인증 안된 토큰이 UsernamePasswordAuthenticationToken와 같은 상위 인터페이스, 클래스를 구현 상속해서 지원이 가능한지 체크합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class LoginAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    // [로그인 실행 2] 실제 인증 프로세스 구현
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UserDetails user = userDetailsService.loadUserByUsername((String) authentication.getPrincipal());

        // 비밀번호 체크
        if(bCryptPasswordEncoder.matches((String)authentication.getCredentials(), user.getPassword())){
            return new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities()); // 인증 된 객체
        } else {
            throw new LoginAuthenticationException("비밀번호가 다릅니다.");
        }
    }

    // 해당 인증로직 지원하는지 검사
    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}



 

 

 

 

3. UserDetailsService DB 로직 구현

 

UserServiceImpl

  • 스프링 시큐리티 메서드를 구현하여 DB에 있는 사용자인지 조회하면 됩니다.
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class UserServiceImpl implements UserService , UserDetailsService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    // [로그인 실행 3] security DB 로그인 인증
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User findUser = userRepository.findByEmail(username);

        if(findUser == null){
            throw new UsernameNotFoundException("해당 유저를 찾을 수 없습니다.");
        }
        return new CustomUserDetails(findUser);
    }
    
    // 회원가입 로직 생략
}

UserRepository

  • JPA를 이용하여 사용자 조회를 합니다.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Boolean existsByEmail(String email);

    User findByEmail(String email);
}

 

 

 

 

 

 

4. SecurityConfig 설정

 

LoginAuthenticationFilter

 

  • setAuthenticationManager()
    → 인증 관리자 주입
  • setFilterProcessesUrl()
    → 로그인할 때 /login 에 접근할 때만 동작하던 것을 내가 원하는 Url 로 변경
  • setAuthenticationSuccessHandler()
    → 인증 성공 했을 때 실행시킬 핸들러
    → Jwt말고 OAuth등 로그인 성공 시켰을 때도 재사용하려고 핸들러로 만들게 되었습니다.
  • setAuthenticationFailureHandler()
    → 인증 실패 했을 때 실행시킬 핸들러
    Jwt말고 OAuth등 로그인 실패 했을 때도 재사용하려고 핸들러로 만들게 되었습니다.

 

SecurityConfig

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

    @Value("${spring.security.cors.allowed-methods}")
    private String[] ALLOW_METHODS;
    @Value("${spring.security.cors.allowed-origins}")
    private String  ALLOW_CROSS_ORIGIN_DOMAIN;

    // 시큐리티에게 AuthenticationConfiguration 주입 받기
    private final AuthenticationConfiguration authenticationConfiguration;
    private final LoginSuccessHandler loginSuccessHandler;
    private final LoginFailHandler loginFailHandler;

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

    // 로그인 인증 필터
    @Bean
    public LoginAuthenticationFilter loginAuthenticationFilter() throws Exception {
        LoginAuthenticationFilter loginAuthenticationFilter = new LoginAuthenticationFilter();
        loginAuthenticationFilter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
        loginAuthenticationFilter.setFilterProcessesUrl("/api/login");  // 로그인 경로 /login -> /api/login 으로 변경
        loginAuthenticationFilter.setAuthenticationSuccessHandler(loginSuccessHandler);  // 로그인 성공했을 때 실행시킬 핸들러
        loginAuthenticationFilter.setAuthenticationFailureHandler(loginFailHandler);      // 로그인 실패했을 때 실행시킬 핸들러
        return loginAuthenticationFilter;
    }

    // 시큐리티 설정
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        final String[] ALL_URL = new String[]{"/api/login", "/api/user/save"};
        final String[] ADMIN_URL = new String[]{"/api/admin"};
        final String[] ROLES = new String[]{UserRole.SUPER.getCode(), UserRole.MANAGER.getCode(), UserRole.ADMIN.getCode()};

		// CORS 설정 (추가)
        http.cors((cors -> cors.configurationSource(new CorsConfigurationSource() {
                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {

                        CorsConfiguration configuration = new CorsConfiguration();

                        configuration.setAllowedOrigins(List.of(ALLOW_CROSS_ORIGIN_DOMAIN));
                        configuration.setAllowedMethods(List.of(ALLOW_METHODS));
                        configuration.setAllowedHeaders(Collections.singletonList("*"));
                        configuration.setAllowCredentials(true);
                        configuration.setMaxAge(3600L);

                        return configuration;
                    }
                })));
        // csrf disable
        http.csrf((auth) -> auth.disable());

        // From 로그인 방식 disable
        http.formLogin((auth) -> auth.disable());

        // http basic 인증 방식 disable
        http.authorizeHttpRequests((auth) -> auth
                        .requestMatchers(ALL_URL).permitAll()
                        .requestMatchers(ADMIN_URL).hasAnyRole(ROLES)
                        .anyRequest().authenticated());
        // 로그인 필터로 대체 (추가)
        http.addFilterAt(loginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        // 세션 설정
        http.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }

    // 인증 관리자
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception{
        return authenticationConfiguration.getAuthenticationManager();
    }
}

 

인증 관리자 빈 등록

  • AuthenticationConfiguration.getAuthenticationManager()
    → 시큐리티에 AuthenticationConfiguration을 주입받아 이 안에서 Manager를 꺼냈습니다.

 

시큐리티 설정 추가

  • WebConfig에서 하는 CORS설정과 별개 이므로 Security CORS 설정 하였습니다.
  • 시큐리티 구현 인증 필터를 제가 상속받아 구현한 것으로 필터를 교체해 주었습니다.

 

필터 추가할 때

  • addFiltrer(A) : A.class 필터 추가
  • addFiltrerAt(A, B) : A.class필터가 B.class 필터 위치를 대체한다. 
  • addFilterAfter(A, B) : A.class필터가 B.class 필터 이후에 위치한다.
  • addFilterBefore(A, B) : A.class필터가 B.class 필터 이전에 위치한다.

 

 

 

 

 

 

5. JWT 모듈인 JwtHelper 제작

 

저는 AccessToken의 만료 시간은 30분으로 두고 진행하였습니다.

RefreshToken은 발급하는 것도 몰래 껴놓기!

 

JwtHelper

  • AccessToken과 refreshToken 을 발급해줍니다.
  • 토큰에서 주체, 만료기간, 권한을 추출할 수 있습니다.
@Slf4j
@Component
public class JwtHelper {
    private static final int ACCESS_TOKEN_VALIDITY = 30 * 60 * 1000;    // 30분
    private static final int REFRESH_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;  // 1일

    @Value("${spring.security.jwt.secret}")
    private String secretKey;

    // 토큰 생성
    public String generateAccessToken(String subject, String role){
        return JWT.create()
                .withSubject(subject)
                .withClaim("role", role)
                .withIssuedAt(new Date(System.currentTimeMillis()))     // 토큰 발급 시간
                .withExpiresAt(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY))    // 토큰 만료 시간
                .sign(Algorithm.HMAC512(secretKey));
    }

    // 토큰 생성
    public String generateRefreshToken(String subject){
        return JWT.create()
                .withSubject(subject)
                .withIssuedAt(new Date(System.currentTimeMillis()))     // 토큰 발급 시간
                .withExpiresAt(new Date(System.currentTimeMillis() + REFRESH_TOKEN_VALIDITY))    // 토큰 만료 시간
                .sign(Algorithm.HMAC512(secretKey));
    }

    // 토큰 유효성 체크
    public boolean validation(String token){
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC512(secretKey)).build();
            verifier.verify(token);
        } catch (JWTVerificationException ex){  // 변조 했거나 만료 되었으면 예외
            return false;
        }
        return true;
    }

    // 토큰에서 주체 추출
    public String extractSubject(String token){
        return JWT.decode(token)
                .getSubject();
    }

    // 토큰에서 유효시간 추출
    public long extractExpiredAt(String token){
        return JWT.decode(token)
                .getExpiresAt()
                .getTime();
    }

    // 토큰에서 권한 추출
    public String extractRole(String token){
        return JWT.decode(token)
                .getClaim("role")
                .asString();
    }
}

 

 

 

 

 

 

6. 인증 성공 시 실행하는 핸들러 (AuthenticationSuccessHandler) 구현

 

LoginSuccessHandler

 

  • void onAuthenticationSuccess()
    → 로그인 인증에 성공하였으니 JWT를 발급하는 로직을 넣었습니다.
    → JWT를 사용자에게 줄 때 헤더에 포함 또는 Cookie에 넣어서 내려줄 수 있는데 저는 쿠키로 선택하였습니다.
    → API로 로그인을 요청했었기 때문에 응답 값을 생성 해주었습니다.

군침이 싹도노
그림3 군침이 싹도는 쿠키

@Slf4j
@Component
@RequiredArgsConstructor
public class LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final JwtHelper jwtHelper;

    // [로그인 실행 5] 인증 성공하여 로그인 성공하면 실행하는 핸들러
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String email = userDetails.getUsername();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        String role = authorities.stream()
                .findFirst()
                .map(GrantedAuthority::getAuthority)
                .orElse(UserRole.ADMIN.getCode());


        // 방법 1) 쿠키에 넣어서 전달
        String accessToken = jwtHelper.generateAccessToken(email, role);
        String refreshToken = jwtHelper.generateRefreshToken(email);
        Cookie accessTokenCookie = createCookie(accessToken, "accessToken");
        Cookie refreshTokenCookie = createCookie(refreshToken, "refreshToken");

        // 방법 2) HTTP header에 넣어서 전달
        //response.addHeader("Authorizetion", "Bearer " + accessToken);

        // 응답 메시지 작성
        String jsonResponse = new ObjectMapper().writeValueAsString(new LoginResponse(HttpServletResponse.SC_OK, "성공적으로 로그인이 되었습니다."));
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
        response.setStatus(HttpServletResponse.SC_OK);
        response.getWriter().write(jsonResponse);

        // 쿠키
        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);
    }

    // 쿠키 내려주기
    private Cookie createCookie(String accessToken, String cookieName) {
        Cookie accessTokenCookie = new Cookie(cookieName, accessToken);
        long expiration = jwtHelper.extractExpiredAt(accessToken);
        int maxAge = (int) ((expiration - new Date(System.currentTimeMillis()).getTime()) / 1000);
        accessTokenCookie.setMaxAge(maxAge);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setSecure(false);

        return accessTokenCookie;
    }
}

 

 

 

7. 인증 실패 시 실행하는 핸들러 (AuthenticationFailureHandler) 구현

 

많고 많은 에러 중에 뭘, 어떻게 내려줘야 하지~?

 

LoginFailHandler

 

  • void onAuthenticationFailure()
    → 예외 메시지는 예외가 터진 시점에 msg를 넣어주었기 때문에 예외에서 메시지만 꺼내서 응답값에 넣었습니다.
    → API로 로그인을 요청했었기 때문에 응답 값을 생성 해주었습니다.
@Slf4j
@Component
public class LoginFailHandler implements AuthenticationFailureHandler {

    // [로그인 실행 5] 로그인 실패하면 실행하는 핸들러
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

        // 응답 메시지 작성
        String jsonResponse = new ObjectMapper().writeValueAsString(new LoginResponse(HttpServletResponse.SC_UNAUTHORIZED, exception.getMessage()));
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        response.getWriter().write(jsonResponse);
    }
}

 

 

 

 

 

8. 인증 성공 후 JWT 과 응답값 생성 및 결과

 

포스트맨으로 테스트 (정상일 때)

로그인 성공 응답 값
그림4 로그인 성공 응답 값
로그인 성공 jwt 쿠키
그림5 로그인 성공 jwt 쿠키

 

 

포스트맨으로 테스트 (실패일 때)

로그인 실패
그림6 로그인 실패

 

 

Front 단 테스트

Front단 로그인 성공
그림7 Front단 로그인 성공

 

 

감사합니다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함