컴퓨터/Spring

[스프링 시큐리티] AccessToken + RefreshToken을 이용한 로그인 구현(최신코드, TDD)

도도새 도 2023. 12. 29. 21:15

스프링 시큐리티

 

스프링 시큐리티를 이용해 리프래쉬 토큰, 액세스 토큰으로 관리되는 회원의 로그인, 권한 등의 코드를 작성한다. 사실 워낙 많이 사용되는 방식이기에 여느 블로그에서 긁어오면 될 줄 알았으나... 버전이 달라짐에 따라 문제가 약간 있어서 찾아보는데 고생을 조금 했다. 2023년 12월 29일 기준 최신 코드로 스프링 시큐리티 인증 인가 관련 작업을 TDD형식으로 구현한다.

 

이 포스팅에서는 실제 스프링 시큐리티를 사용한 인증, 인가의 구현 뿐 아닌 보안과 관련된 몇몇 용어를 정리하여 스프링에서의 보안에 대한 이해를 돕고자 한다.

 

인증과 인가

 

보안 관련 프로그래밍을 할 때 늘 접하는 용어는 인증과 인가이다.

 

인증(Authentication)이란?

인증이란 사용자가 누구인지 확인하는 단계이다. 예를 들어 로그인이 있다. 사용자가 폼을 통해 아이디와 비밀번호를 입력할 경우 해당 데이터를 데이터베이스 정보와 비교하여 인증하는 방식이 많이 사용된다. JWT을 이용할 경우 인증이 완료 될 경우 사용자에게 JWT토큰을 헤더로 전달하여 이후 인가에 사용하게 된다.

 

인가(Authorization)이란?

인가는 인증을 통해 검증된 사용자가 애플리케이션 내부 리소스에 접근할 대 그 권리를 가지는지 확인하는 과정이다. 예를 들어 사용자가 특정 게시판에 게시글 작성을 할 경우, 해당 게시글을 쓸 권한이 있는지 확인하는 것이다.

 

JWT토큰을 이용할 경우 이 인가는 앞서 인증 단계에서 발급 받은 JWT 토큰을 이용하여 이루어진다.

 

접근 주체(Principal)란?

접근 주체란 애플리케이션에 접근하는 사용자, 디바이스, 시스템 등을 의미한다. 즉 인증과 인가를 받아야 하는 대상이다.

 

 

스프링 시큐리트를 사용하는 이유 : 

스프링 시큐리티는 스프링에서 인증, 인가와 같은 각종 보안 관련 기능을 제공하는 프레임워크이다. 이를 이용하면 직접 보안 관련 코드를 짜는 것 보다 더욱 효과적으로 시스템의 보안을 설정할 수 있다.

 

스프링 시큐리티의 동작

 

스프링 시큐리티는 서블릿에서 사용되는 기술인 서블릿 필터를 기반으로 동작한다. 서블릿 필터는 클라이언트의 요청이 들어오면 Dispatcher Servlet으로 가기 전에 실행되는 연속된 동작들이다.

 

스프링 시큐리티 도식

Dispatcher Servlet?

Dispatcher Servlet은 Java 기반의 웹 애플리케이션에서 클라이언트 요청을 적절한 핸들러(또는 컨트롤러)로 전달하는 역할을 하는 서블릿이다.

 

Servlet?

서블릿은 Java를 기반으로 하는 웹 애플리케이션에서 동적인 웹 페이지를 생성하기 위한 Java의 클래스이다. Java EE의 표준 스펙인 서블릿은 서버 측에서 실행되며 클라이언트 요청을 처리하고 응답을 생성하는데 사용한다.

 

즉, 필터는 이러한 역할을 하는 클래스인 Dispatcher Servlet에 도달하기 전 실행되는 일련의 행위이다. 구체적으로는 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미한다.

 

여기서 스프링 시큐리티의 경우 사용하고자 하는 필터 체인을 서블릿 컨테이너와 필터 사이에 놓기 위하여 DelegatingFilterProxy를 사용한다. 이는 서블릿 컨테이너와 스프링 시큐리터 필터 체인 간의 다리 역할을 하게 된다. 이 내부에는 역할을 위임할 필터체인 프록시(FilterChainProxy)를 지니고 있다. 이를 이용하여 스프링 시큐리티에서 제공하는 각종 보안 필터를 적용하여 사용 할 수 있게 된다.

 

