Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5기] 4주차 쇼핑몰 과제 제출 - 호아 #6

Open
wants to merge 58 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
3dc52b3
docs: 쇼핑몰 기능요구사항, 용어사전, 모델링 정리
hoa0217 Jul 16, 2024
d6efd0d
docs: 쇼핑몰 기능요구사항, 용어사전, 모델링 정리
hoa0217 Jul 16, 2024
678d643
feat(Member): 멤버 엔티티 생성
hoa0217 Jul 17, 2024
8bd6257
feat(BaseEntity): 생성, 수정시간 추적을 위한 BaseEntity 생성
hoa0217 Jul 17, 2024
6e7df10
feat(Client): 손님 엔티티 생성
hoa0217 Jul 17, 2024
928534b
feat(Client): 사장님 엔티티 생성
hoa0217 Jul 17, 2024
d2e2c85
chore: 프로젝트 설정파일 생성
hoa0217 Jul 17, 2024
3319770
Merge remote-tracking branch 'origin/hoa0217' into hoa0217
hoa0217 Jul 17, 2024
1835c1b
feat(ProfanityChecker): 비속어 포함 여부 체커 생성
hoa0217 Jul 18, 2024
bddba6e
feat(Product): 상품 엔티티 생성
hoa0217 Jul 18, 2024
a769498
feat(Product): 상품 엔티티 생성
hoa0217 Jul 18, 2024
b7f4d48
refactor(BaseEntity): BaseEntity, AuditableBaseEntity 분리하여 추적 데이터 추가
hoa0217 Jul 18, 2024
2c02897
docs: 회원권한에 따른 행위 설명 추가
hoa0217 Jul 18, 2024
023d495
refactor(Product): exception 패키지 분리
hoa0217 Jul 18, 2024
9f0235f
refactor(WishProduct): 위시 상품 엔티티 생성
hoa0217 Jul 18, 2024
1bc4f89
refactor(Client): 사용자는 위시 상품을 추가하고 삭제할 수 있다.
hoa0217 Jul 18, 2024
cdaafda
Merge remote-tracking branch 'origin/hoa0217' into hoa0217
hoa0217 Jul 18, 2024
b81c74d
test(ProfanityChecker): 테스트용 페이크 ProfanityChecker 객체 생성
hoa0217 Jul 19, 2024
e2591dc
refactor(ProductName): ProductName 패턴 버그 수정(한국어 추가) 및 null 체크 추가
hoa0217 Jul 19, 2024
38bf9cd
test(ProductName): ProductName 테스트 추가
hoa0217 Jul 19, 2024
02f9f2d
refactor(ProductPrice): ProductPrice null 체크 추가
hoa0217 Jul 19, 2024
e9fcbe7
test(ProductPrice): ProductPrice 테스트 추가
hoa0217 Jul 19, 2024
9574467
test(PasswordEncoder): 테스트용 페이크 PasswordEncoder 객체 생성
hoa0217 Jul 19, 2024
c4fb160
test(Password): Password 테스트 추가
hoa0217 Jul 20, 2024
f4b44f8
test(Product): Product 테스트 추가
hoa0217 Jul 20, 2024
e926d3a
refactor(WishProducts): 위시상품은 중복해서 추가할 수 없으며, WishProducts에 없는 상품은 삭제…
hoa0217 Jul 20, 2024
b68078f
test(Client): Client 테스트 추가
hoa0217 Jul 20, 2024
34dab1a
refactor(Member): isValidPassword 메서드 추가
hoa0217 Jul 26, 2024
b426646
feat(Member): MemberRepository 생성
hoa0217 Jul 26, 2024
b2ffb9f
feat(AuthService): 인증 책임을 갖는 서비스 클래스 생성
hoa0217 Jul 26, 2024
ec28d39
test(Client): Client 테스트 생성
hoa0217 Jul 26, 2024
9a370f3
feat(OwnerLoginService): Owner 로그인 전용 서비스 생성
hoa0217 Jul 26, 2024
7f7c93c
refactor(WishProduct): product의 id만 의존하도록 개선
hoa0217 Jul 28, 2024
57c2671
feat(WishProductMapper): Client바운디드컨텍스트에서 product에 대한 검증과 반환에 대한 책임을 …
hoa0217 Jul 28, 2024
fdb081e
feat(ClientLoginService): Client의 회원가입과 로그인에대한 책임을 갖는 어플리케이션 서비스 생성
hoa0217 Jul 28, 2024
7562872
feat(ClientWishService): Client의 위시상품 추가/삭제/조회에대한 책임을 갖는 어플리케이션 서비스 생성
hoa0217 Jul 28, 2024
5baba47
test(RestClientTest): RestClient를 생성하여 purgomalum api를 테스트한다
hoa0217 Jul 28, 2024
28c22b4
feat(ProfanityChecker): ProfanityChecker 구현체 생성
hoa0217 Jul 28, 2024
5379860
refactor: exception 클래스 정리
hoa0217 Jul 28, 2024
7719408
refactor(ProductService): Product CUD 기능 추가
hoa0217 Jul 28, 2024
9ddb97b
refactor(Member): role 정보 얻는 메서드 생성
hoa0217 Aug 10, 2024
a52a72e
refactor(AuthService): 로그인 시 토큰을 생성해서 넘겨준다
hoa0217 Aug 10, 2024
73d5c5f
refactor(JwtGenerator): JWT 토큰을 생성한다
hoa0217 Aug 10, 2024
432d67b
feat(JwtProvider): JWT 토큰을 검증하고 인증정보를 반환한다.
hoa0217 Aug 10, 2024
f33f027
feat(JwtAuthenticationFilter): JWT 토큰을 추출하고 유효성을 검사하는 필터 생성
hoa0217 Aug 10, 2024
7e57b7b
refactor(Member): role 정보 추출 시 유효하지 않으면 예외를 던진다.
hoa0217 Aug 10, 2024
69e5ecf
refactor(AuthService): 권한검증 로직 추가
hoa0217 Aug 10, 2024
bf4b266
test(AuthService): 권한검증 로직 추가
hoa0217 Aug 10, 2024
9caf8e3
refactor(AuthService): 권한 Enum 추가
hoa0217 Aug 11, 2024
7ae2ea4
feat(ArgumentResolver): 로그인을 수행한 Member를 파라미터로 받아올 수 있는 Resolver 생성
hoa0217 Aug 11, 2024
787f1ad
feat(Controller): Client, Owner 표현계층 생성
hoa0217 Aug 11, 2024
30832b2
feat(SecurityConfig): 스프링 시큐리티 설정
hoa0217 Aug 11, 2024
23be3db
feat(Controller): Product 표현계층 생성
hoa0217 Aug 11, 2024
53552ff
style: 코드 포맷팅
hoa0217 Aug 11, 2024
5e8767f
refactor(ClientController): Client는 하트를 누른상품들을 조회할 수 있다.
hoa0217 Aug 11, 2024
c8d8ee4
refactor(ProductController): 시큐리티 권한검증 어노테이션 추가
hoa0217 Aug 11, 2024
070f6a1
refactor(LoginOwner): 사용되지 않는 Resolver 제거
hoa0217 Aug 11, 2024
5f40e92
refactor(LoginClient): WebConfig로 설정
hoa0217 Aug 11, 2024
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
107 changes: 106 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,106 @@
# spring-shopping-product
# spring-shopping-product

