- 지난 포스팅 : 네이버 검색 API를 활용하여 상품을 키워드로 검색하고, 최저가를 등록하는 작업까지 진행
- 오늘은 각 회원별로 이용자/관리자를 나눠 회원가입을 진행
- 이용자 -> 자신이 선택한 최저가 등록 상품만 볼 수 있음.
- 관리자 -> 모든 이용자가 등록한 최저가 등록 상품을 볼 수 있음.
- 비 로그인 -> 검색은 가능하지만 최저가 등록을 할 수 없음.
- 회원정보를 입력받아 회원 가입을 진행한다.
- 로그인 : 그 회원이 현재 등록된 회원인지
인증
한다.- 인가 : 등록 된 회원이 접근할 수 있는 리소스를
인가
해준다.
- 해당 유저가 실제 존재하는지 확인하는 작업
- 쿠키 - 세션 방식은 서버가 현재 특정 유저가 로그인 되어있다 라는 상태를 저장하는 방식이다.
- 인증과 관련된 약간의 정보를 서버가 가지고 있는 개념.
1) 사용자가 로그인 요청을 보낸다.
2) 서버는DB
를 뒤져서 아이디와 패스워드가 맞는지 전부 확인한다.
3) 실제 유저DB
와 정보가 일치한다면, 인증을 통과한 것으로 보고,세션 저장소
에 해당 유저가 로그인 되었다는 정보를 넣는다.
4) 세션 저장소에는 유저의 정보와는 상관없는 난수인Session Id
를 발급한다.
5) 서버는 로그인 요청에 대한 응답으로Session Id
를 내어준다.
6) 사용자는 이Session Id
를 쿠키에 가지고 있다가, 앞으로 요청 마다 HttpHeader에 이 값을 같이 넣어서 보내준다.
7) 클라이언트의 요청에서쿠키
값을 발견 했다면, 서버는 저장된 세션 저장소에서 쿠키를 검증한다.
Http
는 상태를 저장하지 않는다.- 따라서 1번 요청과 2번 요청은 같은 요청이지만, 같은 요청인지 인식하지 못한다.
- 이 때 이 같은 요청에 대한 인식을 하고 그에 따른 작업의 반복을 줄이기 위해,
쿠키
,세션
의 개념이 등장한다.
- 서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용
- 서버에서 클라이언트 별로 유일무이한 '세션 ID' 를 부여한 후 클라이언트 별 필요한 정보를 서버에 저장
- 서버에서 생성한 '세션 ID' 는 클라이언트의 쿠키값('세션 쿠키' 라고 부름)으로 저장되어 클라이언트 식별에 사용됨
1) 클라이언트에 저장 될 목적으로 생성한 작은 정보를 담은 파일
2)Name
: 쿠키를 구별하는데 사용되는 키(중복될 수 없음)
3)Value
: 쿠키의 값
4)Domain
: 쿠키가 저장된 도메인
5)Path
: 쿠키가 사용되는 경로
6)Expires
: 쿠키의 만료기간
- | 쿠키 | 세션 |
---|---|---|
설명 | 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일 | 서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용 |
저장위치 | 클라이언트 (웹 브라우져) | 웹 서버 |
사용 예 | 사이트 팝업의 "오늘 다시보지 않기" 정보 저장 | 로그인 정보 저장 |
만료 시점 | 쿠키 저장 시 만료일시 설정 가능(브라우져 종료시도 유지 가능) | 다음 조건 중 하나가 만족될 경우 만료됨 1. 브라우져 종료 시까지 2. 클라이언트 로그아웃 시까지 3. 서버에 설정한 유지기간까지 해당 클라이언트의 재요청이 없는 경우 |
용량 제한 | 브라우져 별로 다름 (크롬 기준) - 하나의 도메인 당 180개 - 하나의 쿠키 당 4KB(=4096byte) | 개수 제한 없음 (단, 세션 저장소 크기 이상 저장 불가능) |
보안 | 취약 (클라이언트에서 쿠키 정보를 쉽게 변경, 삭제 및 가로채기 당할 수 있음) | 비교적 안전 (서버에 저장되기 때문에 상대적으로 안전) |
JWT ( Json Web Token )
은 인증에 필요한 정보를 암호화하는 것.- 쿠키 - 세션 방식과 유사하게
JWT
토근을 Http 헤더에 실어 서버가 클라이언트를 식별한다.HEADER
,VERIFY SIGNATURE
: 암호화 방식PAYLOAD
: 유저의 정보
1) 사용자가 로그인 요청을 보낸다
2) 서버는DB
를 뒤져서 아이디와 비밀번호를 대조해본다.
3) 실제 유저 테이블의 정보와 일치한다면 인증을 통과한 것으로 보고 유저의 정보를JWT
로 암호화해서 내보낸다.
4) 서버는 로그인 요청 응답으로JWT
토큰을 준다.
5) 클라이언트는 그 토큰을 저장하고 앞으로 요청 시 토큰을 같이 전송한다.
6) 클라이언트의 요청에서 토큰을 발견했다면 서버는 토큰을 검증한다.
7) 이후에는 로그인 된 유저에 따른 정보를 내어준다.
- 로그인 정보를
Server
에 저장하지 않고, Client정보를JWT
로 암호화하여 저장- 모든 서버에서 동일한
Secret Key
보유
- 장점 : 동시 접속자 부하를 낮춘다.
- 단점 : 구현이 복잡하고, 한번 생성된
JWT
를 일부만 만료시킬 수 없음
1) 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 → 에서 사용자 정보를 가져와 확인
- JWT 는 누구나 평문으로 복호화 가능
- 하지만 Secret Key 가 없으면 JWT 수정 불가능
- 결국 JWT 는 Read only 데이터
- JWT 정보 보기 링크 : https://jwt.io/
- 이렇게 절차를 걸쳐 인증이 완료 되었다면, 회원 가입에 따른 로그인이 성공한다.
- 그렇다면 로그인 후 그 정보가 유지 되어야 하며, 회원 / 관리자에 따른 권한이 부여되어야 한다.
- 그걸
인가
라고 부른다.
- Dependency 추가
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'
- 내가 만들 서버의 JWT Key ( application.properties )
jwt.secret.key=7ZWt7ZW0OTntmZTsnbTtjIXtlZzqta3snYTrhIjrqLjshLjqs4TroZzrgpjslYTqsIDsnpDtm4zrpa3tlZzqsJzrsJzsnpDrpbzrp4zrk6TslrTqsIDsnpA=
- JwtUtil.java
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 javax.annotation.PostConstruct;
import javax.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();
}
}
기능 | Method | URL | Request |
---|---|---|---|
회원가입 페이지 | GET | /api/user/signup | -- |
회원가입 | POST | /api/user/signup | POST Form 태그 { "username" : String, ”password”:String, ”email : String, ”admin” : boolean, ”adminToken" : String } |
로그인 페이지 | GET | /api/user/login | -- |
로그인 | POST | /api/user/signup | { "username" : String, ”password” : String } |
- Enum.java
package com.sparta.myselectshop.entity;
public enum UserRoleEnum {
USER, // 사용자 권한
ADMIN // 관리자 권한
}
- User.java
package com.sparta.myselectshop.entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.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.java
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 javax.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.interface
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.java
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.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
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);
}
}
- ProductService.java
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 lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
@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 가져오기
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("사용자가 존재하지 않습니다.")
);
// 사용자 권한 가져와서 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 {
productList = productRepository.findAll();
}
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);
}
}
- JWT UI로 확인해보기