이 보안 필터체인을 적용하기 위해서 WebSecurityConfigurerAdapter 클래스를 상속받아 설정 가능하다. 별도의 설정이 없다면 디폴트로 UsernamePasswordAuthenticationFilter를 통해 인증을 처리하게 된다.

 

필터 적용 예시 코드

// Java Config 예시
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .permitAll()
                .and()
            .logout()
                .permitAll();
    }
}

 

인증 과정 도식화

 

(UsernamePasswordAuthenticationFilter를 통한 인증)

스프링 시큐리티의 동작 과정은 위에서 보는 바와 같다.

  1. 클라이언트로부터 요청이 들어오면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임된다. 그 중 UsernamePasswordAuthenticationFilter(AuthenticationFilter에 해당)에서 인증을 처리한다.
  2. AutehnticationFilter는 요청에서 username과 password를 추출하여 토큰을 생성한다
  3. 이 토큰을 AuthenticationManager에 전달한다. AuthenticationManager는 인터페이스이며 보통 구현체인 ProviderManager를 사용한다.
  4. ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달한다.
  5. AuthenticationProvider는 토큰 정보를 UserDetailsService에 전달한다.
  6. UserDetailsService는 전달 받은 정보를 통해 DB에서 일치하는 사용자를 찾아 UserDetails객체를 생성한다.
  7. 생성된 UserDetails객체는 AuthenticationProvider로 전달된다. 이때 인증을 수행하며, 성공시 ProviderManager로 권한을 담은 토큰을 전달한다.
  8. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다.
  9. AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장한다.

 

이번 포스팅에서는 JWT토큰을 사용하여 인증을 처리할 것이기 때문에 위 절차에 앞서 JWT 관련 필터가 선행되어야한다.

 

*SecurityContext란?

인증(Authentication) 및 권한 부여(Authorization) 정보를 저장하는 컨테이너이다. **SecurityContext**는 애플리케이션 전역에서 사용되며, 현재 사용자의 보안 관련 정보를 쉽게 액세스할 수 있도록 도와준다.

예를 들어 시큐리티 컨텍스트에 사용자 정보가 등록되면, 아래의 작업이 가능하다.

 

// 현재 SecurityContext에 접근
SecurityContext context = SecurityContextHolder.getContext();

// 현재 Authentication 객체 가져오기
Authentication authentication = context.getAuthentication();

// 현재 사용자의 이름 가져오기
String username = authentication.getName();

// 현재 사용자의 권한 가져오기
List<GrantedAuthority> authorities = (List<GrantedAuthority>) authentication.getAuthorities();

 

 

시큐리티 관련 구현

 

위의 도식도에 따라 필요한 인터페이스들을 구현한다.

 

USER 엔터티

 

우선 userDetail 인터페이스의 구현해야 할 메소드는 아래와 같다.

public interface UserDetails extends Serializable {

	/**
	 * Returns the authorities granted to the user. Cannot return <code>null</code>.
	 * @return the authorities, sorted by natural key (never <code>null</code>)
	 */
	Collection<? extends GrantedAuthority> getAuthorities();

	/**
	 * Returns the password used to authenticate the user.
	 * @return the password
	 */
	String getPassword();

	/**
	 * Returns the username used to authenticate the user. Cannot return
	 * <code>null</code>.
	 * @return the username (never <code>null</code>)
	 */
	String getUsername();

	/**
	 * Indicates whether the user's account has expired. An expired account cannot be
	 * authenticated.
	 * @return <code>true</code> if the user's account is valid (ie non-expired),
	 * <code>false</code> if no longer valid (ie expired)
	 */
	boolean isAccountNonExpired();

	/**
	 * Indicates whether the user is locked or unlocked. A locked user cannot be
	 * authenticated.
	 * @return <code>true</code> if the user is not locked, <code>false</code> otherwise
	 */
	boolean isAccountNonLocked();

	/**
	 * Indicates whether the user's credentials (password) has expired. Expired
	 * credentials prevent authentication.
	 * @return <code>true</code> if the user's credentials are valid (ie non-expired),
	 * <code>false</code> if no longer valid (ie expired)
	 */
	boolean isCredentialsNonExpired();

	/**
	 * Indicates whether the user is enabled or disabled. A disabled user cannot be
	 * authenticated.
	 * @return <code>true</code> if the user is enabled, <code>false</code> otherwise
	 */
	boolean isEnabled();

}

 

이를 구현한 엔티티 클래스 User를 만든다.

@Getter
@Entity
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Table(name="user")
public class User implements UserDetails {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(nullable = false, unique = true)
	private String userId;

	@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
	private String password;