## 기능요구사항

### 상품

- 상품을 추가, 조회, 수정, 삭제할 수 있다.
- 상품 이미지는 파일을 업로드하지 않고 URL로 저장한다.
- 상품 이름은 공백을 포함하여 최대 15자까지 입력할 수 있다.
- 특수문자는 `()`,`[]`,`+`,`-`,`&`,`/`,`_`만 가능하다.
- 상품 이름에는 비속어를 포함할 수 없다.
- [PurgoMalum](https://www.purgomalum.com/)에서 욕설이 포함되어 있는지 확인한다.
- 회원이 위시리스트에 상품을 담으면 상품 하트수가 올라간다.

### 회원

- 회원는 가입, 로그인을 할 수 있다.
- 회원는 이메일, 비밀번호, 이름을 입력하여 가입한다.
- 회원는 이메일과 비밀번호를 입력하여 로그인을 수행하고 토큰을 발급받는다.
- 토큰은 JWT와 SpringSecurity를 사용하여 구현한다.

### 위시리스트

- 발급받은 토큰을 사용하여 위시리스트에 상품을 추가, 조회, 삭제할 수 있다.

## 용어사전

### 상품(Product)

| 한글명 | 영문명 | 설명 |
|--------|---------------------|---------------------------------|
| 상품 | Product | 쇼핑몰에 등록된 상품. |
| 상품 이름 | Product Name | 상품을 구분하는 이름. 비속어를 사용할 수 없다. |
| 비속어 | Profanity | 불쾌감을 주는 단어로 상품 이름에 사용될 수 없다. |
| 상품 가격 | Product Price | 상품 1개의 가격. 가격은 0원이상이다. |
| 상품 이미지 | Product Image | 상품을 보여주는 이미지. |
| 상품 하트수 | Product Heart Count | 상품의 하트 수. 손님이 하트를 누르면 숫자가 올라간다. |

### 회원(Member)

| 한글명 | 영문명 | 설명 |
|-------|-----------------|------------------------------------------------|
| 회원 | Member | 쇼핑몰에 가입된 사람. |
| 이메일 | Email | 회원가입 및 로그인 시 사용되는 아이디. 중복될 수 없다. |
| 비밀번호 | Password | 회원가입 및 로그인 시 사용되는 비밀번호. 암호화되어 저장된다. |
| 회원 이름 | Member Name | 회원을 구분하기 위한 이름. |
| 회원 권한 | Member Priority | 회원별 행위를 제한하기 위한 권한. ex) 사장님(Owner), 손님(Client) |

