쇼핑몰 - 5 [spring security + JWT + Redis 로그인]

2024. 11. 19. 12:59·Java/쇼핑몰

✅ 이전 포스팅

2024.11.12 - [Java/쇼핑몰] - 쇼핑몰 - 4 [회원 가입 구현]

 

쇼핑몰 - 4 [회원 가입 구현]

✅ 이전 포스팅2024.11.06 - [Java/쇼핑몰] - 쇼핑몰 - 3 [GitHub Actions를 통한 CI/CD]" data-og-description="✅ 이전 포스팅2024.11.03 - [Java/쇼핑몰] - 쇼핑몰 - 2 " data-og-description="✅ 이전 포스팅2024.11.01 - [Java/쇼핑

magicmk.tistory.com

 

이전 포스팅에서는 쇼핑몰의 회원가입을 구현했다.

이번 포스팅에서는 JWT를 이용해 accessToken, refreshToken을 발급하는 내용을 담도록 한다.

이전에 JWT와 mybatis를 이용하 인증/인가에 대해서 작성한 적이 있는데 해당 포스팅에는 accessToken만 언급되고

refreshToken에 대해서는 구현하지 않았는데 이번 기회에 refreshToken을 구현하고 redis와 연결하도록 한다.


✅ Security Config

package toy.shop.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import toy.shop.jwt.JwtAccessDeniedHandler;
import toy.shop.jwt.JwtAuthenticationFilter;
import toy.shop.jwt.JwtExceptionHandler;
import toy.shop.jwt.JwtProvider;
import toy.shop.service.oauth.CustomFailHandler;
import toy.shop.service.oauth.CustomOAuth2UserService;
import toy.shop.service.oauth.CustomSuccessHandler;

import java.util.Arrays;
import java.util.List;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final String[] allowedUrls = {
            "/swagger-ui/**",
            "/v3/**",
            "/api/global/**",
            "/api/auth/**",
            "/images/**"
    };
    private final JwtProvider jwtProvider;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtExceptionHandler jwtExceptionHandler;

    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomSuccessHandler customSuccessHandler;
    private final CustomFailHandler customFailHandler;

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable)                                                                                           // CSRF 보호 비활성화
                .formLogin(AbstractHttpConfigurer::disable)                                                                                      // Form 로그인 비활성화
                .httpBasic(AbstractHttpConfigurer::disable)                                                                                      // http basic 인증 방식 비활성화
                .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))                                    // X-Frame-Options 설정
                // 경로 작업
                .authorizeHttpRequests(request -> request
                        .requestMatchers(allowedUrls).permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .requestMatchers("/api/**").hasAnyRole("USER", "COMPANY", "ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider, jwtExceptionHandler), UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(handling -> handling
                        .accessDeniedHandler(jwtAccessDeniedHandler))

                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
                                        .userService(customOAuth2UserService))
                        .successHandler(customSuccessHandler)
                        .failureHandler(customFailHandler)
                )

                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));       // Session 사용하지 않음

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000")); // 모든 도메인 허용, 필요시 특정 도메인 설정 가능
        configuration.setAllowedMethods(List.of("*")); // 허용할 HTTP 메서드
        configuration.setAllowedHeaders(List.of("*")); // 모든 헤더 허용
        configuration.setAllowCredentials(true); // 자격 증명 허용

        configuration.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정 적용

        return source;
    }
}

✅ 로그인

🟨 DTO

🔶 LoginRequestDTO

package toy.shop.dto.member;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
@Schema(description = "로그인에 필요한 요청 정보", requiredProperties = {"email", "password"})
public class LoginRequestDTO {

    @Schema(description = "회원 이메일")
    @NotBlank(message = "해당 값은 필수값 입니다.")
    private String email;

    @Schema(description = "회원 비밀번호")
    @NotBlank(message = "해당 값은 필수값 입니다.")
    private String password;
}

 

🟨 Controller

package toy.shop.controller.global;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import toy.shop.dto.Response;
import toy.shop.dto.jwt.JwtResponseDTO;
import toy.shop.dto.member.LoginRequestDTO;
import toy.shop.service.member.MemberService;

import static toy.shop.controller.ResponseBuilder.buildResponse;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController implements AuthControllerDocs {

    private final MemberService memberService;
    
    @PostMapping("/sign-in")
    public  ResponseEntity<Response<?>> signIn(@RequestBody @Valid LoginRequestDTO parameter) {
        JwtResponseDTO result = memberService.login(parameter);

        return buildResponse(HttpStatus.OK, "로그인 성공", result);
    }
}

 

🟨 Service

