본문 바로가기

Backend/Spring

[Spring Boot] JWT token, refresh token을 활용한 회원 도메인 구현

안녕하세요. 이번 포스팅에서는

JWT token과 refresh token을 활용한 회원 가입, 로그인 구현을 진행해보겠습니다.

 

Spring을 활용하여 애플리케이션을 개발할 때 Rest API를 구현하신다면, JWT token을 들어보셨을 겁니다. 간단하게 JWT token에 대해 살펴보겠습니다.

 

JWT token

JWT는 JSON web token의 약자입니다. URL-safe(URL로 이용할 수 있는 문자로만 구성된)의 JSON입니다.

 

Access Token & Refresh Token

Access Token과 Refresh Token 모두 JWT token으로 이루어집니다. Access Token은 유효기간이 짧은 인증 도구이며, Access Token의 유효기간이 만료되었을 때 Refresh Token을 통하여 Access Token을 재발급받게 됩니다. 따라서 Refresh Token의 유효기간은 Access Token에 비해 훨씬 길게 설정되며, Refresh Token의 유효기간 역시 만료되게 되면 재 로그인을 진행해야 합니다.

 

Access Token만을 활용할 경우의 문제점

제목 그대로 만약 Refresh Token을 통한 재발급의 과정이 없다고 생각해보면, 구현은 쉽지만 보안이 취약해집니다. 만약 Access Token을 탈취당하게 되면, 탈취한 해커가 해당 Token으로 모든 인증과정을 진행할 수 있기 때문입니다. 따라서 Access Token의 유효기간을 짧게 설정하면서, 해당 Token의 유효기간이 만료되게 되면 재발급을 받는 형태로 인증을 구현하게 됩니다.

 

Senario

1. 로그인을 시도합니다. 이때 유저의 ID와 Password를 서버 DB와 대조한 뒤, 올바른 유저라면 

Access Token + Refresh Token을 발급해줍니다.

 

2. 모든 요청(request)의 Header에 Access Token을 넣어서 요청을 보냅니다. 서버는 요청을 처리하기 전 앞단에서 해당 토큰을 통한 인증을 먼저 확인하게 됩니다.

 

2-1) Access Token을 통한 인증이 정상적으로 작동했다면, 요청된 자원을 클라이언트에게 반환해줍니다.

 

2-2) Access Token을 통한 인증이 실패할 경우, 

     2-2-1) 만약 refresh Token의 유효기간이 남아있다면, 새로운 Access Token을 발급해줍니다.

     2-2-2) refresh Token의 유효기간이 남아있지 않다면, 새로 로그인을 시도해야 합니다.

 

 

환경 설정

<pom.xml>

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.5.5</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com._chanho</groupId>
	<artifactId>movie_recommendation</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>movie_recommendation</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>11</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
		<dependency>
			<groupId>com.auth0</groupId>
			<artifactId>java-jwt</artifactId>
			<version>3.18.2</version>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>
	</build>

</project>

 

Maven을 활용하였으며, DB로 MySQL을 사용하였습니다. 그 외에 Security, Lombok, JPA의 의존관계를 추가해 주었습니다. 또한 JWT를 위한 의존관계도 추가해주셔야 합니다.

 

 

Domain

<AppUser>

@Entity @Data @NoArgsConstructor @AllArgsConstructor
@Builder
public class AppUser {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String nickname;

    private String username;
    private String password;

    @ManyToMany(fetch = FetchType.EAGER)
    private Collection<Role> roles = new ArrayList<>();

}

 

Spring Security의 User와 헷갈리기 쉬워서 AppUser로 도메인 이름을 지었습니다. 

username과 password는 뒤에 Spring Security의 User에서 사용하는 필드명입니다.

 

그 외에 PK로 사용할 id, 애플리케이션에서 사용할 nickname, 그리고 권한을 추가해 주었습니다.

 

<Role>

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Role {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public Role(String roleName) {
        this.name = roleName;
    }
}

 

Role은 간단히 name만 추가해 주었습니다. 

 

<Main Class>

@SpringBootApplication
public class MovieRecommendationApplication {

	public static void main(String[] args) {
		SpringApplication.run(MovieRecommendationApplication.class, args);
	}

	@Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

	@Bean
	CommandLineRunner run(AppUserService userService) {
		return args -> {
			userService.saveRole(new Role(null, "ROLE_USER"));
			userService.saveRole(new Role(null, "ROLE_MANAGER"));
			userService.saveRole(new Role(null, "ROLE_ADMIN"));
			userService.saveRole(new Role(null, "ROLE_SUPER_ADMIN"));
		};
	}
}

 

