Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,28 @@
# spring-shopping-precourse
# spring-shopping-precourse

## 상품 API
-[x] 이름, 가격, 이미지를 받아 상품을 등록한다.
- [x] 이미지의 url 형식이 맞는지 확인한다.
- [x] 상품 이름은 최대 15자까지 입력할 수 있다.
- [x] `( ), [ ], +, -, &, /, _` 이외의 특수문자가 들어가 있는지 확인한다.
- [ ] 상품 이름은 비속어를 포함할 수 없다.
-[x] 기존 상품의 정보를 수정한다. => 등록과 똑같은 예외를 체크해야 한다.
-[x] 특정 상품 조회
-[x] 모든 상품의 목록을 조회한다.
-[x] 특정 상품을 삭제한다.
-[x] 없는 상품인지 체크한다.

## 회원 API
-[x] 회원가입: 이메일과 비밀번호를 입력받는다.
-[x] 이메일을 받았을 때, 기존 회원이라면 회원가입을 취소한다.
-[x] 로그인: 이메일, 비밀번호가 맞는지 확인한다.
-[x] 토큰 발급

## 위시리스트 API
-[ ] 인증된 토큰을 가진 사용자에 한해 진행한다.
- [ ] 위시 리스트 상품을 추가한다.
- [ ] 추가할 때 상품이 있는 상품인지 확인한다.
- [ ] 위시 리스트에 이미 추가된 상품인지 확인한다.
- [ ] 위시 리스트 상품을 조회한다.
-[ ] 위시 리스트에서 상품을 삭제한다.
- [ ] 위시 리스트에 없는 상품을 삭제하면 에러를 발생한다.
9 changes: 9 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ plugins {

group = "camp.nextstep.edu"
version = "0.0.1-SNAPSHOT"
val lombokVersion = "1.18.34"

java {
toolchain {
Expand All @@ -24,14 +25,22 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-security-crypto")
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-mysql")
implementation("org.jetbrains.kotlin:kotlin-reflect")
compileOnly("org.projectlombok:lombok:$lombokVersion")
annotationProcessor("org.projectlombok:lombok:$lombokVersion")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.mockito.kotlin:mockito-kotlin")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

Expand Down
27 changes: 27 additions & 0 deletions src/main/java/shopping/common/entity/BaseEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package shopping.common.entity;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class BaseEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
protected LocalDateTime createdAt;

@LastModifiedDate
@Column(nullable = false)
protected LocalDateTime updateAt;
}
18 changes: 18 additions & 0 deletions src/main/java/shopping/common/exception/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package shopping.common.exception;

import lombok.Getter;

@Getter
public enum ErrorCode {
PRODUCT_NOT_FOUND(40401, "Product를 찾지 못했습니다."),
USER_NOT_FOUND(40402, "유저를 찾지 못했습니다."),
WISHLIST_NOT_FOUND(40403, "위시리스트를 찾지 못했습니다.");

private final int code;
private final String message;

ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
}
12 changes: 12 additions & 0 deletions src/main/java/shopping/common/exception/NotFoundException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package shopping.common.exception;

import lombok.Getter;

@Getter
public class NotFoundException extends RuntimeException {
private final ErrorCode errorCode;

public NotFoundException(ErrorCode errorCode) {
this.errorCode = errorCode;
}
}
34 changes: 34 additions & 0 deletions src/main/java/shopping/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package shopping.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import shopping.config.jwt.JwtAuthenticationFilter;
import shopping.config.jwt.JwtTokenProvider;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.and().build();
}
}
31 changes: 31 additions & 0 deletions src/main/java/shopping/config/jwt/JwtAuthenticationFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package shopping.config.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// Request Header에서 JWT 토큰 추출
String token = jwtTokenProvider.resolveToken(request);