package toy.shop.service.member;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import toy.shop.dto.jwt.JwtResponseDTO;
import toy.shop.dto.member.LoginRequestDTO;
import toy.shop.jwt.JwtProvider;
import toy.shop.service.RedisService;

import java.util.Optional;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
    
    private final JwtProvider jwtProvider;
    private final RedisService redisService;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    private final String SERVER = "Server";
    
    /**
     * 사용자가 로그인할 때 호출되는 메서드.
     * 사용자 인증 후, JWT 액세스 토큰 및 리프레시 토큰을 생성하여 반환합니다.
     *
     * @param parameter 사용자의 이메일과 비밀번호를 포함하는 로그인 요청 DTO
     * @return 생성된 JWT 정보 (액세스 토큰, 리프레시 토큰 포함)
     */
    @Transactional
    public JwtResponseDTO login(LoginRequestDTO parameter) {
        try {
            Authentication authentication = authenticateUser(parameter.getEmail(), parameter.getPassword());
            String email = authentication.getName();
            String authorities = extractAuthorities(authentication);

            // 토큰 생성 및 저장
            return generateAndStoreToken(SERVER, email, authorities);
        } catch (BadCredentialsException e) {
            throw new BadCredentialsException("사용자 정보가 잘못 되었습니다.");
        }
    }

    /**
     * 사용자 인증을 처리하고 인증된 Authentication 객체를 반환합니다.
     * SecurityContext에 인증 정보를 설정합니다.
     *
     * @param email 사용자의 이메일
     * @param password 사용자의 비밀번호
     * @return 인증된 사용자 정보가 담긴 Authentication 객체
     */
    private Authentication authenticateUser(String email, String password) {
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(email, password);
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        return authentication;
    }

    /**
     * JWT 토큰을 생성하고 리프레시 토큰을 Redis에 저장합니다.
     * 기존에 Redis에 리프레시 토큰이 있을 경우 삭제 후 새로운 토큰을 저장합니다.
     *
     * @param provider 서비스 제공자 정보 (예: "SERVER")
     * @param email 사용자 이메일
     * @param authorities 사용자의 권한 목록을 콤마로 연결한 문자열
     * @return 생성된 JWT 정보 (액세스 토큰 및 리프레시 토큰 포함)
     */
    public JwtResponseDTO generateAndStoreToken(String provider, String email, String authorities) {
        String redisKey = "RT(" + provider + "):" + email;

        // Redis에 기존 RT가 있으면 삭제
        Optional.ofNullable(redisService.getValues(redisKey)).ifPresent(rt -> redisService.deleteValues(redisKey));

        // 새로운 토큰 생성 및 Redis에 저장
        JwtResponseDTO tokenDto = jwtProvider.createToken(email, authorities);
        redisService.setValuesWithTimeout(redisKey, tokenDto.getRefreshToken(), jwtProvider.getTokenExpirationTime(tokenDto.getRefreshToken()));

        return tokenDto;
    }

    /**
     * Authentication 객체에서 사용자의 권한 목록을 추출하여 콤마로 구분된 문자열로 반환합니다.
     *
     * @param authentication 사용자 인증 정보가 담긴 Authentication 객체
     * @return 사용자의 권한 목록을 콤마로 연결한 문자열
     */
    private String extractAuthorities(Authentication authentication) {
        return authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));
    }
}

 

메서드가 잘게 나누어져 있어서 복잡해 보일 수 있지만 JavaDoc을 달아뒀기 때문에 이해하는데 문제는 없으리라 생각된다.

 

🟨 Repository

package toy.shop.repository.member;

import org.springframework.data.jpa.repository.JpaRepository;
import toy.shop.domain.member.Member;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    /**
     * 이메일 주소를 기반으로 회원 정보를 조회하는 메서드입니다.
     *
     * @param email 조회할 회원의 이메일 주소
     * @return 주어진 이메일 주소를 가진 회원을 Optional로 반환하며, 존재하지 않으면 Optional.empty()를 반환합니다.
     */
    Optional<Member> findByEmail(String email);

    /**
     * 주어진 이메일 주소를 가진 회원이 존재하는지 확인하는 메서드입니다.
     *
     * @param email 존재 여부를 확인할 이메일 주소
     * @return 해당 이메일 주소를 가진 회원이 존재하면 true, 존재하지 않으면 false를 반환합니다.
     */
    boolean existsByEmail(String email);
}

✅ JWT

🟨 JwtProvider

package toy.shop.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import toy.shop.cmmn.exception.JwtAuthenticationException;
import toy.shop.dto.jwt.JwtResponseDTO;
import toy.shop.service.RedisService;