#### 사장님(Owner)

| 한글명 | 영문명 | 설명 |
|-----|-------|--------------------|
| 사장님 | Owner | 쇼핑몰에서 상품을 관리하는 회원. |

#### 손님(Client)

| 한글명 | 영문명 | 설명 |
|--------|--------------|----------------------------------|
| 손님 | Client | 쇼핑몰에서 상품을 조회하고 구매를 희망하는 회원. |
| 위시 상품 | Wish Product | 손님이 구매를 희망하는 상품. |
| 하트 누르기 | Wish | 손님이 위시 상품 등록을 위해 상품에 하트를 누르는 행위. |

## 모델링

### 상품

#### 프로퍼티

- `상품(Product)`은 `상품 이름(Product Name)`, `상품 가격(Product Price)`를
필수로 `상품 이미지(Product Image)`를 선택적으로 갖는다.
- `상품 이름(Product Name)`은 공백 포함 최대 15자까지 갖는다.
- `상품 이름(Product Name)`에는 `()`,`[]`,`+`,`-`,`&`,`/`,`_`을 제외한 특수문자를 사용할 수 없다.
- `상품 이름(Product Name)`에는 비속어를 사용할 수 없다.
- `상품 가격(Product Price)`은 0원 이상이다.
#### 행위

- `사장님(Owner)`은 `상품(Product)`을 추가할 수 있다.
- `사장님(Owner)`과 `손님(Client)`는 `상품(Product)`을 조회할 수 있다.
- `사장님(Owner)`은 `상품(Product)`을 수정할 수 있다.
- `사장님(Owner)`은 `상품(Product)`을 삭제할 수 있다.

### 회원(Member)

#### 프로퍼티