	@Column(nullable = false, unique = true)
	private String email;

	@ElementCollection(fetch = FetchType.EAGER)
	private List<String> roles = new ArrayList<>();

	private String grantType;

	@CreationTimestamp
	private Timestamp createDate;

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
	}


	@Override
	@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
	public String getUsername() {
		return this.userId;
	}

	@Override
	@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
	public boolean isAccountNonExpired() {
		return true;
	}

	@Override
	@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
	public boolean isCredentialsNonExpired() {
		return false;
	}

	@Override
	@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
	public boolean isEnabled() {
		return true;
	}
}

 

UserRepository

 

다음으로 DB에 접근하기 위핸 리퍼지토리를 만든다. Spring Data JPA를 이용한다.

조금 더 유연한 구조를 위해 Optional로 리턴한다.

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> getByUserId(String userId);
}

 

다음으로 이를 이용해 실제 유저를 서치해오는 service를 만든다. 위에서 보았듯이 이 서비스는 UserDetailsService를 구현한다.

@Service
public class UserService implements UserDetailsService {

    UserRepository userRepository;

    @Autowired
    UserService(UserRepository userRepository){
        this.userRepository = userRepository;
    }

    public boolean isUserExist(UserLoginDTO userLoginDTO){
        if(userRepository.getByUserId(userLoginDTO.getUserId()).isPresent()){
            return true;
        }
        return false;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.getByUserId(username).get();
    }
}

실제로 구현되어야 할 것은 loadUserByUsername이다. 이는 유저 엔티티 객체를 유저를 리턴하도록 구현한다.

 

JwtTokenProvider

다음으로 JWT 토큰을 생성해줄 JwtTokenProvider을 구현한다.

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    private final UserDetailsService userDetailsService;
   // private static final Key SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256);

    @Value("${springboot.jwt.secret}")
    private String secretKey = "secretKey";
    private static final long ACCESS_EXPIRATION_TIME = 3 * 60 * 60 * 1000;//3시간
    private static final long REFRESH_EXPIRATION_TIME = 30 * 60 * 1000;//30분

    @PostConstruct
    protected void init(){
        log.info("[JwtTokenProvider-init] 초기화 시작 - secretKey : ${}", secretKey);
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
        log.info("[JwtTokenProvider-init] 초기화 완료 - secretKey : ${}", secretKey );
    }

    public String generateAccessToken(String userId, List<String> roles){
        return generateToken(userId, roles, ACCESS_EXPIRATION_TIME);
    };

    public String generateRefreshToken(String userId, List<String> roles){
        return generateToken(userId, roles, REFRESH_EXPIRATION_TIME);
    };

    public String generateToken(String userId, List<String> roles, long expirationTime){
        log.info("[generateToken] 토큰 생성");

       // Claims claims = Jwts.claims().subject(userId).build();

        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", roles);

        Date now = new Date();
        Date expiration = new Date(now.getTime() + expirationTime);
        log.info("[generateToken] 토큰 생성2");

        String jwts = Jwts.builder()
                .claims(claims)
                .subject(userId)
                .issuedAt(now)
                .expiration(expiration)
                .signWith(SignatureAlgorithm.HS256, secretKey.getBytes())
                .compact();
        log.info("[generateToken] 토큰 생성 완료");

        return jwts;
    }

    public boolean isValidateToken(String token) {
        log.info("[isValidateToken] - accesstoken : {}", token);
        try {
            Jwts.parser().setSigningKey(secretKey.getBytes()).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("[isValidateToken] : Invalid JWT Token", e);
            return false;
        } catch (ExpiredJwtException e) {
            log.info("[isValidateToken] : Expired JWT Token", e);
            return false;
        } catch (UnsupportedJwtException e) {
            log.info("[isValidateToken] : Unsupported JWT Token", e);
            return false;
        } catch (IllegalArgumentException e) {
            log.info("[isValidateToken] : JWT claims string is empty.", e);
            return false;
        }
    }

    private String resolveToken(HttpServletRequest request){
        log.info("[resolveToken] 토큰 헤더에서 추출");
        return request.getHeader("X-AUTH-TOKEN");
    }

    private Claims parseClaims(String token) {
        return Jwts.parser().setSigningKey(secretKey.getBytes()).build().parseClaimsJws(token).getBody();
    }

    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);

        if (claims.get("roles") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }


        Collection<? extends GrantedAuthority> authorities =
                ((List<?>) claims.get("roles")).stream()
                        .map(authority -> new SimpleGrantedAuthority((String) authority))
                        .collect(Collectors.toList());

        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public String getUserName(String token){
        log.info("[getUserName] 토큰에서 회원 이름 추출");
        String info = parseClaims(token).getSubject();
        log.info("[getUserName] 토큰에서 회원 이름 추출 완료, info : {}", info);

        return info;
    }
}

 

