[TIL] 241113 Spring Security 적용하기

MONA·2024년 11월 13일

나혼공

목록 보기
28/92

Spring Security 적용

WebSecurityConfig을 통해 설정하고 인증, 인가 필터를 적용하여 인증, 인가 기능을 마무리했다.

Spring Security를 적용해 로그인 구현하기

WebSecurityConfig

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;
    private final AuthenticationConfiguration authenticationConfiguration;
    public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService, AuthenticationConfiguration authenticationConfiguration) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
        this.authenticationConfiguration = authenticationConfiguration;
    }
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
        filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
        return filter;
    }
    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() {
        return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
    }
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // CSRF 설정
        http.csrf((csrf) -> csrf.disable());
        // 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
        http.sessionManagement((sessionManagement) ->
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );
        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests
                        .requestMatchers("/api/members/auth/signup").permitAll() // 회원가입
                        .requestMatchers( "/api/members/auth/login").permitAll() // 로그인 요청 제외
                        .anyRequest().authenticated() // 그 외 모든 요청에 대해 인증처리
        );
        // 필터
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
        return http.build();
    }
}

JwtAuthenticationFilter 인증 처리

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final JwtUtil jwtUtil;
    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
        setFilterProcessesUrl("/api/members/auth/login");
    }
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
            return getAuthenticationManager().authenticate(
                    new UsernamePasswordAuthenticationToken(
                            requestDto.getEmail(),
                            requestDto.getPassword(),
                            null
                    )
            );
        } catch (IOException e) {
            logger.error("Error parsing LoginRequestDto in attemptAuthentication", e);
            return null;
        } catch (AuthenticationException e) {
            logger.error("Authentication error in attemptAuthentication", e);
            throw e;
        }
    }
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        String email = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getEmail(); // username == email
        UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
        String token = jwtUtil.createToken(email, role);
        jwtUtil.addJwtToCookie(token, response);
    }
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        logger.info("로그인 실패");
        response.setStatus(401);
    }
}

JwtAuthorizationFilter 인가 처리

public class JwtAuthorizationFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;
    public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
        String tokenValue = jwtUtil.getTokenFromCookies(req);
        if (StringUtils.hasText(tokenValue)) {
            // JWT 토큰 substring
            tokenValue = jwtUtil.substringToken(tokenValue);
            log.info(tokenValue);
            if (!jwtUtil.validateToken(tokenValue)) {
                log.error("Token Error");
                return;
            }
            Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
            try {
                setAuthentication(info.getSubject());
            } catch (Exception e) {
                log.error(e.getMessage());
                return;
            }
        }
        filterChain.doFilter(req, res);
    }
    // 인증 처리
    public void setAuthentication(String email) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        Authentication authentication = createAuthentication(email);
        context.setAuthentication(authentication);
        SecurityContextHolder.setContext(context);
    }
    // 인증 객체 생성
    private Authentication createAuthentication(String email) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(email);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }
}

UserDetailsServiceImpl

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private final UserService userService;
    public UserDetailsServiceImpl(UserService userService) {
        this.userService = userService;
    }
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        P_user user = userService.findByEmail(email);
        if (user == null) {
            throw new UsernameNotFoundException("User not found by email: " + email);
        }
        return new UserDetailsImpl(user);
    }
}

UserDetailsImpl

public class UserDetailsImpl implements UserDetails {
    private final P_user user;
    public UserDetailsImpl(P_user user) {
        this.user = user;
    }
    public P_user getUser() {
        return user;
    }
    @Override
    public String getPassword() {
        return user.getPassword();
    }
    @Override
    public String getUsername() {
        return user.getEmail();
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        UserRoleEnum role = user.getRole();
        String authority = role.getAuthority();
        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(simpleGrantedAuthority);
        return authorities;
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return true;
    }
}

이렇게 하고 UserController와 UserService에서 로그인 메서드를 삭제했다.
Spring Security에서 해주니까. 👍

그리고 유저 정보를 가져올 때마다 토큰을 검증하고 객체를 조회해서 반환하는 메서드를 사용했었는데 토큰 검증 과정도 필요없어서 날렸다.
이후 유저 객체는 이렇게 조회할 수 있었다.

// UserService

public ResponseDto getMypage(UserDetailsImpl userDetails) {
	P_user user = userDetails.getUser();
    ...
}

