JWT (JSON Web Token) is a tandard that is mostly used for securing REST APIs.
JWT follows stateless authentication mechanism.
Client and Server 사이에 가장 안전하게 통신하는 방법.
Q. 언제 JWT를 사용하면 좋을까?
A. Authorization(권한 부여) - 로그인, Information Exchange
JWT 구조
토큰값을 디코드하면 , Header, Payload, Signature 정보를 알 수 있다.
JWT는 어떻게 동작할까?
1. POST 통신으로 로그인
2. uersname, password의 유효성 검사 이후, Secret Key를 통해 JWT 생성
3. return JWT
4. 인증헤더에 JWT 전송
5. Secret Key를 통해 JWT 유효성 검사
6. return response
Spring Security + JWT
개발 과정
1. Adding JWT Dependeny (maven)
2. Create JwtAuthenticationEntryPoint
3. Add jwt properies in application.properties file
4. Create JwtTokenProvider
5. JwtAuthenticationFilter
6. Create JWTAuthResponse DTO
7. Configure JWT in Spring Security Configuration (SecurityConfig.java)
8. Change login/signin API to return token to client (AuthController.java)
1. JWT maven dependency
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
2. Create JwtAuthenticationEntryPoint
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint
{
// 예외시 호출, 허가 받지 않은 유저가 접근 할때
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
}
3. Add jwt properies in application.properties file
###
app.jwt-secret= JWTSecretkey
# 7일
app.jwt-expiration-milliseconds= 604800000
4. Create JwtTokenProvider
@Component
public class JwtTokenProvider
{
@Value("${app.jwt-secret}")
private String jwtSecret;
@Value("${app.jwt-expiration-milliseconds}")
private int jwtExpirationInMs;
public String generateToken(Authentication authentication)
{
String username= authentication.getName();
Date currentDate = new Date();
Date expireDate= new Date(currentDate.getTime() + jwtExpirationInMs);
String token = Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
return token;
}
// get username from the token
public String getUsernameFromJWT(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret) // 시크릿 키 필요
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
public boolean validateToken(String token)
{
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
return true;
}catch(SignatureException ex) {
throw new BlogAPIException(HttpStatus.BAD_REQUEST, "Invalid JWT signature");
}catch(MalformedJwtException ex) {
throw new BlogAPIException(HttpStatus.BAD_GATEWAY, "Invalid JWT token");
}catch(ExpiredJwtException ex) {
throw new BlogAPIException(HttpStatus.BAD_GATEWAY, "Expired JWT token");
}catch(UnsupportedJwtException ex) {
throw new BlogAPIException(HttpStatus.BAD_GATEWAY, "Unsupported JWT token");
}catch(IllegalArgumentException ex) {
throw new BlogAPIException(HttpStatus.BAD_GATEWAY, "JWT claims string is empty");
}
}
}
5. JwtAuthenticationFilter
public class JwtAuthenticationFilter extends OncePerRequestFilter // a single execution 보장
{
// inject
@Autowired
private JwtTokenProvider tokenProvider;
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// get JWT(token) from http request
String token = getJWTfromRequest(request);
// validate token
if(StringUtils.hasText(token) && tokenProvider.validateToken(token))
{
// get username from token
String username= tokenProvider.getUsernameFromJWT(token);
// load user associated with token
UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// set String Security
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(request, response);
}
// Bearer <accessToken>
private String getJWTfromRequest(HttpServletRequest request)
{
String bearerToken = request.getHeader("Authorization");
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7, bearerToken.length());
}
return null;
}
}
6. Create JWTAuthResponse DTO
package com.springboot.blog.payload;
public class JWTAuthResponse
{
private String accessToken;
private String tokenType= "Bearer";
public String getAccessToken() {
return accessToken;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public String getTokenType() {
return tokenType;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
public JWTAuthResponse(String accessToken) {
super();
this.accessToken = accessToken;
}
}
7. Configure JWT in Spring Security Configuration (SecurityConfig.java)
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Autowired
private JwtAuthenticationEntryPoint authenticationEntryPoint;
// class
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception
{
http.csrf().disable()
.exceptionHandling()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.antMatchers(HttpMethod.GET, "/api/**").permitAll()
.antMatchers("/api/auth/**").permitAll()
.anyRequest()
.authenticated();
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
// 인증시 비밀번호 암호화
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserDetailsService).passwordEncoder(passwordEncoder());
}
// 로그인을 위한 인증 매니저
@Override
@Bean
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
// @Override
// @Bean
// protected UserDetailsService userDetailsService() {
// UserDetails kjhUser = User.builder().username("kjh").password(passwordEncoder().encode("password"))
// .roles("USER").build();
// UserDetails admin = User.builder().username("admin").password(passwordEncoder().encode("admin"))
// .roles("ADMIN").build();
//
// return new InMemoryUserDetailsManager(kjhUser,admin);
// }
}
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
SessionCreationPolicy.STATELESS : 스프링시큐리티가 생성하지도않고 기존것을 사용하지도 않음 = JWT 와 같은 토큰방식을 쓸 때 사용하는 설정
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
jwt 인증받은 요청만 허용
8. Change login/signin API to return token to client (AuthController.java)
@RestController
@RequestMapping("/api/auth")
public class AuthController
{
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserRepository userRepository;
@Autowired
private RoleRepository roleRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtTokenProvider tokenProvider;
@PostMapping("/signin")
public ResponseEntity<JWTAuthResponse> authnticateUser(@RequestBody LoginDto loginDto)
{
Authentication authentication= authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
loginDto.getUsernameOrEmail() , loginDto.getPassword() ));
SecurityContextHolder.getContext().setAuthentication(authentication);
// get Token form tokenProvider
String token= tokenProvider.generateToken(authentication);
return ResponseEntity.ok(new JWTAuthResponse(token));
}
@PostMapping("/signup")
public ResponseEntity<?> registerUser(@RequestBody SignUpDto signUpDto)
{
// add check for username exists in a DB
if(userRepository.existsByUsername(signUpDto.getUsername())) {
return new ResponseEntity<>("유저 이름이 존재합니다.",HttpStatus.BAD_REQUEST);
}
// add check for email exists in DB
if(userRepository.existsByEmail(signUpDto.getEmail())) {
return new ResponseEntity<>("이메일이 존재합니다.",HttpStatus.BAD_REQUEST);
}
// create user Entity
User user= new User();
user.setName(signUpDto.getName());
user.setUsername(signUpDto.getUsername());
user.setEmail(signUpDto.getEmail());
user.setPassword(passwordEncoder.encode(signUpDto.getPassword()));
Role role= roleRepository.findByName("ROLE_USER").get();
user.setRoles(Collections.singleton(role)); // 리스트에 하나의 객체를 set 타입으로 삽입할 경우
userRepository.save(user);
return new ResponseEntity<String>("회원가입 성공!",HttpStatus.OK);
}
}
Test
로그인 시
접근 권한이 없는 메소드 접근 시
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<PostDto> createPost(@Valid @RequestBody PostDto postDto){
return new ResponseEntity<>(postService.createPost(postDto),HttpStatus.CREATED);
}
올바른 접근 시
헤더에 토큰값 추가
JWT 토큰을 복호화하여 사용자가 누구인지 확인하기
@GetMapping
public PostResponse getAllPosts(
@RequestParam(defaultValue=AppConstants.DEFAULT_PAGE_NUMBER, required=false)int pageNo,
@RequestParam(defaultValue=AppConstants.DEFAULT_PAGE_SIZE, required=false)int pageSize,
@RequestParam(defaultValue=AppConstants.DEFAULT_SORT_BY, required=false)String sortBy,
@RequestParam(defaultValue=AppConstants.DEFAULT_SORT_DIRECTION, required =false) String sortDir,
@RequestHeader("Authorization") String jwtToken
) {
String username= tokenProvider.getUsernameFromJWT( jwtToken.substring(7, jwtToken.length()));
System.out.println(":: "+ username);
return postService.getAllPosts(pageNo,pageSize, sortBy,sortDir);
}
파라미터에 @RequestHeader("Authorization") String jwtToken 추가
헤더에 있는 "Authorization" 의 값을 가져온다.
@Autowired
private JwtTokenProvider tokenProvider;
String username= tokenProvider.getUsernameFromJWT( jwtToken.substring(7, jwtToken.length()));
System.out.println(":: "+ username);
JwtTokenProvider
@Component
public class JwtTokenProvider
{
...
// get username from the token
public String getUsernameFromJWT(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret) // 시크릿 키 필요
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
...
}
JwtTokenProvider 의 getUsernameFromJWT() 를 통해 복호화
username 출력 결과
MySql을 참고해 확인해보면 email(admin@naver.com) 값이 가져와졌는데..
왜 이메일 값이 가져와졌을까?
JwtTokenProvider.java
토큰 생성 메소드에 주목
@Component
public class JwtTokenProvider
{
...
public String generateToken(Authentication authentication)
{
String username= authentication.getPrincipal().toString();
System.out.println("username: "+ username);
* 테스트 위해 username 호출 값 변경
authentication.getPrincipal().getName() -> authentication.getPrincipal().toString();
출력
username: org.springframework.security.core.userdetails.User [Username=admin@naver.com, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_ADMIN]]
토큰을 생성할때 Username에 admin@naver.com이 바인딩 되어있다.
public class CustomUserDetailsService implements UserDetailsService
@Override
public UserDetails loadUserByUsername(String usernameOrEmail) throws UsernameNotFoundException {
System.out.println("usernameOrEmail: "+usernameOrEmail);
User enUser= userRepository.findByUsernameOrEmail(usernameOrEmail, usernameOrEmail)
.orElseThrow(()-> new UsernameNotFoundException("User not found with username or email: "+ usernameOrEmail));
// 시큐리티로 감싸서 반환
return new org.springframework.security.core.userdetails.User(enUser.getEmail(), enUser.getPassword(), mapRolesToAuthorizities(enUser.getRoles()));
}
이는 시큐리티에 사용자 (User)를 등록할때 enUser.getEmail() 즉, email을 Username 으로 등록했었다.
그래서 authentication.getName(); 을 실행하면 등록했었던 email 값이 반환된다.
JWT 를 클라이언트단에서 따로 저장할 수 있는 곳에 저장해서
로직에서 사용자 정보가 필요할 때 헤더에 같이 실어 보내어
서버단에서 헤더의 "Authorization" 를 통해 jwt를 추출하여
사용자 정보를 얻어 활용하면 좋을 것 같다.
댓글