[Spring] Jwt 방식 인증방식 + Security 로그인 로그아웃

hyewon jeong·2023년 6월 28일
1

Spring

목록 보기
50/59

프로젝트환경

  • 스프링부트 : 2.7
  • java 11
  • 인텔리 제이

email을 username(id) 로 설정함

1. JWT 구현

1-1. JWT dependency 추가하기

    //jwt
    compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

1-2. application.yml secretkey 설정

jwt:
  secret:
    key: 7ZWt7ZW0OTntmZTsnbTtjIXtlZzqta3snYTrhIjrqLjshLjqs4TroZzrgpjslYTqsIDsnpDtm4zrpa3tlZzqsJzrsJzsnpDrpbzrp4zrk6TslrTqsIDsnpA=

1-3. UserDetailsImpl

package study.wonyshop.security.service;

@Getter
@NoArgsConstructor
public class UserDetailsImpl implements UserDetails {

  private User user;
  private String email;//username 즉 id로 email 사용함

  public UserDetailsImpl(User user, String email) {
    this.user = user;
    this.email = email;
  }


  @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); // 권한을 simpleGrantedAuthority로 추상화하여 관리함
    return authorities;
  }

  @Override
  public String getPassword() {
    return null;
  }

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

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

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

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

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

1-4. UserDetailsServiceImpl

/**
 * 이메일을 통해 유저정보를 담은 UserDetails 을 반환하는 서비스
 */
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;

  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    User user = findByEmail(email);
    return new UserDetailsImpl(user,user.getEmail());
  }


  private User findByEmail(String email) {
    return userRepository.findByEmail(email).orElseThrow(
        ()->new CustomException(ExceptionStatus.WRONG_EMAIL)
    );
  }
  }