다음으로 필터를 구현한다.

 

JwtAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        log.info("[doFilter] - 필터 시작");
        TokenDTO token = resolveToken((HttpServletRequest) servletRequest);
        log.info("[doFilter] - 토큰값: {}", token);

        //토큰 검사
        log.info("[doFilter] - 토큰 유효성 테스트 시작");
        if(token != null && jwtTokenProvider.isValidateToken(token.getAccessToken())){
            log.info("[doFilter] - if내부 토큰값 : {}", token.getAccessToken());
            log.info("[doFilter] - 토큰 유효성 테스트 결과 : true");
            Authentication authentication = jwtTokenProvider.getAuthentication(token.getAccessToken());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        log.info("[doFilter] - 토큰 유효성 테스트 결과 : false");

        filterChain.doFilter(servletRequest, servletResponse);
    }

    private TokenDTO resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        log.info("[resolveToken] - 토큰값: {}", bearerToken);

        /*
        headers: {
            'Authorization': `Bearer ${tokens.accessToken}`,
            'Refresh-Token': tokens.refreshToken,
          }
         */
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return TokenDTO.builder().accessToken(bearerToken.substring(7))
                    .refreshToken(request.getHeader("Refresh-Token"))
                    .build();
        }
        return null;
    }
}

 

 

SecurityConfig

 

그리고 시큐리티 설정을 구현한다. 이 설정 클래스에서 각종 제약을 걸 수 있으며, 어떤 필터를 앞단에 둘 것인지 쉽게 설정 가능하다.

@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터체인에 등록
public class SecurityConfig {
    private final JwtTokenProvider jwtTokenProvider;

    @Autowired
    public SecurityConfig(JwtTokenProvider jwtTokenProvider){
        this.jwtTokenProvider = jwtTokenProvider;
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        return http
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .csrf(AbstractHttpConfigurer::disable)
                // 특정 URL에 대한 권한 설정.
                .authorizeHttpRequests((authorizeRequests) -> {
                    authorizeRequests.requestMatchers("/sign-api/**").permitAll(); // 권한 없어도 열람 가능

                    authorizeRequests.requestMatchers("/auth-test/**").authenticated(); //제한
                    authorizeRequests.requestMatchers("/manager/**")
                            // ROLE_은 붙이면 안 된다. hasAnyRole()을 사용할 때 자동으로 ROLE_이 붙기 때문
                            .hasAnyRole("ADMIN", "MANAGER");

                    authorizeRequests.requestMatchers("/admin/**")
                            // ROLE_은 붙이면 안 된다. hasRole()을 사용할 때 자동으로 ROLE_이 붙기 때문
                            .hasRole("ADMIN");

                    authorizeRequests.anyRequest().permitAll();
                })


//                .formLogin((formLogin) -> {
//                    /* 권한이 필요한 요청은 해당 url로 리다이렉트 REST서버가 아닐 경우 사용*/
//                    formLogin.loginPage("/login");
//                })
                .build();
    }
}

참고로 permitAll을 하였어도 필터를 건너뛰는 것이 아니다. 필터에서 적용한 Authentication이 유효하지 않아도 실행 할 수 있다는 의미이다. 즉, 앞선 필터인 JwtAuthenticationFilter는 실행된다.

 

로그인, 로그아웃

 

간단한 TDD형식으로 통합 테스트를 작성하는 방식으로 구현하도록 한다. TDD를 하는 방법은 실패하는 테스트 작성(즉, 특정 메소드, 객체 등이 동작해야 할 방법을 정의한다. 처음에는 당연히 해당 코드가 없으므로 실패하는 테스트가 된다), 테스트를 통과시키는 코드 작성, 리팩토링의 작업을 거친다. 이를 활용하면 객체의 행동을 미리 정의함으로서 객체의 역할이 부각되며 더욱 객체지향적인 코드를 작성할 수 있게 된다. 하지만 이번엔 간략하게만 테스트를 작성한다.

@SpringBootTest(classes = MyrecipeApplication.class)
@Transactional
@AutoConfigureMockMvc
public class userTest {
    @Autowired
    MockMvc mockMvc;

    @Autowired
    JwtTokenProvider jwtTokenProvider;

