[Spring Boot] Spring Security+JWT 맛보기 (3) - JPA

CNH·2024년 3월 6일

개발

목록 보기
17/17

개요

JWT 맛보기의 마침표를 찍을 수 있는 포스팅이다. 이번에는 기존에 구현해놓은 기능들을 MariaDB와 JPA를 사용하여 완성해보고자 한다.

목표

  1. MariaDB 세팅
  2. JPA로 User 테이블과 RefreshToken 테이블 정의 및 활용
  3. logout 완성

구현

1. 기본 세팅

윈도우 로컬에 MariaDB와 WorkBench 격인 HeidiSQL을 미리 준비해준다. 또한 스프링 프로젝트의 build.gradle에도 관련 dependency를 추가해주고,

    // MariaDB
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
    // JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

application.properties에도 관련 내용을 추가해준다. 나는 database명을 jpatest로 했다.

spring.datasource.url=jdbc:mariadb://localhost:3307/jpatest
spring.datasource.username=본인 마리아DB ID
spring.datasource.password=본인 마리아DB 비밀번호
spring.jpa.hibernate.ddl-auto=update
spring.datasource.driverClassName=org.mariadb.jdbc.Driver

2. JPA를 위한 Entity 생성

JPA를 활용하기 위해서는, DTO class를 @Entity를 붙인다고 한다. 그러면 이게 하나의 테이블이 된다(사실 잘모르)고 하는 것 같다. 기존에 만들어 둔 User.java를 다음과 같이 수정하자.

package com.example.securitytest;


import jakarta.persistence.*;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.beans.factory.annotation.Autowired;
import java.io.Serializable;
import java.util.*;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Entity
public class User implements UserDetails, Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String email;

    private String password;
    
    @ElementCollection(fetch = FetchType.EAGER) //"failed to lazily initialize a collection of role" 오류 발생하여 추가
    @Builder.Default
    private Set<Role> roles = new HashSet<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles;
    }

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

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

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

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

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

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

}

또한 RefreshToken을 저장해야하니 해당 클래스도 만들어 주자.

package com.example.securitytest;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.*;

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Getter
@Setter
public class RefreshToken {

    @Id
    private String email;

    private String refreshToken;
}

또한 Repository도 만들어야 한댄다. 약간 DAO느낌?

package com.example.securitytest;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, String> {

    Optional<User> findByEmail(String email);
}
package com.example.securitytest;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {

    
    Optional<RefreshToken> findByEmail(String email);

    Optional<RefreshToken> findByRefreshToken(String refreshToken);
}

3. Redis를 MariaDB로 코드 변경

3-1. 회원가입

차근차근 수정해보자. 우선 회원가입. DB를 사용하지 않았기에 회원가입은 거의 스킵하다시피 했다. 이제 제대로 구현해보자.

//UserService.java
    public User signup(SignUpRequest request){


        userRepository.findByEmail(request.email()).ifPresent(
                (user) -> {throw new RuntimeException("이미 가입된 이메일입니다.");}
        );

        User user = User.builder()
                .email(request.email()).password(request.password())
                .roles(Set.of(Role.USER))
                .build();
        return userRepository.save(user);
    }

사실 별거 없다. User테이블에 이메일이 있는지 검사하고, 없으면 User테이블에 추가해준다. 아까 위에서 만든 UserRepository를 사용하는건데, .save() 메소드를 통해 데이터를 추가할 수 있다고 한다. (아까 UserRepository 만들었을 때 save()메소드는 따로 재정의하지 않았다.)

3-2. 로그인

이제 로그인을 수정해보자.

//UserController.java
    @PostMapping("/login")
    public JwtTokenResponse login(@RequestBody LoginUserRequest request, HttpServletResponse response){
        log.info("controller login 진입");
        User user = userService.login(request);

        JwtTokenResponse jwtTokenResponse = jwtTokenProvider.makeJwtTokenResponse(user);

        //DB에 refreshtoken 저장
        System.out.println(refreshTokenRepository);
        Optional<RefreshToken> currentRefreshTokenDto = refreshTokenRepository.findByEmail(user.getEmail());
        if(currentRefreshTokenDto.isPresent()){
            refreshTokenRepository.delete(currentRefreshTokenDto.get());
        }
        refreshTokenRepository.save(
                RefreshToken.builder()
                        .email(user.getEmail()).refreshToken(jwtTokenResponse.refreshToken())
                        .build());

        return jwtTokenResponse;
    }
