본문 바로가기
프로젝트/토이 프로젝트

Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 2

by 진꿈청 2024. 2. 14.

변명으로 시작하자면.. 정보처리기사와 치과 때문에 토이 프로젝트에 진행이 빠르지 못했다..

그래도 매일 매일 조금씩 진행한 Spring Boilerplate를 담고자 한다. 

 

JWT를 사용하여 Spring Boilerplate를 구현한건 처음이 아니지만,

늘 할 때마다 새롭고 난해한 것 같다.

 

그리고 요번에는 테스트 코드도 함께 작성하며 이해하고 진행하였기에 좀 더 오래 걸린 것 같다.

잘 한번 정리해보자.(테스트 코드와 관련해서는 다음 글에서 다루겠다.)

 

Spring Security는 무엇인가?

 

Spring Security는 인증 및 인가를 지원하는 Spring의 보안 프레임워크이다. Spring MVC에서 보안을 적용하기 위한 표준이라고 할 수 있다.

 

Spring Security는 Interceptor, Servlet Filter를 이용한 Security를 직접 구현할 필요가 없다.

Interceptor = DispatcherServlet과 Controller 사이에 동작

 

 

 

세션 대신 JWT를 사용하는 이유가 뭘까?

 

기존의 세션 기반 인증 방식은 어떻게 하더라도 서버에 부하를 주게 작동하게 되어있었다.

또한, REST API를 이용한 CSR 방식의 백엔드 서버를 개발하려면 Stateless를 유지해야 하므로 세션 방식은 옳지 않다.

 

JWT는 Stateless를 유지하며 동시에 Message Integrity, Authentication를 만족해준다.

Confidentiality와 관련해서는 Base64로 인코딩을 하지만, 디코딩이 가능하다.

 

토큰이 탈취될 경우 사용자 정보가 제공되므로 민감한 정보는 토큰에 담으면 안된다.

 

 

Refresh Token을 사용하는 이유는 뭘까?

 

Access Token이 탈취되었을 경우 공격자는 서버에 사용자의 권한으로 쉽게 접속이 가능하다.(문제 발생)

따라서, Access Token은 유효 기간을 짧게 하고 Refresh Token은 유효 기간을 길게 하여

Refresh Token으로 Access Token을 재발급한다. Refresh Token은 Redis에 저장.

이때, Refresh Token은 AES 방식으로 비밀키 암호화하여 보안을 유지한다.

 

우선, Spring SecurityFilterChain의 동작 과정이다.

 

SecurityFilterChain

 

 

Spring Security는 기본적으로 순서가 있는 Filter를 제공한다. 따라서, 만약 Spring Security가 기본 제공하는

Filter를 사용하는 것이 아니라면 필터의 순서를 정해줘야 사용가능하다.

 

우선, Spring Boilerplate에 중점적으로 사용되는 Filter는 UsernamePasswordAuthenticationFilter이다.

 


UsernamePasswordAuthenticationFilter

 

1. 해당 필터를 거친 뒤 다음 필터로 동작을 넘기지 않는다.

(따라서, AuthenticationSuccessHandlerAuthenticationFailureHandler를 구현해주어야 한다.)

 

2. 해당 필터는 기본적으로 /login으로 접근할 때 동작하기 때문에 setFilterProcessesUrl()로 Url를 설정해줘야 한다.

 

 

다음으로는 Spring의 Filter 동작 과정이다.

 

Spring MVC request life cycle

 

Spring Security 필터는 DispatcherServlet이 동작하기 전에 동작한다.

 

그로 인한 영향은 다음과 같다

 

1. Spring Security 필터에서의 예외는 @ExceptionHandler로 잡히지 않는다.

2. 앞서, 설명한 것처럼 AuthenticationSuccessHandler 등을 구현해주어야 한다.

3. 필터가 다르기 때문에 하나의 서블릿을 실행하는 동안 다른 서블릿에 대한 요청이 들어오면,

    다른 서블릿에도 같은 필터가 존재한다면 그 필터가 또 실행된다.(리소스 낭비)

 

 

