Project MySelectShop - JWT
📌 필요한 기능
- ✔️ 키워드로 상품 검색하고 그 결과를 목록으로 보여주기
- ✔️ 회원가입
- ✔️ 로그인
- 로그인 성공 시 토큰 발급
- 로그아웃
- 로그인 한 유저만 관심상품 등록, 조회, 최저가 등록 가능
- 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번 요청
- 서버가 세션 ID를 생성하고, 응답 헤더에 전달
- 세션 ID 형태: "SESSIONID = 12A345"
- 클라이언트가 쿠키를 저장 ('세션쿠키')
- 클라이언트가 서버에 2번 요청
- 쿠키값 (세션 ID) 포함하여 요청
- 서버가 세션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 요청을 하게된다면?
- 해결방법
- Sticky Session: Client 마다 요청 Server 고정
- 세션 저장소 생성
- 해결방법
3. 세션 저장소 생성
- Session storage 가 모든 Client 의 로그인 정보 소유
4. JWT 사용
- 로그인 정보를 Server 에 저장하지 않고, Client 에 로그인정보를 JWT 로 암호화하여 저장 → JWT 통해 인증/인가
- 모든 서버에서 동일한 Secret Key 소유
- Secret Key 통한 암호화 / 위조 검증 (복호화 시)
- JWT 장/단점
- 장점
- 동시 접속자가 많을 때 서버 측 부하 낮춤
- Client, Sever 가 다른 도메인을 사용할 때
- 예) 카카오 OAuth2 로그인 시 JWT Token 사용
- 단점
- 구현의 복잡도 증가
- 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
- Client 가 전달한 JWT 위조 여부 검증 (Secret Key 사용)
- JWT 유효기간이 지나지 않았는지 검증
- 검증 성공시,
- JWT → 에서 사용자 정보를 가져와 확인
- ex) GET /api/products : JWT 보낸 사용자의 관심상품 목록 조회
📌 JWT 구조
- JWT 는 누구나 평문으로 복호화 가능
- 하지만 Secret Key 가 없으면 JWT 수정 불가능
- → 결국 JWT 는 Read only 데이터
https://jwt.io/
JWT를 Decode하는 사이트이다.
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