97일차 - JPA (토큰 파싱 정보 수정, 권한에 따른 렌더링 설정, 등급업 처리), React (회원가입)

Yohan·2024년 7월 16일
0

코딩기록

목록 보기
139/156
post-custom-banner

React

  • 로그인 성공시 dto의 정보 저장 (영구)

action함수, useActionData

  • action함수 데이터 읽는 법
    -> formData에 input의 입력값 존재,
    -> input의 name속성의 이름이 존재해야 하고, 그 값을 formData.get() 안에 넣음
import React from 'react';
import {Form, Link, redirect, useActionData} from 'react-router-dom';  // Link 컴포넌트 추가
import styles from './LoginForm.module.scss';
import { AUTH_URL } from "../../config/host-config";

const LoginForm = () => {

    const errorText = useActionData();

    return (
        <>
            <Form method="post" className={styles.form}>
                <h1>Log in</h1>
                <p>
                    <label htmlFor="email">Email</label>
                    <input id="email" type="email" name="email" required/>
                </p>
                <p>
                    <label htmlFor="password">Password</label>
                    <input id="password" type="password" name="password" required/>
                </p>
                <div className={styles.actions}>
                    <button type="submit" className={styles.loginButton}>Login</button>
                </div>

                {errorText && <p className={styles.error}>{errorText}</p>}

                <div className={styles.registerLink}>
                    <Link to="/sign-up">회원이 아니십니까? 회원가입을 해보세요</Link>
                </div>
            </Form>
        </>
    );
}

export default LoginForm;

// login form에 대한 트리거 함수. (form태그 대신 action함수 버전. Form태그)
// route-config에서 LoginForm 컴포넌트의 직속 부모에다 등록
// 파라미터로 온갖 정보들이 들어있고, 거기서 request만 디스트럭쳐링.
export const loginAction = async ({ request }) => {

    // 입력데이터 읽기
    const formData = await request.formData();

    const payload = {
        email: formData.get('email'),
        password: formData.get('password'),
    };

    const response = await fetch(`${AUTH_URL}/sign-in`, {
        method: 'POST',
        headers: { 'Content-Type' : 'application/json' },
        body: JSON.stringify(payload)
    })


    // 실패한 경우 (status코드가 422번.)
    if (response.status === 422) {
        const errorText = await response.text();

        // 액션함수가 리턴한 데이터를 컴포넌트에서 쓰는법
        // 컴포넌트에서 useActionData 훅을 사용
        return errorText
    }

    // 성공한 경우
    const responseData = await response.json();
    console.log(responseData)

    // 브라우저 저장소
    // localStorage: 창 꺼도 안없어짐
    // sessionStorage: 창 끄면 없어짐
    // JSON으로 파싱을 안하면 [object, Object]로 출력되니, 파싱해서 저장.
    localStorage.setItem('userData', JSON.stringify(responseData))

    return redirect('/')
}

route에서 로그인 회원 관리하기

  • route의 최상위 꼭대기에 loader를 설정하면 하위 컴포넌트는 사용할 수 있지만 children한테는 loader를 보낼 수 없다.
    -> id를 부여하면 모든 곳에서 데이터를 사용할 수 있다.
  • auth.js
// 로그인한 유저의 정보 가져오기
const getUserData = () => {
    const userDataJson = localStorage.getItem('userData')
    return  JSON.parse(userDataJson);
};

// 인증 토큰만 가져오기
export const getUserToken = () => {
  return getUserData().token;
};

// 로그인 회원정보를 불러오는 Loader
export const userDataLoader = () => {
    return getUserData();
}
  • route-config.js
    • id를 최상위에 걸어줌