위의 내용을 토대로 필터 이해, 문제점을 확인한 뒤 코드에 적용 및 실습해보도록 하였다.

 

 

로그인 과정

1. 클라이언트에서 사용자의 ID, Password를 받아 서버에 로그인 요청.

2. 서버는 전달받은 ID, Password를 가진 User 객체를 조회하고 인증되었다면, Access Token과 Refresh Token 생성.

3. 생성한 Refresh Token을 Redis에 저장한 후 HttpServletResponse 헤더에 담아 전달.

4. 클라이언트는 발급받은 Access Token을 HttpServletRequest 헤더에 담아 서버가 허용한 권한 범위 내에서 API 사용.

 

구현

 

JwtTokenProvider

JWT를 생성하고 검증하는 클래스.

 

/**
 * JWT 토큰을 생성, 토큰 복호화 및 정보 추출, 토큰 유효성 검증 클래스
 * jwt.secret는 토큰의 암호화, 복호화를 위한 secret key로 HS256 알고리즘 사용을 위해, 256비트보다 커야함.
 * 한 단어에 1바이트이므로, 32글자 이상 설정.
 */
@Component
@Slf4j
public class JwtTokenProvider {

    public static final String BEARER = "Bearer";
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String REFRESH_HEADER = "Refresh";
    public static final String BEARER_PREFIX = "Bearer ";
    private static final String AUTHORITIES_KEY = "auth";

    @Value("${jwt.secret-key}")
    private String secretKey;

    @Getter
    @Value("${jwt.accessToken-validity-in-seconds}")
    private long accessTokenValidityInSeconds;

    @Getter
    @Value("${jwt.refreshToken-validity-in-seconds}")
    private long refreshTokenValidityInSeconds;

    private Key key;

    @PostConstruct
    public void keyInit(){
        String base64EncodedSecretKey = Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
        byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.accessTokenValidityInSeconds *= 1000;
        this.refreshTokenValidityInSeconds *= 1000;
    }

    // 유저 정보를 토대로 AccessToken, RefreshToken 생성
    public TokenDto generateToken(CustomUserDetails customUserDetails){
        // 권한 가져오기
        Map<String, Object> claims = new HashMap<>();
        claims.put(AUTHORITIES_KEY, customUserDetails.getRole_str());

        Date accessValidity = getTokenValidityInSeconds(accessTokenValidityInSeconds);
        Date refreshValidity = getTokenValidityInSeconds(refreshTokenValidityInSeconds);

        // AccessToken 생성
        String accessToken = Jwts.builder()
                .setClaims(claims)
                .setSubject(customUserDetails.getUsername())
                .setExpiration(accessValidity)
                .setIssuedAt(Calendar.getInstance().getTime())
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // RefreshToken 생성
        String refreshToken = Jwts.builder()
                .setSubject(customUserDetails.getUsername())
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(refreshValidity)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER)
                .authorizationType(AUTHORIZATION_HEADER)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .accessTokenExpiresIn(accessTokenValidityInSeconds)
                .build();
    }

    public Date getTokenValidityInSeconds(long tokenValidityInSeconds) {
        long now = (new Date()).getTime();
        return new Date(now + tokenValidityInSeconds);
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼냄
    public Authentication getAuthentication(String accessToken){
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);
        if(claims.get(AUTHORITIES_KEY) == null){
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        String authority = claims.get(AUTHORITIES_KEY).toString();


        CustomUserDetails customUserDetails = CustomUserDetails.of(
                claims.getSubject(), authority);


        log.info("# AuthUser.getRoles 권한 체크 = {}", customUserDetails.getAuthorities().toString());

        return new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());

    }

    // 토큰 정보 검증
    public boolean validateToken(String token, HttpServletResponse response) throws IOException {
        try{
            parseClaims(token);
        } catch(MalformedJwtException e){
            log.info("Invalid JWT token");
            log.trace("Invalid JWT token trace = {}", e);
            sendErrorResponse(response, "손상된 토큰입니다.");
        } catch (ExpiredJwtException e){
            log.info("Expired JWT token");
            log.trace("Expired JWT token trace = {}", e);
            sendErrorResponse(response, "만료된 토큰입니다.");
        } catch (UnsupportedJwtException e){
            log.info("Unsupported JWT token");
            log.trace("Unsupported JWT token trace = {}", e);
            sendErrorResponse(response, "지원하지 않는 토큰입니다.");
        } catch(IllegalArgumentException e){
            log.info("JWT claims string is empty");
            log.trace("JWT claims string is empty trace = {}", e);
            sendErrorResponse(response, "시그니처 검증에 실패한 토큰입니다.");
        } catch(BadCredentialsException e){
            log.info("Login Info Error");
            log.trace("Login Info Error is empty trace = {}", e);
            sendErrorResponse(response, "로그인 정보가 잘못되었습니다.");
        }
        return true;
    }

    public Claims parseClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public void accessTokenSetHeader(String accessToken, HttpServletResponse response){
        String headerValue = BEARER_PREFIX + accessToken;
        response.setHeader(AUTHORIZATION_HEADER, headerValue);
    }

    public void refreshTokenSetHeader(String refreshToken, HttpServletResponse response){
        response.setHeader("Refresh", refreshToken);
    }

    // Request Header의 Access Token 정보 추출
    public String resolveAccessToken(HttpServletRequest request){
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)){
            return bearerToken.substring(7);
        }

        return null;
    }

    // Request Header에 Refresh Token 정보 추출
    public String resolveRefreshToken(HttpServletRequest request){
        String bearerToken = request.getHeader(REFRESH_HEADER);
        if(StringUtils.hasText(bearerToken)){
            return bearerToken;
        }

        return null;
    }

    // JWT 예외처리
    private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(new Response(HttpStatus.UNAUTHORIZED.value(), message)));
    }
}

 

