이전에 설정했던 이전 security 설정글 security 설정에서 했던 설정을 기반으로 시작한다.
security를 사용하는 것에는 권한에 따라 페이지에 접근을 막는 기능을 사용하기 위함도 있지만 암호화를 간단하게 처리할 수 있게 해주는 기능 또한 security를 사용함으로써 얻을 수 있는 장점이다.
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//security 암호화 encoder를 bean으로 등록
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // rest api 이므로 기본설정 사용안함. 기본설정은 비인증시 로그인폼 화면으로 리다이렉트 된다.
.csrf().disable() // rest api이므로 csrf 보안이 필요없으므로 disable처리.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt token으로 인증하므로 세션은 필요없으므로 생성안함.
.and()
.authorizeRequests() // 다음 리퀘스트에 대한 사용권한 체크
.antMatchers("/**").permitAll(); // 가입 및 인증 주소는 누구나 접근가능
//.antMatchers("/v1/**").permitAll() // 가입 및 인증 주소는 누구나 접근가능
//.anyRequest().hasRole("USER"); // 그외 나머지 요청은 모두 인증된 회원만 접근 가능
}
@Override
public void configure(WebSecurity web) throws Exception {
}
}
먼저 security의 encoder를 bean으로 등록시켜준다. 그후에 우리가 비밀번호를 전달 받아 저장시키는 위치에 encoder 을 가져다가 사용하면 끝이다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class LoginServiceImpl implements LoginService{
private final LoginRepository loginRepository;
private final PasswordEncoder passwordEncoder; //encoder 추가
@Transactional
@Override
public MemberVo join(MemberDto md){
String id = md.getId();
String pw = md.getPw();
String rePw = md.getRePw();
String email = md.getEmail();
MemberEntity me = MemberEntity.builder()
.id(id)
.pw(passwordEncoder.encode(pw)) //비밀번호 암호화
.email(email)
.auth(MemberRoles.MEMBER.getRole())
.build();
MemberEntity save = loginRepository.save(me);
return MemberVo.builder().id(save.getId()).build();
}
}
다음과 같이 비밀번호를 전달받아 저장하기 전 encoder를 사용 암호화하여 저장한다.
그리고 테스트를 하면 다음과 같이 멋지게 오류가 난다.
오류도 사실 api스럽게 처리되어야하는데 아직 error 처리가 되지 않았다!
이유는 짐작이 가지만 그래도 서버 오류 코드를 살펴보자.
내가 비밀번호 컬럼의 길이를 20자로 제한해놨는데 암호화된 비밀번호의 크기가 68자로 들어왔기 때문에 exception이 발생한것이다.
MemberEntity를 정의해둔 파일에서 길이를 넉넉하게 200글자로 수정하고 다시 실행후 테스트해보자.
정상적으로 return 받았고 db도 확인해보자.
db에도 비밀번호가 직접 입력된 비밀번호가 아닌 암호화되어 저장된 것을 확인할 수 있다.
지난 포스팅 h2 DB연동 및 테스트 회원가입 로직 만들기 에서는 권한을 String으로 split하여 진행하도록 코드를 작성했었는데 이 방법도 사실 나쁜건 아니지만 그래도 권한 테이블을 따로 빼놓고 해당 테이블에서 키값으로 권한 값을 가져오는게 더 괜찮은 방법 같아서 해당 방법으로 수정하려고 한다.
@Column(name = "auth")
@CollectionTable(name = "member_roles")
@JoinColumn(name = "member_idx")
@ElementCollection(fetch = FetchType.LAZY)
private List<String> auth;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.auth.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
MemberEntity에 다음과 같이 코드를 수정해준다. auth라는 컬럼을 List로 빼고 @ElementCollection
어노테이션을 추가하여 jpa에 컬렉션 객체임을 알려준다. fetch는 entity를 조회할 때 한방에 모든 리스트를 다 가져올지 아닐지 선택하는 것인데 이후에 회원 조회를 구현할 때 자세히 보자.
위처럼 작성했을 때 테이블이 다음과 같이 나눠지며 이후에 MEMBER의 권한을 join하여 가져올 수 있다.
dependencies {
//...
implementation 'org.springframework.boot:spring-boot-starter-validation' //validated 추가
}
의존성을 추가한다.
@PostMapping(value = "/join")
public ResponseEntity join(@RequestBody @Valid MemberDto md, BindingResult bindingResult){
MemberVo join = loginService.join(md);
CommonV1 result = CommonV1.builder()
.code("200")
.result("success")
.msg("회원가입 성공")
.data(join)
.build();
return ResponseEntity.status(HttpStatus.OK).body(result);
}
controller에는 @Valid
어노테이션을 검사할 dto에 추가해주고 BindingResult
객체를 추가해준다.
BindingResult
유효성 검사 중 에러가 발생한 변수에 대한 결과 값이 들어있는 객체
@Getter
@Setter
@NoArgsConstructor
public class MemberDto {
@NotEmpty
private String id;
@NotEmpty
private String pw;
@NotEmpty
private String rePw;
@NotEmpty
private String email;
}
그리고 다음과 같이 @NotEmpty
를 사용하여 값이 비어있지 않도록 검사해준다.
다음과 같이 id
값은 빈값으로 rePw
는 null로 보냈다.
그러면 BindingResult
객체에 2개의 에러 값이 들어있으므로 해당 유효성 검사를 통해 예외처리를 해주면 된다.
@Valid와 @Validated 두개가 존재하는데 @Valid는 java에서 제공하는 유효성 검사고 @Validated는 Spring에서 좀 더 추가하여 제공해주는 기능이다. @Validated를 사용하면 @Valid를 내장하여 사용하기 때문에 편한걸로 사용하면 된다.
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class JoinValidException extends RuntimeException {
private String msg;
private HttpStatus status;
private Queue<ObjectError> allErrors;
}
exception에 login 패키지를 만들어 아래에 joinValidException을 만든다. 나는 Queue 인자에 Error값들을 담아서 보내려고 한다.
@RestControllerAdvice
public class LoginAdvice {
@ExceptionHandler(JoinValidException.class)
public ResponseEntity joinValidException(JoinValidException jve){
Queue<ObjectError> allErrors = jve.getAllErrors();
List<CommonError> errorList = new ArrayList<>();
for(ObjectError oe : allErrors){
String detail = ((FieldError)oe).getField() + " " + oe.getDefaultMessage();
CommonError commonError = new CommonError(jve.getMsg(), detail);
errorList.add(commonError);
}
CommonErrorV1<List<CommonError>> result = new CommonErrorV1<>(errorList);
return new ResponseEntity<CommonErrorV1>(result, jve.getStatus());
}
}
그 후 advice를 추가해준다.
advice에 login 패키지를 만들어 LoginAdvice를 추가했다.
그 후 postman으로 테스트 결과를 확인할 수 있다.
exception 정규화 참고글
error field 명 가져오기 stack over flow 참고글
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs(uriHost = "localhost", uriPort = 8080)
@Transactional(readOnly = true)
class LoginControllerTest {
@Autowired
MockMvc mock;
@Test
@DisplayName("회원가입 실패")
void joinFail() throws Exception{
//given
//요청 데이터가 모두 비어있을 경우
JSONObject json = new JSONObject();
json.put("id", "");
json.put("pw","");
json.put("rePw","");
json.put("email","");
//when
ResultActions act = mock.perform(post("/v1/join").contentType(MediaType.APPLICATION_JSON).content(json.toString()));
//then
act.andExpect(status().is4xxClientError())
.andExpect(jsonPath("$.errors[0].msg").value("잘못된 회원가입 데이터 요청"));
}
@Test
@DisplayName("회원가입 성공")
void joinSuccess() throws Exception{
//given
JSONObject json = new JSONObject();
String testId = "testId";
json.put("id", testId);
json.put("pw","testPw");
json.put("rePw","testPw");
json.put("email","testEmail@mail.com");
//when
ResultActions act = mock.perform(post("/v1/join").contentType(MediaType.APPLICATION_JSON).content(json.toString()));
//then
act.andExpect(status().isOk())
.andExpect(jsonPath("$.result").value("success"))
.andExpect(jsonPath("$.data.id").value(testId));
//docs
act.andDo(document("login/join",
preprocessRequest(prettyPrint()), //request json 형식으로 이쁘게 출력
preprocessResponse(prettyPrint()), //response json 형식으로 이쁘게 출력
requestFields(
fieldWithPath("id").type(JsonFieldType.STRING).description("회원 아이디"),
fieldWithPath("pw").type(JsonFieldType.STRING).description("회원 비밀번호"),
fieldWithPath("rePw").type(JsonFieldType.STRING).description("비밀번호 확인"),
fieldWithPath("email").type(JsonFieldType.STRING).description("회원 이메일")
),
responseFields(
fieldWithPath("result").type(JsonFieldType.STRING).description("결과"),
fieldWithPath("code").type(JsonFieldType.STRING).description("결과 코드"),
fieldWithPath("msg").type(JsonFieldType.STRING).description("결과 메세지"),
fieldWithPath("data.id").type(JsonFieldType.STRING).description("가입 성공한 회원 아이디"),
fieldWithPath("data.pw").description("빈 값"),
fieldWithPath("data.email").description("빈 값")
)
));
}
}
테스트 코드는 실패했을 경우와 성공했을 경우 2가지를 작성했으며 성공한 경우를 docs에 추가했다.
build 시 정상적으로 테스트가 진행된것을 확인할 수 있다.
정상적으로 build 후 docs문서도 확인해보자.
회원가입 api 내용이 정상적으로 추가된것을 확인할 수 있다!
@ElementCollection
, @CollectionTable
어노테이션에 대해 학습할 수 있었다.@valid
어노테이션을 통해 자동 유효성 체크를 할 수 있었다. 또한 유효성 검사 코드량을 줄일 수 있었다.@Valid
어노테이션과 BindingResult
의 사용법을 학습할 수 있었다.
보안 관련 질문이 있습니다.
실제 서비스를 할 때, DB에 회원 정보를 추가해주는 회원가입 API인 "http://localhost:8080/v1/join" 가 외부에 노출되게 될텐데요.
악의적인 공격자가 해당 API에 dummy데이터(validated를 통과할만한 값으로)를 넣어 무차별 호출하게 되면 DB의 MEMBER 테이블을 임의로 채워버릴 수 있을 것 같다는 생각이 듭니다.
이에 대한 방지대책은 어떤게 적절할까요?