import java.security.Key;
import java.util.Date;

@Slf4j
@Component
@Transactional(readOnly = true)
public class JwtProvider implements InitializingBean {

    private final UserDetailsServiceImpl userDetailsService;
    private final RedisService redisService;

    private static final String AUTHORITIES_KEY = "role";
    private static final String EMAIL_KEY = "email";

    private final String secretKey;
    private static Key signingKey;

    private final Long accessTokenValidityInMilliseconds;
    private final Long refreshTokenValidityInMilliseconds;

    public JwtProvider(
            UserDetailsServiceImpl userDetailsService,
            RedisService redisService,
            @Value("${jwt.secret}") String secretKey,
            @Value("${jwt.access_expire_time}") Long accessTokenValidityInMilliseconds,
            @Value("${jwt.refresh_expire_time}") Long refreshTokenValidityInMilliseconds) {
        this.userDetailsService = userDetailsService;
        this.redisService = redisService;
        this.secretKey = secretKey;
        this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds;
        this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds;
    }

    // 시크릿 키 설정
    @Override
    public void afterPropertiesSet() throws Exception {
        byte[] secretKeyBytes = Decoders.BASE64.decode(secretKey);
        signingKey = Keys.hmacShaKeyFor(secretKeyBytes);
    }

    /**
     * 액세스 토큰과 리프레시 토큰을 생성합니다.
     *
     * @param email       사용자 이메일
     * @param authorities 사용자 권한
     * @return JwtResponseDTO 액세스 토큰과 리프레시 토큰이 포함된 DTO
     */
    @Transactional
    public JwtResponseDTO createToken(String email, String authorities){
        Long now = System.currentTimeMillis();

        String accessToken = Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setHeaderParam("alg", "HS512")
                .setExpiration(new Date(now + accessTokenValidityInMilliseconds))
                .setSubject("access-token")
                .claim(EMAIL_KEY, email)
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(signingKey, SignatureAlgorithm.HS512)
                .compact();

        String refreshToken = Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setHeaderParam("alg", "HS512")
                .setExpiration(new Date(now + refreshTokenValidityInMilliseconds))
                .setSubject("refresh-token")
                .signWith(signingKey, SignatureAlgorithm.HS512)
                .compact();

        return JwtResponseDTO.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    /**
     * 토큰으로부터 Claims 객체를 추출합니다.
     *
     * @param token JWT 토큰
     * @return Claims 토큰에서 추출된 클레임 정보
     */
    public Claims getClaims(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(signingKey)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException e) { // Access Token
            return e.getClaims();
        }
    }

    /**
     * 토큰으로부터 인증 정보를 추출합니다.
     *
     * @param token JWT 토큰
     * @return Authentication 인증 정보
     */
    public Authentication getAuthentication(String token) {
        String email = getClaims(token).get(EMAIL_KEY).toString();
        UserDetailsImpl userDetailsImpl = userDetailsService.loadUserByUsername(email);
        return new UsernamePasswordAuthenticationToken(userDetailsImpl, "", userDetailsImpl.getAuthorities());
    }

    /**
     * 토큰의 만료 시간을 반환합니다.
     *
     * @param token JWT 토큰
     * @return long 토큰 만료 시간 (밀리초 단위)
     */
    public long getTokenExpirationTime(String token) {
        return getClaims(token).getExpiration().getTime();
    }

    /**
     * 리프레시 토큰의 유효성을 검증합니다.
     *
     * @param refreshToken 리프레시 토큰
     * @return boolean 유효한 경우 true, 그렇지 않으면 false
     */
    public boolean validateRefreshToken(String refreshToken) {
        try {
            String redisValue = redisService.getValues(refreshToken);
            if ("delete".equals(redisValue)) { // 회원 탈퇴 여부 확인
                return false;
            }
            Jwts.parserBuilder()
                    .setSigningKey(signingKey)
                    .build()
                    .parseClaimsJws(refreshToken);
            return true;
        } catch (SignatureException e) {
            log.error("Invalid JWT signature.");
        } catch (MalformedJwtException e) {
            log.error("Invalid JWT token.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims string is empty.");
        } catch (NullPointerException e) {
            log.error("JWT Token is empty.");
        }
        return false;
    }