구현 과정에서 문제가 있었는데,
WebSecurityConfig에서 requestMatchers().permitAll() 설정을 해두어도 로그인, 회원가입 시 필터에 걸려 에러가 발생해 Forbidden이 반환되었다.

permitAll() 처리를 하면 필터를 거치지 않고 바로 진입하는 줄 알았는데 그게 아니었다.

requestMatchers().permitAll()로 URL을 설정하면, 해당 요청에 대해 인증이 필요하지 않다는 것을 의미하고, 이것이 필터 체인을 완전히 건너뛴다는 뜻은 아니다.
여전히 Spring Security의 필터 체인을 거치지만 필터 체인 내에서 인증 및 권한 확인을 수행하지 않고 요청을 컨트롤러로 전달하는 것이다.

그리고 돌이켜보니 사실 필터에 걸려 에러가 난 것도 아니었다. 내가 조건 분기처리를 잘못했던 것 같다.
그렇게 몇 시간을 보내어 로그인은 가능케 했으나, 여전히 나의 JWT 토큰은 Bearer 접두사와 함께 쿠키에 담겨 주고받는 상태였다.

이왕 수정할거 쿠키를 어떻게 보내는 게 좋을지 부터 고민되었다.

토큰을 쿠키에 저장하는 게 나을까, 헤더에 저장하는 게 나을까

보안 요구사항, 사용 시나리오, 애플리케이션 구조에 따라 달라진다.

1. 쿠키에 저장한다

장점

  • 자동 전송: 브라우저가 쿠키를 자동으로 전송하기 때문에 추가적인 작업 없이 서버에 전달됨
  • 보안 속성 적용 가능: HttpOnly, Secure 속성을 설정해 클라이언트 측에서의 접근을 막고, HTTPS 연결에서만 전송하게 할 수 있어 보안성이 높음
  • CORS 문제 완화: 브라우저는 동일 출처 정책을 따르기에 쿠키 기반 인증이 관련 이슈를 줄이는 데 유리할 수 있음
    단점
  • CSRF 취약성: 자동 전송되므로 CSRF 공격에 취약할 수 있음. 방지하기 위해서는 CSRF 토큰이 추가로 필요
  • 관리 복잡성: 쿠키에 저장 시 만료 설정, 갱신 등 세부 설정을 맞추는 관리가 필요할 수 있음

2. 헤더에 저장한다

장점

  • 직접 통제 가능: 클라이언트 측 코드에서 명시적으로 헤더에 토큰을 포함시킬 수 있어서 요청을 보다 세밀하게 제어할 수 있음
  • CSRF 보호: 헤더는 브라우저에서 자동으로 추가되지 않기에 CSRF 공격에 상대적으로 안전함
  • 멀티 플랫폼 지원에 유리: 동일한 백엔드 서버를 웹, iOS, Android 등 여러 플랫폼에서 사용할 경우 다양한 플랫폼 간 통일성을 유지하는 데 유리함
    단점
  • JavaScript 접근 필요: 클라이언트 측에서 직접 토큰을 추가하기 때문에 클라이언트 JS 코드가 토큰에 접근하며, XSS 공격에 취약할 수 있음
  • CORS 문제 가능성: 추가적인 CORS 설정이 필요할 수 있음

요약

  • 쿠키: 보안속성을 적용하고 CSRF 토큰을 사용해 보안을 강화할 수 있음. CSRF 방어가 필요한 웹 애플리케이션에 유리
  • 헤더: XSS 방어가 잘 되어있는 경우에 권장됨. 특히 CSRF에 취약하지 않은 모바일 앱이나 클라이언트가 토큰을 직접 관리할 수 있는 상황에 적합

현재 진행하는 프로젝트의 요구사항과 비즈니스 로직을 보면 (아마도) 모바일 앱일 확률이 높았다. 그리고 이왕 헤더에 저장하는 김에 리프레시 토큰과 엑세스 토큰을 함께 적용해보기로 했다.

Access Token, Refresh Token 적용하기