@Value를 사용하여 application.yml로 부터 얻어온 값들 중 secretKey는 토큰의 암호화, 복호화를 위한 secret key이다.

HS256 알고리즘 사용을 위해, 256비트보다 커야함. 한 단어에 1바이트이므로, 32글자 이상 설정.

 

JWT 생성 시 들어갈 정보

  • setClaims(): JWT에 포함시킬 Custom Claims를 넣는데 이때, Custom Claims는 인증된 사용자 정보다.
  • setSubject(): JWT에 대한 제목을 넣음(여기선 Email) 주로 id값을 넣는다.
  • setIssuedAt(): JWT 발행 일자를 넣는다. java.util.Date 타입
  • setExpiration(): JWT 만료기한 지정. 타입은 같다.
  • signWith(): 서명을 위한 Key 객체를 설정. JWT는 기본적으로 HS256을 사용한다.
  • compact(): JWT를 생성하고 직렬화함.

keyInit(): JWT 서명에 사용될 SecretKey 생성. Keys.hmacShaKeyFor() 메서드로 HMAC 알고리즘을 적용한 Key 객체 생성.

 

generateTokenDto: CustomUserDetails의 유저 정보를 토대로 Access/Refresh Token을 생성하는 메소드

 

validateToken(): JWT에 포함된 Signature를 parseClaims() 메소드를 통해 검증한 뒤 위조 여부 확인. 검증에 성공하면 JWT를 파싱하여 Claims를 얻어옴.

  • 이때, 앞서 설명한 예외처리를 위해 예외와 관련된 정보를 Response를 통해 반환.

parseClaims(): JWT를 파싱해서 Claims를 얻어옴

 

access/refreshTokenSetHeader(): 토큰들을 헤더에 담음

 

resolveAccess/RefreshToken(): request의 헤더로부터 토큰을 읽어옴.

 

TokenDto

클라이언트에게 토큰을 전달하기 위한 DTO.

/**
 * 클라이언트에 토큰을 보내기 위한 DTO
 * grantType은 JWT에 대한 인증 타입이다.
 */
@Builder
@Data
@AllArgsConstructor
public class TokenDto {

    private String grantType; // JWT에 대한 인증 타입. Bearer를 사용
    private final String authorizationType;
    private String accessToken;
    private String refreshToken;
    private final Long accessTokenExpiresIn;
}

 

 

Response

예외 발생시 Response 반환.(Builder를 사용해도 좋다.)