그리고 Role은 App을 실행하며, 네개의 권한을 넣어주었습니다. 

 

Service

< interface >

public interface AppUserService {
    AppUser saveUser(AppUser appUser);
    AppUser getUser(String username);
    List<AppUser> getUsers();

    Role saveRole(Role role);
    void grantRoleToUser(String username, String roleName);
}

 

service에서 간단하게 다음의 메서드만 구현해 주었습니다. saveUser는 sign up의 개념으로 이해하시면 될 것 같습니다.

 

그 뒤로 getUsers와 같은 메서드로 권한 인증을 검사하겠습니다.

 

<implementation>

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class AppUserServiceImpl implements AppUserService, UserDetailsService {
    private final AppUserRepository appUserRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser user = appUserRepository.findByUsername(username);
        if(user == null) {
            log.error("User not found in the database {}", username);
            throw new UsernameNotFoundException("User not found in the database");
        } else {
            log.info("User found in the database: {}", username);
        }

        Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
        user.getRoles().forEach(role -> {
            authorities.add(new SimpleGrantedAuthority(role.getName()));
        });
        return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
    }

    @Override
    public AppUser saveUser(AppUser appUser) {
        log.info("Saving new user {} to the db", appUser.getNickname());
        return appUserRepository.save(appUser);
    }

    @Override
    public AppUser getUser(String username) {
        return appUserRepository.findByUsername(username);
    }

    @Override
    public List<AppUser> getUsers() {
        return appUserRepository.findAll();
    }

    @Override
    public Role saveRole(Role role) {
        log.info("Saving new role {} to the db", role.getName());
        return roleRepository.save(role);
    }

    @Override
    public void grantRoleToUser(String username, String roleName) {
        log.info("Grant new role {} to {}", roleName, username);
        AppUser appUser = appUserRepository.findByUsername(username);
        Role role = roleRepository.findByName(roleName);

        appUser.getRoles().add(role);
    }
}

 

나중에 인증 부여를 진행할 때 Spring Security에서 해당 유저에 대한 정보를 찾을 수 있어야 합니다. 따라서 UserDetails를 구현해주어야 합니다. 

해당 메서드를 구현하여, Spring Security의 User로 반환해주었습니다.

Controller

@RequestMapping("/api")
@RestController @RequiredArgsConstructor
public class AppUserResource {
    private final AppUserService appUserService;
    private final RoleRepository roleRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    @GetMapping("/users")
    public ResponseEntity<List<AppUser>> getUsers() {
        return ResponseEntity.ok().body(appUserService.getUsers());
    }

    @PostMapping("/user/save")
    public ResponseEntity<AppUser> saveUser(@RequestBody SignUpForm signUpForm) {
        URI uri = URI.create(
                ServletUriComponentsBuilder
                        .fromCurrentContextPath()
                        .path("/api/user/save").toUriString());

        AppUser appUser = signUpForm.toEntity();
        appUser.setPassword(passwordEncoder.encode(appUser.getPassword()));
        appUser.getRoles().add(roleRepository.findByName("ROLE_USER"));

        return ResponseEntity.created(uri).body(appUserService.saveUser(appUser));
    }

    @PostMapping("/role/save")
    public ResponseEntity<Role> saveRole(@RequestBody Role role) {
        URI uri = URI.create(
                ServletUriComponentsBuilder
                        .fromCurrentContextPath()
                        .path("/api/role/save").toUriString());

        return ResponseEntity.created(uri).body(appUserService.saveRole(role));
    }

    @PostMapping("/role/grant-to-user")
    public ResponseEntity<?> grantRole(@RequestBody RoleToUserForm form) {
        appUserService.grantRoleToUser(form.getUserName(), form.getRoleName());
        return ResponseEntity.ok().build();
    }

    @GetMapping("/token/refresh")
    public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String authorizationHeader = request.getHeader(AUTHORIZATION);