export const router = createBrowserRouter([
    {
        path: '/',
        element: <RootLayout/>,
        errorElement: <ErrorPage/>,
        loader: userDataLoader,
        id: 'user-data',
        children: [
        ......
        ]

loader 데이터 사용하기

  • 하위컴포넌트가 아닌 children에서는 useLoaderData()가 아닌 useRouteLoaderData() 를 사용!
    • 로그인시 입력받은 정보들을 모두 사용할 수 있음
import React from 'react';
import LoginForm from "../components/auth/LoginForm";
import Main from "../components/auth/Main";
import {useRouteLoaderData} from "react-router-dom";

const WelcomePage = () => {

    // 상위 라우트 페이지의 loader 데이터 불러오기
    // 파라미터로 id를 넣어준다.
    const userData = useRouteLoaderData('user-data');

    return (
        <>
            { <LoginForm/>}
            { <Main/>}
        </>
    );
};

export default WelcomePage;

로그아웃

  • Logout.js 라는 컴포넌트를 만들되, return할 컴포넌트를 만들지 않고
    export할 액션 함수만 작성, 이 경우 반드시 redirect가 존재해야함
  • Logout.js
import {redirect} from "react-router-dom";

// 컴포넌트인데 실제 컴포넌트가 없는 경우
// 반드시 redirect코드가 필요
export const logoutAction = () => {
    localStorage.removeItem('userData'); 
    // setItem했을때 이름 localStorage.setItem('userData', JSON.stringify(responseData))
    return redirect('/')
};
  • route-config.js
const homeRouter = [
    {
        index: true,
        element: <WelcomePage />,
        action: loginAction
    }, // 웰컴페이지 (로그인화면 or 로그인완료화면)
    {
        path: 'sign-up',
        element: <SignUpPage />
    }, // 회원가입 페이지
    {
        path: 'logout',
        action: logoutAction // url이 /logout 일때 발동
    }
];
  • Main.js
    • 웰컴페이지에 이미 로그인 액션이 걸려있으므로 로그아웃 액션을 걸기위해선 직접 액션을 걸어준다
const Main = () => {

    const userData = useRouteLoaderData('user-data');

    return (
        <>
          <h2>{userData.email}님 환영합니다.</h2>
          <h3>현재 권한: [ {userData.role} ]</h3>
          {/* 다른 라우트의 액션을 트리거하는 방법 */}
          {/* action 속성을 추가하면 route에 걸린 /logout에 걸린 action을 사용 */}
          <Form action='/logout' method="POST">
              <button>Logout</button>
          </Form>
        </>
    );
};

  • 프론트에서 fetch를 보낼때, 토큰 정보를 headers에 담아서 보내야 한다.
const response = await fetch(`${EVENT_URL}/page/${currentPage}?sort=date`,{
      headers: { 'Authorization': `Bearer ` + token }
    });

event게시판에 로그인 조건 걸기

  • 로그인 했을 경우에만 event 게시판에 들어갈 수 있게 처리
  • auth.js
// 접근 권한을 확인하는 loader
export const authCheckLoader = () => {
  const userData = getUserData();
  if (!userData) {
    alert('로그인이 필요한 서비스입니다.');
    return redirect('/');
  }
  return null; // 현재 페이지에 머묾
};
  • route-config.js
    • eventsRouter에 모두 조건을 걸어야 하기 때문에 eventsRouter를 children으로 가지고있는 곳에 걸어줌

import React from 'react';
import { createBrowserRouter } from 'react-router-dom';

import Home from '../pages/Home';
import RootLayout from '../layout/RootLayout';
import ErrorPage from '../pages/ErrorPage';
import Events from '../pages/Events';
import EventDetail, 
  { loader as eventDetailLoader, action as deleteAction } 
from '../pages/EventDetail';
import EventLayout from '../layout/EventLayout';
import NewEvent from '../pages/NewEvent';
import EditPage from '../pages/EditPage';
import { action as manipulateAction } 
  from '../components/EventForm';
import WelcomePage from '../pages/WelcomePage';
import SignUpPage from '../pages/SignUpPage';
import { loginAction } from '../components/auth/LoginForm';
import { authCheckLoader, userDataLoader } from './auth';
import { logoutAction } from '../pages/Logout';


// 라우터 설정
const eventsRouter = [
  { 
    index: true, 
    element: <Events />,
    // loader: eventListLoader, // loader는 최초 1번만 실행 (무한스크롤시 사용못함)
  },
  { 
    path: ':eventId', 
    loader: eventDetailLoader,
    // element: <EventDetail />,
    // loader가 children에게 직접적으로 연결되지 않아
    // EventDetail에서 loader를 사용하지 못하고 있음.
    id: 'event-detail', // loader에게 ID 부여
    children: [
      { 
        index: true, 
        element: <EventDetail />,
        action: deleteAction
       },
      { 
        path: 'edit',
        element: <EditPage />,
        action: manipulateAction 
      },
    ]
  },
  { 
    path: 'new', 
    element: <NewEvent />,
    // 서버에 갱신데이터요청을 보낼 때 트리거
    action: manipulateAction
  },
];

const homeRouter = [

  {
    index: true,
    element: <WelcomePage />,
    action: loginAction
  }, // 웰컴 페이지 (로그인화면 or 로그인완료화면)
  {
    path: 'sign-up',
    element: <SignUpPage />
  }, // 회원가입 페이지
  {
    path: 'logout',
    action: logoutAction
  }
];

export const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    loader: userDataLoader,
    id: 'user-data', // 꼭대기에 걸었으므로 id를 통해 children까지 (homeRouter, eventsRouter) loader를 사용가능
    children: [
      { 
        path: '/', 
        element: <Home />,
        children: homeRouter
      },
      {
        path: 'events',
        element: <EventLayout />,
        loader: authCheckLoader, // eventsRouter에 대해 로그인 조건을 걺
        children: eventsRouter
      },
    ]
  },
]);