1-5. JwtProvider

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtProvider {

  // Header KEY 값
  public static final String AUTHORIZATION_HEADER = "Authorization";
  // 사용자 권한 값의 KEY
  private static final String AUTHORIZATION_KEY = "auth";
  // Token 식별자
  private static final String BEARER_PREFIX = "Bearer ";

  private static final long ACCESS_TOKEN_TIME =
      1000 * 60 * 30L; // 30 분 1000ms(=1s) *60=(1min)*30 =(30min)
  private static final long REFRESH_TOKEN_TIME = 1000 * 60 * 60 * 24 * 7L;// 7일

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

  //HMAC-SHA 키를 생성
  private Key key;
  private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;


  private final UserDetailsService userDetailsService;
  private final RedisDao redisDao;

  // 이 코드는 HMAC-SHA 키를 생성하는 데 사용되는 Base64 인코딩된 문자열을 디코딩하여 키를 초기화하는 용도로 사용
  @PostConstruct//의존성 주입이 이루어진 후 초기화를 수행하는 어노테이션
  public void init() {
    byte[] bytes = Base64.getDecoder()
        .decode(secretKey);// Base64로 인코딩된 값을 시크릿키 변수에 저장한 값을 디코딩하여 바이트 배열로 변환
    //* Base64 (64진법) : 바이너리(2진) 데이터를 문자 코드에 영향을 받지 않는 공통 ASCII문자로 표현하기 위해 만들어진 인코딩
    key = Keys.hmacShaKeyFor(
        bytes);//디코팅된 바이트 배열을 기반으로 HMAC-SHA 알고르즘을 사용해서 Key객체로 반환 , 이를 key 변수에 대입
  }

  /**
   * Header 에서 토큰 가져오기
   *
   * @param request
   * @return
   */
  public String resolveToken(HttpServletRequest request) {
    String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
      return bearerToken.substring(7);
    }
    return null;
  }

  /**
   * 토큰 생성 메서드 username 대신 email
   *
   * @param role
   * @param tokenExpireTime
   * @return Token
   */

  private String createToken(String email, UserRoleEnum role, Long tokenExpireTime) {
    Date date = new Date();
    return BEARER_PREFIX + Jwts.builder()
        .claim(AUTHORIZATION_KEY, role)// JWT에 사용자 역할 정보를 클레임(claim)으로 추가합니다.
        .setSubject(email)//JWT의 주제(subject)를 사용자 이름으로 설정합니다.
        .setIssuedAt(date)
        .setExpiration(new Date(date.getTime() + tokenExpireTime))
        .signWith(key, SignatureAlgorithm.HS256) //: JWT에 서명을 추가합니다. key는 서명에 사용되는 비밀 키이며,
        .compact();
  }

  /**
   * 유저 로그인 후 토큰 발행 username 대신 email
   *
   * @param role
   * @return 에세스토큰과 리프레쉬토큰을 담은 DTO 반환
   */
  public TokenResponse createTokenByLogin(String email, UserRoleEnum role) {
    String accessToken = createToken(email, role, ACCESS_TOKEN_TIME);
    String refreshToken = createToken(email, role, REFRESH_TOKEN_TIME);
    redisDao.setRefreshToken(email, refreshToken, REFRESH_TOKEN_TIME);
    return new TokenResponse(accessToken, refreshToken);
  }

  //AccessToken 재발행 + refreshToken 함께 발행

  public TokenResponse reissueAtk(String email, UserRoleEnum role, String reToken) {
    // 레디스 저장된 리프레쉬토큰값을 가져와서 입력된 reToken 같은지 유무 확인
    if (!redisDao.getRefreshToken(email).equals(reToken)) {
      throw new CustomException(
          ExceptionStatus.AUTHENTICATION);
    }
    String accessToken = createToken(email, role, ACCESS_TOKEN_TIME);
    String refreshToken = createToken(email, role, REFRESH_TOKEN_TIME);
    redisDao.setRefreshToken(email, refreshToken, REFRESH_TOKEN_TIME);
    return new TokenResponse(accessToken, refreshToken);
  }

  /**
   *  토큰으로 유저정보 가져오기
   * @param token
   * @return
   */
  public Claims getUserInfoFromToken(String token){
    return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
  }


  /**  Header에서 가져온 토큰 검증하는 메소드
   *
   * @param token
   * @return
   */
  public boolean validateToken(String token) {
    try {
      //parser : parsing을 하는 도구. parsing : token에 내재된 자료 구조를 빌드하고 문법을 검사한다.
      //Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)는
      // 주어진 토큰을 파싱하기 위해 JWT 파서를 설정하고, 서명 키를 설정한 뒤, 토큰을 파싱하여 JWT 서명 검사를 수행합니다.
      Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
      return true; // 유효하면 true
    } catch (SecurityException | MalformedJwtException |
             UnsupportedJwtException e) {// 전: 권한 없다면 발생 , 후: JWT가 올바르게 구성되지 않았다면 발생
      log.info("Invalid JWT token, 만료된 jwt 토큰 입니다.");
    } catch (IllegalArgumentException e) {
      log.info("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
    }
    return false;
  }

  /**
   * 남은 에세스토큰의 만료시간 조회
   * @param accessToken
   * @return
   */
  public Long getExpiration(String accessToken){
    //에세스 토큰 만료시간
    Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody()
        .getExpiration();
    //현재시간
    long now = new Date().getTime();
    return (expiration.getTime()-now);
  }

  /**
   * 일반 유저 인증 객체 생성
   * @param email
   * @return
   */
  public Authentication createUserAuthentication(String email) {
    UserDetails userDetails = userDetailsService.loadUserByUsername(email);
    return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
  }

}

1-6. JwtAuthFilter

@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

  private final RedisDao redisDao;
  private final JwtProvider jwtProvider;

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws IOException, ServletException {
    String token = jwtProvider.resolveToken(request);
    if (token != null) {
    String blackList = redisDao.getBlackList(token);
      if (blackList != null) {
        if (blackList.equals("logout")) {
          throw new IllegalArgumentException("Please Login again.");
        }
      }
     if(!jwtProvider.validateToken(token)){
       response.sendError(401, "만료되었습니다.");
       jwtExceptionHandler(response,"401", HttpStatus.BAD_REQUEST.value() );
       return;
     }
     // 검증 후 인증 객체 생성하여 securityContextHolder에서 관리
      Claims userInfo = jwtProvider.getUserInfoFromToken(token);
     setAuthentication(userInfo.getSubject());//subject = email
    }
    filterChain.doFilter(request,response);
  }

  private void setAuthentication(String email ) {
    SecurityContext context = SecurityContextHolder.createEmptyContext();
    Authentication authentication = jwtProvider.createUserAuthentication(email);
    context.setAuthentication(authentication);
    SecurityContextHolder.setContext(context);

  }



  public void jwtExceptionHandler(HttpServletResponse response, String msg, int statusCode) {
    response.setStatus(statusCode);
    response.setContentType("application/json");
    try {
      String json = new ObjectMapper().writeValueAsString(new SecurityExceptionDto(statusCode, msg));
      //, ObjectMapper를 사용하여 SecurityExceptionDto 객체를 JSON 문자열로 변환
      response.getWriter().write(json); //JSON 문자열을 응답으로 작성
    } catch (Exception e) {
      log.error(e.getMessage());
    }
  }
}