        if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            try{
                String refreshToken = authorizationHeader.substring("Bearer ".length());
                Algorithm algorithm = Algorithm.HMAC256("secretKey".getBytes());

                JWTVerifier verifier = JWT.require(algorithm).build();

                DecodedJWT decodedJWT = verifier.verify(refreshToken);

                String username = decodedJWT.getSubject();
                AppUser user = appUserService.getUser(username);

                String accessToken = JWT.create()
                        .withSubject(user.getUsername())
                        .withExpiresAt(new Date(System.currentTimeMillis() + 10 * 60 * 1000))
                        .withIssuer(request.getRequestURI().toString())
                        .withClaim("roles", user.getRoles().stream().map(Role::getName).collect(Collectors.toList()))
                        .sign(algorithm);


                Map<String, String> tokens = new HashMap<>();
                tokens.put("access_token", accessToken);
                tokens.put("refresh_token", refreshToken);

                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                new ObjectMapper().writeValue(response.getOutputStream(), tokens);
            }catch (Exception e) {
                response.setHeader("error", e.getMessage());
                response.setStatus(FORBIDDEN.value());

                Map<String, String> error = new HashMap<>();
                error.put("error_message", e.getMessage());
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                new ObjectMapper().writeValue(response.getOutputStream(), error);
            }
        } else {
            throw new RuntimeException("Refresh token is missing");
        }
    }
}

@Data
class RoleToUserForm {
    private String userName;
    private String roleName;
}

@Data @Builder
class SignUpForm {
    private String nickname;
    private String username;
    private String password;

    public AppUser toEntity() {
        return AppUser
                .builder().nickname(this.nickname).username(this.username).password(this.password)
                .roles(new ArrayList<>())
                .build();
    }
}

 

리팩토링이 좀 안되어있긴 합니다 (죄송합니다 ㅠㅠ). 나중에 해당 코드를 응용하실 때 리팩터링을 적절하게 하셔서 사용하시면 좋을 것 같습니다.

 

login의 경우 Spring Security의 기본적인 login을 활용하여 구현했기 때문에 별도의 API를 작성하지 않았습니다. 

 

그 외에 다른 메서드는 크게 특별한 것이 없고, refresh token부분을 보시면,

 

1. request의 header에 "Bearer JWTTOKENxxxxx" 의 형식으로 전달하기 때문에 "Bearer "의 문자열을 삭제해주었습니다.

 

2. 기본적으로 JWT는 HMAC256을 활용하여 구현하는 경우가 많습니다. "secretKey"부분을 애플리케이션에 적절하게 변형하여 사용하셔도 무방합니다.

 

3. verifier에 알고리즘을 적용한 뒤, refreshToken에 대한 유효성을 확인합니다.

만약 유효성이 확인되었다면, 

 

새로운 Access Token을 발급한 뒤, 반환 해 줍니다.

 

Security Config

@Configuration @EnableWebSecurity @RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final UserDetailsService userDetailsService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)
                .passwordEncoder(bCryptPasswordEncoder);
        super.configure(auth);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManagerBean());
        customAuthenticationFilter.setFilterProcessesUrl("/api/login");

        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        http.authorizeRequests().antMatchers("/api/login/**", "/api/user/save", "/api/token/refresh/**").permitAll();
        http.authorizeRequests().antMatchers(GET, "/api/user/**").hasAnyAuthority("ROLE_USER");
        http.authorizeRequests().antMatchers(POST, "/api/user/save/**").hasAnyAuthority("ROLE_ADMIN");

        http.authorizeRequests().anyRequest().authenticated();
        http.addFilter(customAuthenticationFilter);
        http.addFilterBefore(new CustomAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

 

login, signup, refresh의 경우 별다른 권한에 대한 제한을 두지 않았습니다.

 

api/login의 경우 별도의 customAuthenticationFilter를 추가하여 해당 로그인한 유저를 AuthenticationManager에 추가해주었습니다.

 

customAuthorizationFilter의 경우 모든 요청의 앞단에 진행할 수 있게 Filter를 추가해주었습니다.

 

Custom Authentication & Custom Authorization

<CustomAuthenticationFilter>

@Slf4j
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;
    private HashMap<String, String> jsonRequest;

    public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @Override
    protected String obtainPassword(HttpServletRequest request) {
        String password  = super.getPasswordParameter();
        if(request.getHeader("Content-Type").equals(MediaType.APPLICATION_JSON_VALUE)) {
            return jsonRequest.get(password);
        }
        return request.getParameter(password);
    }

    @Override
    protected String obtainUsername(HttpServletRequest request) {
        String username  = super.getUsernameParameter();
        if(request.getHeader("Content-Type").equals(MediaType.APPLICATION_JSON_VALUE)) {
            return jsonRequest.get(username);
        }
        return request.getParameter(username);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if(request.getHeader("Content-Type").equals(MediaType.APPLICATION_JSON_VALUE)) {
            log.info("Json Login Attempt");

            ObjectMapper mapper = new ObjectMapper();
            try {
                this.jsonRequest =
                        mapper.readValue(request.getReader().lines().collect(Collectors.joining()),
                                new TypeReference<HashMap<String, String>>() {
                });
            } catch (IOException e) {
                e.printStackTrace();
                throw new AuthenticationServiceException("Request Content-Type (application/json) Parsing error");
            }
        }

        String username = obtainUsername(request);
        String password = obtainPassword(request);

        log.info("{} attempt to login with {}", username, password);
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        return authenticationManager.authenticate(authenticationToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        User user = (User) authentication.getPrincipal();
        Algorithm algorithm = Algorithm.HMAC256("secretKey".getBytes());

        String accessToken = JWT.create()
                .withSubject(user.getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis() + 10 * 60 * 1000))
                .withIssuer(request.getRequestURI().toString())
                .withClaim("roles", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()))
                .sign(algorithm);


        String refreshToken = JWT.create()
                .withSubject(user.getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis() + 300 * 60 * 1000))
                .withIssuer(request.getRequestURI().toString())
                .sign(algorithm);

        Map<String, String> tokens = new HashMap<>();
        tokens.put("access_token", accessToken);
        tokens.put("refresh_token", refreshToken);

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        new ObjectMapper().writeValue(response.getOutputStream(), tokens);
    }
}

 

