🔐 JWT(Json Web Token)
- JWT란?
JWT는 하나의 인터넷 표준 인증 방식으로 인증에 필요한 정보들을 암호화시킨 JSON 형태의 토큰을 말한다.
- JWT를 사용한 인증방식
이전 포스팅에서 JWT의 개념에 대해서 알아보았다. 이번 포스팅에서는 스프링부트를 사용한 실제 적용방법을 알아보자.
[Security] JWT 소개
🔐 HTTP의 특징 및 쿠키(Cookie)와 세션(Session)의 등장 JWT를 소개하기 전에 HTTP의 특징과 쿠키와 세션의 등장 배경에 대하여 알아보자. 기본적으로 HTTP 프로토콜 환경은 'Connectionless', 'Stateless'한 특
caffeineoverflow.tistory.com
🔐 프로젝트 구조
전체 프로젝트 구성은 아래와 같으며, 각 각의 파일을 순차적으로 살펴보도록 하겠다.
🔐 Dependency 추가
build.gradle 파일에 JPA, H2 DB, 롬복, Spring security, JWT 등에 대한 의존성을 명시해준다.
dependencies {
// Spring Basic
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// jpa
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// database
runtimeOnly 'com.h2database:h2'
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// jwt
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-security'
}
🔐 설정 정보
application.yml 파일에 DB 정보 등에 대한 설정을 해준다.
spring:
datasource:
url: jdbc:h2:tcp://localhost/~/test
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
logging.level:
org.hibernate.SQL: debug
🔐 도메인 및 비즈니스 로직
- Member.java
사용자 도메인 객체는 사용자ID, 이름, 비밀번호, 권한 정보를 가지고 있다.
package app.web.domain;
import lombok.*;
import javax.persistence.*;
@NoargsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberId;
private String name;
private String password;
@Enumerated(EnumType.STRING)
private MEMBER_ROLES roles;
@Builder
private Member(Long memberId, String name, String password, MEMBER_ROLES roles) {
this.memberId = memberId;
this.name = name;
this.password = password;
this.roles = roles;
}
}
- MEMBER_ROLES.java
사용자 권한을 정의하고 있으며, 관리자(ADMIN) 또는 일반 사용자(USER) 권한으로 구성되어 있다.
package app.web.domain;
public enum MEMBER_ROLES {
ADMIN, USER
}
- MemberController.java
회원 이름과 비밀번호를 입력하여 로그인에 성공하면 토큰을 발급한 후 응답을 내려주는 역할을 한다.
package app.web.controller.v1;
import app.web.domain.Member;
import app.web.jwt.JwtTokenProvider;
import app.web.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
@RestController
@RequiredArgsConstructor
public class MemberControllerV1 {
private final JwtTokenProvider jwtTokenProvider;
private final MemberService memberService;
/**
* 로그인
*/
@PostMapping("/v1/login")
public ResponseEntity login(@RequestBody Member member) {
Member findMember = memberService.findMemberByName(member.getName());
if (findMember != null && findMember.getPassword().equals(member.getPassword())) {
return ResponseEntity
.ok()
.body(jwtTokenProvider.createToken(String.valueOf(findMember.getMemberId()), Arrays.asList(findMember.getRoles().toString())));
} else {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("로그인 정보가 올바르지 않습니다.");
}
}
}
- AuthController.java
권한에 따라 접근이 가능한지 테스트를 위한 컨트롤러이다.
회원 및 관리자는 '/v1/user/access'에 접근 가능하며, '/v1/admin/access'는 오직 관리자 권한이 있을 때만 접근이 가능하다.
package app.web.controller.v1;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class AuthControllerV1 {
/**
* 회원과 관리자 권한 접근
*/
@PostMapping("/v1/user/access")
public ResponseEntity accessUser() {
return ResponseEntity
.ok()
.body("Hello User World!");
}
/**
* 관리자 권한 접근
*/
@PostMapping("/v1/admin/access")
public ResponseEntity accessAdmin() {
return ResponseEntity
.ok()
.body("Hello Admin World!");
}
}
- MemberService.java
회원 등록 및 조회 기능이 있다.
중점적으로 봐야할 점은 재정의한 loadUserByUsername() 메소드이다.
해당 클래스는 Spring Security에서 제공하는 UserDetailsService를 상속받은 서비스 클래스이다.
UserDetailsService클래스의 loadUserByUsername() 메소드를 재정의한 것으로 사용자 정보 및 권한 정보를 리턴한다.
이 리턴 값을 토대로 사용자가 유효한 사용자인지 요청한 자원에 대한 접근 권한이 있는지 판단한다.
package app.web.service;
import app.web.domain.Member;
import app.web.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
private final MemberRepository memberRepository;
/**
* 회원 등록
*/
public Long saveMember(Member member) {
return memberRepository.save(member).getMemberId();
}
/**
* 회원 전체 조회
*/
public List<Member> findAllMember() {
return memberRepository.findAll();
}
/**
* 회원 조회
*/
public Member findMember(Long memberId) {
return memberRepository.findByMemberId(memberId);
}
/**
* 회원 조회
*/
public Member findMemberByName(String name) {
return memberRepository.findByName(name);
}
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
Member member = memberRepository.findByMemberId(Long.parseLong(userId));
List<String> authList = new ArrayList<>();
authList.add(member.getRoles().name());
return new User(
String.valueOf(member.getMemberId()),
member.getPassword(),
Arrays.asList(member.getRoles().name()).stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())
);
}
}
- MemberRepository.java
JPA를 사용하여 사용자를 조회하는 기능을 담당하고 있다.
package app.web.repository;
import app.web.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
Member findByMemberId(Long memberId);
Member findByName(String name);
}
🔐 JWT 핵심 로직
- JwtTokenProvider.java
토큰을 생성하고 토큰에서 정보를 추출하는 역할을 담당한다.
package app.web.jwt;
import app.web.service.MemberService;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
@RequiredArgsConstructor
@Component
@Slf4j
public class JwtTokenProvider {
private String secretKey = "app";
private long tokenValidTime = 3 * 60 * 1000L; // 토큰 유효시간(3분)
private final MemberService memberService;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public String createToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk);
claims.put("roles", roles);
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) // 만료 시간
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = memberService.loadUserByUsername(this.getUserPk(token));
Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
return auth;
}
// 토큰에서 PK로 사용된 값을 추출한다.
public String getUserPk(String token) {
String userPk = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
return userPk;
}
// 헤더정보에서 Authorization의 값을 추출한다.
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 (SignatureException e) {
log.error("Invalid JWT signature", e);
} catch (MalformedJwtException e) {
log.error("Invalid JWT token", e);
} catch (ExpiredJwtException e) {
log.error("Expired JWT token", e);
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token", e);
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty.", e);
}
return false;
}
}
- JwtAuthenticationFilter.java
사용자 요청 값 중 헤더에서 토큰을 추출하여 토큰의 유효성을 검사한다.
package app.web.jwt;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@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); // 토큰을 토대로 인증정보를 추출
SecurityContextHolder.getContext().setAuthentication(authentication); // 토큰에서 추출한 인증정보 셋팅
}
chain.doFilter(request, response);
}
}
- SecurityConfig.java
토큰에서 인증정보를 추출하여 토큰을 발급받은 사용자가 요청한 URL에 접근할 수 있는지 판단한다.
- ADMIN 권한을 가진 사용자 : '/v1/admin/**'과 '/v1/user/**' 패턴과 일치하는 URL일 경우, 리소스에 접근이 가능하다.
- USER 권한을 가진 사용자 : '/v1/user/**' 패턴과 일치하는 URL일 경우에만 리소스에 접근이 가능하다.
package app.web.jwt;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.httpBasic().disable()
.authorizeRequests()
// /v1/user/** 패턴으로 들어오는 요청에 대해서는 ADMIN 또는 USER 권한이 있어야힌다.
.antMatchers("/v1/user/**").hasAnyAuthority("ADMIN", "USER")
// /v1/admin/** 패턴으로 들어오는 요청에 대해서는 ADMIN 권한이 있어야한다.(=ADMIN 권한만 접근 가능하다)
.antMatchers("/v1/admin/**").hasAuthority("ADMIN")
// 이외 요청에 대해서는 모두 접근이 가능하다.
.anyRequest().permitAll()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
🔐 JWT 발급과 접근 테스트
테스트 사전 조건은 다음과 같다.
- 이름과 패스워드를 사용하여 로그인을 성공하면 토큰이 발급된다.
- admin은 'ADMIN' 권한을 가진 사용자이며, member-0 사용자는 'USER' 권한을 가진 사용자이다.
이제 아래의 순서로 테스트를 진행해보도록 하자.
- 관리자 계정으로 로그인하여 토큰을 발급 받은 후, 해당 토큰으로 사용자 페이지(/v1/user/**)로 접근이 가능 여부 확인
- 접근 가능하다. - 관리자 계정으로 로그인하여 토큰을 발급 받은 후, 해당 토큰으로 관리자 페이지(/v1/admin/**)로 접근이 가능 여부 확인
- 접근 가능하다. - 사용자 계정으로 로그인하여 토큰을 발급 받은 후, 해당 토큰으로 사용자 페이지(/v1/user/**)로 접근이 가능 여부 확인
- 접근 가능하다. - 사용자 계정으로 로그인하여 토큰을 발급 받은 후, 해당 토큰으로 관리자 페이지(/v1/admin/**)로 접근이 가능 여부 확인
- 접근 불가능하다.
'Spring' 카테고리의 다른 글
[SpringBoot] Bucket4j를 이용한 트래픽 제한 (0) | 2023.02.23 |
---|---|
[SpringBoot] REST Docs (0) | 2023.02.19 |
[Security] JWT 소개 (0) | 2023.02.12 |
[Spring] 스프링 AOP (0) | 2023.02.07 |
[Spring] 스프링 IoC (0) | 2023.02.05 |