    @Autowired
    JdbcTemplate jdbc;

    @Autowired
    ObjectMapper objectMapper;

    @Autowired
    UserRepository userRepository;

    @Autowired
    PasswordEncoder passwordEncoder;

테스트 클래스는 위의 형태로 시작하도록 한다. AutoConfigureMockMvc 어노테이션을 사용하여 mock 요청을 컨트롤러에 날릴 수 있게 된다. 또한 Transactional을 테스트에서 사용 할 경우, 테스트가 끝날 때 트랜잭션 범위에 있는 모든 DB 작업이 롤백된다.

 

Sigin in, Sign up Test

    @BeforeEach
    public void setupDb() {
        String encodedPw = passwordEncoder.encode("testOne");
        String insertQuery = "INSERT INTO user(user_id, password, grant_type, email) " +
                "VALUES('testOne', '" + encodedPw + "', 'normal', 'testOne@ggg.com')";
        jdbc.execute(insertQuery);
    }

    @Test
    public void testSetup(){
        Assertions.assertTrue(userRepository.getByUserId("testOne").isPresent(), "유저 데이터가 세팅되지 않았습니다.");
    }

    @Test
    public void when_loginInfoIsCollect_Expect_token() throws Exception {
        String username = "testOne";
        String password = "testOne";
        String accessToken = "testAccessToken";
        String refreshToekn = "testRefreshToken";
        Authentication authentication = mock(Authentication.class);
        given(authentication.getName()).willReturn(username);

        MvcResult result = mockMvc.perform(post("/sign-api/sign-in").contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(UserLoginDTO.builder()
                                .userId(username).userPassword(password).grantType("normal").email("testOne@ggg.com")
                                .build())))
                .andExpect(status().isOk())
                .andReturn();

        String authrizationHeader = result.getResponse().getHeader("Authorization");
        String responseBody = result.getResponse().getContentAsString();

        Assertions.assertTrue(authrizationHeader != null, "헤더가 비어있습니다.");
        Assertions.assertTrue(responseBody.contains("accessToken"), "액세스 토큰이 반환되지 않았습니다.");
        Assertions.assertTrue(responseBody.contains("refreshToken"), "리프래쉬 토큰이 반환되지 않았습니다.");
    }
    
    @Test
    public void when_signupInfoIsCorrect_Expect_success() throws Exception{
        UserSiginUpDTO userSiginUpDTO = UserSiginUpDTO.builder()
                .userId("siginUpTestOne")
                .userPassword("siginUpTestOne")
                .email("siginUpTestOne@gmail.com")
                .grantType("normal")
                .build();


        mockMvc.perform(post("/sign-api/sign-up")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(userSiginUpDTO)))
                .andExpect(status().isOk())
                .andReturn();

        Optional<User> signupUser = userRepository.getByUserId("siginUpTestOne");

        Assertions.assertTrue(signupUser.isPresent(), "등록된 사용자가 존재하지 않습니다");
        Assertions.assertTrue(signupUser.get().getUserId().equals("siginUpTestOne"), "아이디가 일치하지 않습니다." );
        Assertions.assertTrue(signupUser.get().getEmail().equals("siginUpTestOne@gmail.com"), "이메일이 일치하지 않습니다." );
        Assertions.assertTrue(signupUser.get().getGrantType().equals("normal"), "회원가입 방식이 일치하지 않습니다." );
    }

우선 DB에 회원 정보를 세팅한다.

SignIn의 경우 로그인이 성공했을 때 토큰을 response body로 반환할 것을 기대한다

SignUp의 경우 회원가입이 성공햇을 때 해당 유저 ID가 DB에 저장되어있을 것을 기대한다.

 

위 테스트 코드를 만족시키는 서비스 컨트롤러를 작성한다. 서비스에 대한 유닛 테스트는 진행하지 않았다.

@Service
@Slf4j
public class SignServiceImpl implements SignService {
    public UserRepository userRepository;
    public JwtTokenProvider jwtTokenProvider;
    public PasswordEncoder passwordEncoder;

    @Autowired
    SignServiceImpl(UserRepository userRepository, JwtTokenProvider jwtTokenProvider, PasswordEncoder passwordEncoder){
        this.userRepository = userRepository;
        this.jwtTokenProvider = jwtTokenProvider;
        this.passwordEncoder = passwordEncoder;
    }
    @Override
    public SignUpResultDTO signUp(String id, String password, String email, String role) {
        User user;
        
        return null;
    }