JPA

토큰 위조 검사 후 인증 완료 처리

  • JwtAuthFilter
    • userId 이외에 사용할 정보가 더 필요하므로 tokenInfo로 수정
    • 토큰 위조 검사후에 토큰 인증 완료 처리
    • 인증 완료 후에 auth에 인증 정보들을 넣어두고 클라이언트에서 사용
public class JwtAuthFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;

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

        try {
            // 요청 메세지에서 토큰을 파싱
            // 토큰정보는 요청헤더에 포함되어 전송됨
            String token = parseBearerToken(request);

            log.info("토큰 위조 검사 필터 작동!");
            if (token != null) {
                // 토큰 위조 검사
                TokenUserInfo tokenInfo = tokenProvider.validateAndGetTokenInfo(token);

                // 인증 완료 처리
                /*
                     스프링 시큐리티에게 인증완료 상황을 전달하여
                     403 상태코드 대신 정상적인 흐름을 이어갈 수 있도록
                 */

                // 인가처리를 위한 권한 리스트
                List<SimpleGrantedAuthority> authorities = new ArrayList<>();

                authorities.add(new SimpleGrantedAuthority(tokenInfo.getRole().toString()));

                AbstractAuthenticationToken auth
                        = new UsernamePasswordAuthenticationToken(
                        tokenInfo, // 인증 완료 후 컨트롤러에서 사용할 정보 (userId대신 여러 필드를 담은 객체, dto가 와도됨)
                        null, // 인증된 사용자의 패스워드 - 보통 null로 둠
                        authorities  // 인가정보(권한) 리스트
                );

                // 인증 완료시 클라이언트의 요청 정보들을 세팅
                auth.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );

                // 스프링 시큐리티에게 인증이 끝났다는 사실을 전달
                SecurityContextHolder.getContext().setAuthentication(auth);

            }

        } catch (Exception e) {
            log.warn("토큰이 위조되었습니다.");
            e.printStackTrace();
        }


        // 필터체인에 내가 만든 커스텀필터를 실행하도록 명령
        // 필터체인: 필터는 여러개임 우리가 체인에 걸어 놓은 필터를
        // 실행명령
        filterChain.doFilter(request, response);
    }

글쓰기 제한

  • EventService
    • 일반 회원은 4개의 이벤트만 등록가능
      -> 4개이상 등록시 에러 발생
    // 이벤트 등록
    public void saveEvent(EventSaveDto dto, String userId) {


        // 로그인한 회원 정보 조회
        EventUser eventUser = eventUserRepository.findById(userId).orElseThrow();

        // 로그인한 회원 권한 조회 확인 + 등록 개수 확인
        // 권한에 따른 글쓰기 제한
        if (
                eventUser.getRole() == Role.COMMON
                        && eventUser.getEventList().size() >= 4
        ) {
            throw new IllegalStateException("일반 회원은 더이상 이벤트를 등록할 수 없습니다.");
        }

        Event newEvent = dto.toEntity();
        newEvent.setEventUser(eventUser);

        Event savedEvent = eventRepository.save(newEvent);
        log.info("saved event: {}", savedEvent);
    }

