Spring JWT 토큰 서버 구현(+ ajax 프론트테스트)
회원가입/로그인, 회원정보를 조회 기능을 가진 Spring서버에서 jwt token을 통해 인증처리를 하도록 서버구현을 하고자 한다. 구현 후 테스트는 html에서 ajax를 통해 로그인 및 토큰을 발급받고자 한다. 토큰을 획득한뒤에 회원정보조회 api호출도 잘 되는지를 확인해보겠다.
참고로 본인은 백엔드 개발자이다보니, html과 ajax로 간단히 만들었다. 현업에서는 주로 react, vue.js 등 프론트엔드 프레임워크를 통해 구성할테니 양해 바란다.
*사용기술 :타임리프, mysql, jpa, springboot 2.7.x, jwt, 타임리프
*github : https://github.com/kimseonguk197/spring_jwt_server
토큰과 세션이란
토큰과 세션은 인증을 위한 기술이지만, 기본적으로 다른 개념이다.
세션은 서버 측에서 인증 정보를 저장하고, 클라이언트가 요청을 보낼 때마다 세션 ID를 이용하여 서버에서 저장된 인증 정보를 찾아 사용하는 방식이다. 보안성이 좋은 편이지만 서버 측에서 상태를 유지해야 하므로 서버 자원을 많이 사용할 수 있다.
반면 토큰은 서버와 클라이언트 간에 인증 정보를 주고 받는 방식이다. 클라이언트가 로그인을 하면 서버에서 인증을 처리하고, 그 결과를 기반으로 토큰을 발급하여 클라이언트에게 전달한다. 이후 클라이언트가 요청을 보낼 때마다 해당 토큰을 포함시켜 전달하며, 서버는 해당 토큰을 검증하여 인증 여부를 확인한다. 이때에 토큰을 검증하는 방식은 세션처럼 DB에 값을 저장해두고 검증하는 것이 아니라, 최초에 토큰을 생성해서 발급할때 사용한 secret키를 가지고 다시 암호화된 값을 만들어, 프로그램상에서 클라이언트가 보내온 값과 비교하는 것이다. 그래서, 서버 측에서 상태를 유지할 필요가 없으므로 세션에 비해 서버 자원을 덜 사용하고, 토큰은 클라이언트 측에서 저장하기 때문에 확장성이 높다.
바로 jwt토큰을 발행하는 프로그램 및 회원가입과 로그인을 구현해보겟다.
스프링 서버 구현
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// 스프링 시큐리티 사용
testImplementation 'org.springframework.security:spring-security-test'
implementation 'mysql:mysql-connector-java'
// 토큰 라이브러리 추가
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
application yml은 일반적인 웹개발시 필요한 jpa, mysql 외엔 별다른 설정이 필요치 않다.
SecurityConfig.java
@EnableWebSecurity
public class SecurityConfig {
// 직접만든 토큰provider를 통해 api요청시 filter역할
private final JwtTokenProvider jwtTokenProvider;
public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
// WebSecurityConfigurerAdapter가 deprecated됨에 따라 SecurityFilterChain을 사용하였다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable()
.httpBasic().disable()
.authorizeRequests()
// token테스팅을 위한ui, 회원가입api, doLogin api는 token filter에 걸리지 않도록 하였다.
.antMatchers("/token","/authors/new", "/doLogin")
.permitAll()
.anyRequest().authenticated()
.and()
// 세션을 사용하지 않겠다는 선언
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 직접만든 tokenProvider를 통한 filter
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
}
위 config파일은 http 기본설정과 토큰filter를 담은 Bean객체 생성을 위함이다. 자세한 내용은 주석을 참고하고, 아래 Filter코드로 넘어가보도록 하자.
JwtAuthenticationFilter.java
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 JWT 를 받아옵니다.
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// token이 있는지 검사 && secretKey검사와 유효시간 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
위 필터코드는 request헤더에 token이 있는지 없는지 검사를 하고, validation의 핵심로직은 jwtTokenProvider에 요청한다. jwtTokenProvider에 토큰을 생성하는 기능과 토큰의 validation을 체크하는 기능이 담긴다.
JwtTokenProvider.java
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
// 편의상 아래와 같이 사용하지만, yml에서 설정파일로 가지고 있는게 좋습니다.
private String secretKey = "myprojectsecret";
// 토큰 유효시간 30분
private long tokenValidTime = 30 * 60 * 1000L;
private final AuthorService authorService;
// 객체 초기화, secretKey를 Base64로 인코딩한다.
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public String createToken(String userPk) {
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위, 보통 여기서 user를 식별하는 값을 넣는다.
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과
// signature 에 들어갈 secret값 세팅
.compact();
}
//인증 객체 생성
public Authentication getAuthentication(String token) {
UserDetails userDetails = authorService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// Request의 Header에서 token 값을 가져옵니다. "Authorization" : "TOKEN값'
public String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
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;
}
}
}
최대한 주석을 꼼꼼히 달았으니, 토큰 발행과 검증은 주석을 참고하길 바란다.
이제 서비스로직을 봐보도록 하자. 우리의 service구성은 매우 단순하다. 1개의 Controller와 Service로 구성돼 있다. 별 내용이 없다. mysql에 회원정보를 저장하고, 조회해오는 기능이다. 일반적인 서비스와 다를바가 전혀 없지만, AuthorService안에 UserDetailsService를 구현받고, 이 안에 loadbyusername이라는 메서드를 overide하고 있다. 이 loadbyusername이 jwtTokenProvider에서 인증정보객체를 만들어 filtering용도로 사용되게 된다.
AutherService.java
@Service
@Transactional
public class AuthorService implements UserDetailsService {
private final AuthorRepository repository;
private final PasswordEncoder passwordEncoder;
public AuthorService(AuthorRepository repository, PasswordEncoder passwordEncoder) {
this.repository = repository;
this.passwordEncoder = passwordEncoder;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Author author = repository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 Email 입니다."));
return new User(author.getEmail(), author.getPassword(), Arrays.asList(new SimpleGrantedAuthority(author.getRole().toString())));
}
public void create(Author author){
repository.save(author);
author.encodePassword(passwordEncoder);
}
public void login(AuthorPostForm authorPostForm) {
Author author = repository.findByEmail(authorPostForm.getEmail())
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
if (!passwordEncoder.matches(authorPostForm.getPassword(), author.getPassword())) {
throw new IllegalArgumentException("이메일 또는 비밀번호가 맞지 않습니다.");
}
}
public Optional<Author> findById(Long memberId){
return repository.findById(memberId);
}
}
위의 service가 UserDetailsService를 구현받는 이유는 loadByUserName을 사용하기 위함이다. 해당 메서드는 TokenProvider에서 인증객체를 만들때 사용된다. 해당 인증객체는 Filter에서 SecurityContextHolder에 담겨서 프로그램전역에서 인증Filter를 위한 사용자정보로서 사용된다.
(사실, jwt token임에도 filter시마다 매번 DB를 조회해야한다는 점에서 문제가 있는 로직이라 생각합니다. 토큰의 장점에 맞게 DB조회를 안하는 방법은 추후 github에 업데이트 해놓겠습니다.)
AuthorController.java
@Controller
public class AuthorController {
private final AuthorService authorService;
private final JwtTokenProvider jwtTokenProvider;
public AuthorController(AuthorService authorService, JwtTokenProvider jwtTokenProvider) {
this.authorService = authorService;
this.jwtTokenProvider = jwtTokenProvider;
}
// 회원가입
@PostMapping("/authors/new")
public void create(AuthorPostForm authorPostForm){
Author author = new Author();
author.setName(authorPostForm.getName());
author.setEmail(authorPostForm.getEmail());
author.setPassword(authorPostForm.getPassword());
author.setCreateDate(LocalDateTime.now());
if(authorPostForm.getRole().equals("user")){
author.setRole(Role.USER);
}else{
author.setRole(Role.ADMIN);
}
authorService.create(author);
}
// login로직
@PostMapping("/doLogin")
@ResponseBody
public HashMap<String, Object> doLogin(@RequestBody AuthorPostForm authorPostForm) {
authorService.login(authorPostForm);
String jwt = jwtTokenProvider.createToken(authorPostForm.getEmail());
HashMap<String,Object> map = new HashMap<String,Object>();
map.put("token", jwt);
return map;
}
// 회원정보 조회
@GetMapping("authors/api/findById/{authorId}")
@ResponseBody
public Author findById(@PathVariable() Long authorId){
return authorService.findById(authorId).orElse(null);
}
// 토큰 테스트 화면 렌더링
@GetMapping("/token")
public String tokenTest(Model model){
return "/token";
}
}
컨트롤러의 경우 회원가입, 로그인 구현을 해놓았고, 로그인 시에 token이 provide 된다. 테스트를 위한 용도로 회원정보조회 api또한 구현돼 있으니 참고하시길 바란다. 실제 db및 jpa repository또한 구현돼 있으나, 관련 내용은 생략하겠습니다.
테스트
요렇게 생긴 UI를 통해 테스트를 진행할 예정이다. 토큰없이 회원정보 서비스 호출을 호출하면, 에러가 발생할것이고, 로그인 후에 token을 발급받고 api를 호출하면 정상적으로 호출이 되도록 할 것이다.
token.html
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<div>
<button id="test">(토큰이 필요한)회원정보 서비스 호출</button>
<button id="goodLogin">정상 email pw Login</button>
<button id="badLogin">틀린 email pw Login</button>
<button id="logout">로그아웃</button>
</div>
<script src="http://code.jquery.com/jquery-latest.min.js"></script>
<script type="text/javascript">
$(document).ready(function() {
$('#test').click(function() {
$.ajax({
beforeSend: function(req) {
if (localStorage.token) {
req.setRequestHeader('Authorization', localStorage.token);
}
},
dataType: "json",
type: "GET",
contentType: "application/json",
url: 'http://localhost:8081/authors/api/findById/27',
success: function(data) {
alert('조회하신 데이터 : 이름 : ' + data.name + ' email 주소 : '+ data.email);
},
error: function(request,status,error) {
alert("로그인이 되지 않았습니다. "+ "code:"+request.status+"\n"+"message:"+request.responseText+"\n"+"error:"+error);
}
});
});
$('#goodLogin').click(function() {
$.ajax({
url: "http://localhost:8081/doLogin",
dataType: "json",
type: "POST",
contentType: "application/json",
data: JSON.stringify({"email":"test10@naver.com", "password":"test1234"}),
success: function(data) {
alert("정상 로그인 성공 token : "+data.token);
localStorage.token = data.token;
},
error:function(request,status,error){
alert("code:"+request.status+"\n"+"message:"+request.responseText+"\n"+"error:"+error);
}
});
});
$('#badLogin').click(function() {
$.ajax({
type: "POST",
url: "http://localhost:8081/doLogin",
dataType: "json",
contentType: "application/json",
data: {
email: "test10@naver.com",
password: "test4321"
},
success: function(data) {
alert("비정상 로그인 성공");
},
error: function() {
alert("로그인 실패");
}
});
});
$('#logout').click(function() {
localStorage.clear();
});
});
</script>
</body>
</html>
ajax를 통해 로그인 api를 호출하여 token을 받아온뒤, localstorage에 저장을 한다. 저장된 토큰을 가지고 API를 호출하면, 스프링의 filter를 통해 정상 토큰임을 인증받고, 서비스를 이용할 수 있게 된다.
테스트결과는 아래와 같다. 로그인이 되지 않으면, 에러 POP이 뜬다.(생략)
추가적으로 위와같이 서버사이드에서 화면을 렌더링해주는 것이 아니라면, 별도의 cors처리를 해줘야 한다는점도 유의하길 바란다.
지금까지 간단한 token서비스 구현을 해보았다. 현재의 구성은 토큰 1개로만 인증을 관리하는 것인데, access/refresh토큰 2개를 가지고 조금 더 유연하게 서비스를 운영할 수도 있을 것이다.