Sparta/What I Learned

22.12.29

코딩하는 또롱이 2022. 12. 29. 23:51

 Project MySelectShop - JWT 

 

 

📌 필요한 기능

  1. ✔️ 키워드로 상품 검색하고 그 결과를 목록으로 보여주기
  2. ✔️ 회원가입
  3. ✔️ 로그인
  4. 로그인 성공 시 토큰 발급
  5. 로그아웃
  6. 로그인 한 유저만 관심상품 등록, 조회, 최저가 등록 가능
  7. ADMIN 계정은 모든 상품 조회 가능

 


 

위의 4 ~ 7번을 구현하기 위해 선행 학습 되어야할 부분

 

✍️ 쿠키와 세션

📌사용자를 구별하지 못 하는 HTTP

  • HTTP 는 상태를 저장하지 않는다. ('Stateless' 하다)
  • 아래 그림에서 클라이언트의 요청 (GET http://spartcodingclub.kr)을 서버에게 보낸 후 응답을 받을 때까지가 하나의 HTTP 요청이다. 하지만 HTTP 상태는 기억되지 않기 때문에 웹 서버에서는 1번과 2번이 같은 클라이언트의 요청인지 알 수 없다.

그 래 서❗❗

쿠키와 세션 모두 HTTP 에 상태 정보를 유지(Stateful)하기 위해 사용된다. 즉, 쿠키와 세션을 통해 서버에서는 클라이언트 별로 인증 및 인가를 할 수 있게 된다. 

 

🤔 쿠키란?

  • 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일

  • 구성요소
    • Name (이름): 쿠키를 구별하는 데 사용되는 키 (중복 될 수 없음)
    • Value (값): 쿠키의 값
    • Domain (도메인): 쿠키가 저장된 도메인
    • Path (경로): 쿠키가 사용되는 경로
    • Expires (만료기한): 쿠키의 만료기한 (만료기한 지나면 삭제)

 

🤔 세션이란?

  • 서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용
  • 서버에서 클라이언트 별로 유일무이한 '세션 ID' 를 부여한 후 클라이언트 별 필요한 정보를 서버에 저장
  • 서버에서 생성한 '세션 ID' 는 클라이언트의 쿠키값('세션 쿠키' 라고 부름)으로 저장되어 클라이언트 식별에 사용됨
  • 세션 동작 방식

위 그림에서와 같이 서버는 세션 ID를 사용하여 세션을 유지한다.

  1. 클라이언트가 서버에 1번 요청
  2. 서버가 세션 ID를 생성하고, 응답 헤더에 전달
    • 세션 ID 형태: "SESSIONID = 12A345"
  3. 클라이언트가 쿠키를 저장 ('세션쿠키')
  4. 클라이언트가 서버에 2번 요청
    • 쿠키값 (세션 ID) 포함하여 요청
  5. 서버가 세션ID 를 확인하고, 1번 요청과 같은 클라이언트임을 인지

 

 

 


 

✍️ JWT

 

🤔 JWT란?

Json 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token으로 토큰의 한 종류이다. 보통 쿠키 저장소에 담겨져있는 ‘저장된 쿠키’라고 생각하면 된다.

 

📌 JWT를 사용하는 이유

 

1. 서버가 1대인 경우

  •  Session1 이 모든 Client 의 로그인 정보 소유

 

 

2. 서버가 2대 이상인 경우

  • Session1 이 모든 Client 의 로그인 정보 소유
  • Session 마다 다른 Client 로그인 정보를 가지고 있을 수 있음
    • Session1: Client1, Client2, Client3
    • Session2: Client4
    • Session3: Client5, Client6
  • Client 1 로그인 정보를 가지고 있지 않은 Sever2 나 Server3 에 API 요청을 하게된다면?
    • 해결방법
      1. Sticky Session: Client 마다 요청 Server 고정
      2. 세션 저장소 생성

 

3. 세션 저장소 생성

  • Session storage 가 모든 Client 의 로그인 정보 소유

 

4. JWT 사용

  • 로그인 정보를 Server 에 저장하지 않고, Client 에 로그인정보를 JWT 로 암호화하여 저장 → JWT 통해 인증/인가

  • 모든 서버에서 동일한 Secret Key 소유
  • Secret Key 통한 암호화 / 위조 검증 (복호화 시)

  • JWT 장/단점
    1. 장점
      • 동시 접속자가 많을 때 서버 측 부하 낮춤
      • Client, Sever 가 다른 도메인을 사용할 때
        • 예) 카카오 OAuth2 로그인 시 JWT Token 사용
    2. 단점
      • 구현의 복잡도 증가
      • JWT 에 담는 내용이 커질 수록 네트워크 비용 증가 (클라이언트 → 서버)
      • 기 생성된 JWT 를 일부만 만료시킬 방법이 없음
      • Secret key 유출 시 JWT 조작 가능

 

📌 JWT 사용 흐름

 

1. Client 가 username, password 로 로그인 성공 시

  • "로그인 정보" → JWT 로 암호화 (Secret Key 사용)
    Sample

  • JWT 를 Client 응답에 전달 (JWT 전달방법은 개발자가 정함)
    예) 응답 Header 에 아래 형태로 JWT 전달
Authorization: BEARER <JWT>

ex)
Authorization: BEARER eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzcGFydGEiLCJVU0VSTkFNRSI6IuultO2DhOydtCIsIlVTRVJfUk9MRSI6IlJPTEVfVVNFUiIsIkVYUCI6MTYxODU1Mzg5OH0.9WTrWxCWx3YvaKZG14khp21fjkU1VjZV4e9VEf05Hok
  •  Client 에서 JWT 저장 (쿠키, Local storage 등)

 

2. Client 에서 JWT 통해 인증방법

  • JWT 를 API 요청 시마다 Header 에 포함
    예) HTTP Headers
Content-Type: application/json
Authorization: Bearer <JWT>
...
  • Server
    1. Client 가 전달한 JWT 위조 여부 검증 (Secret Key 사용)
    2. JWT 유효기간이 지나지 않았는지 검증
    3. 검증 성공시,
      • JWT → 에서 사용자 정보를 가져와 확인
      • ex) GET /api/products : JWT 보낸 사용자의 관심상품 목록 조회

 

📌 JWT 구조

  • JWT 는 누구나 평문으로 복호화 가능
  • 하지만 Secret Key 가 없으면 JWT 수정 불가능
  • → 결국 JWT 는 Read only 데이터
https://jwt.io/

JWT를 Decode하는 사이트이다.

header, payload, verify signature 입력!

 

 


 

JWT 구현

 

 📌dependency 추가하기

 아래의 세 줄을 build.gradle에 추가하고 코끼리 눌러주면 된다.

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'

 

📌 application.properties 추가하기

jwt.secret.key 는 선생님이 어떠한 문자열을 base64로 인코딩한 값이다. 너무 짧아도 안 되기 때문에 길이를 맞춰놓으셨다고 한다.

jwt.secret.key=7ZWt7ZW0OTntmZTsnbTtjIXtlZzqta3snYTrhIjrqLjshLjqs4TroZzrgpjslYTqsIDsnpDtm4zrpa3tlZzqsJzrsJzsnpDrpbzrp4zrk6TslrTqsIDsnpA=

 

