#05 로그인 토큰 사용하기 (feat. JwtToken)

김대진·2023년 4월 10일
0

MyMemory Project

목록 보기
6/9
post-thumbnail

사이트에서 로그인을 진행하였을 때, 로컬/세션 스토리지나 쿠키에 사용자의 데이터를 그대로 담는 것은 보안에 매우 취약하다.

그래서 서버에서는 로그인 요청이 들어올 때 토큰을 발급해 주고, 프론트에서 데이터를 요청할 때마다 토큰과 함께 요청해야 데이터를 넘겨줘야 한다.

우리 프로젝트에서는 다음과 같이 config패키지가 구성되어 있다.

여기서 SwaggerConfigWebConfig를 제외한 모든 클래스를 만들 것이다.
(WebConfig 에 대한 내용은 https://velog.io/@phraqe/Sekkison8 를 참고 바란다.)

01. 라이브러리 추가

다음 라이브러리를 build.gradle에 추가해 준다.

build.gradle

//security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt:0.9.1'

02. JwtTokenProvider

먼저, JwtToken을 생성/발급해주는 JwtTokenProvider를 만들자.

JwtTokenProvider.class

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
    private String secretKey = "webfirewood";
    private long tokenValidTime = 30 * 60 * 1000L;     // 유효시간 30분
    private final UserDetailsService userDetailsService;

    // 객체 초기화, secretKey를 Base64로 인코딩
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // 토큰 생성
    public String createToken(String userPk) {  // userPK = username
        Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // 토큰 유효시각 설정
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 암호화 알고리즘과, secret 값
                .compact();
    }

    // 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // 토큰 유효성, 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }
}

03. JwtAuthenticationFilter

그리고 유효한 토큰이라면 토큰으로부터 사용자 정보를 받아 SecurityContext에 저장하는 JwtAuthenticationFilter를 작성하자.

JwtAuthenticationFilter.class

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

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 헤더에서 토큰 받아오기
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        // 토큰이 유효하다면
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰으로부터 유저 정보를 받아
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            // SecurityContext 에 객체 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

04. CustomUserDetailService

Spring Security에서 loadUserByUsername함수를 통해 유저를 가져올 수 있도록 CustomUserDetailService를 작성하자.

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
	private final UserRepository userRepository;

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		User user = userRepository.findByUsername(username);
		if (user == null) throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
		return user;
	}
}

05. WebSecurityConfig

WebSecurityConfig를 다음과 같이 작성한다.

@RequiredArgsConstructor
@EnableWebSecurity  //Spring Security 설정 활성화
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	private final JwtTokenProvider jwtTokenProvider;

	//암호화에 필요한 PasswordEncoder Bean 등록
	@Bean
	public BCryptPasswordEncoder bCryptPasswordEncoder() {
		return new BCryptPasswordEncoder();
	}

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

	@Override
	public void configure(WebSecurity web) throws Exception {
		web.ignoring().antMatchers("/resources/**");
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
				.csrf().disable().headers().frameOptions().disable()
				.and()

				//세션 사용 안함
				.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
				.and()

				//URL 관리
				.authorizeRequests()
				.antMatchers("/api/users/**", "/api/memos/**").authenticated()
				.anyRequest().permitAll()
				.and()

				// JwtAuthenticationFilter를 먼저 적용
				.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
	}
}

BCryptPasswordEncoder에 대해서는 https://velog.io/@phraqe/Sekkison07 에 정리해 놓았다.

thymeleaf 파일에서 jscss를 불러오는 것도 api를 사용해 호출하는 형식이기에 web.ignoring().antMatchers("/resources/**")를 통해 resource 패키지를 WebSecurity에서 제외시켰다.

페이지 요청이 아닌 api 데이터 요청인 /api/users/**, /api/memos/**에만 WebSecurity를 적용시켰다.

addFilterBefore를 통해 데이터 요청이 왔을 때 JwtToken을 먼저 확인하고 서버 함수를 실행한다.

06. 예제 코드

이렇게 작성만 되어 있다면, 이제 서버에서 api 데이터 요청을 위해서는 토큰이 필요하게 된다. 다음과 같이 서버 로그인 로직에서 토큰 발급을, 프론트 데이터 요청에서 토큰을 담아 요청하도록 하자.

UserController.class

@PostMapping("/login")
    public TokenResponse login(@RequestBody @Validated LoginRequest request) {
        User user = request.loginUser();
        return TokenResponse.of(userService.loginUser(user));
    }

UserService.class

public String loginUser(User user) {
        User loginUser = userRepository.findByUsername(user.getUsername());
        if (loginUser == null) throw new MyMemoryException(404, "일치하는 계정이 없습니다");

        if (!bCryptPasswordEncoder.matches(user.getPassword(), loginUser.getPassword())) {
            throw new MyMemoryException(404, "일치하는 계정이 없습니다");
        }
        // 로그인에 성공하면 username, roles 로 토큰 생성 후 반환
        return jwtTokenProvider.createToken(loginUser.getUsername());
    }

login.js

function login() {
  $.ajax({
    contentType: 'application/json',
    url: `/api/login`,
    type:"POST",
    dataType: 'json',
    data: JSON.stringify({
      "username": id,
      "password": pw
    }),
    success : function(data) {
      if (data.status == 200)
        localStorage.setItem("token" , data.token);
      else
        alert(data.message);
    }
  })
}

header.js (데이터 요청 예시)

$.ajax({
  contentType: 'application/json',
  url:`/api/users`,
  type:"GET",
  dataType: 'json',
  headers: { 'X-AUTH-TOKEN': localStorage.getItem("token") },
  success : function(data) {
    if (data.status == 200)
      alert(`${data.name}님 환영합니다`);
    else
      alert(data.message);
  }
});

로그인 로직에서의 토큰 발급과 데이터 요청에 토큰을 사용하는 방법을 알아보았다.

이번 프로젝트에서는 토큰을 로컬스토리지에 담아 사용하였지만, 쿠키에 담는 방법도 있고 각자 장단점이 존재한다고 한다.

만약 쿠키에 토큰을 담아 사용한다면 ajax요청에 일일이 header를 넣어 주는 것이 아니라 자동으로 가능하다고 하니 편한 방법을 사용하기 바란다.

profile
만재 개발자

0개의 댓글