[미니프로젝트] JWT 를 이용한 로그인 유지.

박제현·2024년 1월 10일
0

jelog 만들기

목록 보기
12/15
post-thumbnail

SessionStorage 를 이용하자.

JWT 를 이용하여 무분별한 API 접근을 방지하였다.
인가된 토큰을 가진 사용자들만 API 에 접근할 수 있으므로, 이를 이용하여 로그인 유지를 구현 해보자.

우선, 내가 생각한 구현 방법은 아래와 같다.

  1. 로그인 시 발행되는 토큰을 sessionStorage 에 저장.
  2. 해당 토큰에 해당하는 계정 또한 sessionStorage 에 저장.
  3. 페이지 로드 시 토큰과 계정을 통한 로그인
  4. 로그아웃 시 토큰과 계정을 sessionStorage 에서 삭제.

차례차례 구현해보자.

1. 로그인 시 발행되는 토큰을 sessionStorage 에 저장.
2. 해당 토큰에 해당하는 계정 또한 sessionStorage 에 저장.

const onClickLoginButton = async () => {
    if (modalState === "login") {
      axios
        .post(`/api/user/login`, {
          userEmail: userEmail,
          userPw: userPw,
        })
        .then((res) => {
          console.log(res.data);
          const message = res.data.message;
          const token = res.data.token;

          axios
            .get(`/api/user/detail`, {
              params: {
                userEmail: userEmail,
              },
              headers: {
                Authorization: `Bearer ${token}`,
              },
            })
            .then(async (res) => {
              console.log(res.data);
              const userInfo = res.data;
              if (isRemember) {
                await localStorage.setItem("userEmail", userEmail);
                await localStorage.setItem("token", token);
                await localStorage.setItem(
                  "userInfo",
                  JSON.stringify(userInfo)
                );
              }
              await sessionStorage.setItem("userEmail", userEmail);
              await sessionStorage.setItem("token", token);
              await sessionStorage.setItem(
                "userInfo",
                JSON.stringify(userInfo)
              );

              setUserInfo(res.data);
              setIsLogin(true);
              userIconButtonRef.current.style.backgroundColor = "";
            })
            .catch((err) => {
              console.error(err);
            });

          setShowModal(false);
          navigate("/");
        })
        .catch((err) => {
          console.log(err);
        });
    }

사용자 입력으로 받은 userEmailuserPw 로 우선 POST 요청을 보낸다.
정상적인 입력일 경우, 서버로 부터 메세지와 토큰을 응답 받게 된다.

userEmailtoken 을 sessionStorage 에 저장한 뒤, 해당 계정에 대한 정보를 받아온 다음, 로그인 된 상태로 홈으로 이동한다.

localStorage 의 경우 페이지가 종료 되더라도 저장되어 있으므로, 로그인 유지를 선택한 유저들만 저장한다.

3. 페이지 로드 시 토큰과 계정을 통한 로그인

  useEffect(() => {
    var token = "";
    var userEmail = "";
    if (sessionStorage.getItem("userEmail")) {
      userEmail = sessionStorage.getItem("userEmail");
      token = sessionStorage.getItem("token");
    } else {
      userEmail = localStorage.getItem("userEmail");
      token = localStorage.getItem("token");
    }

    if (token != null) {
      axios
        .get(`/api/user/detail`, {
          params: {
            userEmail: userEmail,
          },
          headers: {
            Authorization: `Bearer ${token}`,
          },
        })
        .then((res) => {
          sessionStorage.setItem("userInfo", JSON.stringify(res.data));
          setUserInfo(res.data);
          setIsLogin(true);
        })
        .catch((err) => {
          console.error(err);
          setIsLogin(false);
        });
    }
  }, []);

처음 페이지가 로드될 때, localStorage 에서 userEmailtoken 을 가져오고 유효한 값일 경우 로그인 상태를 설정한다.

    @GetMapping("/detail")
    public ResponseEntity<User> getUserData(@RequestParam String userEmail){
        User user = userService.findByEmail(userEmail);
        user.setUserPw(null);
        return ResponseEntity.ok(user);
    }

서버에서는 userEmailRequsetParam 으로 받아 user 정보를 리턴한다.

주의할 점은 user 오브젝트 자체를 넘겨버릴 경우 해당 user의 비밀번호까지 넘겨주게 되므로, 비밀번호를 null로 초기화 하여 넘겨준다.

4.로그아웃 시 토큰과 계정을 sessionStorage 에서 삭제.

  const onClickLogoutButton = () => {
    moreInfoSectionRef.current.style.display = "none";
    setIsLogin(false);
    localStorage.removeItem("userEmail");
    localStorage.removeItem("token");
    localStorage.removeItem("userInfo");
    sessionStorage.removeItem("userEmail");
    sessionStorage.removeItem("token");
    sessionStorage.removeItem("userInfo");
    console.log("logoutButton Clicked");
  };

로그아웃을 클릭하면 저장된 정보들을 storage에서 제거해준다.

URL 접근 권한 설정하기 ⛔️

URL로 악의적인 접근을 통제하기 위해서, 유효한 token 을 가지고 있지 않은 경우 직접적인 URL 접근을 제한한다.

function PrivateRoute({ authenticated, component: Component }) {
  return authenticated ? (
    Component
  ) : (
    <Navigate to="/" {...alert("접근할 수 없는 페이지입니다.")} />
  );
}

export default PrivateRoute;

authenticated 를 state 로 관리하여, 인가된 사용자만 요청하는 컴포넌트를 화면에 표시할 수 있도록 작성한다.

사용 방법은 아래와 같다.

	<Route
          path="/write"
          element={
            <PrivateRoute
              authenticated={access}
              component={
                <WritePage
                  userInfo={userInfo}
                />
              }
            />
          }
        />

클라이언트 단에서는 위와 같은 방법으로 접근을 제한하고, 서버 단에서는 JwtFilter 를 이용하여 접근을 제한한다.

public class WebSecurityConfig {

    private final UserService userService;

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

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

        return httpSecurity
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .cors(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(requests -> {
                    requests.requestMatchers("/api/user/login", "/api/user/register", "/api/user/valid", "/api/post/all", "/api/jwt/*").permitAll();
                    requests.anyRequest().authenticated();
                })
                .sessionManagement(
                        sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .addFilterBefore(new JwtFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

권한 없이 접근이 가능한 API 들을 제외하고, 모든 API 는 인가된 사용자만 호출할 수 있다.

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    
    private final String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);


        if(authorization == null || !authorization.startsWith("Bearer ")){
            logger.error("Authorization 이 없습니다.");
            filterChain.doFilter(request, response);
            return;
        }

        // Token 꺼내기
        String token = authorization.split(" ")[1];

        // Token 유효 검사
        if(JwtUtil.isExpired(token, secretKey)){
            logger.error("만료된 Token 입니다.");
            filterChain.doFilter(request, response);
        }

        logger.info("유효 토큰 입니다.");


        // UserEmail 꺼내기;
        String userEmail = JwtUtil.getClaimUserEmail(token, secretKey);

        // 권한 부여
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(userEmail, null, List.of(new SimpleGrantedAuthority("USER")));

        // Detail 삽입
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);

    }
}

filterChain은 위와 같이 설정하였다.

게시글 등록하기 📰.

우선 게시글 등록 API 를 개발하기 전에, post 테이블의 대한 간단한 수정이 필요했다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Getter
@Table
@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    private Long postId;

    @ManyToOne(targetEntity = User.class)
    @JoinColumn(name = "user_id")
    private User user;

    @Column
    @CreatedDate
    private LocalDateTime createdAt;

    @Column
    @LastModifiedDate
    private LocalDateTime updatedAt;

    @Column
    private String title;

    @Column
    private String content;

    @Column
    private String tags;

    @Builder
    public Post(User user, String title, String content, String tags) {
        this.user = user;
        this.title = title;
        this.content = content;
        this.tags = tags;
    }

    public void update(String title, String content, String tags){
        this.title = title;
        this.content = content;
        this.tags = tags;
    }
}