📌 토큰 생성에 필요한 값

// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
private static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
private static final long TOKEN_TIME = 60 * 60 * 1000L; // 밀리세컨드 기준 : 1시간 설정

@Value("${jwt.secret.key}") // application.properties에 넣어둔 키 값을 가져온다
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // HS256의 알고리즘을 이용하여 암호화 할 것

@PostConstruct
public void init() {
    byte[] bytes = Base64.getDecoder().decode(secretKey);
    key = Keys.hmacShaKeyFor(bytes);
}

 

 

📌 Header에서 Token 가져오기

// header 토큰을 가져오기
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;
}

 

 

📌 JWT 생성

// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
    Date date = new Date();

    return BEARER_PREFIX +
            Jwts.builder()
                    .setSubject(username)
                    .claim(AUTHORIZATION_KEY, role) 
                    // 역할 가져옴
                    .setExpiration(new Date(date.getTime() + TOKEN_TIME)) 
                    // 토큰 유효성
                    // 3번째 줄의 Date와 getTime()은 현재 시간, TOKEN_TIME은 토큰 생성 함수에서 가져온 것, 즉 토큰은 1시간만 유효함!
                    .setIssuedAt(date) 
                    // 토큰이 만들어진 시간
                    // 없어도 된다
                    .signWith(key, signatureAlgorithm) 
                    // 토큰 생성함수에서 만든 secretKey()를 이용하여 만든 Key 객체와 Key 객체를 어떤 알고리즘을 이용하여 암호화 할 것인지 정함
                    // 알고리즘은 signnatureAlgorithm에 의하여 HS256라는 알고리즘을 이용함
                    .compact();
                    // String 형식의 JWT 토큰으로 반환됨
}

 

 

📌 JWT 검증

// 토큰 검증
public boolean validateToken(String token) {
    try {
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        return true;
    } catch (SecurityException | MalformedJwtException e) {
        log.info("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
    } catch (ExpiredJwtException e) {
        log.info("Expired JWT token, 만료된 JWT token 입니다.");
    } catch (UnsupportedJwtException e) {
        log.info("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
    } catch (IllegalArgumentException e) {
        log.info("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
    }
    return false;
}

 

 

📌 JWT 에서 사용자 정보 가져오기

// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
    return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    // 이미 검증 부분에서 유효성이 있다는 가정하에 가져오는 것이므로, try-while문을 쓰지 않는다.
}

 

위의 5가지 코드들을 다 합쳐 놓은것

[jwt > JwtUtil]

더보기
package com.sparta.myselectshop.jwt;


import com.sparta.myselectshop.entity.UserRoleEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import java.security.Key;
import java.util.Base64;
import java.util.Date;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String AUTHORIZATION_KEY = "auth";
    private static final String BEARER_PREFIX = "Bearer ";
    private static final long TOKEN_TIME = 60 * 60 * 1000L;

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    // header 토큰을 가져오기
    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;
    }

    // 토큰 생성
    public String createToken(String username, UserRoleEnum role) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(username)
                        .claim(AUTHORIZATION_KEY, role)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date)
                        .signWith(key, signatureAlgorithm)
                        .compact();
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }

}

 


 

JWT 적용

 

📌 User API 변경

기능 Method URL Request Response
회원가입 페이지 GET /api/user/signup   signup.html
회원가입 POST /api/user/signup POST Form 태그
{
"username" : String,
”password” : String,
”email : String,
”admin” : boolean,
”adminToken" : String
}
redirect:/api/user/login
로그인 페이지 GET /api/user/login  
login.html
로그인 POST /api/user/login ajax
{
"username" : String, ”password” : String
}
Header Authorization : Bearer <JWT> success

 

📌 로그인 성공 시 Response Header에 토큰 보내는 코드 수정

 

더보기

[ShopController]

package com.sparta.myselectshop.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

@Controller
@RequestMapping("/api")
public class ShopController {

    @GetMapping("/shop")
    public ModelAndView shop() {
        return new ModelAndView("index");
    }
}

 

[UserController]

이전에는 Form 태그로 넘어왔기 때문에 ModelAttribute 형식으로 받아왔기 때문에 @ResponseBody 어노테이션이없었지만, 바뀐 함수에는 ajax를 통 body로 넘어가기 때문에 @ResponseBody 어노테이션이 붙어있고, HttpServletResoponse response 객체가 추가되었다. 우리가 HttpReques에서 Header 서버로부터 넘어 받은 것처럼 클라이언트 측으로 넘겨줘야하기 때문이다.

package com.sparta.myselectshop.controller;

import com.sparta.myselectshop.dto.LoginRequestDto;
import com.sparta.myselectshop.dto.SignupRequestDto;
import com.sparta.myselectshop.service.UserService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;

@Controller
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class UserController {
    // 팸플릿에 있는 html 파일만 반환하기 때문에 Controller 어노테이션을 사용 (RestController 어노테이션 X)

    private final UserService userService; // UserService랑 연결

    @GetMapping("/signup")
    public ModelAndView signupPage() {
        return new ModelAndView("signup");
    }

    @GetMapping("/login")
    public ModelAndView loginPage() {
        return new ModelAndView("login");
    }

    @PostMapping("/signup") // 회원가입 구현
    public String signup(SignupRequestDto signupRequestDto) {
        userService.signup(signupRequestDto);
        return "redirect:/api/user/login";
    }

    @ResponseBody
    @PostMapping("/login")
    public String login(@RequestBody LoginRequestDto loginRequestDto, HttpServletResponse response) {
        userService.login(loginRequestDto, response);
        return "success";
    }
}


 

[UserService]

1)의존성 주입

JwtUtil에서 @Component 어노테이션으로 bean이 생성되었기 때문에 UserService에서 의존성 주입이 가능하다.

private final JwtUtil jwtUtil;

2) 토큰 생성

addHeader()를 사용하면 Header쪽에 같이 넣어 줄 수 있다. key 값에는 AUTHORIZATION_HEADER와 우리가 만든 createToken() 메소드를 사용해서 Token을 만들어 준다. 만들어 줄 때, 유저의 이름과 권한을 설정해준다.

response.addHeader(JwtUtil.AUTHORIZATION_HEADER, jwtUtil.createToken(user.getUsername(), user.getRole()));

완성코드

package com.sparta.myselectshop.service;

