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

[hoony-lab-issue23] authentication: jwt token 기능 #26

Merged
merged 27 commits into from
Sep 5, 2021
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
444d5a8
[#12] fix: UserService에 Transactional 수정
hoony-lab Aug 14, 2021
ba81367
[#23] build: jjwt dependency 추가
hoony-lab Aug 16, 2021
af12b06
[#23] feat: JWT 관련 static util 추가
hoony-lab Aug 16, 2021
0d80ed6
[#23] feat: token 생성&주입 로직 추가
hoony-lab Aug 16, 2021
9bae468
[#23] feat: SuccessHandler and EntryPoint for JwtAuth
hoony-lab Aug 22, 2021
289daa9
[#23] fix: JwtUserDetails to security.User
hoony-lab Aug 22, 2021
4e24753
[#23] fix: JwtAuthenticationFilter log
hoony-lab Aug 22, 2021
a41f4a3
[#23] fix: delete unnecessary jwt releated files
hoony-lab Aug 26, 2021
b3672fe
[#23] feat: add JwtAuthenticationFilter extends OncePerRequestFilter
hoony-lab Aug 26, 2021
c8df5b7
[#23] feat: add JwtProvider for Component
hoony-lab Aug 26, 2021
2ee1c32
[#23] feat: addFilter JwtAuthenticationFilter
hoony-lab Aug 26, 2021
533f645
[#23] fix: update UserService
hoony-lab Aug 26, 2021
c117a6f
[#23]: feat: add getUser in UserController
hoony-lab Aug 26, 2021
a090ace
[#23] test: add JwtProviderTest
hoony-lab Aug 26, 2021
bc1850e
[#23] test: add JwtAuthenticationFilterTest
hoony-lab Aug 26, 2021
d500e12
[#23] test: add GET_api_user_getUser in UserControllerTest
hoony-lab Aug 26, 2021
f10b793
[#23] build: delete unused dependency
hoony-lab Aug 30, 2021
238d351
[#23] fix: validateDuplicateUser param
hoony-lab Aug 30, 2021
6e2a63b
[#23] fix: delete unused method
hoony-lab Aug 30, 2021
53cf055
[#23] fix: conform to realworld API spec
hoony-lab Aug 30, 2021
ee671a8
[#23] fix: secret, access-time in JwtProvider
hoony-lab Aug 30, 2021
70af05c
[#23] feat: add UserService expcetion test
hoony-lab Aug 30, 2021
7f32020
[#23] fix
hoony-lab Aug 30, 2021
829940b
[#23] fix: integrate getClaimsFromToken and isValidToken in JwtProvider
hoony-lab Sep 3, 2021
3283a57
[#23] fix: add generated token in joinUser
hoony-lab Sep 3, 2021
199c904
[#23] fix: replace magic literal
hoony-lab Sep 3, 2021
97af41c
[#23] fix: 코드 위치 수정
hoony-lab Sep 5, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,22 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

implementation 'org.springframework.boot:spring-boot-starter-security'

implementation 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

implementation 'io.jsonwebtoken:jjwt:0.9.1'
DolphaGo marked this conversation as resolved.
Show resolved Hide resolved
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'

runtimeOnly 'com.h2database:h2'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Expand Down
27 changes: 20 additions & 7 deletions src/main/java/com/study/realworld/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,29 +1,42 @@
package com.study.realworld.config;

import com.study.realworld.config.auth.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
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.WebSecurityConfigurerAdapter;
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.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

private final JwtAuthenticationFilter jwtAuthenticationFilter;

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin().disable()
.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/api/hello", "/h2-console/**").permitAll()
.antMatchers("/api/users/**", "/api/users/login/**").permitAll()
.antMatchers("/api/articles/**").permitAll()
.antMatchers("/api/tags/**").permitAll()
.antMatchers("/error").permitAll()
.anyRequest().authenticated();
.authorizeRequests()
.antMatchers("/api/hello", "/h2-console/**").permitAll()
.antMatchers("/api/users/**", "/api/users/login/**").permitAll()
.antMatchers("/api/articles/**").permitAll()
.antMatchers("/api/tags/**").permitAll()
.antMatchers("/error").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
;
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.study.realworld.config.auth;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtProvider jwtProvider;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("{} {} : {}", request.getMethod(), request.getRequestURI(), request.getHeader(HttpHeaders.AUTHORIZATION));
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
DolphaGo marked this conversation as resolved.
Show resolved Hide resolved
Copy link

Choose a reason for hiding this comment

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

Suggested change
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("{} {} : {}", request.getMethod(), request.getRequestURI(), request.getHeader(HttpHeaders.AUTHORIZATION));
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
private static final String DoFilterMessage = "{} {} : {}";
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info(DoFilterMessage, request.getMethod(), request.getRequestURI(), request.getHeader(HttpHeaders.AUTHORIZATION));
String header = request.getHeader(HttpHeaders.AUTHORIZATION);

매직 스트링 제안드립니다!

Copy link
Member Author

Choose a reason for hiding this comment

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

소스코드에 주로 여러 번 등장하는 일반적인 리터럴 값을 매직 리터럴이라고 한다.
의견 감사드립니다 ! 반영해보도록할게요 ~


if(header != null) {
String accessToken = header.split(" ")[1];
DolphaGo marked this conversation as resolved.
Show resolved Hide resolved

if(jwtProvider.isValidToken(accessToken)) {
String email = jwtProvider.getSubjectFromToken(accessToken);
final UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(email, accessToken, Collections.emptyList());

SecurityContextHolder.getContext().setAuthentication(authentication);
}
}

filterChain.doFilter(request, response);
}

}
66 changes: 66 additions & 0 deletions src/main/java/com/study/realworld/config/auth/JwtProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.study.realworld.config.auth;

import com.study.realworld.user.domain.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtProvider {

private static final String SECRET_KEY = "real-world-with-hoony-lab";
private static final int ACCESS_TIME = 30;

public String generateJwtToken(User user) {
return Jwts.builder()
.setSubject(user.getEmail())
.setHeader(createHeader())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 1000 * ACCESS_TIME))
DolphaGo marked this conversation as resolved.
Show resolved Hide resolved
.signWith(SignatureAlgorithm.HS512, createKey())
.compact();
}

public Claims getClaimsFromToken(String token) {
return Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(SECRET_KEY))
.parseClaimsJws(token)
.getBody();
}

public String getSubjectFromToken(String token) {
return this.getClaimsFromToken(token)
.getSubject();
}

public boolean isValidToken(String token) {
try {
Claims claims = this.getClaimsFromToken(token);
return true;
} catch (JwtException | NullPointerException e) {
return false;
}
}
Copy link
Member

Choose a reason for hiding this comment

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

getClaimsFromToken을 두 메소드에서쓰는데 동시에 Filter에서도 두 메소드를 하나의 flow에서 호출하는 이유가 있을까요?

Copy link
Member Author

Choose a reason for hiding this comment

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

이렇게 작성한 이유는 100% 제가 짠 코드가 아니기 때문인 듯 싶습니다.
곰곰히 보니까 getClaimsFromToken 함과 동시에 valid 확인을 하면 좋을 것 같습니다 !


private Map<String, Object> createHeader() {
Map<String, Object> header = new HashMap<>();
header.put("alg", "HS512");
header.put("typ", "JWT");
return header;
}

private Key createKey() {
byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
return new SecretKeySpec(secretKeyBytes, SignatureAlgorithm.HS512.getJcaName());
}

}
21 changes: 14 additions & 7 deletions src/main/java/com/study/realworld/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,54 +8,55 @@
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.NoSuchElementException;

@RequiredArgsConstructor
@Transactional
@Transactional(readOnly = true)
@Service
public class UserService {

private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;

@Transactional(readOnly = true)
public List<User> findAll() {
return userRepository.findAll();
}

@Transactional(readOnly = true)
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException(id + " not found"));
}

@Transactional(readOnly = true)
public User findByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new NoSuchElementException(email + " not found"));
}

@Transactional(propagation = Propagation.NEVER)
hoony-lab marked this conversation as resolved.
Show resolved Hide resolved
public User save(UserJoinRequest request) {
User user = UserJoinRequest.toUser(request);
user.encodePassword(passwordEncoder);

validateDuplicateUser(user);

return userRepository.save(user);
}

@Transactional
public User deleteById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException(id + " not found"));
User user = this.findById(id);

userRepository.delete(user);
DolphaGo marked this conversation as resolved.
Show resolved Hide resolved
return user;
}

@Transactional(readOnly = true)
public User login(UserLoginRequest request) {
User user = this.findByEmail(request.getEmail());

validateMatchesPassword(user, request.getPassword());

return user;
Expand All @@ -67,4 +68,10 @@ private void validateMatchesPassword(User user, String rawPassword) {
}
}

private void validateDuplicateUser(User user) {
DolphaGo marked this conversation as resolved.
Show resolved Hide resolved
if(userRepository.findByEmail(user.getEmail()).isPresent()) {
throw new DuplicateKeyException(user.getEmail() + " duplicated email");
}
}

}
34 changes: 26 additions & 8 deletions src/main/java/com/study/realworld/user/web/UserController.java
Original file line number Diff line number Diff line change
@@ -1,38 +1,56 @@
package com.study.realworld.user.web;

import com.study.realworld.config.auth.JwtProvider;
import com.study.realworld.user.domain.User;
import com.study.realworld.user.dto.UserJoinRequest;
import com.study.realworld.user.dto.UserLoginRequest;
import com.study.realworld.user.dto.UserResponse;
import com.study.realworld.user.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;

@Slf4j
@RequiredArgsConstructor
@RequestMapping("/api")
@RestController
public class UserController {

private final JwtProvider jwtProvider;
private final UserService userService;

@PostMapping("/users")
public ResponseEntity<UserResponse> joinUser(@RequestBody UserJoinRequest request) {
User user = userService.save(request);
String token = null;

return ResponseEntity.ok().body(UserResponse.of(user, token));
return ResponseEntity.ok()
.body(UserResponse.of(user, "token"));
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
.body(UserResponse.of(user, "token"));
.body(UserResponse.of(user, ???));

토큰 생성 로직을 만드셨는데 추가를 잊으신거같네요 :)

}

@PostMapping("/users/login")
public ResponseEntity<UserResponse> loginUser(@RequestBody UserLoginRequest request) {
User user = userService.login(request);
String token = null;
String token = jwtProvider.generateJwtToken(user);

return ResponseEntity.ok()
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
hoony-lab marked this conversation as resolved.
Show resolved Hide resolved
.body(UserResponse.of(user, token));
}

@GetMapping("/user")
public ResponseEntity<UserResponse> getUser(HttpServletRequest servletRequest) {
String email = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
DolphaGo marked this conversation as resolved.
Show resolved Hide resolved
User user = userService.findByEmail(email);
String token = servletRequest.getHeader(HttpHeaders.AUTHORIZATION).split(" ")[1];

return ResponseEntity.ok().body(UserResponse.of(user, token));
return ResponseEntity.ok()
.body(UserResponse.of(user, token));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.study.realworld.config.auth;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class JwtAuthenticationFilterTest {

private static final String EMAIL = "email";
private static final String TOKEN = "token";
Comment on lines +27 to +28
Copy link
Member

Choose a reason for hiding this comment

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

static으로 설정한 이유가 있을까요?

Copy link
Member Author

Choose a reason for hiding this comment

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

음 어디서 본것같아서 무지성 static 이긴 합니다. 지양하는게 좋을까요?

Copy link
Member

Choose a reason for hiding this comment

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

물론 static이 생각할 부분이 적어서 편하기는 한데 해당 클래스를 테스트하지 않는 경우에도 static이 올라가게 되니까 굳이 필요없는 공수를 들이는것같다고 생각해서요 :)
크게 문제되는 사항은 아니긴해요 ㅎㅎ

Copy link
Contributor

Choose a reason for hiding this comment

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

확장성(다른 테스트 메소드 추가)를 고려하면
static 선언이 약이 될수도 독이 될수도 있을 것 같아요

예를 들어 Controller Test 같은 곳에서 공통되는 uri (/api/users)는 모든 테스트 코드에서 공통적으로 쓰일테니까 URI 같은걸로 선언 해두면 확실히 편할 수 있습니다. swagger에서 사용되는 변수들도 이에 해당될 수도 있겠네요

그런데 파라미터, 변수같은 것들은 웬만하면 비슷하긴 하겠지만 테스트 케이스마다 다른 것을 쓸 수 있어서 애매할 수도 있을 것 같아요. 일단 무지성으로 다 쓰다가 나중에 공통되는 부분들 나오면 나중에 따로 model로 만들어서 가져오는 방법도 있을 것 같네요

물론 항상 애매한것도 아닌것 같아서 그냥 어떠한 방법으로 써도 될 것 같다는.. 그런 의견입니다 ㅎㅎ (사실 저도 지금 테스트 하는데 변수들 하나하나 다 쓰는데 너무 귀찮더라구용)


@Mock
private HttpServletRequest request;

@Mock
private HttpServletResponse response;

@Mock
private FilterChain filterChain;

@Mock
private JwtProvider jwtProvider;

@InjectMocks
private JwtAuthenticationFilter jwtAuthenticationFilter;

@BeforeEach
void beforeEach() {
SecurityContextHolder.clearContext();
}

@Test
void doFilterInternal() throws ServletException, IOException {
// given
when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn("Bearer token");
hoony-lab marked this conversation as resolved.
Show resolved Hide resolved
when(jwtProvider.isValidToken(anyString())).thenReturn(true);
when(jwtProvider.getSubjectFromToken(anyString())).thenReturn(EMAIL);

jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);

// when
UsernamePasswordAuthenticationToken authentication =
(UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();

// then
assertThat(authentication).isNotNull();
assertThat(authentication.getPrincipal()).isEqualTo(EMAIL);
assertThat(authentication.getCredentials()).isEqualTo(TOKEN);
}

}