- `회원(Member)`은 `이메일(Email)`, `비밀번호(Password)`, `회원 이름(Member Name)`, `회원 권한(Member Priority)`을 필수로
갖는다. 그리고 `회원 권한(Member Priority)`에 따라 `위시 상품(Wish Product)`를 갖는다.
- `이메일(Email)`은 다른 회원과 중복될 수 없다.
- `비밀번호(Password)`는 암호화되어야한다.
- `회원 권한(Member Priority)`은 `사장님(Owner)`, `손님(Client)`이 있다.
> `회원 권한(Member Priority)`에 따라 행위가 다르며 이는 아래에서 설명한다.

#### 행위

#### 사장님(Owner)
- `사장님(Owner)`는 `이메일(Email)`, `비밀번호(Password)`, `회원 이름(Member Name)`을 입력하여 가입한다.
- 기본적으로 갖는 권한은 `사장님(Owner)`이다.
- `사장님(Owner)`는 `이메일(Email)`과 `비밀번호(Password)`를 입력하여 로그인한다.

#### 손님(Client)
- `손님(Client)`는 `이메일(Email)`, `비밀번호(Password)`, `회원 이름(Member Name)`을 입력하여 가입한다.
- 기본적으로 갖는 권한은 `손님(Client)`이다.
- `손님(Client)`는 `이메일(Email)`과 `비밀번호(Password)`를 입력하여 로그인한다.
- `손님(Client)`는 `위시 상품(Wish Product)`등록을 위해 하트를 누를 수 있다.
- `손님(Client)`는 `위시 상품(Wish Product)`을 조회할 수 있다.
- `손님(Client)`는 `위시 상품(Wish Product)`을 삭제할 수 있다.
11 changes: 11 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
// security
implementation("org.springframework.boot:spring-boot-starter-security")

// jwt
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.5")

// lombok
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
}

kotlin {
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/shopping/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@EnableAsync
@SpringBootApplication
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package shopping.common.auth.exception;

import shopping.common.exception.SpringShoppingException;

public class UnAuthenticatedException extends SpringShoppingException {

public UnAuthenticatedException(final String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package shopping.common.auth.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import shopping.common.auth.token.JwtProvider;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String AUTHORIZATION_TYPE = "Bearer";

private final JwtProvider jwtTokenProvider;

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

// 2. validateToken으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Security Context에 인증 정보를 저장했습니다. uri: " + requestURI);
}
filterChain.doFilter(request, response);
}

private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(AUTHORIZATION_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package shopping.common.auth.handler;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package shopping.common.auth.handler;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
12 changes: 12 additions & 0 deletions src/main/java/shopping/common/auth/resolver/LoginClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package shopping.common.auth.resolver;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginClient {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package shopping.common.auth.resolver;

import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import shopping.common.auth.exception.UnAuthenticatedException;
import shopping.member.client.domain.Client;
import shopping.member.common.application.AuthService;
import shopping.member.common.domain.Member;
import shopping.member.common.domain.MemberRole;

@Component
@RequiredArgsConstructor
public class LoginClientArgumentResolver implements HandlerMethodArgumentResolver {

private final AuthService authService;

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(LoginClient.class)
&& parameter.getParameterType().equals(Client.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
try {
return getClient();
} catch (RuntimeException e) {
throw new UnAuthenticatedException("인증에 실패하였습니다.");
}
}

private Member getClient() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
return authService.findMember(userDetails.getUsername(), MemberRole.CLIENT);
}
}
35 changes: 35 additions & 0 deletions src/main/java/shopping/common/auth/token/JwtGenerator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package shopping.common.auth.token;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class JwtGenerator implements TokenGenerator {

private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60;
private final Key key;

public JwtGenerator(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}

public String generate(String email, String role) {

long now = (new Date()).getTime();

// Access Token 생성
return Jwts.builder()
.setSubject(email)
.claim("auth", role)
.setExpiration(new Date(now + ACCESS_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
}
Loading