import com.sparta.myselectshop.dto.LoginRequestDto;
import com.sparta.myselectshop.dto.SignupRequestDto;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.entity.UserRoleEnum;
import com.sparta.myselectshop.jwt.JwtUtil;
import com.sparta.myselectshop.repository.UserRepository;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository; // Repository랑 연결
    private static final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";

    private final JwtUtil jwtUtil;

    @Transactional
    public void signup(SignupRequestDto signupRequestDto) {
        String username = signupRequestDto.getUsername();
        String password = signupRequestDto.getPassword();
        String email = signupRequestDto.getEmail();

        // 회원 중복 확인
        Optional<User> found = userRepository.findByUsername(username);
        if (found.isPresent()) {
            throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
        }

        // 사용자 ROLE 확인
        UserRoleEnum role = UserRoleEnum.USER;
        if (signupRequestDto.isAdmin()) {
            if (!signupRequestDto.getAdminToken().equals(ADMIN_TOKEN)) {
                throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
            }
            role = UserRoleEnum.ADMIN;
        }

        User user = new User(username, password, email, role);
        userRepository.save(user);
    }


    @Transactional(readOnly = true)
    public void login(LoginRequestDto loginRequestDto, HttpServletResponse response) {
        String username = loginRequestDto.getUsername();
        String password = loginRequestDto.getPassword();

        // 사용자 확인
        User user = userRepository.findByUsername(username).orElseThrow(
                () -> new IllegalArgumentException("등록된 사용자가 없습니다.")
        );
        // 비밀번호 확인
        if(!user.getPassword().equals(password)){
            throw  new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
        }

        response.addHeader(JwtUtil.AUTHORIZATION_HEADER, jwtUtil.createToken(user.getUsername(), user.getRole()));
    }
}

 

[templates > login ]

 login() 생성

ajax를 사용할 때 Token을 받아와서 쿠키에 Authorization key와 함께 받아온 key 값을 같이 넘겨준다.

function login() {

  let username = $('#username').val();
  let password = $('#password').val();

  if (username == '') {
    alert('ID를 입력해주세요');
    return;
  } else if(password == '') {
    alert('비밀번호를 입력해주세요');
    return;
  }

  $.ajax({
    type: "POST",
    url: `/api/user/login`,
    contentType: "application/json",
    data: JSON.stringify({username: username, password: password}),
    success: function (response, status, xhr) {
      if(response === 'success') {
        let host = window.location.host;
        let url = host + '/api/shop';

        document.cookie =
                'Authorization' + '=' + xhr.getResponseHeader('Authorization') + ';path=/';
        window.location.href = 'http://' + url;
      } else {
        alert('로그인에 실패하셨습니다. 다시 로그인해 주세요.')
        window.location.reload();
      }
    }
  })
}

완성코드

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="preconnect" href="https://fonts.gstatic.com">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
  <link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic&display=swap" rel="stylesheet">
  <link th:href="@{/style.css}" rel="stylesheet">
  <meta charset="UTF-8">
  <title>로그인 페이지</title>
</head>
<body>
<div id="login-form">
  <div id="login-title">Log into Select Shop</div>
  <button id="login-kakao-btn" onclick="location.href='https://kauth.kakao.com/oauth/authorize?client_id=&redirect_uri=http://localhost:8080/api/user/kakao/callback&response_type=code'">
    카카오로 로그인하기
  </button>
  <button id="login-id-btn" onclick="location.href='/api/user/signup'">
    회원 가입하기
  </button>
  <div class="login-id-label">아이디</div>
  <input id="username" type="text" name="username" class="login-input-box">

  <div class="login-id-label">비밀번호</div>
  <input id="password" type="password" name="password" class="login-input-box">

  <button id="login-id-submit" onclick="login()">로그인</button>

  <div id="login-failed" style="display:none" class="alert alert-danger" role="alert">로그인에 실패하였습니다.</div>
</div>
</body>
<script>
  const href = location.href;
  const queryString = href.substring(href.indexOf("?")+1)
  if (queryString === 'error') {
    const errorDiv = document.getElementById('login-failed');
    errorDiv.style.display = 'block';
  }

  function login() {

    let username = $('#username').val();
    let password = $('#password').val();

    if (username == '') {
      alert('ID를 입력해주세요');
      return;
    } else if(password == '') {
      alert('비밀번호를 입력해주세요');
      return;
    }

    $.ajax({
      type: "POST",
      url: `/api/user/login`,
      contentType: "application/json",
      data: JSON.stringify({username: username, password: password}),
      success: function (response, status, xhr) {
        if(response === 'success') {
          let host = window.location.host;
          let url = host + '/api/shop';

          document.cookie =
                  'Authorization' + '=' + xhr.getResponseHeader('Authorization') + ';path=/';
          window.location.href = 'http://' + url;
        } else {
          alert('로그인에 실패하셨습니다. 다시 로그인해 주세요.')
          window.location.reload();
        }
      }
    })
  }

</script>
</html>

 

[templates > index]

UI 변경

<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>

  <link th:href="@{/style.css}" rel="stylesheet">
  <script type="text/javascript" th:src="@{/basic.js}"></script>
  <title>나만의 셀렉샵</title>
</head>
<body>
<div class="header" style="position:relative;">
  <!--headr 추가 부분-->
  <div id="login-true" style="display: none">
    <div id="header-title-login-user">
      <div id="username"></div> 님의
    </div>
    <div id="header-title-select-shop">
      Select Shop
    </div>
    <a id="login-text" href="javascript:logout()">
      로그아웃
    </a>
  </div>
  <div id="login-false" >
    <div id="header-title-select-shop">
      Select Shop
    </div>
    <a id="sign-text" href="/api/user/signup">
      회원가입
    </a>
    <a id="login-text" href="/api/user/login">
      로그인
    </a>
  </div>

  <!--/headr 추가 부분-->
</div>
<div class="nav">
  <div class="nav-see active">
    모아보기
  </div>
  <div class="nav-search">
    탐색하기
  </div>
</div>
<div id="see-area">
  <div id="product-container">
    <div class="product-card" onclick="window.location.href='https://spartacodingclub.kr'">
      <div class="card-header">
        <img src="https://shopping-phinf.pstatic.net/main_2085830/20858302247.20200602150427.jpg?type=f300"
             alt="">
      </div>
      <div class="card-body">
        <div class="title">
          Apple 아이폰 11 128GB [자급제]
        </div>
        <div class="lprice">
          <span>919,990</span>원
        </div>
        <div class="isgood">
          최저가
        </div>
      </div>
    </div>
  </div>
</div>
<div id="search-area">
  <div>
    <input type="text" id="query">
  </div>
  <div id="search-result-box">
    <div class="search-itemDto">
      <div class="search-itemDto-left">
        <img src="https://shopping-phinf.pstatic.net/main_2399616/23996167522.20200922132620.jpg?type=f300" alt="">
      </div>
      <div class="search-itemDto-center">
        <div>Apple 아이맥 27형 2020년형 (MXWT2KH/A)</div>
        <div class="price">
          2,289,780
          <span class="unit">원</span>
        </div>
      </div>
      <div class="search-itemDto-right">
        <img src="/images/icon-save.png" alt="" onclick='addProduct()'>
      </div>
    </div>
  </div>
  <div id="container" class="popup-container">
    <div class="popup">
      <button id="close" class="close">
        X
      </button>
      <h1>⏰최저가 설정하기</h1>
      <p>최저가를 설정해두면 선택하신 상품의 최저가가 떴을 때<br/> 표시해드려요!</p>
      <div>
        <input type="text" id="myprice" placeholder="200,000">원
      </div>
      <button class="cta" onclick="setMyprice()">설정하기</button>
    </div>
  </div>
</div>
</body>
</html>

 

[static > basic]

Token 값을 가져와 권한에 따라 기능 사용 여부를 수정

관심상품 조회하기 기능 미구현

let targetId;