JwtAuthenticationFilter

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
        setFilterProcessesUrl("/api/members/auth/login");
    }

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

        try {
            LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
            return getAuthenticationManager().authenticate(
                    new UsernamePasswordAuthenticationToken(
                            requestDto.getEmail(),
                            requestDto.getPassword(),
                            null
                    )
            );
        } catch (IOException e) {
            logger.error("Error parsing LoginRequestDto in attemptAuthentication", e);
            return null;
        } catch (AuthenticationException e) {
            logger.error("Authentication error in attemptAuthentication", e);
            throw e;
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                            FilterChain chain, Authentication authResult) throws IOException, ServletException {
        String email = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getEmail();
        UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();

        String accessToken = jwtUtil.createAccessToken(email, role.name());
        String refreshToken = jwtUtil.createRefreshToken(email);

        jwtUtil.addAccessTokenToHeader(accessToken, response);  // 액세스 토큰을 헤더에 추가
        jwtUtil.addRefreshTokenToCookie(refreshToken, response);  // 리프레시 토큰을 쿠키에 추가
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        logger.info("로그인 실패");
        response.setStatus(401);
    }
}

JwtAuthorizationFilter

public class JwtAuthorizationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

    public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String accessToken = jwtUtil.getTokenFromHeader(request, "Authorization");

        if (StringUtils.hasText(accessToken)) {
            try {
                // 액세스 토큰이 유효하면 인증 설정
                if (jwtUtil.validateToken(accessToken)) {
                    setAuthentication(jwtUtil.getUserInfoFromToken(accessToken).getSubject());
                }
            } catch (ExpiredJwtException e) {
                // 액세스 토큰이 만료된 경우 리프레시 토큰 사용
                String refreshToken = jwtUtil.getTokenFromCookies(request, JwtUtil.REFRESH_TOKEN_COOKIE);
                if (StringUtils.hasText(refreshToken) && jwtUtil.validateToken(refreshToken)) {
                    String email = jwtUtil.getUserInfoFromToken(refreshToken).getSubject();
                    String role = userDetailsService.findRoleByEmail(email);

                    // 새로운 액세스 토큰 발급 및 헤더 추가
                    String newAccessToken = jwtUtil.createAccessToken(email, role);
                    jwtUtil.addJwtToHeader(newAccessToken, response, "Authorization");

                    // 새 액세스 토큰으로 인증 설정
                    setAuthentication(email);
                } else {
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    return;
                }
            }
        }

        filterChain.doFilter(request, response);
    }


    // 인증 처리
    public void setAuthentication(String email) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        Authentication authentication = createAuthentication(email);
        context.setAuthentication(authentication);

        SecurityContextHolder.setContext(context);
    }

    // 인증 객체 생성
    private Authentication createAuthentication(String email) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(email);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }
}

JwtUtil

@Component
@PropertySource("classpath:application.yml")
public class JwtUtil {
    // Header KEY 값
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String REFRESH_TOKEN_COOKIE = "refresh_token";
    public static final String BEARER_PREFIX = "Bearer ";
    // 사용자 권한 값의 KEY
    public static final String AUTHORIZATION_KEY = "auth";
    // Token 식별자

    // 토큰 만료시간
    private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
    private static final long ACCESS_TOKEN_EXPIRATION = 30 * 60 * 1000L; // 30분
    private static final long REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60 * 1000L; // 7일