@AllArgsConstructor
public class Response {

    @JsonProperty
    private int status;

    @JsonProperty
    private String message;
}

 

@JsonProperty: serializer하는 과정에서 private 등으로 선언하면 json 변환 과정에서 에러가 발생하기에 해결하기 위한 방법.

 

JwtAuthenticationFilter

 

로그인 검증 및 JWT 발급을 담당한다. 이 클래스에서 앞서 설명한 UsernamePasswordAuthenticationFilter를 상속한다.

사용자 이름과 암호를 받아 인증을 시도한다.

 

/**
 * 로그인 검증 및 JWT 발급 담당
 * UsernamePasswordAuthenticationFilter == Spring Security에서 사용자 이름과 암호를 받아 인증 시도
 */
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final UserService userService;
    private final RedisService redisService;
    private final AES128Config aes128Config;

    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        // ServletInputStream을 LoginDto에 담기 위해 역직렬화 수행
        ObjectMapper objectMapper = new ObjectMapper();
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());

        return authenticationManager.authenticate(authenticationToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal(); // CustomUserDetails 클래스의 객체를 얻음
        TokenDto tokenDto = jwtTokenProvider.generateToken(customUserDetails);
        String accessToken = tokenDto.getAccessToken();
        String refreshToken = tokenDto.getRefreshToken();
        String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
        jwtTokenProvider.accessTokenSetHeader(accessToken, response);
        jwtTokenProvider.refreshTokenSetHeader(encryptedRefreshToken, response);
        User findUser = userService.findUserAndCheckUserExists(customUserDetails.getId());
//        Responder.loginSuccessResponse(response, findUser)

        // 로그인 성공시 Refresh Token Redis 저장(key = Email / value = Refresh Token)
        long refreshTokenValidityInSeconds = jwtTokenProvider.getRefreshTokenValidityInSeconds();
        redisService.setValues(findUser.getEmail(), refreshToken, Duration.ofMillis(refreshTokenValidityInSeconds));

        this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
    }
}

 

 

JwtVerificationFilter

 

JWT 검증 및 SecurityContextHolderAuthentication을 등록하는 클래스이다.

JwtAuthenticationFilter 다음에 동작하는 필터로 OncePerRequestFilter이다.

 

OncePerRequestFilter은 동일한 request안에서는 필터링을 한번만 할 수 있게 도와준다.

따라서, 인증/인가를 거치고 url로 포워딩하면, 포워딩 요청의 인증/인가 필터를 다시 거치지 않고 다음 로직을 실행하게 해준다.

즉, 필터가 중복으로 실행되는 리소스 낭비를 방지할 수 있다.

 

/**
 * OncePerRequestFilter는 각 HTTP 요청에 대해 한 번만 실행되는 것을 보장.
 * HTTP 요청마다 JWT를 검증하는 것은 비효율적이기 때문.
 */
@Slf4j
@RequiredArgsConstructor
public class JwtVerificationFilter extends OncePerRequestFilter {
    // 인증에서 제외할 url
    private static final List<String> EXCLUDE_URL =
            List.of("/",
                    "/h2",
                    "/users/signup",
                    "/auth/login",
                    "/auth/reissue");
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisService redisService;

    // JWT 인증 정보를 현재 쓰레드의 SecurityContext에 저장(가입/로그인/재발급 Request 제외)
    // HTTP 요청에서 JWT를 꺼내 검증한 후 검증이 되면 SecurityContext에 저장
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException, java.io.IOException {
        try {
            String accessToken = jwtTokenProvider.resolveAccessToken(request);
            if (StringUtils.hasText(accessToken) && doNotLogout(accessToken)
                    && jwtTokenProvider.validateToken(accessToken, response)) {
                setAuthenticationToContext(accessToken);
            }
            // TODO: 예외처리 리팩토링
        } catch (RuntimeException e) {
            if (e instanceof RuntimeException) {
                ObjectMapper objectMapper = new ObjectMapper();
                String json = objectMapper.writeValueAsString(new IllegalStateException());
                response.getWriter().write(json);
                response.setStatus(403);
            }
        }
        filterChain.doFilter(request, response);
    }