$(document).ready(function () {
    // cookie 여부 확인하여 로그인 확인
    const auth = getToken();

    if(auth !== '') {
        $('#username').text('수강생');
        $('#login-true').show();
        $('#login-false').hide();
    } else {
        $('#login-false').show();
        $('#login-true').hide();
    }

    // id 가 query 인 녀석 위에서 엔터를 누르면 execSearch() 함수를 실행하라는 뜻입니다.
    $('#query').on('keypress', function (e) {
        if (e.key == 'Enter') {
            execSearch();
        }
    });
    $('#close').on('click', function () {
        $('#container').removeClass('active');
    })

    $('.nav div.nav-see').on('click', function () {
        $('div.nav-see').addClass('active');
        $('div.nav-search').removeClass('active');

        $('#see-area').show();
        $('#search-area').hide();
    })
    $('.nav div.nav-search').on('click', function () {
        $('div.nav-see').removeClass('active');
        $('div.nav-search').addClass('active');

        $('#see-area').hide();
        $('#search-area').show();
    })

    $('#see-area').show();
    $('#search-area').hide();

    showProduct();
})


function numberWithCommas(x) {
    return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

function execSearch() {
    /**
     * 검색어 input id: query
     * 검색결과 목록: #search-result-box
     * 검색결과 HTML 만드는 함수: addHTML
     */
        // 1. 검색창의 입력값을 가져온다.
    let query = $('#query').val();

    // 2. 검색창 입력값을 검사하고, 입력하지 않았을 경우 focus.
    if (query == '') {
        alert('검색어를 입력해주세요');
        $('#query').focus();
        return;
    }
    // 3. GET /api/search?query=${query} 요청
    $.ajax({
        type: 'GET',
        url: `/api/search?query=${query}`,
        success: function (response) {
            $('#search-result-box').empty();
            // 4. for 문마다 itemDto를 꺼내서 HTML 만들고 검색결과 목록에 붙이기!
            for (let i = 0; i < response.length; i++) {
                let itemDto = response[i];
                let tempHtml = addHTML(itemDto);
                $('#search-result-box').append(tempHtml);
            }
        }
    })

}

function addHTML(itemDto) {
    /**
     * class="search-itemDto" 인 녀석에서
     * image, title, lprice, addProduct 활용하기
     * 참고) onclick='addProduct(${JSON.stringify(itemDto)})'
     */
    return `<div class="search-itemDto">
        <div class="search-itemDto-left">
            <img src="${itemDto.image}" alt="">
        </div>
        <div class="search-itemDto-center">
            <div>${itemDto.title}</div>
            <div class="price">
                ${numberWithCommas(itemDto.lprice)}
                <span class="unit">원</span>
            </div>
        </div>
        <div class="search-itemDto-right">
            <img src="../images/icon-save.png" alt="" onclick='addProduct(${JSON.stringify(itemDto)})'>
        </div>
    </div>`
}

function addProduct(itemDto) {
    /**
     * modal 뜨게 하는 법: $('#container').addClass('active');
     * data를 ajax로 전달할 때는 두 가지가 매우 중요
     * 1. contentType: "application/json",
     * 2. data: JSON.stringify(itemDto),
     */
    // 1. POST /api/products 에 관심 상품 생성 요청
    $.ajax({
        type: "POST",
        url: '/api/products',
        contentType: "application/json",
        data: JSON.stringify(itemDto),
        success: function (response) {
            // 2. 응답 함수에서 modal을 뜨게 하고, targetId 를 reponse.id 로 설정
            $('#container').addClass('active');
            targetId = response.id;
        }
    })
}

function showProduct() {
    /**
     * 관심상품 목록: #product-container
     * 검색결과 목록: #search-result-box
     * 관심상품 HTML 만드는 함수: addProductItem
     */

    // 1. GET /api/products 요청
    $.ajax({
        type: 'GET',
        url: '/api/products',
        success: function (response) {
            // 2. 관심상품 목록, 검색결과 목록 비우기
            $('#product-container').empty();
            $('#search-result-box').empty();
            // 3. for 문마다 관심 상품 HTML 만들어서 관심상품 목록에 붙이기!
            for (let i = 0; i < response.length; i++) {
                let product = response[i];
                let tempHtml = addProductItem(product);
                $('#product-container').append(tempHtml);
            }
        }
    })
}

function addProductItem(product) {
    // link, image, title, lprice, myprice 변수 활용하기
    return `<div class="product-card" onclick="window.location.href='${product.link}'">
                <div class="card-header">
                    <img src="${product.image}"
                         alt="">
                </div>
                <div class="card-body">
                    <div class="title">
                        ${product.title}
                    </div>
                    <div class="lprice">
                        <span>${numberWithCommas(product.lprice)}</span>원
                    </div>
                    <div class="isgood ${product.lprice > product.myprice ? 'none' : ''}">
                        최저가
                    </div>
                </div>
            </div>`;
}

function setMyprice() {
    /**
     * 1. id가 myprice 인 input 태그에서 값을 가져온다.
     * 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
     * 3. PUT /api/product/${targetId} 에 data를 전달한다.
     *    주의) contentType: "application/json",
     *         data: JSON.stringify({myprice: myprice}),
     *         빠뜨리지 말 것!
     * 4. 모달을 종료한다. $('#container').removeClass('active');
     * 5, 성공적으로 등록되었음을 알리는 alert를 띄운다.
     * 6. 창을 새로고침한다. window.location.reload();
     */
        // 1. id가 myprice 인 input 태그에서 값을 가져온다.
    let myprice = $('#myprice').val();
    // 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
    if (myprice == '') {
        alert('올바른 가격을 입력해주세요');
        return;
    }
    // 3. PUT /api/product/${targetId} 에 data를 전달한다.
    $.ajax({
        type: "PUT",
        url: `/api/products/${targetId}`,
        contentType: "application/json",
        data: JSON.stringify({myprice: myprice}),
        success: function (response) {
            // 4. 모달을 종료한다. $('#container').removeClass('active');
            $('#container').removeClass('active');
            // 5. 성공적으로 등록되었음을 알리는 alert를 띄운다.
            alert('성공적으로 등록되었습니다.');
            // 6. 창을 새로고침한다. window.location.reload();
            window.location.reload();
        }
    })
}

function logout() {
    // 토큰 값 ''으로 덮어쓰기
    document.cookie =
        'Authorization' + '=' + '' + ';path=/';
    window.location.reload();
}

function  getToken() {
    let cName = 'Authorization' + '=';
    let cookieData = document.cookie;
    let cookie = cookieData.indexOf('Authorization');
    let auth = '';
    if(cookie !== -1){
        cookie += cName.length;
        let end = cookieData.indexOf(';', cookie);
        if(end === -1)end = cookieData.length;
        auth = cookieData.substring(cookie, end);
    }
    return auth;
}

F12를 눌러 콘솔창을 열고 application을 누른다.

그런 다음 주소창을 클릭하면 값이 저장되어 있는지 확인 할 수 있다.

브라우저에 회원가입하고 쿠키를 확인 하면 Value에 Bearer로 시작하는 JWT Token이 제대로 들어와 있고 key 값으로 Authorization이 저장되어 는 걸 확인 할 수 있다.

이 부분을 decode 해보자. 사진처럼 드래그 되어 있는 부분만 복사해서 

https://jwt.io/의 Encoded 부분에 붙여넣으면 사진처럼 내가 넣은 값들이 전부 잘 나타나는 것을 알 수 있다.

PAYLOAD 안에는 password 와 같이 중요한 정보는 넣어선 안된다. 만약 다른 사용자가 Token 값을 알게되면 PAYLOAD에 보여서 다 털릴 수 있기 때문이다 음하핫😎 

우리는 secret key 값을 이용해서 어느정도 보안을 유효하게 만들어 뒀기 때문이다 그리고 탈취가 됐다하더라도 우리가 Token 만료 시간을 설정해두었기 때문에 그 이후로는 들어올 수 없어 보안성이 어느정도 있다 캬캬🤩

 

 


 

JWT를 사용하여
관심상품 조회하기

 

 

📌 User, Product 변경하기

위에서는 User, Product를 만들기만 했을 뿐 연결고리가 없기 때문에 상관 관계를 만든다.

더보기

[Product]

객체는 따로 없다.

private Long userId; 가 삽입되었다.

생성자를 통해서 값을 넣어줄 때 userId를 받도록 수정

package com.sparta.myselectshop.entity;

import com.sparta.myselectshop.dto.ProductMypriceRequestDto;
import com.sparta.myselectshop.dto.ProductRequestDto;
import com.sparta.myselectshop.naver.dto.ItemDto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import jakarta.persistence.*;

@Getter
@Setter
@Entity // DB 테이블 역할을 합니다.
@NoArgsConstructor
public class Product extends Timestamped{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // ID가 자동으로 생성 및 증가합니다.
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String image;

    @Column(nullable = false)
    private String link;

    @Column(nullable = false)
    private int lprice;

    @Column(nullable = false)
    private int myprice;

    @Column(nullable = false)
    private Long userId;

    public Product(ProductRequestDto requestDto, Long userId) {
        this.title = requestDto.getTitle();
        this.image = requestDto.getImage();
        this.link = requestDto.getLink();
        this.lprice = requestDto.getLprice();
        this.myprice = 0;
        this.userId = userId;
    }

    public void update(ProductMypriceRequestDto requestDto) {
        this.myprice = requestDto.getMyprice();
    }

    public void updateByItemDto(ItemDto itemDto) {
        this.lprice = itemDto.getLprice();
    }

}

 

[ProductRepository]

Use rId가 동일한 Product를 찾아옴

Product Id와 User Id가 일치하는 Product를 가져옴

package com.sparta.myselectshop.repository;

import com.sparta.myselectshop.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findAllByUserId(Long userId);
    Optional<Product> findByIdAndUserId(Long id, Long userId);
}

 