//validateToken으로 토큰 유효성 검사
if(token != null && jwtTokenProvider.validateToken(token)){
//토큰이 유효하다면 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장한다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
109 changes: 109 additions & 0 deletions src/main/java/shopping/config/jwt/JwtTokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package shopping.config.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.security.Keys;
import org.springframework.util.StringUtils;
import shopping.entity.User;

import java.security.Key;
import java.util.*;
import java.util.stream.Collectors;

@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String SECRET_KEY;
private static final String AUTHORITIES_KEY = "authority"; //role로 줄 예정
private final Long ACCESS_TOKEN_EXPIRE_TIME = 1000L*60*600; //10hour
private final Long REFRESH_TOKEN_EXPIRE_TIME = 1000L*60*60*24*14; //14day

private Key key;

protected JwtTokenProvider() {
byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
this.key = Keys.hmacShaKeyFor(keyBytes);
}

//accesstoken 생성
public String createAccessToken(User user){
return createToken(user, ACCESS_TOKEN_EXPIRE_TIME);
}

//refreshToken 생성
public String createRefreshToken(User user){
return createToken(user, REFRESH_TOKEN_EXPIRE_TIME);
}

public String createToken(User user, long expireTime){
//토큰을 발급하는 시간을 기준으로 토큰 유효기간 설정
Date now = new Date();
Date validity = new Date(now.getTime() + expireTime);

//claim에 넣을 정보 설정하기
Claims claims = Jwts.claims().subject(String.valueOf(user.getId())).build(); //authId 저장

return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key,SignatureAlgorithm.HS512)
.compact();
}

//request 헤더에서 토큰 parsing
public String resolveToken(HttpServletRequest request){
String token = request.getHeader(HttpHeaders.AUTHORIZATION);

if(!StringUtils.hasText(token) || !token.startsWith("Bearer ")){
throw new IllegalStateException("토큰이 없습니다.");
}

return token.substring(7);
}

//토큰 검증
public boolean validateToken(final String token){
try{
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(key).build().parseClaimsJws(token);
return !claimsJws.getBody().getExpiration().before(new Date());
}catch (JwtException | IllegalArgumentException e){//추후에 log에 기록남도록 하기
return false;
}
}

//access token에 들어있는 정보를 꺼내서 authentication 만들기
public Authentication getAuthentication(String token){
Claims claims = parseClaims(token);
String authId = claims.getSubject();

Collection<? extends GrantedAuthority> authorities = Arrays.stream(new String[]{claims.get(AUTHORITIES_KEY).toString()})
.map(SimpleGrantedAuthority::new).collect(Collectors.toList());

return new UsernamePasswordAuthenticationToken(authId, "", authorities);
}

public String getUserIdFromToken(String token){
return parseClaims(token).getSubject();
}

//jwt 토큰 복호화한 후 정보 추출
private Claims parseClaims(String accessToken){
try{
return Jwts.parser().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e){
return e.getClaims();
} catch (JwtException e){
throw new RuntimeException("유효하지 않은 토큰입니다.");//log에 기록남도록 하기
}
}
}
29 changes: 29 additions & 0 deletions src/main/java/shopping/entity/Email.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package shopping.entity;

import jakarta.persistence.Embeddable;
import lombok.*;

import java.util.regex.Pattern;

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode
public class Email {
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)" +
"*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$");

private String value;

@Builder
public Email(String value) {
validate(value);
this.value = value;
}

private void validate(String value) {
if(!EMAIL_PATTERN.matcher(value).matches()){
throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다.");
}
}
}
29 changes: 29 additions & 0 deletions src/main/java/shopping/entity/Image.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package shopping.entity;

import jakarta.persistence.Embeddable;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.regex.Pattern;

@Embeddable
@Getter
@NoArgsConstructor
public class Image {
private static final Pattern IMAGE_URL_PATTERN = Pattern.compile("^(http://|https://).*\\.(jpg|jpeg|png|gif)$");

private String imageUrl;

@Builder
public Image(String imageUrl){
validate(imageUrl);
this.imageUrl = imageUrl;
}

private void validate(String imageUrl){
if(!IMAGE_URL_PATTERN.matcher(imageUrl).matches()){
throw new IllegalArgumentException("이미지 URL의 형식이 올바르지 않습니다. " + imageUrl);
}
}
}
Loading