할일 카드 저장 프로젝트를 마쳤다.
회원가입/로그인 파트를 구현에 실패했다. Entity들의 연관관계를 맺고, CRUD하는데에는 성공했다.
오늘은 개인과제 해설 영상을 보면서 부족했던 부분들에 대해 다시 공부하는 시간을 가져야한다. 우선 이번에 실패한 개념들을 공부해보자.
1. 정규 표현식
회원가입시 개발자가 원하는 형태의 아이디와 비밀번호를 받을 수 있다. Validation의존성을 추가하여 사용하자.
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
Build.Gradle에 추가해서 사용한다. 사용방식은 다음과 같이 사용한다.
@Pattern(regexp = "^[a-z0-9]{4,10}$")
private String username;
@Pattern(regexp = "^[a-zA-Z0-9]{8,15}$")
private String pwd;
@Pattern어노테이션을 이용해서 정규 표현식을 받겠다고 선언한다. regexp는 regural expression의 준말.
^로 시작을 알리고 []안에는 입력을 허용할 범위를 적어주고 {}안에 총 입력글자 수를 적어준다. $로 정규표현식을 마친다.
이렇게 요청DTO에 @Pattern 어노테이션을 달아서 입력을 정규표현식에 맞춰서 받는다. 그리고 Controller단에서
@PostMapping("/signup")
public ResponseEntity<CommonResponseDto> signup(@Valid @RequestBody UserRequestDto userRequestDto){
... }
다음과 같이 @Valid 어노테이션을 달아서 정규표현식만 받겠다는 선언을 해주면 된다.
1.1) 정규표현식에 맞지 않는 입력을 받았을 때 예외처리
public ResponseEntity<CommonResponseDto> signup(@Valid @RequestBody UserRequestDto userRequestDto
, BindingResult bindingResult){
// 정규 표현식을 지켰는지 확인하기 위해
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
if(fieldErrors.size() > 0) {
for (FieldError fieldError : bindingResult.getFieldErrors()) {
log.error(fieldError.getField() + " 필드 : " + fieldError.getDefaultMessage());
return ResponseEntity.badRequest().body(
new CommonResponseDto(fieldError.getDefaultMessage(), HttpStatus.BAD_REQUEST.value()));
}
정규표현식을 잘 받았는지 확인하기 위해서는 메서서드에 BindingResult 객체를 인자로 가져온다.
BindingResult의 getFieldErrors()메서드를 이용해 에러값을 가져온다. 그리고 위처럼 로그로 에러를 찍어줄 수도 있고
응답을 하기 위해 ResponseEntity를 이용해 에러 메세지(정규표현식과 맞지 않는 입력)과 상태코드를 같이 반환해줬다.
2. ResponseEntity<T>를 활용하여 응답
ResponseEntity는 HttpEntity를 상속받은 클래스.
ResponseEntity를 활용하여 Status, Header와 Body에 내가 원하는 값들을 넣어서 응답할 수 있다. 이번 프로젝트에서는 Status와 Body에 값을 넣어서 응답한다.
// 성공적으로 요청/응답이 완료될 때 사용한 코드
return ResponseEntity.status(HttpStatus.CREATED.value()).
body(new CommonResponseDto("회원가입 성공", HttpStatus.CREATED.value()));
// 요청이 옳바르지 않을 때(여기에선 중복된 회원이 회원가입 했을 경우 IllegalAccessError 를 뱉음)
} catch (IllegalAccessError exception){
return ResponseEntity.badRequest().
body(new CommonResponseDto("중복된 회원이 존재합니다.",
HttpStatus.BAD_REQUEST.value()));
이렇게 status()메서드에 응답시 주고 싶은 상태코드를 넣어 줄 수 있다. 성공적으로 요청/실패가 이루어질 경우 201을 뱉는다. 이때 HttpStatus.CREATED.value()값이 201이다. 요청과 응답에 실패할 경우 400을 뱉는다. HttpStatus.BAD_REQUEST.value()의 값은 400.
이렇게 ResponseEntity를 이용하여 응답하면 Header, Body, Status를 설정해서 응답할 수 있으니 앞으론 이 클래스를 사용해야겠다.
3. Filter에서 로그인 인증 유효성 판단
로그인 인증 유효성 판단의 흐름을 제대로 잡고 가야 한다. 처음으로는 JWT를 다루기 위한 Util 클래스를 만들어 준다.
JwtUtil에는 필드값으로 인증 header key값, 사용자 권한의 key값, 토큰을 식별해주는 bearer, 토큰 만료시간, JWT 시크릿 키값, 키를 암호화 해줄 알고리즘, 키를 담을 키 객체가 필요하다.
JwtUtil이 가져야할 메서드들은 다음과 같다.
토큰 만들기, 토큰 유효성 검사하기, 토큰에서 사용자 정보 가져오기, jwt 토큰을 갖는 헤더를 찾아서 bearer 토큰 값 잘라주기. 여기서 쿠키로 jwt값을 넣어서 인증을 하는 방식을 사용한다면, 쿠키에 jwt 토큰을 넣어주는 메서드가 있으면 된다.
Jwts.builder()를 통해 토큰을 만들어 준다.
public String createToken(String username) {
Date date = new Date();
// 토큰 만료시간 60분
long TOKEN_TIME = 60 * 60 * 1000;
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username)
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
이렇게 토큰에 어떤 정보를 넣어줄지 내가 정해줄 수 있다. setSubject() 메서드를 이용하여 토큰에 사용자 정보를 넣어준다. setExpiration()메서드로 토큰의 유효시간을 정해준다. setIssedAt()은 토큰의 발행 시간을 저장한다. signWith()메서드에선 어떤 알고리즘으로 key값을 암호화했는지 알려준다. compact()메서드로 토큰을 완성!
이렇게 완성된 토큰은 사용자에게 응답하여 건내주게 된다. 건내받은 jwt 토큰은 사용자가 요청할 때마다 헤더에 담겨서 넘어오고, 넘겨받은 헤더에서 토큰을 찾고, 유효성을 확인하고, 토큰을 사용할 수 있도록 가공한 후 사용자의 정보를 가져오면 된다
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
토큰의 유효성 검사. Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);을 통해 이 토큰이 유효한 jwt의 secret key를 갖는지 확인한다. 만약 이 토큰이 유효하지 않다면 예외를 던지게 된다.
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
토큰에서 사용자 정보를 가져온다. 사용자의 정보는 Claims에 담긴다는 것을 저번에 정리하면서 배웠다. getBody()메서드를 이용해 사용자의 정보를 Claims에 넣어서 저장.
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
토큰에 내가 찾는 jwt토큰이 있는지를 확인하고, 토큰이 있으면? bearer을 잘라주는 메서드.
bearer은 토큰의 식별자라고 했다. bearer 토큰이 있는지 확인하고 bearer을 잘라주며 반환.
다음으로 정리할 부분은 Filter파트. JwtUtil으로 jwt를 만들고, 다뤘으면 사용해야한다. 사용하는 곳이 바로 Filter다 jwtFilter를 구현해보자. Filter단에서 사용자의 정보를 담는 그릇은 UserDetails인터페이스를 구현한 UserDetailsImpl 클래스를 사용했다. 인터페이스를 구현해야 하기 때문에 인터페이스가 갖는 메서드들을 오버라이드 해주면 된다.
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = jwtUtil.resolveToken(request);
if(Objects.nonNull(token)){
if(jwtUtil.validateToken(token)){
Claims info = jwtUtil.getUserInfoFromToken(token);
// 인증정보에 요저정보(username) 넣기
// username -> user 조회
String username = info.getSubject();
SecurityContext context = SecurityContextHolder.createEmptyContext();
// -> userDetails 에 담고
UserDetailsImpl userDetails = userDetailsService.getUserDetails(username);
// -> authentication의 principal 에 담고
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
// -> securityContent 에 담고
context.setAuthentication(authentication);
// -> SecurityContextHolder 에 담고
SecurityContextHolder.setContext(context);
// -> 이제 @AuthenticationPrincipal 로 조회할 수 있음
context.setAuthentication(authentication);
와! 정말 어렵다. 하지만 이건 이 메서드의 일부만 가져온 것. 유효성 검사를 해서 유효성이 인증이 된 상태에서 토큰에서 사용자 정보를 가져오는 코드만 이정도 분량이다. 천천히 뜯어보면서 어떻게 작동하는지 보자.
우선 필터단이기에 인자로 요청과 응답을 모두 받는다. 이 필터를 통해서 요청이 들어오고, 필터를 지나면서 응답을 보내야 하기 때문.
토큰을 JwtUtil의 resolveToken으로 넘어온 요청에 bearer 토큰값이 있는지 확인한다(jwt토큰을 갖는지) 확인이 되면? validateToken으로 토큰의 유효성을 판단한다. 토큰의 유효성이 확인되면? 이 사용자가 보낸 요청은 인증된 사용자의 요청이다 라고 판단. 토큰에서 사용자 정보를 꺼내서 다음 필터로 보내면 된다. JwtUtil의 getUserInfoFromToke으로 Claim에 사용자 정보를 넣어준다. 토큰을 만들때 사용자 정보를 setSubject()로 넣어줬다. 따라서 사용자 정보는 getSubject()로 받아주자! SecurityContext에 인증 정보를 넣어주기 위해 우선 빈 객체를 만들어준다. 위에서 말했듯이 이제는 유저의 정보를 UserDetails에 넣어줄 것. Authentication 객체의 principal에 사용자의 정보를 담아주고 securityContextHolder에 담아서 context의 setAuthentication을 넣어주면 이제 인증이 필요할 때마다 멀티 쓰레드로 인증을 처리해준다고 한다. 이렇게 필터단에서 로그인 인증을 하는 방법에 대해 배웠다. 이 이후에서는 내가 구현에 성공했기 때문에 다음에 다시 정리하자.
오늘 정말 길었다. 아직 제대로 이해한지 모르겠지만... 그래도 회원가입/로그인 같은 경우엔 좀 답이 정해져 있는 것 같다. 처음에 잡아놓고 가면 될것 같기때문에 이번기회에 정리를 하고, 구현을 같이 해보니 조금 감이 잡혔다.