프로젝트 소개
여태까지 여러 프로젝트를 진행했었는데, 그때마다 블로깅을 안했던 것 같다.
이유는 내가 아직 부족한 점이 많아서 좀 더 성장하고 난 이후에 블로깅을 하고 싶었다.
이번 프로젝트는 커뮤니티를 만드는 것인데, 여러가지 기능을 넣어보고 성능 최적화도 진행할 생각이다.
단순 API 개발이지만, 타 개발자와 협업한다는 생각으로 진행할 것이다.
프로젝트 설정
(java 11, Spring-Boot)
Dependency
이후 추가로 JWT와 Swagger를 추가해야한다.
이번에 진행할 내용은 다음과 같다.
- Spring Security + JWT를 이용한 로그인 구현
- response를 커스텀하여 응답을 보기 좋게 수정
- Swagger 적용
- 예외처리
security+jwt를 활용한 로그인 구현의 경우 대부분의 내용이 저번글과 겹치므로 달라진 부분 위주로 설명할 예정이다.
https://kimtaesoo99.tistory.com/118
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.7'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
// jwt
implementation 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'
// Swagger
implementation 'io.springfox:springfox-boot-starter:3.0.0'
}
tasks.named('test') {
useJUnitPlatform()
}
swagger와 jwt를 추가로 넣어주었다.
Member
우선 저번 포스팅에서는 이메일을 가지고 로그인을 진행했었다.
이번에는 username을 가지고 로그인을 진행하도록 하겠다.
@Getter
@NoArgsConstructor
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Column(nullable = false, unique = true)
private String username;
@JsonIgnore
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
@Enumerated(EnumType.STRING)
private Authority authority;
@Builder
public Member(Long id, String username, String password, String name, Authority authority) {
this.id = id;
this.username = username;
this.password = password;
this.name = name;
this.authority = authority;
}
}
Setter를 안만든 이유는 수정자를 만들 경우 다른곳에서 쉽게 값을 변경할 수 있기때문이다.
만약 값을 변경하고 싶다면, 엔티티 내부에서 값이 변경되는 메서드를 만드는 방법을 사용하자.
권한의 경우 저번 포스팅과 동일하게 ROLE_USER, ROLE_ADMIN을 만들었다.
@Enumerated 어너테이션에서 EnumType을 선택할 수 있는데,
한 가지는 EnumType.ORDINAL 이고 다른 한 가지는 위와 같은 EnumType.STRING이다.
둘의 차이점은 ORDINAL의 경우 0 , 1 ,2, .. 이런식으로 enum의 위치에 따라 값을 지정하게된다.
이러한 방식이 위험한 이유는 중간에 새로운 권한을 넣을 경우 값이 밀려지게 되고,
DB에 있던 원래 값들은 밀리지 않아서 의도한대로 설계되지않는다.
따라서 STRING을 사용하여 정확하게 이름을 매핑하도록 설계하였다.
SwaggerConfig
@Import(BeanValidatorPluginsConfiguration.class)
@Configuration
// http://localhost:8080/swagger-ui/index.html
public class SwaggerConfig {
@Bean
public Docket api() {
return new Docket(DocumentationType.OAS_30)
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("com.example.community.controller"))
.paths(PathSelectors.any())
.build()
.securityContexts(Arrays.asList(securityContext()))
.securitySchemes(Arrays.asList(apiKey()));
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("Community")
.description("Community REST API Documentation")
.license("kimtaesoo7@naver.com")
.licenseUrl("https://github.com/kimtaesoo99/community")
.version("1.0")
.build();
}
private static ApiKey apiKey() {
return new ApiKey("Authorization", "Bearer Token", "header");
}
private SecurityContext securityContext() {
return SecurityContext.builder().securityReferences(defaultAuth())
.operationSelector(oc -> oc.requestMappingPattern().startsWith("/api/")).build();
}
private List<SecurityReference> defaultAuth() {
AuthorizationScope authorizationScope = new AuthorizationScope("global", "global access");
return List.of(new SecurityReference("Authorization", new AuthorizationScope[] {authorizationScope}));
}
}
이번 프로젝트에 Swagger 기능을 사용하기위해 SwaggerConfig가 추가 되었다.
Swagger기능을 사용하는 이유는 간단하게 API 명세서를 만들어 주기 때문이다.
ApiInfo 는 이름 그대로 Swagger-ui 에서 메인으로 보여질 정보를 설정한다.
Docket 은 api 의 그룹명이나 이동경로, 보여질 api 가 속한 패키지 등의 자세한 정보를 담는다.
SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.antMatchers( "/v3/api-docs", "/swagger-resources/**",
"/swagger-ui.html", "/webjars/**", "/swagger/**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF 설정 Disable
http.csrf().disable()
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
// exception handling 할 때 우리가 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.headers()
.frameOptions()
.sameOrigin()
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
.and()
.authorizeHttpRequests()
.antMatchers("/api/**").permitAll()
.anyRequest().authenticated() // 나머지 API 는 전부 인증 필요
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));
return http.build();
}
}
추가된점은 WebSecurityCustomizer인데, 이는 아래와 같은 주소로 접근할시 아래 인증을 무시하게된다.
즉 swagger의 경우 권한이 없어도 들어갈 수 있도록 설정하였다.
AuthController
@Api(value = "Sign Controller", tags = "Sign")
@RequiredArgsConstructor
@RestController
@RequestMapping("/api")
public class AuthController {
private final AuthService authService;
@ApiOperation(value = "회원가입", notes = "회원가입 진행")
@ResponseStatus(HttpStatus.CREATED)
@PostMapping("/sign-up")
public Response register(@Valid @RequestBody SignUpRequestDto signUpRequestDto) {
authService.signup(signUpRequestDto);
return success();
}
@ApiOperation(value = "로그인", notes = "로그인을 한다.")
@PostMapping("/sign-in")
@ResponseStatus(HttpStatus.OK)
public Response signIn(@Valid @RequestBody LoginRequestDto req) {
return success(authService.signIn(req));
}
@ApiOperation(value = "토큰 재발급", notes = "토큰 재발급 요청")
@ResponseStatus(HttpStatus.OK)
@PostMapping("/reissue")
public Response reissue(@RequestBody TokenRequestDto tokenRequestDto) {
return success(authService.reissue(tokenRequestDto));
}
}
Swagger에 관한 어네테이션들이 추가되었는데, Swagger의 어너테이션들을 다음과 같은 기능을한다.
@Api - 해당 클래스가 Swagger 리소스라는 것을 명시함
- value - 태그를 작성함
- tags - 사용하여 여러 개의 태그를 정의할 수도 있음
@ApiOperation - 한 개의 operation(즉 API URL , Method)을 선언함
- value - API에 대한 간략한 설명(제목같은 느낌)을 작성
- notes - 더 자세한 설명을 작성
@ApiResponse - operation의 가능한 response를 명시함
- code - 응답 코드를 작성
- message - 응답에 대한 설명을 작성
- responseHeaders - 헤더를 추가할 수 있음
@ApiParam - 파라미터에 대한 정보를 명시함
- value - 파라미터 정보를 작성함
- required - 필수 파라미터가 아니면 true 아니면 false를 작성함
- example - 테스트를 할 때 보여줄 예시를 작성
SignUpRequestDto
@ApiModel(value = "회원가입 요청")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignUpRequestDto {
@ApiModelProperty(value = "아이디", notes = "아이디를 입력해주세요", required = true, example = "kimtaesoo")
@NotBlank(message = "아이디를 입력해주세요.")
private String username;
@ApiModelProperty(value = "비밀번호", required = true, example = "123456")
@NotBlank(message = "비밀번호를 입력해주세요.")
// @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,}$", message = "비밀번호는 최소 8자리이면서 1개 이상의 알파벳, 숫자, 특수문자를 포함해야합니다.")
private String password;
@ApiModelProperty(value = "사용자 이름", notes = "사용자 이름은 한글 또는 알파벳으로 입력해주세요.", required = true, example = "김태수")
@NotBlank(message = "사용자 이름을 입력해주세요.")
@Size(min = 2, message = "사용자 이름이 너무 짧습니다.")
private String name;
}
swagger관련 어너테이션과 Validation 기능을 사용해서 유효성 검사를 하였다.
Response 커스텀하기
@JsonInclude(JsonInclude.Include.NON_NULL) // null 값을 가지는 필드는, JSON 응답에 포함되지 않음
@Getter
@AllArgsConstructor
public class Success<T> implements Result {
private T data;
}
Success는 성공시 받아갈 데이터가 존재하므로, 데이터를 받는 클래스를 만들었다.
Result를 구현하였는데 Result는 단순하게 Success와 Failure를 묶는 역할을한다.
@Getter
@AllArgsConstructor
public class Failure implements Result{
private String msg;
}
Failure는 데이터가 실패할때마다 추가되는 실패문구 뿐이므로NON_NULL을 제거하였다.
@JsonInclude(JsonInclude.Include.NON_NULL) // null 값을 가지는 필드는, JSON 응답에 포함되지 않음
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class Response {
private boolean success;
private int code;
private Result result;
public static Response success() {
return new Response(true, 0, null);
}
public static <T> Response success(T data) {
return new Response(true, 0, new Success<>(data));
}
public static Response failure(int code, String msg) {
return new Response(false, code, new Failure(msg));
}
}
- 요청 처리를 성공 : 데이터를 반환할 필요가 없으며, 성공했을 경우에는 Result를 null로 만들어주며, 성공여부에 참을, 코드에 0을 넣었다.
- 요청 처리를 성공 + 응답 데이터가 존재 : 데이터를 반환하기 위해 Response생성자의 Result를 new Success에 데이터를 넣어 반환시켰다.
- 요청 처리를 실패 : 에러 코드는 상황에 따라 다르다. 그리고, Failure에 실패 문구를 추가해 만들었다.
ExceptionAdvice
@RestControllerAdvice
@Slf4j
public class ExceptionAdvice {
// 500 에러
@ExceptionHandler(IllegalArgumentException.class) // 지정한 예외가 발생하면 해당 메소드 실행
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 각 예외마다 상태 코드 지정
public Response illegalArgumentExceptionAdvice(IllegalArgumentException e) {
log.info("e = {}", e.getMessage());
return Response.failure(500, e.getMessage().toString());
}
// 401 응답
// 아이디 혹은 비밀번호 오류시 발생
@ExceptionHandler(LoginFailureException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Response loginFailureException() {
return Response.failure(401, "로그인에 실패하였습니다.");
}
// 409 응답
// username 중복
@ExceptionHandler(UsernameAlreadyExistsException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public Response memberEmailAlreadyExistsException(UsernameAlreadyExistsException e) {
return Response.failure(409, e.getMessage() + "은 중복된 아이디 입니다.");
}
@RestControllerAdvice를 사용하기 때문에, 자체적으로 @ResponseBody가 포함되어 있다.
따라서 커스텀으로 만든 Response 객체를 이용해서 반환을 하도록 하였다.
@ExceptionHandler 어노테이션은, 지정한 예외가 발생하면 해당 메소드를 실행시켜준다.
@ResponseStatus는 예외마다 상태 코드를 지정해줄 수 있습니다.
LoginFailureException
public class LoginFailureException extends RuntimeException {
}
RuntimeException을 상속받았다.
UsernameAlreadyExistException
public class UsernameAlreadyExistsException extends RuntimeException{
public UsernameAlreadyExistsException(String message) {
super(message);
}
}
회원가입시 닉네임이 중복되는 경우 발생되는예외이다.
이 예외처리에서는 "~~'는 중복된 아이디입니다." 라는 메시지를 보내야하므로,message를 매개변수로 받아준다.
이런식으로 exception을 만들어주면 Advice에서는 Exception을 관리하고, 메시지를 원하는대로 출력해준다.
동작 테스트
우선 포스트맨을 사용하여 테스트를 해보자.
회원가입
회원가입이 잘되었다는 응답이 왔다.
여기서 한번더 같은 이름으로 가입을하려한다면?
에러가 발생한다.
로그인
로그인 성공시 토큰이 잘 응답된다.
반대로 로그인을 실패하면?
에러가 터진다.
토큰 재발급
새로운 토큰이 잘 발급된다.
이번에는 Swagger를 사용해보자
http://localhost:8080/swagger-ui/index.html
에 접속하자.
우선 들어오면 이런 화면이뜬다.
회원가입부터 진행해보자.
201 Created가 잘 응답된다.
다음은 로그인을 진행해보자
토큰이 잘 발급되었다.
마지막으로 토큰재발급을 테스트해보자
새로운 토큰이 잘 발급되었다.
테스트 코드
이제 마지막으로 테스트코드를 작성해보자.
AuthControllerTest
@ExtendWith(MockitoExtension.class)
class AuthControllerTest {
@InjectMocks
AuthController authController;
@Mock
AuthService authService;
MockMvc mockMvc;
ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void beforeEach(){
mockMvc = MockMvcBuilders.standaloneSetup(authController).build();
}
@Test
public void 회원가입_테스트() throws Exception{
//given
SignUpRequestDto req = new SignUpRequestDto("test", "test1", "1234");
//when
mockMvc.perform(
post("/api/sign-up")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
).andExpect(status().isCreated());
//then
verify(authService).signup(req);
}
@Test
public void 로그인_테스트() throws Exception {
// given
LoginRequestDto req = new LoginRequestDto("test123", "test");
given(authService.signIn(req)).willReturn(new TokenResponseDto("access", "refresh"));
// when, then
mockMvc.perform(
post("/api/sign-in")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.data.accessToken").value("access"))
.andExpect(jsonPath("$.result.data.refreshToken").value("refresh"));
verify(authService).signIn(req);
}
@Test
public void Json응답_테스트() throws Exception {
// 응답결과로 반환되는 JSON 문자열이 올바르게 제거되는지 검증
// given
SignUpRequestDto req = new SignUpRequestDto("test", "1234", "name");
// when, then
mockMvc.perform(
post("/api/sign-up")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.result").doesNotExist());
}
@Test
public void 토큰_재발급_테스트() throws Exception {
// given
TokenRequestDto req = new TokenRequestDto("access", "refresh");
// when, then
mockMvc.perform(
post("/api/reissue")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk());
}
}
MemberFactory
public class MemberFactory {
public static Member createMember(){
Member member = Member.builder()
.username("username")
.name("name")
.password("1245")
.authority(Authority.ROLE_USER)
.build();
return member;
}
}
멤버 객체를 바로 만들어주는 메서드이다.
AuthService
@ExtendWith(MockitoExtension.class)
public class AuthServiceTest {
AuthService authService;
@Mock
AuthenticationManagerBuilder authenticationManagerBuilder;
@Mock
MemberRepository memberRepository;
@Mock
PasswordEncoder passwordEncoder;
@Mock
TokenProvider tokenProvider;
@Mock
RefreshTokenRepository refreshTokenRepository;
@BeforeEach
void beforeEach() {
authService = new AuthService(authenticationManagerBuilder, memberRepository, passwordEncoder, tokenProvider, refreshTokenRepository);
}
@Test
void 회원가입_테스트() {
// given
SignUpRequestDto req = new SignUpRequestDto("username", "1234", "name");
// when
authService.signup(req);
// then
verify(passwordEncoder).encode(req.getPassword());
verify(memberRepository).save(any());
}
@Test
void 로그인실패_테스트() {
// given
given(memberRepository.findByUsername(any())).willReturn(Optional.of(createMember()));
// when, then
assertThatThrownBy(() -> authService.signIn(new LoginRequestDto("email", "password")))
.isInstanceOf(LoginFailureException.class);
}
@Test
void 비밀번호_검증_테스트() {
// given
given(memberRepository.findByUsername(any())).willReturn(Optional.of(createMember()));
given(passwordEncoder.matches(anyString(), anyString())).willReturn(false);
// when, then
assertThatThrownBy(() -> authService.signIn(new LoginRequestDto("username", "password")))
.isInstanceOf(LoginFailureException.class);
}
}
테스트도 잘 성공하는 것을 볼 수 있다.
https://github.com/kimtaesoo99/community
'프로젝트 > 커뮤니티' 카테고리의 다른 글
[프로젝트] 커뮤니티 REST API 서버만들기 #6 Comment API만들기 (0) | 2023.01.25 |
---|---|
[프로젝트] 커뮤니티 REST API 서버만들기 #5 게시판 부가기능 추가 (0) | 2023.01.24 |
[프로젝트] 커뮤니티 REST API 서버만들기 #4 Board API만들기 (0) | 2023.01.23 |
[프로젝트] 커뮤니티 REST API 서버만들기 #3 - Message API 만들기 (6) | 2023.01.21 |
[프로젝트]커뮤니티 REST API 서버만들기 #2 - Member API 만들기 (2) | 2023.01.20 |