    private void setAuthenticationToContext(String accessToken) {
        Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        log.info("# Token verification success!");
    }

    private boolean doNotLogout(String accessToken) {
        String isLogout = redisService.getValues(accessToken);
        return isLogout.equals("false");
    }

    // EXCLUDE_URL과 동일한 요청이 들어왔을 경우, 현재 필터를 진행하지 않고 다음 필터 진행
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        boolean result = EXCLUDE_URL.stream().anyMatch(exclude -> exclude.equalsIgnoreCase(request.getServletPath()));

        return result;
    }


}

 

doNotLogout(): Logout한 Access Token으로 접속하였는지 검사한다.

 

setAuthenticationToContext(): 해당 AccessToken이 문제가 없다면 SecurityContextHolder에 등록한다.

 

CustomUserDetailService

 

로그인시 입력한 ID/Password를 실제 DB에 저장되어 있는 User를 UserDetails로 만들어 비교해주는 클래스이다.

attemptAuthentication()을 수행되고 난 뒤,

AuthenticationManager -> AuthenticationProvider -> loadUserByUsername() 순으로 실행된다.

 

그 후, DaoAuthenticationProvider에서 Bcrypt의 matches를 이용해 암호를 비교한다.

(DB에는 암호화된 값이 들어가는데 아무리 봐도 로그인 패스워드는 암호화하는 것이 없어 타고타고 들어가다 보니 알게되었다.)

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    /**
     * loadByUsername() : 사용자 이름(email)을 입력받아 User에서 사용자 정보를 조회한다.
     * 조회한 User 객체가 존재하면 createUserDetails() 메서드를 사용해서 CusotmUserDetails 객체를 생성하고 반환한다.
     */
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return userRepository.findByEmail(email)
                .map(this::createUserDetails)
                .orElseThrow(NoSuchElementException::new);
    }

    private UserDetails createUserDetails(User user){
        return CustomUserDetails.of(user);
    }
}

 

  • createUserDetails(): DB로부터 찾아온 User 객체를 UserDetails로 바꾸어 반환한다.

 

CustomUserDetails

Spring Security에서 관리하는 User 정보를 관리하는 것으로 UserDetails 인터페이스를 구현한 클래스이다.

 

나는 권한을 Authority Enum 타입으로 선언을 했는데 그러다보니 역할을 제대로 읽어오지 못해 애를 많이 먹었다.

테스트 코드를 통해 검증하려고 하니 계속 오류가 발생해 디버그를 해보았더니 Authority 사용으로 인한 오류였다.

 