1-7 TokenResponseDto

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
public class TokenResponse {

  private final String accessToken;
  private final String refreshToken;

  public TokenResponse(String accessToken, String refreshToken) {
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
  }
}

2. Spring Security 구현

2-1. Spring Security dependency 추가하기

// 스프링 시큐리티
    implementation 'org.springframework.boot:spring-boot-starter-security'

2-2. webConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
//@EnableGlobalMethodSecurity(securedEnabled = true) // @Secured 어노테이션 활성화
@EnableMethodSecurity // 위 어노테이션은 Deprecated
@EnableScheduling // @Scheduled 어노테이션 활성화
public class WebSecurityConfig implements WebMvcConfigurer {
  private final JwtProvider jwtProvider;
  private final RedisDao redisDao;

  private final CustomAccessDeniedHandler customAccessDeniedHandler;
  private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

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


  @Bean
  public WebSecurityCustomizer webSecurityCustomizer() {
    // h2-console 사용 및 resources 접근 허용 설정
    return (web) -> web.ignoring()
        .requestMatchers(PathRequest.toH2Console())
        .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
  }

  /**
   * 필터를 타고 세션이 아닌 jwt방식을 타고 권한을
   * 확인하는 메소드
   * 스프링부트 3,0 미만은 .antMatchers() 로,
   *          3.0 이상은 .requestMatchers() 로 해야 오류가 발생하지 않음
   */
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.csrf().disable();
    // 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

    http.authorizeHttpRequests()
        .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
        .antMatchers("/api/users/signup").permitAll()
        .antMatchers("/api/users/login").permitAll()
//        .antMatchers("/owner/**").hasAnyRole("Owner", "Manager", "GeneralManager")
//              .requestMatchers("/api/general/**").hasRole("GeneralManager")
        .anyRequest().authenticated()//인증이 되어야 한다는 이야기이다.
        .and()
        // JWT 인증/인가를 사용하기 위한 설정
        .addFilterBefore(new JwtAuthFilter(redisDao, jwtProvider),
            UsernamePasswordAuthenticationFilter.class);
    // xss 공격을 막기위한 필터 설정
//            .addFilterBefore(new XssEscapeFilter(xssEscapeUtil), CsrfFilter.class)

    // 401 Error 처리, Authorization, 인증과정에서 실패할 시 처리
    http.exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint);
    // 403 Error 처리, 인증과는 별개로 추가적인 권한이 충족되지 않는 경우
    http.exceptionHandling().accessDeniedHandler(customAccessDeniedHandler);
    return http.build();
  }
  @Override
  public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**")
        .allowedOrigins("*")
        .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD")
        .allowedOriginPatterns("*")
        .exposedHeaders("Authorization");
  }
  }

2-3. 인증 인가 예외처리

2-3-1. SecurityExceptionDto

@Getter
@NoArgsConstructor
public class SecurityExceptionDto {

  private int statusCode;
  private String msg;

  public SecurityExceptionDto(int statusCode, String msg) {
    this.statusCode = statusCode;
    this.msg = msg;
  }
}

2-3-2. CustomAccessDeniedHandler

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
  private static final SecurityExceptionDto exceptionDto =
      new SecurityExceptionDto(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());

  @Override
  public void handle(HttpServletRequest request, HttpServletResponse response,
      AccessDeniedException accessDeniedException) throws IOException {

    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setStatus(HttpStatus.FORBIDDEN.value());

    try (OutputStream os = response.getOutputStream()) {
      ObjectMapper objectMapper = new ObjectMapper();
      objectMapper.writeValue(os, exceptionDto);
      os.flush();
    }

  }

}

2-3-3. CustomAuthenticationEntryPoint

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
  private static final SecurityExceptionDto exceptionDto =
      new SecurityExceptionDto(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());

  @Override
  public void commence(HttpServletRequest request,
      HttpServletResponse response,
      AuthenticationException authenticationException) throws IOException {

    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setStatus(HttpStatus.UNAUTHORIZED.value());

    try (OutputStream os = response.getOutputStream()) {
      ObjectMapper objectMapper = new ObjectMapper();
      objectMapper.writeValue(os, exceptionDto);
      os.flush();
    }
  }
}

