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

[Feat] controller & service 테스트 코드 작성, [Feat] 시큐리티 & jwt 구현, 예외 필터 구현, [Docs] 7주차 발표자료 업로드 #54

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
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
Prev Previous commit
Next Next commit
[Refactor] 시큐리티 & jwt 구현, jwt인증 필터와 예외 필터 추가
kangwook1 committed May 23, 2024
commit 61dfb7ee2d6d3778ab5a1ce43d2160cb3f185888
6 changes: 5 additions & 1 deletion contents/todoListAPI/kangwook/practice/build.gradle
Original file line number Diff line number Diff line change
@@ -26,7 +26,11 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' // 접속 url: localhost:8080/swagger-ui/index.html
implementation 'com.google.code.gson:gson'
implementation 'com.google.code.gson:gson' // 객체를 json으로 변환해주는 구글의 라이브러리
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 의존성은 테스트코드만을 위해서 사용되는거야?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞아. http 요청을 시뮬레이션해야하니까, json 데이터를 본문에 넣어야하기 떄문에 json으로 변환해주는 클래스를 썼어.

implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1' //추가하지 않으면 tokenProvider에서 에러 남
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

나는 이런 의존성을 넣은적이 없어서 그런데, 오류가 났을 때 해당 의존성을 넣으라고 한 레퍼런스를 알려줄 수 있어? 그리고 해당 의존성이 어떤 역할을 수행해?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이게 저 패키지안에 있는 javax.xml.bind.DatatypeConverter 클래스를 이용해서,
jwt토큰을 생성할 때 서명에 쓸 secret key가 base64로 인코딩된 문자열로 넣어도 내부적으로 디코딩해서 알맞는 값으로 넣어주는 역할을 하더라고.

저게 없으면 직접 base64로 secretkey를 디코딩하고 서명에 쓸 키를 따로 만들어줘야하더라.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

나는 저 의존성을 넣지 않고 base64로 시크릿 키를 인코딩해도 잘 동작했어서 질문한 거였어
당연히 직접 디코딩하고 하는 작업도 안했었구

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음.. 버전이 낮아서 그런건가?

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ public enum StatusCode {
/* 2xx: 성공 */
// Member
MEMBER_CREATE(CREATED,"회원 가입 완료"),
MEMBER_LOGIN(OK,"로그인 완료"),
// TodoList
TODO_CREATE(CREATED,"할 일 생성 완료"),
TODO_FOUND(OK,"할 일 조회 완료"),
@@ -24,8 +25,14 @@ public enum StatusCode {
COMMENT_DELETE(OK,"할 일 삭제 완료"),

/* 400 BAD_REQUEST : 잘못된 요청 */
LOGIN_ID_INVALID(BAD_REQUEST,"아이디가 틀렸습니다."),
EMAIL_INVALID(BAD_REQUEST,"이메일이 틀렸습니다."),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아이디 / 비밀번호가 틀렸다고 했을 때 어떤 부분이 틀렸다고 알려주는 게 적합할까? 한 번 고민해봐

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞아. 이게 내가 프론트를 모르다보니까 주는 정보를 어디까지 한정해야하는지 고민이 되더라고.
그래서 jwt를 만들 때도 시큐리티의 인증 예외를 처리하는 클래스에서 그냥 인증이 실패했다고 정보를 줄 지, 따로 jwt 인증 예외 필터를 만들어서 jwt 토큰이 유효하지않다고 처리할 지 고민했어서 일단은 후자를 선택했는데, 전자가 더 나은 거같아.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토큰을 활용한 인증에서의 문제와 내가 여기서 잡은 문제는 약간 다르다고 생각해.
형이 답변해준 내용처럼 jwt를 활용한 시큐리티의 인증 예외에서 주는 정보의 범위는 형이 알아서 판단하는 게 맞는데,

여기 내용은 그 내용이 아니라 틀린 부분을 알려주면 안된다는 의도에서 질문을 한거였어
만약 악의적인 사용자가 로그인을 시도했을 때, 아이디가 틀렸습니다 / 비밀번호가 틀렸습니다 라고 틀린부분을 정확하게 알게 된다면 그것으로 공격이 진행될 수 있거든.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

글쿤..

INPUT_VALUE_INVALID(BAD_REQUEST,"유효하지 않은 입력입니다."),
PASSWORD_INVALID(BAD_REQUEST,"비밀번호가 틀렸습니다."),


/* 401 UNAUTHORIZED : 비인증 사용자 */
ACCESS_TOKEN_INVALID(UNAUTHORIZED,"access 토큰이 유효하지 않습니다."),


/* 404 NOT_FOUNT : 존재하지 않는 리소스 */
MEMBER_NOT_EXIST(NOT_FOUND,"존재하지 않는 멤버입니다."),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 requestMatchers로 로그인하지 않아도 접근할 수 있는 엔드포인트에 대한 정의를 하는데, 만약 이러한 엔드포인트가 여럿 생긴다고 한다면 코드에 몇줄씩 추가하는 방향으로 밖에 구현이 안되는걸까?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그러게. 이건 고쳐봐야겠다.

Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.appcenter.practice.config;

import com.appcenter.practice.exception.JwtExceptionFilter;
import com.appcenter.practice.security.JwtAuthenticationFilter;
import com.appcenter.practice.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
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 org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;

@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.requestMatchers(PathRequest.toH2Console());

}


@Bean
public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {

http
.authorizeHttpRequests(authorizeRequest -> authorizeRequest
.requestMatchers(new MvcRequestMatcher(introspector, "/members/**")).permitAll()
.requestMatchers(new MvcRequestMatcher(introspector, "/swagger-ui/**")).permitAll()
.requestMatchers(new MvcRequestMatcher(introspector, "/v3/api-docs/**")).permitAll()
//스프링 시큐리티는 자동으로 Role_접두어를 붙여준다.
.requestMatchers(new MvcRequestMatcher(introspector, "/**")).hasRole("USER")
.anyRequest().authenticated())
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtExceptionFilter(), JwtAuthenticationFilter.class);




//csrf:사이트간 위조 요청, disalbel 한 이유: jwt는 서버에 인증정보를 저장하지 않기 때문에 필요 없음.
http.csrf(AbstractHttpConfigurer::disable);
http.cors(AbstractHttpConfigurer::disable);
return http.build();

}
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엔드포인트 형식을 고치면서 prefix (comments)를 requestMapping으로 빼주면 좋을 것 같아!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