그래서, toString()을 붙였는데.. 다른 방법이 있을 것 같다. 한번 찾아봐야겠다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class CustomUserDetails extends User implements UserDetails {

    private Long id;
    private String email;
    private String role_str;
    private String password;
    private String nickName;

    private CustomUserDetails(User user){
        this.id = user.getId();
        this.email = user.getEmail();
        this.password = user.getPassword();
        this.role_str = user.getRole().toString();
        this.nickName = user.getNickName();
    }

    private CustomUserDetails(String email, String role){
        this.email = email;
        this.role_str = role;
    }

    private CustomUserDetails(String email, String role, String password) {
        this.email = email;
        this.role_str = role;
        this.password = password;
    }

    public static CustomUserDetails of(User user){
        return new CustomUserDetails(user);
    }

    public static CustomUserDetails of(String email, String role){
        return new CustomUserDetails(email, role);
    }

    public static CustomUserDetails of(String email, String role, String password) {
        return new CustomUserDetails(email, role, password);
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return CustomAuthorityUtils.createAuthorities(role_str);
    }

    @Override
    public String getUsername() {
        return this.email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

  • getAuthorities(): 권한을 생성하고 List<GrantedAuthority> 타입으로 반환한다.
  • isAccount/Credentials.... 여러 메서드들: 사용자 계정 만료, 잠금, 인증 정보 만료, 활성화 등의 여부를 반환하는 메서드이다. 지금은 다룰 내용이 아니므로 전부 true로 반환했다.

 

CustomAuthorityUtils

권한 정보를 생성하고 검증하는 유틸리티 클래스이다.

@Slf4j
public class CustomAuthorityUtils {

    public static List<GrantedAuthority> createAuthorities(String role){
        return List.of(new SimpleGrantedAuthority(role));
    }

    public static void verifiedRole(String role){
        if(role == null){
            throw new IllegalStateException("역할이 없습니다.");
        } else if(!role.equals(ROLE_USER.name()) && !role.equals(ROLE_ADMIN.name())){
            throw new IllegalStateException("맞는 역할이 없습니다.");
        }
    }
}

 

 

Spring Security는 권한 정보를 "ROLE_*" 형태로 파악하기 때문에

Authority 권한 정보를 ROLE_USER, ROLE_ADMIN으로 설정해놨다.

만약, USER, ADMIN으로만 Enum 타입 클래스인 Authority에 넣고 싶다면 그냥 "ROLE_" + 코드만 넣어주면 된다.

 

  • createAuthorities(): 입력된 role 값을 기반으로 권한 정보를 생성하여 List<GrantedAuthority> 타입으로 변환.
  • verifiedRole(): 입력된 role 값이 유효한지를 검증.

 

 

SecurityConfiguration

 

Spring Security 설정을 담당하는 클래스이다.

 

기존에 몇 번 Spring Security를 설정하였을 땐 SpringBoot 2.xx 버전을 사용해서 문제가 없었는데

이번에 SpringBoot 3.xx 버전을 사용하며 SecurityConfiguration을 등록하는 것이 많이 바뀌었다.

 

간단하게 람다식의 사용이 많아졌다. Spring 공식문서에 가면 쉽게 참조할 수 있다.

Cors 설정은 지금 당장 필요하지 않아서 일단 Default로 설정했다.

@Configuration
@RequiredArgsConstructor
@Slf4j
public class SecurityConfiguration {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserService userService;
    private final AES128Config aes128Config;
    private final RedisService redisService;
    private final PasswordEncoder passwordEncoder;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http.csrf(AbstractHttpConfigurer::disable).cors(Customizer.withDefaults());
        http.formLogin(FormLoginConfigurer::disable).httpBasic(HttpBasicConfigurer::disable)
                .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        http.exceptionHandling(configurer -> configurer.authenticationEntryPoint(new CustomAuthenticationEntryPoint()))
                .exceptionHandling(configurer -> configurer.accessDeniedHandler(new CustomAccessDenidedHandler()));
        http.apply(new CustomFilterConfigurer());
        http.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());

        return http.build();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource(){
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("*"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE"));
        configuration.setAllowCredentials(true);
        configuration.addExposedHeader("Authorization");
        configuration.addExposedHeader("Refresh");
        configuration.addAllowedHeader("*");
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
        @Override
        public void configure(HttpSecurity builder) throws Exception {
            log.info("SecurityConfiguration.CustomFilterConfigurer.configure excute");
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager,
                    jwtTokenProvider, userService, redisService, aes128Config);

            JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenProvider, redisService);

            jwtAuthenticationFilter.setFilterProcessesUrl("/auth/login");
            jwtAuthenticationFilter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
            jwtAuthenticationFilter.setAuthenticationFailureHandler(new LoginFailurHandler());

            builder
                    .addFilter(jwtAuthenticationFilter)
                    .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
        }
    }

    @Bean
    public static BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

 

  • http.csrf(AbstractHttpConfigurer::disable): JWT를 사용하므로 CSRF 공격 방지 기능을 사용하지 않는다.
    • CSRF를 disable 해도 괜찮은 유일한 경우는 서비스의 클라이언트들이 브라우저를 사용하지 않는 경우이다.
    • 따라서, stateless한 웹 어플리케이션의 경우 이를 disable해도 괜찮다고 한다.(하지만, CSRF 공격엔 취약해진다.)
    • JWT 토큰을 local storage에 저장하면 이를 해결가능하다.(하지만, XSS라는 공격에 취약해진다.)
    • 그러면? 정답은 없다.. 하지만, 우리는 프로젝트에서 JWT 토큰을 사용하므로 일단은 넘어가기로 한다.
  • http.formLogin(): 폼 기반 로그인 방식 비활성화
  • http.httpBasic(): HTTP 기본 인증 방식을 비활성화
  • http.sessionManagement(): STATELESS(인증에 사용할 세션을 생성하지 않음)
  • http.exceptionHandling(): 예외 처리를 설정
  • authenticationEntryPoint(new CustomAuthenticationEntryPoint()): 인증되지 않은 사용자가 보호된 리소스에 접근할 때 호출할 엔드포인트를 설정
  • accessDenidedHandler(new CustomAccessDeniedHandler()): 인가되지 않은 사용자가 보호된 리소스에 접근할 때 호출할 핸들러 설정
  • apply(new CustomFilterConfigurer()): 사용자 정의 필터 적용
  • authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()): 모든 HTTP 요청에 대해 접근을 허용