3. 로그인 , 로그아웃 구현

3-1. SignUpRequest

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
public class SignUpRequest {

  // 회원가입 비밀번호 재확인 추가
// 정규식이 틀렸을때 발생하는 예외 MethodArgumentNotValidException
// @NotBlank = null 과 "" 과 " " 모두 비허용, @Notnull = "" 이나 " " 은 허용, @NotEmpty = null 과 "" 은 불가, " " 은 허용
  @Email
  private final String email;
  @Pattern(regexp = "[a-zA-Z0-9]{4,8}$", message = "닉네임은 최소 4자 이상, 8자 이하이며, 영문과 숫자만 입력하세요.")
  private final String nickName;

  @Pattern(regexp = "(?=.*[a-zA-Z])(?=.*[0-9])^[a-zA-Z0-9~!@#$%^&*()+|=]{8,15}$", message = "비밀번호는 최소 8자 이상, 15자 이하이며, 영문과 숫자, 특수문자만 입력하세요.")
  private final String password;

  @Pattern(regexp = "(?=.*[a-zA-Z])(?=.*[0-9])^[a-zA-Z0-9~!@#$%^&*()+|=]{8,15}$", message = "비밀번호를 확인해주세요.")
  private final String password2;

  @Pattern(regexp = "(?=.*[0-9])^[0-9]{11}$", message = "-을 제외한 10자리 번호를 입력해주세요")
  private final String phoneNumber;

  @NotBlank
  private final String address;

//  private boolean admin = false;
//
//  private String adminToken = "";

  @Builder
  public SignUpRequest(String email, String nickName, String password, String password2,
      String phoneNumber, String address) {
    this.email = email;
    this.nickName = nickName;
    this.password = password;
    this.password2 = password2;
    this.phoneNumber = phoneNumber;
    this.address = address;
  }

  /**
   * DTO -> Entity
   * @param role
   * @param encodedPassword
   * @return
   */
  public User toEntity(UserRoleEnum role, String encodedPassword) {
    return User.builder()
        .nickname(nickName)
        .email(email)
        .phoneNumber(phoneNumber)
        .password(encodedPassword)
        .address(address)
        .role(role)
        .build();
  }
}

3-2. LoginRequest

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
public class LoginRequest {

private final String email;
private final String password;

  public LoginRequest(String email, String password) {
    this.email = email;
    this.password = password;
  }


}

3-3. UserResponse

@Getter
@NoArgsConstructor(force = true)
public class UserResponse {

  private final String email;
  private final String nickname;
  private final String phoneNumber;
  private final UserRoleEnum role;
  private final String address;
  private final String profileImage;

  /**
   *  유저 생성자를 private로 외부에서 생성 할수 없도록 함
   * @param user
   */
  private UserResponse(User user) {
    this.email = user.getEmail();
    this.nickname = user.getNickname();
    this.phoneNumber = user.getPhoneNumber();
    this.role = user.getRole();
    this.address = user.getAddress();
    this.profileImage = user.getProfileImage();
  }

  /**
   *  유저 생성자를 private로 외부에서 생성 할수 없도록 함으로 써
   *  of 메서드를 통해
   *  유저 객체를 DTO에 담아 반환해줍니다.
   * @param user
   * @return
   */
  public static UserResponse of(User user){
     return new UserResponse(user);
  }


}

3-4. ReissueTokenRequest

package study.wonyshop.security.dto;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
public class ReissueTokenRequest {
 private final String refreshToken;

  public ReissueTokenRequest(String refreshToken) {
    this.refreshToken = refreshToken;
  }
}

3-5. User

@Entity
@Table(name = "USERS") //테이블 user 예약어 있어서 사용할 수 없음)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends TimeStamped {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "user_id", nullable = false)
  private Long id;
  @Column(nullable = false, unique = true)
  private String email;

  @Column(nullable = false, unique = true)
  private String nickname;
  @Column(nullable = false)
  private String password;

  @Column(nullable = false)
  private String address;
  @Setter
  private String profileImage;

  @Column(nullable = false)
  @Enumerated(value = EnumType.STRING)
  private UserRoleEnum role;
  @Column(nullable = false, unique = true)
  private String phoneNumber;
  @OneToMany(mappedBy = "user")
  private List<Order> orders = new ArrayList<>();

  private Boolean inUser ; // 추후 휴면계정 관리 할때 사용 하기 위함

  @Builder
  public User(String email, String nickname, String password, String address, String profileImage,
      UserRoleEnum role, String phoneNumber) {
    this.email = email;
    this.nickname = nickname;
    this.password = password;
    this.address = address;
    this.profileImage = profileImage;
    this.role = role;
    this.phoneNumber = phoneNumber;
  }
}