협업 투두리스트 만들 때 이건 고쳤어! 같은 테이블의 id를 사용할 땐 pathvariable로 얻어오고, 다른 테이블의 id가 필요하면 requestparam으로 받아오게끔 했어. 이러면 uri가 좀 깔끔해지더라고.

Original file line number Diff line number Diff line change
@@ -20,10 +20,10 @@
public class CommentController {
private final CommentService commentService;

@GetMapping(value = "/comments")
public ResponseEntity<CommonResponse<List<ReadCommentRes>>>getCommentList(){
@GetMapping(value = "/comments/{todoId}")
public ResponseEntity<CommonResponse<List<ReadCommentRes>>>getCommentList(@PathVariable Long todoId){
return ResponseEntity
.ok(CommonResponse.of(COMMENT_FOUND.getMessage(), commentService.getCommentList()));
.ok(CommonResponse.of(COMMENT_FOUND.getMessage(), commentService.getCommentList(todoId)));
}

@GetMapping(value = "/comments/{id}")
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
package com.appcenter.practice.controller;

import com.appcenter.practice.common.StatusCode;
import com.appcenter.practice.dto.reqeust.member.LoginMemberReq;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

패키지 경로에 request가 오타가 났네
이 부분은 수정해줘야 될 것 같아

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

땡큐 ㅋㅋ 아예 몰랐네.

import com.appcenter.practice.dto.reqeust.member.SignupMemberReq;
import com.appcenter.practice.dto.response.CommonResponse;
import com.appcenter.practice.service.MemberService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import static com.appcenter.practice.common.StatusCode.MEMBER_CREATE;
import static com.appcenter.practice.common.StatusCode.MEMBER_LOGIN;

@RestController
@RequiredArgsConstructor
@RequestMapping("/members")
@@ -22,8 +26,17 @@ public class MemberController {
@PostMapping
public ResponseEntity<CommonResponse<Long>> signup( @RequestBody @Valid SignupMemberReq reqDto){
return ResponseEntity
.status(StatusCode.MEMBER_CREATE.getStatus())
.body(CommonResponse.of(StatusCode.MEMBER_CREATE.getMessage(), memberService.signup(reqDto)));
.status(MEMBER_CREATE.getStatus())
.body(CommonResponse.of(MEMBER_CREATE.getMessage(), memberService.signup(reqDto)));
}

@PostMapping(value = "/login")
public ResponseEntity<CommonResponse<Void>> login(@RequestBody @Valid LoginMemberReq signInMemberReqDto){

return ResponseEntity.ok()
.header(HttpHeaders.AUTHORIZATION, memberService.login(signInMemberReqDto))
.body(CommonResponse.of(MEMBER_LOGIN.getMessage(), null));
}


}
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.security.Principal;
import java.util.List;

import static com.appcenter.practice.common.StatusCode.*;
@@ -22,8 +23,9 @@ public class TodoController {
private final TodoService todoService;

@GetMapping
public ResponseEntity<CommonResponse<List<ReadTodoRes>>> getTodoList(){
return ResponseEntity.ok(CommonResponse.of(TODO_FOUND.getMessage(), todoService.getTodoList()));
public ResponseEntity<CommonResponse<List<ReadTodoRes>>> getTodoList(Principal principal){
Long memberId=Long.parseLong(principal.getName());
return ResponseEntity.ok(CommonResponse.of(TODO_FOUND.getMessage(), todoService.getTodoList(memberId)));
}

@GetMapping(value = "/{id}")
@@ -32,10 +34,11 @@ public ResponseEntity<CommonResponse<ReadTodoRes>> getTodo(@PathVariable Long id
}

@PostMapping
public ResponseEntity<CommonResponse<Long>> addTodo(@RequestBody @Valid AddTodoReq reqDto){
public ResponseEntity<CommonResponse<Long>> addTodo(Principal principal,@RequestBody @Valid AddTodoReq reqDto){
Long memberId=Long.parseLong(principal.getName());
return ResponseEntity
.status(TODO_CREATE.getStatus())
.body(CommonResponse.of(TODO_CREATE.getMessage(),todoService.saveTodo(reqDto)));
.body(CommonResponse.of(TODO_CREATE.getMessage(),todoService.saveTodo(memberId,reqDto)));
}

@PatchMapping(value = "/{id}")
Original file line number Diff line number Diff line change
@@ -6,14 +6,19 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;

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

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity {
public class Member extends BaseEntity implements UserDetails {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -49,4 +54,42 @@ public void changePassword(String password){
public void addUserAuthority(){
this.role=Role.ROLE_USER;
}
public void passwordEncode(PasswordEncoder passwordEncoder){
this.password=passwordEncoder.encode(password);
}


//grantedAuthority는 부여된 권한을 갖는 인터페이스,SimpleGrantedAuthority는 granteAuthority를 구현한 간단한 클래스
//"ROLE_USER", "ROLE_ADMIN"과 같은 권한을 나타내는 문자열을 SimpleGrantedAuthority 객체로 생성하여 사용할 수 있다.
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> auth=new ArrayList<>();
auth.add(new SimpleGrantedAuthority(role.name()));
return auth;
}

@Override
public String getUsername() {
return email;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.appcenter.practice.dto.reqeust.member;

import com.appcenter.practice.domain.Member;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class LoginMemberReq {

@Email(message = "잘못된 이메일 형식입니다.")
private String email;

@Pattern(message = "비밀번호는 8자 이상의 영어, 숫자, @,$,!,%,*,#,?,& 중 하나 이상을 포함한 특수문자로 이루어져야 합니다."
, regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,}$")
private String password;

public Member toEntity(){
return Member.builder()
.email(email)
.password(password)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -2,7 +2,6 @@

import com.appcenter.practice.domain.Member;
import com.appcenter.practice.domain.Todo;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Builder;
import lombok.Getter;
@@ -12,16 +11,11 @@
@NoArgsConstructor
public class AddTodoReq {

@NotBlank(message = "이메일은 필수 입력 값입니다.")
@Email(message = "이메일 형식이 아닙니다.")
private String email;

@NotBlank(message = "content는 필수 입력 값입니다.")
private String content;

@Builder
public AddTodoReq(String email, String content) {
this.email = email;
public AddTodoReq(String content) {
this.content = content;
}

Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@
import org.springframework.validation.FieldError;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Getter
@@ -23,9 +24,9 @@ private ErrorResponse(String message, List<ValidationError> validationErrors) {

@Getter
public static class ValidationError{
private String field;
private String value;
private String reason;
private final String field;
private final String value;
private final String reason;

private ValidationError(String field, String value, String reason) {
this.field = field;
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.appcenter.practice.exception;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtExceptionFilter extends OncePerRequestFilter {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AuthenticationFilter의 doFilterInternal과 ExceptionFilter의 doFilterInternal의 차이는 뭐고, 형의 security config에서의 필터 구조는 어떻게 되는거야? 두 필터를 다 거치는거야?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AuthenticationFilter는 토큰을 받아서 유효성 검증을 하고 인증객체를 만들어서 세션 저장소에 저장을 하는거고,
ExceptionFiter는 AuthenticationFilter 앞에 위치시켜놓고 AuthenticationFilter에서 일어나는 예외를 처리하는 필터야.


/*
인증 오류가 아닌, JwtAuthenticationFilter에서 일어나는 오류는 이 필터에서 따로 잡아낸다.
이를 통해 JWT 관련 에러와 인증 에러를 따로 잡아낼 수 있다.
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
try {
chain.doFilter(request, response); // JwtAuthenticationFilter로 이동
} catch (CustomException ex) {
// JwtAuthenticationFilter에서 예외 발생하면 바로 setErrorResponse 호출
sendErrorResponse(response,ex);
}
}

private void sendErrorResponse(HttpServletResponse response, CustomException ex) throws IOException {
//응답 상태코드에 status를 json으로 보냄.
response.setStatus(ex.getStatusCode().getStatus().value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
//json으로 보낼 body에 해당하는 포맷을 설정함.
//response.getWriter().write(...)는 내부적으로 flush()가 발생해서 따로 호출할 필요 없음.
response.getWriter().write(String.format("{\"message\": \"%s\"}", ex.getStatusCode().getMessage()));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

호출할 필요는 없지만 호출한 이유는 뭘 까..??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 저게 flush를 따로 호출할 필요가 없다는 뜻이었어. 고쳐야겠다.

}
}
Loading