CustomFilterCongiure Class

  • setFilterProcessesUrl("/auth/login") : 로그인 URL을 설정.
  • setAuthenticationSuccessHandler() , setAuthenticationFailureHandler() : 로그인 성공 및 실패 시 호출되는 핸들러를 설정.
  • addFitler() 메서드를 사용하여 필터를 추가하고, addFilterAfter() 메서드를 사용하여 JwtAuthenticationFilter 다음에 JwtVerificationFilter 를 추가한다.

 

LoginSuccessHandler

 

로그인을 성공했을 때 수행되는 클래스로 LoginResponse 클래스를 사용하여 json 형태로 반환한다.

반환 정보는 CustomUserDetails의 이메일과, 닉네임이다.

public class LoginSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        AuthenticationSuccessHandler.super.onAuthenticationSuccess(request, response, chain, authentication);
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
        LoginResponse loginResponse = new LoginResponse(customUserDetails.getEmail(), customUserDetails.getNickName());
        response.setContentType("application/json");
        response.getWriter().write(converObjectToJson(loginResponse));
    }

    private String converObjectToJson(Object object) throws JsonProcessingException {
        if (object == null) {
            return null;
        }
        ObjectMapper mapper = new ObjectMapper();
        return mapper.writeValueAsString(object);
    }
}

 

LoginFailurHandler

로그인에 실패했을 때 수행되는 클래스로 오류를 response에 넣어 반환한다.

 

UNAUTHORIZED로 고정을 하였고 예외 메시지를 반환한다.

이것과 관련하여 테스트 코드를 작성하는데 애를 좀 먹었다. Exception 처리를 마지막에 하려했는데..

테스트 코드를 작성하려니 일치하지 않아 여러 문제가 발생했다. Exception 처리는 언제하는게 좋을까?

public class LoginFailurHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        writePrintErrorResponse(response, exception);
    }

    private void writePrintErrorResponse(HttpServletResponse response, AuthenticationException exception) throws IOException {
        if(exception instanceof BadCredentialsException){
            sendErrorResponse(response, "비밀번호 불일치");
        }
        else if(exception instanceof AccountExpiredException){
            sendErrorResponse(response, "계정만료");
        }
        else if(exception instanceof UsernameNotFoundException){
            sendErrorResponse(response, "계정없음");
        }
        else if(exception instanceof CredentialsExpiredException){
            sendErrorResponse(response, "비밀번호 만료");
        }
        else if(exception instanceof DisabledException){
            sendErrorResponse(response, "계정 비활성화");
        }
        else if(exception instanceof LockedException){
            sendErrorResponse(response, "계정 잠김");
        }
        else{
            sendErrorResponse(response, "확인된 에러가 없습니다.");
        }
    }

    // JWT 예외처리
    private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(new Response(HttpStatus.UNAUTHORIZED.value(), message)));
    }
}

 

  • writePrintErrorResponse(): 어떤 종류의 예외가 발생했는지를 확인하고 sendErrorResponse() 메소드로 보낸다.
  • sendErrorResponse(): 에러 메시지와 HTTP 값을 Response 객체에 담아 반환한다.

 

LoginResponse

 

앞서, 살펴보았던 것처럼 Email과 NickName을 직렬화하여 반환하기 위해 사용되는 일종의 DTO이다.