    @Value("${spring.jwt.secret.key}") // Base64 Encode 한 SecretKey
    private String secretKey;
    private Key key;
    private static final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    // 로그 설정
    public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }
    // 토큰 생성
    public String createToken(String email, UserRoleEnum role) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(email) // 사용자 식별자값(email)
                        .claim(AUTHORIZATION_KEY, role) // 사용자 권한
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    // 액세스 토큰 생성
    public String createAccessToken(String email, String role) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + ACCESS_TOKEN_EXPIRATION);

        return Jwts.builder()
                .setSubject(email) // 사용자 식별자
                .claim("role", role) // 권한 정보 추가
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    // 리프레시 토큰 생성
    public String createRefreshToken(String email) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION);

        return Jwts.builder()
                .setSubject(email) // 사용자 식별자만 포함
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }


    // 액세스 토큰을 헤더에 추가
    public void addAccessTokenToHeader(String accessToken, HttpServletResponse response) {
        response.setHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + accessToken);
    }

    // 리프레시 토큰을 HttpOnly 쿠키에 추가
    public void addRefreshTokenToCookie(String refreshToken, HttpServletResponse response) {
        Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE, refreshToken);
        cookie.setHttpOnly(true);
        cookie.setSecure(true); // HTTPS에서만 전송되도록 설정
        cookie.setPath("/");
        cookie.setMaxAge(7 * 24 * 60 * 60); // 예: 1주일
        response.addCookie(cookie);
    }


    // JWT Cookie 에 저장
    public void addJwtToCookie(String token, HttpServletResponse res) {
        try {
            // `Bearer `의 공백을 `%20`로 인코딩
            String encodedToken = URLEncoder.encode(token, "utf-8").replace("+", "%20");

            Cookie cookie = new Cookie(AUTHORIZATION_HEADER, encodedToken);
            cookie.setPath("/");
            cookie.setHttpOnly(true); // HttpOnly 설정
            res.addCookie(cookie);
        } catch (UnsupportedEncodingException e) {
            logger.error(e.getMessage());
        }
    }


    // JWT 토큰 substring
    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        logger.error("Not Found Token");
        throw new NullPointerException("Not Found Token");
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            logger.error("Invalid JWT signature");
        } catch (ExpiredJwtException e) {
            logger.error("Expired JWT token");
        } catch (UnsupportedJwtException e) {
            logger.error("Unsupported JWT token");
        } catch (IllegalArgumentException e) {
            logger.error("JWT claims is empty");
        }
        return false;
    }

    public String getTokenFromHeader(HttpServletRequest request, String headerName) {
        // 요청에서 헤더 값 가져오기
        String headerValue = request.getHeader(headerName);

        // 헤더 값이 존재하고, "Bearer "로 시작할 경우
        if (StringUtils.hasText(headerValue) && headerValue.startsWith(BEARER_PREFIX)) {
            // "Bearer "를 제거하고 순수 토큰만 반환
            return headerValue.substring(BEARER_PREFIX.length());
        }

        // 토큰이 없거나 형식이 맞지 않을 경우 null 반환
        return null;
    }

    // 토큰을 헤더에 추가
    public void addJwtToHeader(String token, HttpServletResponse response, String headerName) {
        // 헤더에 "Bearer " 접두사와 함께 토큰을 추가
        response.setHeader(headerName, BEARER_PREFIX + token);
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }
    // 쿠키에서 지정된 이름의 토큰 추출
    public String getTokenFromCookies(HttpServletRequest req, String cookieName) {
        if (req.getCookies() != null) {
            for (Cookie cookie : req.getCookies()) {
                if (cookieName.equals(cookie.getName())) {
                    return cookie.getValue().replace("%20", " "); // 토큰 값 반환
                }
            }
        }
        logger.error("쿠키에서 " + cookieName + " 토큰을 찾을 수 없음");
        return null;
    }

}

TokenController

@RestController
@RequestMapping("/api/token")
public class TokenController {


    private final JwtUtil jwtUtil;
    private final UserService userService;

    public TokenController(JwtUtil jwtUtil, UserService userService) {
        this.jwtUtil = jwtUtil;
        this.userService = userService;
    }

    @PostMapping("/refresh")
    public ResponseEntity<?> refreshAccessToken(HttpServletRequest request, HttpServletResponse response) {
        String refreshToken = jwtUtil.getTokenFromHeader(request, "Refresh-Token");
        String email = jwtUtil.getUserInfoFromToken(refreshToken).getSubject();
        UserRoleEnum role = userService.findRoleByEmail(email);

        if (refreshToken != null && jwtUtil.validateToken(refreshToken)) {

            String newAccessToken = jwtUtil.createAccessToken(email, role.name());

            jwtUtil.addJwtToHeader(newAccessToken, response, "Authorization");
            return ResponseEntity.ok("New access token issued");
        } else {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid refresh token");
        }
    }
}

유저 조회 방법

// 현재 로그인한 유저 객체 반환
    public P_user getUser() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if(authentication != null && authentication.getPrincipal() instanceof UserDetailsImpl userDetailsImpl) {
            String email = userDetailsImpl.getUser().getEmail();
            P_user user = findByEmail(email);
            if (user == null) {
                throw new CustomApiException("해당하는 사용자 없음");
            }
            return user;
        }
        throw new CustomApiException("인증된 사용자 아님");
    }
  • 이렇게 해서 로그인 시 리프레시 토큰을 쿠키에 저장하고, 엑세스 토큰을 헤더에 저장해 전송하는 로직을 완성할 수 있었다.
profile
고민고민고민

0개의 댓글