📌 관심상품 조회 할 때 토큰 보내기

더보기

[ProductController]

고치기 전에는 User쪽으로 HttpServletResponse를 받아와서 response에 Header를 추가하였는데, 지금 코드는 request를 받아와서 request Header 안에 들어있는 Token 값을 가져오게끔 변경.

// 관심 상품 조회하기
@GetMapping("/products")
public List<ProductResponseDto> getProducts(HttpServletRequest request) {
    // 응답 보내기
    return productService.getProducts(request);
}

 

[ProductService]

이전 코드는 모두가 조회할 수 있었다면 새로운 코드는 Token을 통해 검증을 하고 그 권한에 따라 다르게 조회되도록 설정 

JwtUril, UserRepository(DB 연결)  종속성 추가

@Transactional(readOnly = true)
public List<ProductResponseDto> getProducts(HttpServletRequest request) {
    // Request에서 Token 가져오기, JwtUtil 종속성 추가
    String token = jwtUtil.resolveToken(request);
    Claims claims; // JWT 안에 정보를 담을 수 있는 객체

    // 토큰이 있는 경우에만 관심상품 조회 가능
    if(token != null) {
        // Token 검증
        if (jwtUtil.validateToken(token)) {
            // 토큰에서 사용자 정보 가져오기
            claims = jwtUtil.getUserInfoFromToken(token);
        } else {
            throw new IllegalArgumentException("Token Error");
        }

        // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회 , userRepository 종속성 추가
        // claims.getSubject()를 하면 username을 가져옴
        User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
        );

        // 사용자 권한 가져와서 ADMIN 이면 전체 조회, USER 면 본인이 추가한 부분 조회
        UserRoleEnum userRoleEnum = user.getRole();
        System.out.println("role = " + userRoleEnum);

        List<ProductResponseDto> list = new ArrayList<>();
        List<Product> productList;

        if (userRoleEnum == UserRoleEnum.USER) {
            // 사용자 권한이 USER일 경우
            productList = productRepository.findAllByUserId(user.getId());
        } else {
            // 사용자 권한이 ADMIN일 경우
            productList = productRepository.findAll();
        }

        // if문으로 가져온 productList를 for문 돌려서 list로 변환시킨 후 반환
        for (Product product : productList) {
            list.add(new ProductResponseDto(product));
        }

        return list;

    }else {
        return null;
    }
}

 

📌관심상품 추가 할 때 토큰 보내기

더보기

[ProductController]

HttpServletRequest request 추가

// 관심 상품 등록하기
@PostMapping("/products")
public ProductResponseDto createProduct(@RequestBody ProductRequestDto requestDto, HttpServletRequest request) {
    // 응답 보내기
    return productService.createProduct(requestDto, request);
}

 

[ProductService]

Product와 User가 연관 관계가 있다는 가정 하에 토큰 유효성 검증하고 userId를 받아서 product에 넣어줌

@Transactional
public ProductResponseDto createProduct(ProductRequestDto requestDto, HttpServletRequest request) {
    // Request에서 Token 가져오기
    String token = jwtUtil.resolveToken(request);
    Claims claims;

    // 토큰이 있는 경우에만 관심상품 추가 가능
    if (token != null) {
        if (jwtUtil.validateToken(token)) {
            // 토큰에서 사용자 정보 가져오기
            claims = jwtUtil.getUserInfoFromToken(token);
        } else {
            throw new IllegalArgumentException("Token Error");
        }

        // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회
        User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
        );

        // 요청받은 DTO 로 DB에 저장할 객체 만들기
        Product product = productRepository.saveAndFlush(new Product(requestDto, user.getId()));

        return new ProductResponseDto(product);
    } else {
        return null;
    }
}

 

📌 관심상품 최저가 추가 할 때 토큰 보내기

더보기

[ProductController]

Token을 가져와야해서 request 추가

// 관심 상품 최저가 등록하기
@PutMapping("/products/{id}")
public Long updateProduct(@PathVariable Long id, @RequestBody ProductMypriceRequestDto requestDto, HttpServletRequest request) {
    // 응답 보내기 (업데이트된 상품 id)
    return productService.updateProduct(id, requestDto, request);
}

 

[ProductService]

Product와 User가 연관 관계가 있다는 가정 하에 토큰 유효성 검증하고 userId를 받아서 product에 넣어줌

myprice를 업데이트하는 메서드