@AllArgsConstructor
@NoArgsConstructor
@Getter @Setter
public class LoginResponse{

    private String email;
    private String nickName;
}

 

 

CustomAccessDeniedHandler

 

AccessDeniedHandler는 인증이 완료되었으나 해당 엔드포인트에 접근할 권한이 없다면, 403 Forbidden 오류가 발생한다.

HttpStatus 403  Forbidden은 서버가 해당 요청을 이해하였으나, 사용작의 권한이 부족하여 거부된 상태이다.

 

@Slf4j
public class CustomAccessDenidedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.error("권한없는 사용자의 접근");
        sendErrorResponse(response, accessDeniedException.getMessage());
    }

    private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(new Response(HttpStatus.FORBIDDEN.value(), message)));
    }
}

 

  • sendErrorResponse(): 에러 메시지와 HTTP 값을 Response 객체에 담아 반환한다.

 

CustomAuthenticationEntryPoint

 

AuthenticationEntryPoint는 인증이 안된 익명의 사용자가 인증이 필요한 엔드포인트로 접근하면,

Spring Security는 HttpStatus 401 오류를 보여준다.

 

HttpStatus 401 Unauthorized는 사용자가 인증되지 않았거나 유효한 인증 정보가 부족하여 요청이 거부된 것.

따라서, 커스텀 오류 페이지를 보여주거나, 특정 로직 수행 및 JSON 데이터 등으로 응답해야 하는 경우

AuthenticationEntryPoint 인터페이스를 구현하고 시큐리티에 등록한다.

 

즉, 인증되지 않은 사용자가 인증이 필요한 요청 엔드포인트로 접근하려 할 때, 예외를 핸들링해준다.

@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        log.error("가입되지 않은 사용자 접근");
        sendErrorResponse(response, authException.getMessage());
    }

    private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(new Response(HttpStatus.UNAUTHORIZED.value(), message)));
    }

}

 

 

 

글을 마무리하며

 

위의 내용에서 RefreshToken을 AES로 암호화하는 Configuration과 Bcrypt 암호화 방식에 대한 Configuration을 담지 않았다.

또한, 중간중간 테스트 코드를 작성하며 진행했는데 관련 내용도 담지 않았다.

 

다음글에 담으려 하는데 위의 내용을 진행하며 테스트 코드 작성시 많은 오류들을 접했다.

 

위에 잠깐 잠깐 설명한 예외처리라던지 HttpStatus 오류 등.. 많은 곳에서 오류를 발생했다.

 

프론트와 작업을 해야할텐데 참 대화의 중요성 무엇보다도 사전 협의가 중요함을 깨달았다.

또한, 동시에 테스트 코드의 필요성도 알게 되었다. 그냥 코드만 쭉 적고 하다보면 대체 어느 곳에서 오류가 발생했는데 당최 알 수 없을 것 같다..

 

그리고 중간중간 Exception 처리에 관한 고민을 하며 과연 언제 Exception 처리를 하는게 좋을 지 궁금해졌다.

우선, 큰 틀의 Exception 클래스를 미리 만들어놓아야 할까?

 

직렬화, 역직렬화에 대해 대충 알고 있었는데 이번 기회에 좀 더 알아본 것 같다.

Redis에 저장하기 위해선 직렬화를 해야 한다는데 나중에 데이터 캐싱에 대해 공부해보며 한번 더 정리해야 할 것 같다.

(계속 정리해야 할 것이 늘고 있다... 역시, 개발은 끝이 없지만.. 그래서 재밌는 것 같다..)

 

그래도 매번 주먹구구식으로 했었는데 이번 기회에 정리해서 다행이다. 

 

마지막으로, 많이 참고하여 좋은 지식을 얻게 된 블로그가 있다. 이분은 정말 체계적으로 깔끔한 코드를 잘 작성하시는 것 같다.. 본받아야지

 

https://green-bin.tistory.com/

 

개발하는 콩

한 가지를 대하는 태도를 보면, 만 가지를 대하는 태도를 알 수 있다.

green-bin.tistory.com