기본적인 Spring Security의 UsernamePasswordAuthenticationFilter를 확장하여 구현합니다.

 

1. 저희는 API를 개발하고 있기 때문에 login에 대한 요청은 json으로 처리해야 합니다. 하지만 UsernamePasswordAuthenticationFilter의 obtaionUsername과 obtainPassword는 json에 대한 요청을 처리하지 못하기 때문에, @Override하여 Content-Type이 JSON이라면 ObjectMapper를 활용하여 정보를 가져왔습니다.

 

2. Attempt Authentication

인증을 시도하는 메서드입니다. 아까 1에서 얻은 username과 password를 통해 UsernamePasswordAuthenticationToken을 만들어서 AuthenticationManager에 해당 유저의 Authentication을 잡아줍니다.

 

3. Successful Authentication

2.에서 인증 요청에 대해 성공하게 되면 실행됩니다. 인증이 성공했다면(로그인이 정상적으로 작동했다면) 아까와 같은 secretKey와 algorithm으로 accessToken과 refreshToken을 만들어서 반환해줍니다. 

refreshToken의 경우 accessToken보다 훨씬 긴 유지시간을 잡아주시면 됩니다.

 

<CustomAuthorizationFilter>

@Slf4j
public class CustomAuthorizationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if(request.getServletPath().equals("/api/login") || request.getServletPath().equals("/api/token/refresh")) {
            filterChain.doFilter(request, response);
        } else {
            String authorizationHeader = request.getHeader(AUTHORIZATION);
            if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
                try{
                    String token = authorizationHeader.substring("Bearer ".length());
                    Algorithm algorithm = Algorithm.HMAC256("secretKey".getBytes());

                    JWTVerifier verifier = JWT.require(algorithm).build();

                    DecodedJWT decodedJWT = verifier.verify(token);

                    String username = decodedJWT.getSubject();
                    String[] roles = decodedJWT.getClaim("roles").asArray(String.class);
                    Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
                    stream(roles).forEach(role -> {
                        authorities.add(new SimpleGrantedAuthority(role));
                    });
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                            new UsernamePasswordAuthenticationToken(username, null, authorities);

                    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
                    filterChain.doFilter(request, response);
                }catch (Exception e) {
                    log.error("Error logging in: {}", e.getMessage());
                    response.setHeader("error", e.getMessage());
                    response.setStatus(FORBIDDEN.value());

                    Map<String, String> error = new HashMap<>();
                    error.put("error_message", e.getMessage());
                    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    new ObjectMapper().writeValue(response.getOutputStream(), error);
                }
            } else {
                filterChain.doFilter(request, response);
            }
        }
    }
}

 

Authorization은 말 그대로 해당 자원(URI)에 대한 접근 권한을 확인하는 과정입니다. 

 

앞서 진행했던 프로세스와 유사하게, Token에 대한 유효성을 검사하고, 만약 유효성 검증이 성공한다면, SecurityContextHolder에 해당 Authentication을 잡아주시면 됩니다.

 

 

Result

회원가입

 

 

 

로그인

 

 

토큰 만료

 

refresh token

 

새로 발급받은 토큰으로 인증 성공

 

*저의 글에 대한 피드백이나 지적은 언제나 환영합니다.