@Transactional
public Long updateProduct(Long id, ProductMypriceRequestDto requestDto, HttpServletRequest request) {
    // Request에서 Token 가져오기
    String token = jwtUtil.resolveToken(request);
    Claims claims;

    // 토큰이 있는 경우에만 관심상품 최저가 업데이트 가능
    if (token != null) {
        // Token 검증
        if (jwtUtil.validateToken(token)) {
            // 토큰에서 사용자 정보 가져오기
            claims = jwtUtil.getUserInfoFromToken(token);
        } else {
            throw new IllegalArgumentException("Token Error");
        }

        // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회
        User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
        );

        Product product = productRepository.findByIdAndUserId(id, user.getId()).orElseThrow(
                () -> new NullPointerException("해당 상품은 존재하지 않습니다.")
        );

        product.update(requestDto);

        return product.getId();

    } else {
        return null;
    }
}

 

📌 완성된 코드

entity

[User]

더보기
package com.sparta.myselectshop.entity;

import lombok.Getter;
import lombok.NoArgsConstructor;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Getter
@NoArgsConstructor
@Entity(name = "users")
public class User {

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

    // nullable: null 허용 여부
    // unique: 중복 허용 여부 (false 일때 중복 허용)
    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

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

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRoleEnum role;

    public User(String username, String password, String email, UserRoleEnum role) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }

}

[Product]

더보기
package com.sparta.myselectshop.entity;

import com.sparta.myselectshop.dto.ProductMypriceRequestDto;
import com.sparta.myselectshop.dto.ProductRequestDto;
import com.sparta.myselectshop.naver.dto.ItemDto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import jakarta.persistence.*;

@Getter
@Setter
@Entity // DB 테이블 역할을 합니다.
@NoArgsConstructor
public class Product extends Timestamped{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // ID가 자동으로 생성 및 증가합니다.
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String image;

    @Column(nullable = false)
    private String link;

    @Column(nullable = false)
    private int lprice;

    @Column(nullable = false)
    private int myprice;

    @Column(nullable = false)
    private Long userId;

    public Product(ProductRequestDto requestDto, Long userId) {
        this.title = requestDto.getTitle();
        this.image = requestDto.getImage();
        this.link = requestDto.getLink();
        this.lprice = requestDto.getLprice();
        this.myprice = 0;
        this.userId = userId;
    }

    public void update(ProductMypriceRequestDto requestDto) {
        this.myprice = requestDto.getMyprice();
    }

    public void updateByItemDto(ItemDto itemDto) {
        this.lprice = itemDto.getLprice();
    }

}

[Timestamped]

더보기
package com.sparta.myselectshop.entity;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class Timestamped {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column
    private LocalDateTime modifiedAt;
}

[UserRoleEnum]

더보기
package com.sparta.myselectshop.entity;

public enum UserRoleEnum {
    // user의 권한을 알려주는 역할
    USER,  // 사용자 권한
    ADMIN  // 관리자 권한
}
repository

[ProductRepository]

더보기
package com.sparta.myselectshop.repository;

import com.sparta.myselectshop.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findAllByUserId(Long userId);
    Optional<Product> findByIdAndUserId(Long id, Long userId);
}

[UserRepository]

더보기
package com.sparta.myselectshop.repository;

import com.sparta.myselectshop.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}
controller

[ProductController]

더보기
package com.sparta.myselectshop.controller;

import com.sparta.myselectshop.dto.ProductMypriceRequestDto;
import com.sparta.myselectshop.dto.ProductRequestDto;
import com.sparta.myselectshop.dto.ProductResponseDto;
import com.sparta.myselectshop.entity.UserRoleEnum;
import com.sparta.myselectshop.service.ProductService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;

import java.util.Base64;
import java.util.List;

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

    private final ProductService productService;

    // 관심 상품 등록하기
    @PostMapping("/products")
    public ProductResponseDto createProduct(@RequestBody ProductRequestDto requestDto, HttpServletRequest request) {
        // 응답 보내기
        return productService.createProduct(requestDto, request);
    }

    // 관심 상품 조회하기
    @GetMapping("/products")
    public List<ProductResponseDto> getProducts(HttpServletRequest request) {
        // 응답 보내기
        return productService.getProducts(request);
    }

    // 관심 상품 최저가 등록하기
    @PutMapping("/products/{id}")
    public Long updateProduct(@PathVariable Long id, @RequestBody ProductMypriceRequestDto requestDto, HttpServletRequest request) {
        // 응답 보내기 (업데이트된 상품 id)
        return productService.updateProduct(id, requestDto, request);
    }

}

[UserController]

더보기
package com.sparta.myselectshop.controller;

import com.sparta.myselectshop.dto.LoginRequestDto;
import com.sparta.myselectshop.dto.SignupRequestDto;
import com.sparta.myselectshop.service.UserService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;

@Controller
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class UserController {
    // 팸플릿에 있는 html 파일만 반환하기 때문에 Controller 어노테이션을 사용 (RestController 어노테이션 X)

    private final UserService userService; // UserService랑 연결

    @GetMapping("/signup")
    public ModelAndView signupPage() {
        return new ModelAndView("signup");
    }

    @GetMapping("/login")
    public ModelAndView loginPage() {
        return new ModelAndView("login");
    }

    @PostMapping("/signup") // 회원가입 구현
    public String signup(SignupRequestDto signupRequestDto) {
        userService.signup(signupRequestDto);
        return "redirect:/api/user/login";
    }

    @ResponseBody
    @PostMapping("/login")
    public String login(@RequestBody LoginRequestDto loginRequestDto, HttpServletResponse response) {
        userService.login(loginRequestDto, response);
        return "success";
    }
}


[ShopController]

더보기
package com.sparta.myselectshop.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

@Controller
@RequestMapping("/api")
public class ShopController {

    @GetMapping("/shop")
    public ModelAndView shop() {
        return new ModelAndView("index");
    }
}
service

[ProductService]

더보기
package com.sparta.myselectshop.service;