3-6. UserRoleEnum

@Getter
public enum UserRoleEnum {
  ADMIN(Authority.ADMIN),
  MEMBER(Authority.MEMBER),
  SELLER(Authority.SELLER);
  
  
  private final String authority;

  UserRoleEnum(String authority) {
    this.authority = authority;
  }
  
  public static class Authority{
    public static final String ADMIN = "ROLE_ADMIN";
    public static final String SELLER = "ROLE_SELLER";
    public static final String MEMBER = "ROLE_MEMBER";
    
  }
}

3-7. UserController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserController {


  private final UserService userService;
  private final JwtProvider jwtProvider;

  /**
   * 회원가입
   *
   * @param signUpRequest
   * @return 회원가입 성공
   */
  @PostMapping("/signup")
  public ResponseEntity signup(@RequestBody @Valid SignUpRequest signUpRequest) {
    //패스워드 1과 패스워드 2 가 동일 한지 체크
    if (!signUpRequest.getPassword().equals(signUpRequest.getPassword2())) {
      throw new IllegalArgumentException("비밀번호가 일치하지 않습니다. 다시 입력해주세요.");
    }
    return userService.signup(signUpRequest);
  }

  /**
   * 로그인
   * 로그인 시 atk, rtk 이 생성되어 response header 에 담아 보낸다.
   * rtk 는 레디스에 저장한다. 추후 atk 만료시 rtk를 이용해 atk 재발급 하기 위함
   */

  @PostMapping("/login")
  public TokenResponse login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
    UserResponse user = userService.login(loginRequest);
    TokenResponse token = jwtProvider.createTokenByLogin(user.getEmail(),
        user.getRole());//atk, rtk 생성
    response.addHeader(jwtProvider.AUTHORIZATION_HEADER, token.getAccessToken());// 헤더에 에세스 토큰만 싣기
    return token;
  }

  /**
   * 로그아웃
   * 현 accessToken 은 다시 사용하지 못하도록 레디스에 저장해두고,
   * 로그아웃시 레디스에 저장된 refreshToken 삭제
   * @param userDetails
   * @param request
   * @return
   */
  @DeleteMapping("/logout")
  public ResponseEntity logout(@AuthenticationPrincipal UserDetailsImpl userDetails, HttpServletRequest request){
    String accessToken = jwtProvider.resolveToken(request);
    return userService.logout(accessToken,userDetails.getUsername());//username = email

  }

  /**
   *  해당 유저의 정보 확인
   * @param userDetails
   * @return
   */
  @GetMapping("/user-info")
  public UserResponse getUserInfo(@AuthenticationPrincipal UserDetailsImpl userDetails){
    return userService.getUserInfo(userDetails.getUsername());//username = email
  }

  /**
   *  AccessToken  재발급
   * 매 API 호출 시 시큐리티필터를 통해 인증인가를 받게  된다. 이때 만료된 토큰인지 검증하고 만료시 만료된토큰임을 에러메세지로 보낸다.
   * 그럼 클라이언트에서 에러메세지를 확인 후 이 api(atk 재발급 ) 을 요청 하게 된다. 
   * @param userDetails
   * @param tokenRequest : refreshToken 
   * @return AccessToken + RefreshToken 
   */
  @PostMapping("/reissue-token")
  public TokenResponse reissueToken(@AuthenticationPrincipal UserDetailsImpl userDetails,
      @RequestBody ReissueTokenRequest tokenRequest){
    //유저 객체 정보를 이용하여 토큰 발행
    UserResponse user = UserResponse.of(userDetails.getUser());
    return jwtProvider.reissueAtk(user.getEmail(),user.getRole(), tokenRequest.getRefreshToken());
  }

}

3-8. UserService

@Service
@RequiredArgsConstructor
public class UserService {

  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;

  private final JwtProvider jwtProvider;
  private final RedisDao redisDao;

