Json Web Token (JWT)
앞선 포스팅에서 JWT를 이용해 인증을 구현한다고 했다.
그렇다면 이 "인증"은 몇번을 받아야 하는가?
가장 간단히 생각하면 모든 API 요청에 토큰을 함께 보내어 인증을 구현하는 것이다.
그러면 각 API는 맨 처음 토큰을 확인함으로써
접근을 허용, 또는 거부하는 코드를 실행하겠지만 이는 좋은 방법이 아니다.
인증을 필요로하는 API가 여러개일 경우,
동일한 인증 코드를 그 요청 개수만큼 반복해서 실행하기 때문이다.
그렇다면 가장 좋은 방법은 무엇인가?
바로 Spring Security를 사용하는 것이다.
Spring Security를 사용하는 경우,
인증 코드를 한 번만 짜고 이 코드가 모든 API를 수행하기 바로 전에 실행되도록 구현하면 된다.
JWT를 구현하기 위해서 JWT, Spring Security를 Dependency에 추가해야한다.
dependencies {
...
implementation "io.jsonwebtoken:jjwt:0.9.1"
implementation 'org.springframework.boot:spring-boot-starter-security'
}
라이브러리를 추가했다면
모든 인증과 인가와 관련된 클래스를 쉽게 구분하기 위해 security 패키지를 만든다.
그 후, 토큰을 발급하는 TokenProvider.class를 만든다.
package com.example.demo.security;
/*
TokenProvider 을 작성했다면
이제 로그인 부분에서 TokenProvider 를 이용해 토큰을 생성 후 UserDTO 에 이를 반환해야한다.
*/
@Slf4j
@Service
public class TokenProvider {
// 시크릿 키
private static final String SECRET_KEY = "NMA8JPctFuna59f5";
// JWT 라이브러리를 이용해 JWT 토큰을 생성
public String create(UserEntity userEntity) {
// 토큰이 만료되는 기한을 지금으로부터 1일로 설정
Date expiryDate = Date.from(
Instant.now().plus(1, ChronoUnit.DAYS)
);
// JWT Token 생성
return Jwts.builder()
// header 에 들어갈 내용 및 서명을 하기 위한 SECRET_KEY
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
// payload 에 들어갈 내용
.setSubject(userEntity.getId()) // sub
.setIssuer("demo app") // iss
.setIssuedAt(new Date()) // iat
.setExpiration(expiryDate) // exp
.compact();
}
// 토큰을 디코딩, 파싱 및 위조여부 확인
public String validateAndGetUserId(String token) {
/*
parseClaimsJws 메서드가 Base 64로 디코딩 및 파싱
즉, 헤더와 페이로드를 setSigningKey 로 넘어온 시크릿을 이용해 서명 후, token 의 서명과 비교
위조되지 않았다면 페이로드(Claims) 리턴, 위조라면 예외를 날림
그중 우리는 UserId가 필요하므로 getBody 를 부른다.
*/
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token) // 이거 !
.getBody();
// userID 반환
return claims.getSubject();
}
}
위 코드의 실행 프로세스는 간단하다.
create 메서드에서 JWT 라이브러리를 이용해 JWT 토큰을 생성한다.
이 때 임의로 지정한 SECRET_KEY를 개인키로 사용한다.
validateAndGetUserId 메서드는 토큰을 디코딩, 파싱 및 위조 여부를 확인한다.
이후 사용하려는 subject 값, 즉 유저의 아이디를 리턴한다.
JWT라이브러리를 사용하기 때문에
JSON을 생성, 서명, 인코딩, 디코딩, 파싱하는 작업을 따로 하지 않아도 된다.
로그인에 필요한 User 관련 레이어를 구현한다.
사용자를 관리하기 위해서는 Model, Service, Repository, Controller가 필요하다.
// UserEntity.class
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = "username")})
@ToString
public class UserEntity {
@Id
@GeneratedValue(generator = "system-uuid")
@GenericGenerator(name = "system-uuid", strategy = "uuid")
private String id;
@Column(nullable = false)
private String username; // 아이디로 사용할 유저 네임
private String password; // 패스워드
private String role; // 사용자의 역할 (어드민, 일반사용자)
}
UserEntity를 사용하기 위해 Repository를 작성한다.
// UserRepository.interface
@Repository
public interface UserRepository extends JpaRepository<UserEntity, String> {
UserEntity findByUsername(String username); // 이름 조회
Boolean existsByUsername(String username); // 이미 이름이 존재하는 경우
}
UserService는 총 2가지의 메서드를 사용한다.
create() : UserRepository를 이용해 사용자를 생성
getByCredentials() : 로그인을 인증할 때 사용
보통 암호화된 패스워드를 비교하는 경우,
사용자에게 받은 패스워드를 같은 방법으로 암호화한 후
그 결과를 DB값과 비교하는것이 자연스러운 흐름이지만
본문에서는 그렇게 하지 않고 matches() 메서드를 사용한다.
BCryptPasswordEncoder는 같은 값을 인코딩하더라도 할 때마다 값이 다른데,
패스워드에 랜덤하게 의미 없는 값을 붙여 결과를 생성하기 때문이다.
이런 의미 없는 값을 보안 용어로 Salt라 하고, Salt를 붙여 인코딩하는 것을 Salting이라고 한다.
따라서 사용자에게 받은 패스워드를 인코딩하더라도
DB에 저장된 패스워드와는 다를 확률이 높다.
대신 BCryptPasswordEncoder는 어떤 두 값이 일치여부를 알려주는 matches() 메서드를 제공한다.
이 메서드는 Salt를 고려해서 두 값을 비교해준다.
// UserService.class
@Slf4j
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
// 사용자 생성
public UserEntity create(final UserEntity userEntity) {
if (userEntity == null || userEntity.getUsername() == null) {
throw new RuntimeException("Invalid arguments");
}
final String username = userEntity.getUsername();
if (userRepository.existsByUsername(username)) {
log.warn("username already exists {}", username);
throw new RuntimeException("Username already exists");
}
return userRepository.save(userEntity);
}
// 로그인 인증, SpringSecurity를 이용하여 패스워드 암호화하여 matches로 비교
public UserEntity getByCredentials(final String username, final String password, final PasswordEncoder encoder) {
final UserEntity originalUser = userRepository.findByUsername(username);
// matches 메서드를 이용해 패스워드가 같은 지 확인
if (originalUser != null && encoder.matches(password, originalUser.getPassword())) {
return originalUser;
}
return null;
}
}
UserController에서 사용할 UserDTO를 생성한다.
// UserDTO.class
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private String token;
private String username;
private String password;
private String id;
}
UserDTO를 생성했다면 UserController를 구현하자.
UserController는 두 가지 기능을 제공한다.
JWT를 생성하는 TokenProvider를 작성했기 때문에,
로그인 부분에서 TokenProvider를 이용해 토큰을 생성 후 UserDTO에 이를 반환한다.
@Slf4j
@RestController
@RequestMapping("/auth")
public class UserController {
@Autowired
private UserService userService;
@Autowired
private TokenProvider tokenProvider;
private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// 회원가입 API
@PostMapping("/signup")
public ResponseEntity<?> registerUser(@RequestBody UserDTO userDTO) {
try {
if (userDTO == null || userDTO.getPassword() == null) {
throw new RuntimeException("Invalid Password value");
}
// 요청을 이용해 저장할 유저 만들기
UserEntity user = UserEntity.builder()
.username(userDTO.getUsername())
.password(passwordEncoder.encode(userDTO.getPassword())) // 비밀번호 암호화
.build();
// 서비스를 이용해 Repository 에 유저 저장
UserEntity registeredUser = userService.create(user);
UserDTO responseUserDTO = UserDTO.builder()
.id(registeredUser.getId())
.username(registeredUser.getUsername())
.build();
return ResponseEntity.ok().body(responseUserDTO);
} catch (Exception e) {
// 유저 정보는 항상 하나이므로 리스트로 만들어야 하는 ResponseDTO를 사용하지 않고 그냥 UserDTO 리턴
ResponseDTO responseDTO = ResponseDTO.builder()
.error(e.getMessage())
.build();
return ResponseEntity.badRequest().body(responseDTO);
}
}
// 로그인 API
@PostMapping("/signin")
public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO) {
System.out.println("DTO : " + userDTO);
UserEntity user = userService.getByCredentials(
userDTO.getUsername(),
userDTO.getPassword(),
passwordEncoder
);
if (user != null) {
// 토큰 생성
final String token = tokenProvider.create(user);
final UserDTO responseUserDTO = UserDTO.builder()
.username(user.getUsername())
.id(user.getId())
.token(token)
.build();
return ResponseEntity.ok().body(responseUserDTO);
} else {
ResponseDTO responseDTO = ResponseDTO.builder()
.error("Login failed")
.build();
return ResponseEntity
.badRequest()
.body(responseDTO);
}
}
}
위와 같이 설정한 뒤 ,
Postman을 이용하여 회원가입 후 로그인 API를 불러오면 Token을 return한다
리턴받은 토큰을 Base64로 디코딩하면 다음과 같다
{
"alg":"HS512"
}
{
"sub":"4028819a876a924901876a966c520000",
"iss":"demo app",
"iat":1684996014,
"exp":1685082414
}
|bIcv73S!P2[B܅4Lxa~X'O ڿ'O
header와 payload가 잘 출력되고
유효성을 검사하는 {서명} 부분도 알수없는 값으로 채워진것을 확인할 수 있다.
난 Front 단에서 토큰을 Session으로 관리하게 만든 후 이를 Back으로 보내는 방식을 선택했다.
위 리액트 구문에서 localStorage를 사용하는 경우 쿠키 방식,
즉 웹 브라우저 == 클라이언트 == 사용자 컴퓨터 에 로그인 정보를 저장하기 때문에
웹 브라우저를 닫아도 로그인이 유지되어있다.
localStorage 대신 SessionStorage를 사용한다면
웹 서버에 로그인 정보를 저장하기 때문에 웹 브라우저를 닫는 순간
웹 서버가 닫히면서 로그아웃 된다.
로그인을 했으니, 이 이후에 요청되는 모든 api는 로그인 세션 정보를 가지고 있어야한다.
먼저 JWT인증을 관리하는 클래스를 설정한뒤,
SpringSecurity에게 해당 클래스를 사용하도록 설정한다.
/*
스프링 시큐리티에게 JwtAuthenticationFilter 를 사용하라고 알려주는 것
*/
@EnableWebSecurity // Spring Security에서 사용
@Slf4j
public class WebSecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// http 시큐리티 빌더
http.cors()
.and()
.csrf() // 현재는 사용X, Disable
.disable()
.httpBasic() // token 을 사용하므로 basic 인증 Disable
.disable()
.sessionManagement() // Session 기반이 아님을 선언
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests() // '/' 와 '/auth/**' 경로는 인증 안해도 됨
.antMatchers("/", "/auth/**").permitAll()
.anyRequest() // '/' 와 '/auth/**' 이외의 모든 경로는 인증해야됨
.authenticated();
/*
filter 등록,
매 요청마다 CorsFilter 실행한 후에 jwtAuthenticationFilter 실행
*/
http.addFilterAfter(
jwtAuthenticationFilter,
CorsFilter.class
);
return http.build();
}
}
그리고 로그인 세션 정보를 가지고 있어야하는 api 요청 Controller에
@AuthenticationPrincipal 어노테이션을 붙여서 TokenProvider에서 설정한
JWT Subject 내용을 매개변수로 넣어준다.
@RestController
@RequestMapping("/todo")
@RequiredArgsConstructor
@Slf4j
public class TodoController {
private final TodoService service;
@GetMapping("/test")
public ResponseEntity<?> testTodo() {
String str = service.testService();
List<String> list = new ArrayList<>();
list.add(str);
ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();
return ResponseEntity.ok().body(response);
}
@GetMapping
public ResponseEntity<?> retrieveTodoList(@AuthenticationPrincipal String userId) {
// (1) 서비스 메서드의 retrieve 메서드를 사용해 Todo 리스트를 가져온다
List<TodoEntity> entities = service.retrieve(userId);
// (2) 자바 스트림을 이용해 리턴된 엔티티 리스트를 TodoDTO 리스트로 변환
List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
// (3) 변환된 TodoDTO 리스트를 이용해 ResponseDTO를 초기화
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
// (4) ResponseDTO 리턴
return ResponseEntity.ok().body(response);
}
@PostMapping
public ResponseEntity<?> createTodo(@RequestBody TodoDTO dto, @AuthenticationPrincipal String userId) {
try {
// (1) DTO to Entity
TodoEntity entity = TodoDTO.toEntity(dto);
// (2) 생성 당시에는 id가 없어야하기 때문에 null 초기화
entity.setId(null);
// (3) 임시 유저 아이디를 설정
entity.setUserId(userId);
// (4) 서비스를 이용해 Todo 엔티티 생성
List<TodoEntity> entities = service.create(entity);
// (5) 자바 스트림을 이용해 리턴된 엔티티 리스트를 TodoDTO 리스트로 변환
List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
// (6) 변환된 TodoDTO 리스트를 이용해 ResponseDTO를 초기화
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
// (7) ResponseDTO를 리턴
// log.info("생성?");
return ResponseEntity.ok().body(response);
} catch (Exception e) {
// (8) 혹시 예외가 나는 경우 dto 대신 error에 메시지를 넣어 리턴한다.
String error = e.getMessage();
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();
return ResponseEntity.badRequest().body(response);
}
}
@DeleteMapping
public ResponseEntity<?> deleteTodo(@RequestBody TodoDTO dto, @AuthenticationPrincipal String userId) {
try {
// (1) TodoEntity로 변환
TodoEntity entity = TodoDTO.toEntity(dto);
// (2) 임시 유저 아이디를 생성.
entity.setUserId(userId);
// (3) 서비스를 이용해 entity 삭제
List<TodoEntity> entities = service.delete(entity);
// (4) 자바 스트림을 이용해 리턴된 엔티티 리스트를 Todo리스트로 변환
List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
// (5) 변환된 TodoDTO 리스트를 이용해 ResponseDTO를 초기화 한다.
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
// (6) ResponseDTO를 리턴
return ResponseEntity.ok().body(response);
} catch (Exception e) {
// (7) 혹시 예외가 일어나는 경우 dto 대신 error 에 메시지를 넣어 리턴
String error = e.getMessage();
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();
return ResponseEntity.badRequest().body(response);
}
}
@PutMapping
public ResponseEntity<?> updateTodo(@RequestBody TodoDTO dto, @AuthenticationPrincipal String userId) {
// (1) dto를 entity로 변환
TodoEntity entity = TodoDTO.toEntity(dto);
// (2) id를 temporaryUserId로 초기화한다
entity.setUserId(userId);
// (3) 서비스를 이용해 entity를 업데이트한다
List<TodoEntity> entities = service.update(entity);
// (4) 자바 스트림을 이용해 리턴된 엔티티 리스트를 TodoDTO 리스트로 변환
List<TodoDTO> dtos = entities.stream().map(TodoDTO::new).collect(Collectors.toList());
// (5) 변환된 TodoDTO 리스트를 이용해 ResponseDTO를 초기화한다.
ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(dtos).build();
// (6) ResponseDTO 리턴
return ResponseEntity.ok().body(response);
}
}
위 설정들을 통해 BackEnd 단에서
JWT, Spring Security를 사용하여 로그인을 세션 방식으로 구현할 수 있다.
여기서 주의해야할 점은,
JWT를 사용할 때 토큰이 쿠키로 저장되어 클라이언트로 전송될 수 있는데
이 때 HTTPS를 사용하지 않으면 쿠키가 도청될 수 있으며
악의적인 공격자가 토큰을 가로채어 부정한 요청을 보낼 위험이 있다.
때문에 JWT를 사용한 웹 프로젝트는 반드시 HTTPS 로 통신하도록 하자.