//UserService.java
    public User login(LoginUserRequest request){
        log.info("login 진입 {}",request);

        //DB에서 id, pw 확인
        Optional<User> foundUser = userRepository.findByEmail(request.email());
        if(foundUser.isPresent()){
            User user = foundUser.get();
            if(request.password().equals(foundUser.get().getPassword())){
                return user;
            }
        }
        
        throw new RuntimeException("아이디가 존재하지 않거나 아이디나 비밀번호가 일치하지 않습니다.");

    }

우선, UserServicelogin 메소드에서는 DB에서 해당 이메일이 있는지 Optional<>을 이용해 확인하고, isPresent() 함수를 이용해 있다면 비밀번호까지 같은지 비교해준다. 아닐 경우에는 Exception을 발생시켜 주도록 했다.

그다음엔, jwtTokenProvider에서 accessTokenrefreshToken을 만들어주도록 한다. 만드는 과정은 이전에 포스팅했으니 생략하고, 이 때 만든 refreshToken을 다시 UserController로 가져와서 DB에 저장을 한다. 이때, 혹시나 남아있다면 삭제를 하고 추가해주도록 한다.

3-3. 일반 API (/test)

UserController를 아래처럼 수정해주었다.

    // @Transactional //"failed to lazily initialize a collection of role" 오류 발생하여 추가
    @GetMapping("/test")
    public String test(@AuthenticationPrincipal User user){
        log.info("test완료 : {}",user);
        return "test완료 " + user.getEmail();
    }

아직 잘 모르겠지만 주석과 같은 오류가 났고, @Transactional 붙이면 해결된다길래 붙였다가 해결이 안 돼서 User.javaroles 필드 위의 @ElementCollection 옆에 (fetch = FetchType.EAGER)를 붙였다. 사실상 지금은 @Transactional를 지워도 오류가 안 난다.

3-4. AccessToken 재발급

accessToken 만료 시 refreshToken을 가지고 accessToken을 재발급하는 부분이 있었다. 아래와 같이 수정하자.

//UserController.java
@PostMapping("/refreshtoken")
    public JwtTokenResponse updateAccessToken(@RequestBody UpdateAccessTokenRequest request){
        
        //DB에 해당 RefreshToken이 있는지 확인
//        String email = userService.findEmailByRefreshToken(request.refreshToken());
        Optional<RefreshToken> refreshTokenDto = refreshTokenRepository.findByRefreshToken(request.refreshToken());
        log.info("refreshToken : {}", refreshTokenDto);
        if(refreshTokenDto.isEmpty()) {
            throw new RuntimeException("해당 이메일로 로그인된 기록이 없습니다.");
        }

        //DB에 해당 RefreshToken이 있어도 만료여부를 검사해야 함.
        if (jwtTokenProvider.validateToken(refreshTokenDto.get().getRefreshToken()) != JwtCode.ACCESS) {
//            return jwtTokenProvider.makeJwtTokenResponseWithNull();
            refreshTokenRepository.delete(refreshTokenDto.get());
            throw new RuntimeException("refreshtoken이 남아있지만 만료되었습니다. 자동삭제합니다.");
        }

        User user = userService.findUserByEmail(refreshTokenDto.get().getEmail());
        String accessToken = jwtTokenProvider.makeAccessToken(user.getEmail(), user.getRoles());
        return jwtTokenProvider.makeJwtTokenResponseWithToken(accessToken, request.refreshToken());
    }
//UserService.java
    public User findUserByEmail(String email) {

        //실제 DB에서 User 찾아서 갖고옴.
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new RuntimeException("email에 해당하는 user가 없습니다."));

    }

코드는 길어보이지만 우리는 이미 로직을 이해하고 있기에 어렵지 않다. 처음에 DB에 refreshToken이 있는지 확인하고(사실상 없으면 말이 안 됨), 있을 때 가져와서 만료여부를 검사한다. 만약에 만료된 토큰이라면 DB에서 해당 토큰을 삭제하고 예외를 발생시킨다. 아직 만료가 되지 않은 토큰이라면 그 refreshToken에서 User 정보를 뽑아와 새롭게 accessToken을 만들어서 보내주는 로직이다.


정리

이전에 로직을 거의 다 짜놓고 이번에는 DB와 연결한 것 뿐이니까 어렵지 않았다. 다만 기타 작은 이슈들은 트러블슈팅에 정리해야겠다.

profile
끄적끄적....

0개의 댓글