    @Override
    public SignInResultDTO signIn(UserLoginDTO userLoginDTO) throws RuntimeException{
        log.info("[SignServiceImpl-signIn] - 로그인 시도");
        User user = userRepository.getByUserId(userLoginDTO.getUserId()).get();//if not throw NoSuchElementException

        if(!passwordEncoder.matches(userLoginDTO.getUserPassword(), user.getPassword())){
            throw new RuntimeException();
        }
        log.info("[SignServiceImpl-signIn] - 패스워드 일치");

        SignInResultDTO signInResultDTO = SignInResultDTO.builder()
                .refreshToken(jwtTokenProvider.generateRefreshToken(user.getUserId(), user.getRoles()))
                .accessToken(jwtTokenProvider.generateAccessToken(user.getUserId(), user.getRoles()))
                .isSuccess(true)
                .build();
        return signInResultDTO;
    }
}

 

또한 로그인, 로그아웃 컨트롤러를 작성한다. (리프래쉬 토큰을 새로 발급하는 코드도 포함된다)

@Slf4j
@RestController
@RequestMapping("/sign-api")
public class UserController {
    private final JwtTokenProvider jwtTokenProvider;
    private UserService userService;
    private SignService signService;

    @Autowired
    UserController(UserService userService, JwtTokenProvider jwtTokenProvider, SignService signService){
        this.userService = userService;
        this.jwtTokenProvider = jwtTokenProvider;
        this.signService = signService;
    }

    @PostMapping("/hello")
    public String hello(){
        return "hello world";
    }
    @PostMapping("/sign-in")
    public ResponseEntity<TokenDTO> signIn(@RequestBody UserLoginDTO userLoginDTO){
        log.info("[signIn] - 로그인 시도");
        SignInResultDTO signInResultDTO = signService.signIn(userLoginDTO);

        if(signInResultDTO.isSuccess()){
            log.info("[signIn] - 로그인 성공");
            Authentication authentication = new UsernamePasswordAuthenticationToken(userLoginDTO.getUserId(), null, Collections.emptyList());
            String accessToken = jwtTokenProvider.generateAccessToken(authentication.getName(), List.of("USER"));
            String refreshToken = jwtTokenProvider.generateRefreshToken(authentication.getName(), List.of("USER"));

            HttpHeaders headers = new HttpHeaders();
            headers.add("Authorization", "Bearer " + accessToken);
            headers.add("Refresh-Token", refreshToken);

            TokenDTO tokenDTO = TokenDTO.builder().grantType("Bearer")
                    .accessToken(accessToken)
                    .refreshToken(refreshToken)
                    .autorities(List.of("USER"))
                    .build();

            return new ResponseEntity<>(tokenDTO, headers, HttpStatus.OK);
        } else{
            throw new RuntimeException("User not found");
        }
    }

    @PostMapping("/sign-up")
    public ResponseEntity<Map<String, String>> signUn(@RequestBody UserSiginUpDTO userSiginUpDTO) {
        if(signService.signUp(userSiginUpDTO)){
            Map<String, String> response = new HashMap<>();
            response.put("status", "200");
            response.put("msg", "signUp successed");
            return new ResponseEntity<>(response, HttpStatus.OK);
        }else{
            throw new RuntimeException("sign up fail");
        }
    }

    @PostMapping("/get-accesstoken")
    public ResponseEntity<Map<String, String>> getAccessTokenFromRefreshToken(@RequestBody UserAndRefreshDTO dto){
        log.info("[getAccessTokenFromRefreshToken] - 시작", dto.toString());
        if(jwtTokenProvider.isValidateToken(dto.getRefreshToken())){
            log.info("[getAccessTokenFromRefreshToken] - 리프래쉬 검증 성공");

            Map<String, String> resultMap = new HashMap<>();
            resultMap.put("newAccessToken", jwtTokenProvider.generateAccessToken(dto.getUserId(), dto.getRoles()));
            return ResponseEntity.status(HttpStatus.OK).body(resultMap);
        }

        log.info("[getAccessTokenFromRefreshToken] - 리프래쉬 검증 실패");
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
    }
}

 

테스트를 돌려보면?

테스트

코드를 보여주지 않았던 하나의 테스트를 제외한 테스트가 무사 통과하였다.(통과 못한 케이스틑 커스텀 익셉션을 던지는 테스트인데 아직 커스텀 예외를 구현하지 않았기에 통과하지 못하였다)

 

이로서 스프링 시큐리티와 JWT를 이용한 로그인, 회원가입이 완료되었다.