    /**
     * HTTP 요청의 Authorization 헤더에서 JWT 토큰을 추출합니다.
     *
     * @param httpServletRequest HttpServletRequest 객체
     * @return String 추출된 토큰 또는 null
     */
    public String resolveToken(HttpServletRequest httpServletRequest) {
        String bearerToken = httpServletRequest.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    /**
     * 액세스 토큰의 유효성을 검증합니다.
     *
     * @param accessToken 액세스 토큰
     * @throws JwtAuthenticationException 토큰이 유효하지 않은 경우 예외 발생
     */
    public void validateAccessToken(String accessToken) throws JwtAuthenticationException {
        try {
            if (redisService.getValues(accessToken) != null // NPE 방지
                    && redisService.getValues(accessToken).equals("logout")) { // 로그아웃 했을 경우
                throw new JwtAuthenticationException("로그아웃된 토큰입니다.");
            }

            Jwts.parserBuilder()
                    .setSigningKey(signingKey)
                    .build()
                    .parseClaimsJws(accessToken);

        } catch (SignatureException e) {
            log.error("Invalid JWT signature.");
            throw new JwtAuthenticationException("Invalid JWT signature.");
        } catch (MalformedJwtException e) {
            log.error("Invalid JWT token.");
            throw new JwtAuthenticationException("Invalid JWT token.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token.");
            throw new JwtAuthenticationException("Expired JWT token.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token.");
            throw new JwtAuthenticationException("Unsupported JWT token.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims string is empty.");
            throw new JwtAuthenticationException("JWT claims string is empty.");
        } catch (NullPointerException e) {
            log.error("JWT Token is empty.");
            throw new JwtAuthenticationException("JWT Token is empty.");
        }
    }

    /**
     * 토큰이 만료되었는지 확인합니다.
     *
     * @param token JWT 토큰
     * @return boolean 만료된 경우 true, 그렇지 않으면 false
     */
    public boolean isTokenExpired(String token) {
        try {
            Date expiration = getClaims(token).getExpiration();
            return expiration.before(new Date());
        } catch (Exception e) {
            return true; // 만료된 것으로 간주
        }
    }
}

 

JWTProvider 또한 내용이 많아서 보기 어려울 수 있기 때문에 JavaDoc을 달아두었습니다.

 

🟨 JwtAuthenticationFilter

package toy.shop.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import toy.shop.cmmn.exception.JwtAuthenticationException;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final JwtExceptionHandler jwtExceptionHandler;
    private static final AntPathMatcher pathMatcher = new AntPathMatcher();
    private static final List<String> EXCLUDED_URLS = Arrays.asList(
            "/swagger-ui/**",
            "/v3/**",
            "/api/global/**",
            "/api/auth/**",
            "/images/**"
    );

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        // 제외할 경로일 경우 필터 건너뛰기
        String requestURI = request.getRequestURI();
        if (EXCLUDED_URLS.stream().anyMatch(pattern -> pathMatcher.match(pattern, requestURI))) {
            filterChain.doFilter(request, response);
            return;
        }

        // Access Token 추출
        String accessToken = jwtProvider.resolveToken(request);

        try {
            if (accessToken == null) {
                throw new JwtAuthenticationException("토큰이 존재하지 않습니다.");
            }

            // 정상 토큰인지 검사
            jwtProvider.validateAccessToken(accessToken);
            Authentication authentication = jwtProvider.getAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (JwtAuthenticationException e) {
            SecurityContextHolder.clearContext();
            // 예외를 핸들러로 전달하기 위해 필터 체인 진행 차단
            jwtExceptionHandler.commence(request, response, e);
            return;
        }

        filterChain.doFilter(request, response);
    }
}

 

SecurityConfig에서 제외 URL을 설정해도 JWT Filter에서 걸려서 URL 제외를 설정 한 뒤 토큰을 검사하고

토큰일 올바르지 않다면 예외처리를 진행하였다.

 

🟨 JwtExceptionHandler

package toy.shop.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import toy.shop.dto.Response;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtExceptionHandler implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        Response<?> result = Response.builder()
                .status(HttpStatus.UNAUTHORIZED.value())
                .message(authException.getMessage())
                .data(null)
                .build();
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setCharacterEncoding("utf-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE); // application/json
        response.getWriter().write(objectMapper.writeValueAsString(result));
    }
}

 

🟨 JwtAccessDeniedHandler

package toy.shop.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import toy.shop.dto.Response;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        Response<?> result = Response.builder()
                .status(HttpStatus.FORBIDDEN.value())
                .message("해당 리소스에 접근할 권한이 없습니다.")
                .data(null)
                .build();

        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setCharacterEncoding("utf-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE); // application/json
        response.getWriter().write(objectMapper.writeValueAsString(result));
    }
}

 

🟨 UserDetail

🔶 UserDetailsImpl

package toy.shop.jwt;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import toy.shop.domain.Role;
import toy.shop.domain.member.Member;