import com.sparta.myselectshop.dto.ProductMypriceRequestDto;
import com.sparta.myselectshop.dto.ProductRequestDto;
import com.sparta.myselectshop.dto.ProductResponseDto;
import com.sparta.myselectshop.entity.Product;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.entity.UserRoleEnum;
import com.sparta.myselectshop.jwt.JwtUtil;
import com.sparta.myselectshop.naver.dto.ItemDto;
import com.sparta.myselectshop.repository.ProductRepository;
import com.sparta.myselectshop.repository.UserRepository;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;

    @Transactional
    public ProductResponseDto createProduct(ProductRequestDto requestDto, HttpServletRequest request) {
        // Request에서 Token 가져오기
        String token = jwtUtil.resolveToken(request);
        Claims claims;

        // 토큰이 있는 경우에만 관심상품 추가 가능
        if (token != null) {
            if (jwtUtil.validateToken(token)) {
                // 토큰에서 사용자 정보 가져오기
                claims = jwtUtil.getUserInfoFromToken(token);
            } else {
                throw new IllegalArgumentException("Token Error");
            }

            // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회
            User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                    () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
            );

            // 요청받은 DTO 로 DB에 저장할 객체 만들기
            Product product = productRepository.saveAndFlush(new Product(requestDto, user.getId()));

            return new ProductResponseDto(product);
        } else {
            return null;
        }
    }

    @Transactional(readOnly = true)
    public List<ProductResponseDto> getProducts(HttpServletRequest request) {
        // Request에서 Token 가져오기, JwtUtil 종속성 추가
        String token = jwtUtil.resolveToken(request);
        Claims claims; // JWT 안에 정보를 담을 수 있는 객체

        // 토큰이 있는 경우에만 관심상품 조회 가능
        if(token != null) {
            // Token 검증
            if (jwtUtil.validateToken(token)) {
                // 토큰에서 사용자 정보 가져오기
                claims = jwtUtil.getUserInfoFromToken(token);
            } else {
                throw new IllegalArgumentException("Token Error");
            }

            // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회 , userRepository 종속성 추가
            // claims.getSubject()를 하면 username을 가져옴
            User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                    () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
            );

            // 사용자 권한 가져와서 ADMIN 이면 전체 조회, USER 면 본인이 추가한 부분 조회
            UserRoleEnum userRoleEnum = user.getRole();
            System.out.println("role = " + userRoleEnum);

            List<ProductResponseDto> list = new ArrayList<>();
            List<Product> productList;

            if (userRoleEnum == UserRoleEnum.USER) {
                // 사용자 권한이 USER일 경우
                productList = productRepository.findAllByUserId(user.getId());
            } else {
                // 사용자 권한이 ADMIN일 경우
                productList = productRepository.findAll();
            }

            // if문으로 가져온 productList를 for문 돌려서 list로 변환시킨 후 반환
            for (Product product : productList) {
                list.add(new ProductResponseDto(product));
            }

            return list;

        }else {
            return null;
        }
    }

    @Transactional
    public Long updateProduct(Long id, ProductMypriceRequestDto requestDto, HttpServletRequest request) {
        // Request에서 Token 가져오기
        String token = jwtUtil.resolveToken(request);
        Claims claims;

        // 토큰이 있는 경우에만 관심상품 최저가 업데이트 가능
        if (token != null) {
            // Token 검증
            if (jwtUtil.validateToken(token)) {
                // 토큰에서 사용자 정보 가져오기
                claims = jwtUtil.getUserInfoFromToken(token);
            } else {
                throw new IllegalArgumentException("Token Error");
            }

            // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회
            User user = userRepository.findByUsername(claims.getSubject()).orElseThrow(
                    () -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
            );

            Product product = productRepository.findByIdAndUserId(id, user.getId()).orElseThrow(
                    () -> new NullPointerException("해당 상품은 존재하지 않습니다.")
            );

            product.update(requestDto);

            return product.getId();

        } else {
            return null;
        }
    }

    @Transactional
    public void updateBySearch(Long id, ItemDto itemDto) {
        Product product = productRepository.findById(id).orElseThrow(
                () -> new NullPointerException("해당 상품은 존재하지 않습니다.")
        );
        product.updateByItemDto(itemDto);
    }
}

[UserService]

더보기
package com.sparta.myselectshop.service;

import com.sparta.myselectshop.dto.LoginRequestDto;
import com.sparta.myselectshop.dto.SignupRequestDto;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.entity.UserRoleEnum;
import com.sparta.myselectshop.jwt.JwtUtil;
import com.sparta.myselectshop.repository.UserRepository;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository; // Repository랑 연결
    private static final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";

    private final JwtUtil jwtUtil;

    @Transactional
    public void signup(SignupRequestDto signupRequestDto) {
        String username = signupRequestDto.getUsername();
        String password = signupRequestDto.getPassword();
        String email = signupRequestDto.getEmail();

        // 회원 중복 확인
        Optional<User> found = userRepository.findByUsername(username);
        if (found.isPresent()) {
            throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
        }

        // 사용자 ROLE 확인
        UserRoleEnum role = UserRoleEnum.USER;
        if (signupRequestDto.isAdmin()) {
            if (!signupRequestDto.getAdminToken().equals(ADMIN_TOKEN)) {
                throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
            }
            role = UserRoleEnum.ADMIN;
        }

        User user = new User(username, password, email, role);
        userRepository.save(user);
    }


    @Transactional(readOnly = true)
    public void login(LoginRequestDto loginRequestDto, HttpServletResponse response) {
        String username = loginRequestDto.getUsername();
        String password = loginRequestDto.getPassword();

        // 사용자 확인
        User user = userRepository.findByUsername(username).orElseThrow(
                () -> new IllegalArgumentException("등록된 사용자가 없습니다.")
        );
        // 비밀번호 확인
        if(!user.getPassword().equals(password)){
            throw  new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
        }

        response.addHeader(JwtUtil.AUTHORIZATION_HEADER, jwtUtil.createToken(user.getUsername(), user.getRole()));
    }
}
dto

[ProductRequestDto]

더보기
package com.sparta.myselectshop.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ProductRequestDto {
    // 관심상품명
    private String title;
    // 관심상품 썸네일 image URL
    private String image;
    // 관심상품 구매링크 URL
    private String link;
    // 관심상품의 최저가
    private int lprice;
}

[ProductResponseDto]

더보기
package com.sparta.myselectshop.dto;

import com.sparta.myselectshop.entity.Product;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class ProductResponseDto {
    private Long id;
    private String title;
    private String link;
    private String image;
    private int lprice;
    private int myprice;

    public ProductResponseDto(Product product) {
        this.id = product.getId();
        this.title = product.getTitle();
        this.link = product.getLink();
        this.image = product.getImage();
        this.lprice = product.getLprice();
        this.myprice = product.getMyprice();
    }
}

[ProductMypriceRequestDto]

더보기
package com.sparta.myselectshop.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ProductMypriceRequestDto {
    private int myprice;
}

[LoginRequestDto]

더보기
package com.sparta.myselectshop.dto;

import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
public class LoginRequestDto {
    private String username;
    private String password;
}

[SignupRequestDto]

더보기
package com.sparta.myselectshop.dto;

import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
public class SignupRequestDto {
    private String username;
    private String password;
    private String email;
    private boolean admin = false;
    private String adminToken = "";
}
jwt

[JwtUtil]

더보기
package com.sparta.myselectshop.jwt;


import com.sparta.myselectshop.entity.UserRoleEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import java.security.Key;
import java.util.Base64;
import java.util.Date;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String AUTHORIZATION_KEY = "auth";
    private static final String BEARER_PREFIX = "Bearer ";
    private static final long TOKEN_TIME = 60 * 60 * 1000L;

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    // header 토큰을 가져오기
    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;
    }

    // 토큰 생성
    public String createToken(String username, UserRoleEnum role) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(username)
                        .claim(AUTHORIZATION_KEY, role)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date)
                        .signWith(key, signatureAlgorithm)
                        .compact();
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }

}
scheduler

[Scheduler]

더보기
package com.sparta.myselectshop.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ProductRequestDto {
    // 관심상품명
    private String title;
    // 관심상품 썸네일 image URL
    private String image;
    // 관심상품 구매링크 URL
    private String link;
    // 관심상품의 최저가
    private int lprice;
}

[basic.js]

더보기
let targetId;