tags 컬럼과 생성 시각, 수정 시각에 대한 컬럼을 새로 추가 하였다.

@RequiredArgsConstructor
@Service
public class PostService {
    private final PostRepository postRepository;
    private final UserRepository userRepository;

    // C
    public Long write(AddPostRequestDto requestDto) {
        User user = userRepository.findByUserEmail(requestDto.getUserEmail()).orElseThrow(
                () -> new AppException(ErrorCode.USEREMAIL_NOTEXIST, requestDto.getUserEmail() + "는 존재하지 않는 계정입니다."));

        return postRepository.save(
                Post.builder()
                        .user(user)
                        .title(requestDto.getTitle())
                        .content(requestDto.getContent())
                        .tags(requestDto.getTags())
                        .build()
        ).getPostId();
    }

    // R
    public List<Post> getPostsOrderByCreatedAtDesc(){
        return postRepository.findAllByOrderByCreatedAtDesc().orElseThrow(() -> new AppException(ErrorCode.POSTS_NOTEXIST, "포스트가 존재하지 않습니다."));
    }


    // U

    // D
}

게시글을 등록하거나 불러오기 위한 서비스를 작성했다.
post 테이블의 경우 user 테이블과 1:多 관계로 맵핑 되어 있기 때문에, 게시글을 등록하기 위해선 user 정보가 필요하다.

@Getter
@Setter
public class AddPostRequestDto {
    private String userEmail;
    private String title;
    private String content;
    private String tags;
}

dto 의 구조는 간단하다.


DB에 위와 같은 형태로 저장된다.

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/post")
public class PostApiController {
    private final PostService postService;

    // 글 작성
    @PostMapping("/write")
    public ResponseEntity<String> write(@RequestBody AddPostRequestDto requestDto){
        Long postId = postService.write(requestDto);

        return ResponseEntity.ok(String.format("게시글 번호 %d 번으로 글을 등록하였습니다.", postId));
    }

    // 글 불러오기
    @GetMapping("/all")
    public ResponseEntity<List<Post>> getPosts(@RequestParam String orderBy){
        List<Post> posts = postService.getPostsOrderByCreatedAtDesc();
        posts.forEach(post -> {
            post.getUser().setUserPw(null);
        });

        return ResponseEntity.ok(posts);

    }
}

추후에, 게시글을 불러올 때 최신순, 좋아요 순으로 정렬하기 위해 요청 API 는 orderByrequestParam 을 받는다.

게시글이 정상적으로 서버로부터 불러와지는 모습이다.

여담으로...

기존에 만들었던 Jelog 는 수정할 부분이 너무나도 많다.
아,, 이래서 사람들이 코드를 수정 용이하게 작성하는 것인가 깨닳게 된다.
물론, 지금도 개발 실력이 많이 늘었다고는 할 수 없지만.. 수정해야할 부분이 너무 많이 보인다.
혼자 하려니까 힘들다 ㅠ

profile
닷넷 새싹

0개의 댓글