import java.util.ArrayList;
import java.util.Collection;

public class UserDetailsImpl implements UserDetails {

    private final Member member;

    public UserDetailsImpl(Member member) {
        this.member = member;
    }

    public Long getUserId() {
        return member.getId();
    }

    public Role getRole() {
        return member.getRole();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(() -> member.getRole().getRole()); // key: ROLE_권한
        return authorities;
    }

    @Override
    public String getUsername() {
        return member.getEmail();
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }


    // == 세부 설정 == //
    @Override
    public boolean isAccountNonExpired() { // 계정의 만료 여부
        return true;
    }

    @Override
    public boolean isAccountNonLocked() { // 계정의 잠김 여부
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() { // 비밀번호 만료 여부
        return true;
    }

    @Override
    public boolean isEnabled() { // 계정의 활성화 여부
        return true;
    }
}

 

🔶 UserDetailsServiceImpl

package toy.shop.jwt;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import toy.shop.domain.member.Member;
import toy.shop.repository.member.MemberRepository;

@Component
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetailsImpl loadUserByUsername(String email) throws UsernameNotFoundException {
        Member findUser = memberRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다. : " + email));

        if(findUser != null){
            UserDetailsImpl userDetails = new UserDetailsImpl(findUser);
            return  userDetails;
        }

        return null;
    }
}

✅ Redis

package toy.shop.service;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class RedisService {
    
    private final RedisTemplate<String, String> redisTemplate;

    public void setValues(String key, String value){
        redisTemplate.opsForValue().set(key, value);
    }

    // 만료시간 설정 -> 자동 삭제
    public void setValuesWithTimeout(String key, String value, long timeout){
        redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.MILLISECONDS);
    }

    public String getValues(String key){
        return redisTemplate.opsForValue().get(key);
    }

    public void deleteValues(String key) {
        redisTemplate.delete(key);
    }
}

✅ Test

🟨 로그인 실패

사용자 정보 실패

 

🟨 로그인 성공

로그인 성공

 

🟨 Redis 정보 저장

Redis


✅ 마무리

이번 포스팅은 기본적인 로그인의 비즈니스 로직과 해당 로직에서 활용되는 JWT 로직, Redis 로직에 대해 알아봤다.

OAuth2 로그인도 함께 작성할까 했지만 내용이 길어지는 관계로 다음 포스팅에...

 

https://github.com/sideProject-org/shop-back

 

GitHub - sideProject-org/shop-back: 쇼핑몰 서버

쇼핑몰 서버. Contribute to sideProject-org/shop-back development by creating an account on GitHub.

github.com

저작자표시 비영리 (새창열림)

'Java > 쇼핑몰' 카테고리의 다른 글

쇼핑몰 7 - [이메일로 비밀번호 변경 URL 전송]  (0) 2024.12.13
쇼핑몰 - 6 [Spring Security + JWT + OAuth2 + Redis]  (2) 2024.12.02
쇼핑몰 - 4 [회원 가입 구현]  (0) 2024.11.12
쇼핑몰 - 3 [GitHub Actions를 통한 CI/CD]  (3) 2024.11.06
쇼핑몰 - 2 [Docker-compose 사용하기]  (0) 2024.11.03
'Java/쇼핑몰' 카테고리의 다른 글
  • 쇼핑몰 7 - [이메일로 비밀번호 변경 URL 전송]
  • 쇼핑몰 - 6 [Spring Security + JWT + OAuth2 + Redis]
  • 쇼핑몰 - 4 [회원 가입 구현]
  • 쇼핑몰 - 3 [GitHub Actions를 통한 CI/CD]
요술공주밍키
요술공주밍키
조금씩이라도 꾸준히..
  • 요술공주밍키
    삽질의흔적
    요술공주밍키
  • 전체
    오늘
    어제
    • 분류 전체보기 (139)
      • Java (42)
        • Spring Boot (14)
        • Spring Boot 게시판 (14)
        • 공중화장실 찾기 (4)
        • 쇼핑몰 (8)
      • JavaScript (8)
        • NodeJS (2)
      • Python (5)
        • Django (4)
      • Server (10)
        • Docker (4)
        • K8S (0)
        • Jenkins (1)
      • 알고리즘 (24)
        • 프로그래머스 (19)
        • 백준 (5)
      • Etc (21)
        • 개발 팁 (1)
      • 일상 (27)
        • 독서 포스트 (25)
        • 회고록 (2)
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
요술공주밍키
쇼핑몰 - 5 [spring security + JWT + Redis 로그인]
상단으로

티스토리툴바