$(document).ready(function () {
    // cookie 여부 확인하여 로그인 확인
    const auth = getToken();

    if(auth !== '') {
        $('#username').text('수강생');
        $('#login-true').show();
        $('#login-false').hide();
    } else {
        $('#login-false').show();
        $('#login-true').hide();
    }

    // id 가 query 인 녀석 위에서 엔터를 누르면 execSearch() 함수를 실행하라는 뜻입니다.
    $('#query').on('keypress', function (e) {
        if (e.key == 'Enter') {
            execSearch();
        }
    });
    $('#close').on('click', function () {
        $('#container').removeClass('active');
    })

    $('.nav div.nav-see').on('click', function () {
        $('div.nav-see').addClass('active');
        $('div.nav-search').removeClass('active');

        $('#see-area').show();
        $('#search-area').hide();
    })
    $('.nav div.nav-search').on('click', function () {
        $('div.nav-see').removeClass('active');
        $('div.nav-search').addClass('active');

        $('#see-area').hide();
        $('#search-area').show();
    })

    $('#see-area').show();
    $('#search-area').hide();

    showProduct();
})


function numberWithCommas(x) {
    return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

function execSearch() {
    /**
     * 검색어 input id: query
     * 검색결과 목록: #search-result-box
     * 검색결과 HTML 만드는 함수: addHTML
     */
        // 1. 검색창의 입력값을 가져온다.
    let query = $('#query').val();

    // 2. 검색창 입력값을 검사하고, 입력하지 않았을 경우 focus.
    if (query == '') {
        alert('검색어를 입력해주세요');
        $('#query').focus();
        return;
    }
    // 3. GET /api/search?query=${query} 요청
    $.ajax({
        type: 'GET',
        url: `/api/search?query=${query}`,
        success: function (response) {
            $('#search-result-box').empty();
            // 4. for 문마다 itemDto를 꺼내서 HTML 만들고 검색결과 목록에 붙이기!
            for (let i = 0; i < response.length; i++) {
                let itemDto = response[i];
                let tempHtml = addHTML(itemDto);
                $('#search-result-box').append(tempHtml);
            }
        }
    })

}

function addHTML(itemDto) {
    /**
     * class="search-itemDto" 인 녀석에서
     * image, title, lprice, addProduct 활용하기
     * 참고) onclick='addProduct(${JSON.stringify(itemDto)})'
     */
    return `<div class="search-itemDto">
        <div class="search-itemDto-left">
            <img src="${itemDto.image}" alt="">
        </div>
        <div class="search-itemDto-center">
            <div>${itemDto.title}</div>
            <div class="price">
                ${numberWithCommas(itemDto.lprice)}
                <span class="unit">원</span>
            </div>
        </div>
        <div class="search-itemDto-right">
            <img src="../images/icon-save.png" alt="" onclick='addProduct(${JSON.stringify(itemDto)})'>
        </div>
    </div>`
}

function addProduct(itemDto) {
    /**
     * modal 뜨게 하는 법: $('#container').addClass('active');
     * data를 ajax로 전달할 때는 두 가지가 매우 중요
     * 1. contentType: "application/json",
     * 2. data: JSON.stringify(itemDto),
     */
    // 1. POST /api/products 에 관심 상품 생성 요청
    $.ajax({
        type: "POST",
        url: '/api/products',
        contentType: "application/json",
        data: JSON.stringify(itemDto),
        success: function (response) {
            // 2. 응답 함수에서 modal을 뜨게 하고, targetId 를 reponse.id 로 설정
            $('#container').addClass('active');
            targetId = response.id;
        }
    })
}

function showProduct() {
    /**
     * 관심상품 목록: #product-container
     * 검색결과 목록: #search-result-box
     * 관심상품 HTML 만드는 함수: addProductItem
     */

    // 1. GET /api/products 요청
    $.ajax({
        type: 'GET',
        url: '/api/products',
        success: function (response) {
            // 2. 관심상품 목록, 검색결과 목록 비우기
            $('#product-container').empty();
            $('#search-result-box').empty();
            // 3. for 문마다 관심 상품 HTML 만들어서 관심상품 목록에 붙이기!
            for (let i = 0; i < response.length; i++) {
                let product = response[i];
                let tempHtml = addProductItem(product);
                $('#product-container').append(tempHtml);
            }
        }
    })
}

function addProductItem(product) {
    // link, image, title, lprice, myprice 변수 활용하기
    return `<div class="product-card" onclick="window.location.href='${product.link}'">
                <div class="card-header">
                    <img src="${product.image}"
                         alt="">
                </div>
                <div class="card-body">
                    <div class="title">
                        ${product.title}
                    </div>
                    <div class="lprice">
                        <span>${numberWithCommas(product.lprice)}</span>원
                    </div>
                    <div class="isgood ${product.lprice > product.myprice ? 'none' : ''}">
                        최저가
                    </div>
                </div>
            </div>`;
}

function setMyprice() {
    /**
     * 1. id가 myprice 인 input 태그에서 값을 가져온다.
     * 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
     * 3. PUT /api/product/${targetId} 에 data를 전달한다.
     *    주의) contentType: "application/json",
     *         data: JSON.stringify({myprice: myprice}),
     *         빠뜨리지 말 것!
     * 4. 모달을 종료한다. $('#container').removeClass('active');
     * 5, 성공적으로 등록되었음을 알리는 alert를 띄운다.
     * 6. 창을 새로고침한다. window.location.reload();
     */
        // 1. id가 myprice 인 input 태그에서 값을 가져온다.
    let myprice = $('#myprice').val();
    // 2. 만약 값을 입력하지 않았으면 alert를 띄우고 중단한다.
    if (myprice == '') {
        alert('올바른 가격을 입력해주세요');
        return;
    }
    // 3. PUT /api/product/${targetId} 에 data를 전달한다.
    $.ajax({
        type: "PUT",
        url: `/api/products/${targetId}`,
        contentType: "application/json",
        data: JSON.stringify({myprice: myprice}),
        success: function (response) {
            // 4. 모달을 종료한다. $('#container').removeClass('active');
            $('#container').removeClass('active');
            // 5. 성공적으로 등록되었음을 알리는 alert를 띄운다.
            alert('성공적으로 등록되었습니다.');
            // 6. 창을 새로고침한다. window.location.reload();
            window.location.reload();
        }
    })
}

function logout() {
    // 토큰 값 ''으로 덮어쓰기
    document.cookie =
        'Authorization' + '=' + '' + ';path=/';
    window.location.reload();
}

function  getToken() {
    let cName = 'Authorization' + '=';
    let cookieData = document.cookie;
    let cookie = cookieData.indexOf('Authorization');
    let auth = '';
    if(cookie !== -1){
        cookie += cName.length;
        let end = cookieData.indexOf(';', cookie);
        if(end === -1)end = cookieData.length;
        auth = cookieData.substring(cookie, end);
    }
    return auth;
}

 

naver API, rsc 등은 생략하겠다... 넘 많아 ㅋㅅㅋ

 

 

 

 

출처 : https://teamsparta.notion.site/Project-MySelectShop-JWT-4080e8a7689b438cbe2a3ff2a425929d

'Sparta > What I Learned' 카테고리의 다른 글

23.1.1  (0) 2023.01.02
22.12.30  (2) 2022.12.30
22.12.28  (0) 2022.12.28
22.12.27  (0) 2022.12.28
22.12.26  (0) 2022.12.28