  /**
   * 회원가입
   *
   * @param signUpRequest
   * @return
   */
  @Transactional
  public ResponseEntity signup(@Valid SignUpRequest signUpRequest) {
    String email = signUpRequest.getEmail();
    String nickname = signUpRequest.getNickName();
    String password = passwordEncoder.encode(signUpRequest.getPassword());
    String phoneNumber = signUpRequest.getPhoneNumber();
    String address = signUpRequest.getAddress();

    //닉네임 중복 확인
    Optional<User> findNickname = userRepository.findByNickname(nickname);
    if (findNickname.isPresent()) {
      throw new CustomException(ExceptionStatus.DUPLICATED_NICKNAME);
    }
    // 이메일 중복 확인
    Optional<User> findEmail = userRepository.findByEmail(email);
    if (findEmail.isPresent()) {
      throw new CustomException(ExceptionStatus.DUPLICATED_EMAIL);
    }
    // 폰번호 중복 확인
    Optional<User> findPhoneNumber = userRepository.findByPhoneNumber(phoneNumber);
    if (findPhoneNumber.isPresent()) {
      throw new CustomException(ExceptionStatus.DUPLICATED_PHONENUMBER);
    }
    UserRoleEnum role = UserRoleEnum.MEMBER;
    User user = signUpRequest.toEntity(role, password);

    user.setProfileImage("default.png");

    userRepository.save(user);
    return ResponseEntity.ok("회원가입 성공");
  }

  /**
   * 로그인 반환값으로 user를 userResponseDto 담아 반환하고  컨트롤러에서 반환된 객체를 이용하여 토큰 발행한다.
   */
  @Cacheable(cacheNames = CacheNames.LOGINUSER, key = "'login'+ #p0.getEmail()", unless = "#result== null")
  @Transactional
  public UserResponse login(LoginRequest loginRequest) {
    String email = loginRequest.getEmail();
    String password = loginRequest.getPassword();
    User user = userRepository.findByEmail(email).orElseThrow(
        () -> new CustomException(ExceptionStatus.WRONG_EMAIL)
    );
    if (!passwordEncoder.matches(password, user.getPassword())) {
      throw new CustomException(ExceptionStatus.WRONG_PASSWORD);
    }
    return new UserResponse().of(user);// user객체를 dto에 담아서 반환
  }
  @CacheEvict(cacheNames = CacheNames.USERBYEMAIL, key = "'login'+#p1")
  @Transactional
  public ResponseEntity logout(String accessToken, String email) {
    // 레디스에 accessToken 사용못하도록 등록
    Long expiration = jwtProvider.getExpiration(accessToken);
    redisDao.setBlackList(accessToken, "logout", expiration);
    if (redisDao.hasKey(email)) {
      redisDao.deleteRefreshToken(email);
    } else {
      throw new IllegalArgumentException("이미 로그아웃한 유저입니다.");
    }
    return ResponseEntity.ok("로그아웃 완료");
  }
}

3-9. UserRepository

public interface UserRepository extends JpaRepository<User, Long> {


  Optional<User> findByNickname(String nickname);
  @Cacheable(cacheNames = CacheNames.USERBYEMAIL, key = "'login'+#p0", unless = "#result==null")
  Optional<User> findByEmail(String email);

  Optional<User> findByPhoneNumber(String phoneNumber);

}

3-10. 로그아웃에 필요한 레디스 설정 및 관련 클래스

로그인 시 atk, rtk 이 생성되어 response header 에 담아 보낸다.
rtk 는 레디스에 저장한다. 추후 atk 만료시 rtk를 이용해 atk 재발급과 로그아웃시 레디스에 rtk를 삭제하여 로그아웃을 구현한다.

[Spring] Redis 사용법 + jwt 함께 로그인, 로그아웃 구현

4. 결과 ( 포스트맨 이용)

4-1. 로그인

로그인 후 atk, rtk 이 생성되어 response header 에 담아 보낸다.

4-2. 로그아웃

4-3. AccessToken 재발급( +RefreshToken 도 함께 재발행)

profile
개발자꿈나무

4개의 댓글

comment-user-thumbnail
2024년 2월 1일

안녕하세요! 좋은 정보 얻어갑니다!

질문이 있는데,
Key를 생성할때 PostConstruct로 초기화해주는것의 장점이 뭔가요?

1개의 답글
comment-user-thumbnail
2024년 3월 17일

선생님 정말 감사합니다..

1개의 답글