권한에 따른 조건부 렌더링1

직접 걸어주어 조건부 렌더링 가능

  • EventController
    • PREMIUM, ADMIN만 detail 조회 가능
// 단일 조회 요청
    @PreAuthorize("hasAuthority('PREMIUM') or hasAuthority('ADMIN')")
    @GetMapping("/{eventId}")
    public ResponseEntity<?> getEvent(@PathVariable Long eventId) {

권한에 따른 조건부 렌더링2

SecurityConfig에서 설정 가능

  • SecurityConfig
    • antMatchers를 통해 삭제 요청은 ADMIN만 가능, 수정요청은 COMMON도 가능하게 설정 가능
    • hasAnyAuthority를 이용하면 권한 이름 여러개 가능 ("ADMIN", "COMMON")...
// 시큐리티 설정 (스프링 부트 2.7버전 이전 인터페이스를 통해 오버라이딩)
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws  Exception {

        http
                .cors()
                .and()
                .csrf().disable() // 필터설정 off
                .httpBasic().disable() // 베이직 인증 off
                .formLogin().disable() // 로그인창 off
                // 세션 인증은 더 이상 사용하지 않음
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests() // 요청 별로 인가 설정

                // /events/* -> 뒤에 딱 하나만
                // /events/** -> 뒤에 여러개
                // hasAnyAuthority : 권한 이름 여러개 입력가능
                // /events/*에서 DELETE 요청은 ADMIN만 가능하게 할거야~
                .antMatchers(HttpMethod.DELETE,"/events/*").hasAnyAuthority("ADMIN")

                .antMatchers(HttpMethod.PUT, "/auth/promote").hasAuthority("COMMON")
                // 아래의 URL요청은 로그인 없이 모두 허용! (auth에서는 promote 제외)
                .antMatchers("/", "/auth/**").permitAll()
                // 나머지 요청은 전부 인증(로그인) 후 진행!
                .anyRequest().authenticated() // 인가 설정 on
        ;

        // 토큰 위조 검사 커스텀 필터 필터체인에 연결
        // CorsFilter(spring의 필터) 뒤에 커스텀 필터를 연결
        http.addFilterAfter(jwtAuthFilter, CorsFilter.class);

        return http.build();
    }

등급업 처리

  • EventUserController
    • Service에서 로그인한 사용자의 토큰정보에서 userId를 통해 확인
    • 등급 변경을 진행
    • 토큰 재발급 필요 (변경 사항이 있으면)
    • 재발급 후 응답DTO에 담아서 반환
    // Premium회원으로 등급업하는 요청처리
    @PutMapping("/promote")
    public ResponseEntity<?> promote(
            @AuthenticationPrincipal TokenUserInfo userInfo // 로그인한 사용자의 토큰정보
    ) {

        try { // 토큰정보에 있는 userId를 받아서 등급업처리
            LoginResponseDto dto = eventUserService.promoteToPremium(userInfo.getUserId());
            return ResponseEntity.ok().body(dto); // 로그인 응답 정보 반환
        } catch (NoSuchElementException e) {
            log.warn(e.getMessage());
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }
  • EventUserService
    // 등업 처리
    public LoginResponseDto promoteToPremium(String userId) {
        // 회원 탐색
        EventUser eventUser = eventUserRepository.findById(userId).orElseThrow();

        // 등급 변경 (변경하려면? premium으로 변경 후에 다시 save)
        eventUser.promoteToPremium();
        EventUser promotedUser = eventUserRepository.save(eventUser);

        // 등급 변경 후에 토큰 재발급해야함
        String token = tokenProvider.createToken(eventUser);

        // 재발급 후에 LoginResponseDto에 담아 객체 생성
        return LoginResponseDto.builder()
                .token(token)
                .role(promotedUser.getRole().toString())
                .email(promotedUser.getEmail())
                .build();

    }
profile
백엔드 개발자
post-custom-banner

0개의 댓글