스프링부트 프로젝트
스프링부트 REST API 기반 인증 구현
인증(Verification)은 사용자를 정의하는 것에서 시작한다. 일반적으로 웹 애플리케이션에서는 '로그인' 기능을 통해서 인증과정을 거치게 된다. ID와 Password를 알고 있는 '적합한' 사용자라면 웹 애플리케이션을 사용하도록 허용해주는 것이 인증의 기능이다. 반면 인가(Authorization)는 인증된 사용자에게 할 수 있는 행동들을 정의해주는 것이다. 인증 기법에는 Basic Verification, Bearer Verification, JSON Web Token Verification 3가지 방법이 있다.
Basic Verification
가장 기본적인 인증 방식이다. HTTP 요청 header에 ID와 Password를 함께 보내는 것이다. 마지막으로 이를 BASE64로 인코딩한 문자열을 서버로 요청하면 서버에서 문자열을 디코딩해서 인증을 거치게 된다. 이 방법은 중간자 공격(Man in the Middle Attack)을 당할 위험이 높기 때문에 실무에서는 사용하지 않는 방식이다.
또 다른 문제점은 한번 로그인한 사용자를 로그아웃 시킬 방법이 없다는 것이다. 모든 요청에 ID와 Password를 함께 보내는 것도 상당한 비효율이다. 초당 수십만개의 요청을 하는 서버 입장에서는 부담으로 작용할 수 밖에 없는 요인이다. MSA방식으로 인증서버에 요청하는 마이크로서비스들이 많아지면 정상적인 애플리케이션 작동이 불가능해진다. 만약 인증 서버가 멈추면 모든 애플리케이션이 작동이 안되는 단일 장애점 참사가 발생한다.
Bearer Token Verification
Token이란 사용자를 구분하기 위해 사용되는 문자열이다. 최초 클라이언트가 HTTP 요청을 보내면 서버에서 토큰을 생성하여 response에 태워서 보내준다. 이 시점 이후로 클라이언트는 모든 요청을 할 때 서버에서 응답받은 토큰을 태워서 함께 HTTP 요청을 보내게 된다.
서버는 클라이언트의 요청에 따라 토큰을 생성하고 사용자에게 응답으로 토큰을 전송한 후 인증서버에 저장해 놓는다. 이 후 사용자가 요청을 보낼 때 함께 보내온 토큰과 인증서버에 저장되어 있는 토큰 값을 비교해서 사용자를 인증하게 된다. 이 방법은 매번 로그인 하지 않아도 되기 때문에 안전한 인증 방식이 될 수 있다. 또한 토큰이 서버에서 생성되기 때문에 유효기간 및 사용가능 디바이스 설정 등 서버의 자유도가 높은 편이다. 하지만 Bearer Verification 인증 방식 또한 위의 경우 처럼 마이크로서비스의 레이어가 복잡해 질 경우 발생하는 문제를 해결하지 못한다.
JSON Web Token(JWT) Verification
스케일에 따른 문제는 서버에서 전자 서명된 토큰을 통해 해결이 가능하다. 전자 서명된 토큰 중에서 가장 많이 쓰이는 방식이 바로 JSON Web Token(JWT) 방식이다. JWT는 말 그대로 JSON 형식으로 구성된 토큰이며 Header, Payload, signature로 구성되어 있다.
JWT 또한 서버에서 토큰을 생성한다. 기존 토큰 기반과 다른점이라면 서버가 Header와 Payload를 생성한 후에 '전자 서명 Digital Signature'을 한다는 것이다.
Header = {
typ(Type) : 토큰 타입
alg(Algorithm) : 토큰 서명을 발행하는데 사용된 해시 알고리즘
},
Payload = {
sub(Subject) : 토큰의 author, 유일한 식별자
iss(Issuer) : 토큰 발행 주체
iat(issued) : 토큰 발행 날짜와 시간
exp(expiration) : 토큰 만료 시간
}
Signature = {
토큰 발행 주체가 발행한 서명. 토큰 유효성 검사시 사용
}
JSON Web Token에서 전자서명을 통한 인증방법을 다시 한번 살펴보자. 사용자는 HTTP 요청을 서버에 보낸다. 서버는 사용자의 정보를 가지고 Header, Payload로 구성된 토큰을 생성하고 이를 시크릿 키로 전자서명을 한 후 전자서명이 포함된 응답을 내보낸다. 사용자가 다시 요청할 때는 토큰을 붙여서 요청하게 되고 서버에서는 사용자가 보낸 토큰 끝에 있는 전자서명과 새로 생성한 전자서명을 비교한 후 일치하는 경우에 사용자를 인증하게 된다.
User Layer 구현
사용자 인증을 구현하기 위해서는 사용자에 관한 Model, Service, Entity, Repository, Controller, DTO가 필요하다. 가장 먼저 DB와 근접한 Entity부터 구현한다. 사용자가 가지는 정보는 ID, Username, Email, Password 4가지 데이터를 가지게 된다.
Id는 "system-uuid"라는 generator 이름을 붙여주고, strategy는 "uuid" 형식을 사용한다. email 속성은 중복값을 허용하지 않는 Constraints를 추가해준다. 모든 column들은 null 값을 허용하지 않는다. 즉, 모든 값들이 모두 채워져야 한다는 뜻이다.
UserEntity.java
package com.example.damo.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.GenericGenerator;
import javax.persistence.*;
@Entity
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = "email")})
public class UserEntity {
@Id
@GeneratedValue(generator = "system-uuid")
@GenericGenerator(name="system-uuid", strategy = "uuid")
private String id;
@Column(nullable = false)
private String username;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String password;
}
UserEntity를 사용하기 위해 UserRepository를 생성한다. Repository에서는 findByEmail(), findByEmailAndPassword(), existsByEmail() 3가지 메소드를 가진다.
UserRepository.java
package com.example.damo.persistence;
import com.example.damo.model.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<UserEntity, String> {
UserEntity findByEmail(String email);
UserEntity findByEmailAndPassword(String email, String password);
Boolean existsByEmail(String email);
}
DB에 저장된 사용자 정보를 가져오기 위해서 비즈니스 로직을 담는 UserService를 생성한다. UserEntity, UserRepository를 사용해서 사용자 생성 및 로그인 인증을 진행하는 create() 메소드와 email+password 매개변수를 통해 사용자 정보를 가져오는 getByCredentials() 메소드를 구현한다.
메소드에 넘겨주는 매개변수들은 모두 final 키워드를 붙여서 Entity가 안정적으로 할당되도록 한다. final 키워드는 혹시나 로직속에서 Entity값이나 Email 값이 변경되면 로직이 작동되지 않기 때문에 최소한의 안정장치라고 이해하자.
UserService.java
package com.example.damo.service;
import com.example.damo.model.UserEntity;
import com.example.damo.persistence.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public UserEntity create(final UserEntity entity){
// 사용자 미존재
if(entity == null || entity.getEmail() == null){
throw new RuntimeException("Invalid arguments");
}
final String email = entity.getEmail();
// 기존 이메일 존재 / 사용자 생성 실패
if(userRepository.existsByEmail(email)){
log.warn("Email Already Exists!, {}", email);
throw new RuntimeException("Email Already Exists!");
}
// DB에 사용자 정보 저장
return userRepository.save(entity);
}
public UserEntity getByCredentials(final String email, final String password){
return userRepository.findByEmailAndPassword(email, password);
}
}
UserService를 이용해서 사용자를 생성하고 사용자 정보를 가져오는 UserController를 생성한다. 컨트롤러를 사용하기 위해서는 데이터를 담을 클래스 DTO를 먼저 생성한다.
UserDTO.java
package com.example.damo.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private String id;
private String email;
private String password;
private String username;
private String token;
}
컨트롤러에서는 createUser() 메소드와 authenticator() 메소드 2가지를 구현했다. 먼저 사용자 생성로직에서는 UserDTO로 오브젝트를 받아 UserEntity를 build()한다. UserService에서 구현한 create() 메소드를 호출해서 사용자를 생성하고, UserDTO로 변환 후 ResponseEntity.ok() 정상 응답을 사용자에게 보낸다. 만약 에러 발생시 ResponseEntity.badrequest()를 응답한다.
로그인(Authenticator)에서는 사용자 정보(email, password)를 받아서 UserEntity를 생성 후 UserDTO로 변환하여 ResponseEntity.ok() 응답을 보낸다. 만약 사용자를 찾을 수 없으면 ResponseEntity.badrequest()로 응답한다.
UserController.java
package com.example.damo.controller;
import com.example.damo.dto.ResponseDTO;
import com.example.damo.dto.UserDTO;
import com.example.damo.model.UserEntity;
import com.example.damo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.coyote.Response;
import org.springframework.beans.factory.annotation.Autowired;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/auth")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/sign")
public ResponseEntity<?> createUser(@RequestBody UserDTO dto){
try{
UserEntity user = UserEntity.builder()
.password(dto.getPassword())
.username(dto.getUsername())
.email(dto.getEmail())
.build();
UserEntity createdUser = userService.create(user);
UserDTO responseUserDTO = UserDTO.builder()
.username(createdUser.getUsername())
.password(createdUser.getPassword())
.id(createdUser.getId())
.email(createdUser.getEmail())
.build();
return ResponseEntity.ok().body(responseUserDTO);
}catch(Exception e){
ResponseDTO responseDTO = ResponseDTO.builder().error(e.getMessage()).build();
return ResponseEntity.badRequest().body(responseDTO);
}
}
@PostMapping("/signin")
public ResponseEntity<?> authenticator(@RequestBody UserDTO dto){
UserEntity user = userService.getByCredentials(dto.getEmail(), dto.getPassword());
if(user != null){
final UserDTO response = UserDTO.builder()
.username(user.getUsername())
.email(user.getEmail())
.password(user.getPassword())
.id(user.getId())
.build();
return ResponseEntity.ok().body(response);
}else{
ResponseDTO response = ResponseDTO.builder().error("Login Failed").build();
return ResponseEntity.badRequest().body(response);
}
}
}
Entity, Repository, Service, DTO, Controller 로직을 모두 구현하고 PostMan으로 REST API 테스트를 진행한다. 먼저 localhost:8080/auth/sign 로 email, username, password를 Body에 태워서 POST 요청을 하면 정상적으로 사용자 정보가 출력된다.
사용자 조회를 위해서 localhost:8080/auth/signin으로 email, password를 Body에 태워서 POST 요청을 하면 방금 생성한 사용자 데이터를 성공적으로 가져온다.
스프링 시큐리티 사용
위에서 작성한 User Layer의 문제점은 총 3가지다. 먼저 사용자가 로그인 후 로그인 상태정보가 저장이 안된다는 것이다. 로그인을 한 이후에도 다른 서비스에서는 로그인 여부를 확인할 방법이 없다. 두번째로 각 서비스에서 로그인 여부를 확인하는 로직이 없다는 것이다. 즉 모든 서비스에 로그인을 하지 않아도 인증이 되는 상태다. 마지막으로 패스워드가 암호화 되어 있지 않다. 패스워드를 그대로 노출하는 것은 보안규정에 위배되는 사항이다.
이러한 문제점을 해결하기 위해서 스프링 프레임워크에서 제공하는 스프링 시큐리티와 서블릿 필터를 사용한다. 스프링 시큐리티에서 API 요청에서 들어온 ID와 Password를 보내서 토큰을 생성하고 검증하는 작업을 거치게 된다. 사용자가 서비스 마다 코드를 구현할 필요가 없다는게 가장 큰 장점이다.
토큰 생성
토큰을 생성하기 위해서는 먼저 사용자의 HTTP 요청을 받아서 Header, Payload 정보를 토대로 전자 서명을 한다. 이후 토큰을 생성하게 된다.
JSON Web Token 라이브러리를 사용하기 위해서는 스프링 부트 프로젝트 dependency에 추가해준다. 'jjwt'를 build.gradle 안의 dependencies에 추가한다. mavenrepository.com에서 'jjwt' 라이브러리를 검색 후 gradle 전용 선언분을 가져온다.
dependency에 jjwt 라이브러리를 추가하고 gradle을 업데이트 하면 정상적으로 라이브러리 등록이 완료 된다.
JWT 라이브러리가 정상적으로 추가되면, TokenProvider를 생성한다. 이 클래스의 역할은 사용자 정보를 기반으로 토큰을 생성하고 다시 요청된 토큰을 가지고 위조 여부를 판단해 사용자를 인증하는 기능을 가진다.
JWT에서 전자서명을 위해 사용되는 알고리즘은 HS256, HS384 등 다른 알고리즘도 있지만, 아래에서는 HS512를 사용한다. HS512 알고리즘에 SECRET_KEY(Noun) 값으로 요구되는 String의 최소 길이는 128bit이다. 즉 영문자 기준으로 8개의 문자를 SECRET_KEY값으로 할당해줘야 한다.
TokenProvider 클래스는 두가지 메소드를 가진다. 먼저 create() 메소드는 UserEntity를 매개변수로 받아 Header, Payload를 생성하고 HS512알고리즘으로 전자서명한 토큰을 반환한다. validateAndGetUserId() 메소드는 토큰값을 파싱 및 디코딩 하여 Header, Payload, Digital Signature로 분해한 다음 해당 전자서명이 위조된 값인지 확인한 후 유효한 토큰이면 사용자의 ID를 반환한다.
TokenProvider.java
package com.example.damo.security;
import com.example.damo.model.UserEntity;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
@Slf4j
@Service
public class TokenProvider {
// 사용자 정보를 받아 Token을 생성한다.
private static final String SECRET_KEY = "aaaabbbbccccddddaaaabbbbccccddddaaaabbbbccccddddaaaabbbbccccdddd";
public String create(UserEntity entity){
Date expiryDate = Date.from(Instant.now().plus(1, ChronoUnit.DAYS));
/*
{
//header
"alg" : "HS512"
}.
{
//payload
"sub" : ,
"iss" : "damo app",
"iat" : 111,
"exp" : 111,
}.
// SECRET_KEY 사용 전자 서명 부분
*/
return Jwts.builder()
// Header 내용 및 서명할 때 사용하는 SECRET_KEY 삽입
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
//Payload 내용 생성
.setSubject(entity.getId())
.setIssuer("damo app provider")
.setIssuedAt(new Date())
.setExpiration(expiryDate)
.compact();
}
public String validateAndGetUserId(String token){
Claims claims = Jwts.parser()
// SECRET_KEY로 전자 서명 후 token값과 비교
.setSigningKey(SECRET_KEY)
// Base64로 디코딩 & 파싱 작업 진행
.parseClaimsJws(token)
// userId를 가져옴
.getBody();
// 만약 token이 위조된 경우 예외를 날리게 된다.
// 정상적인 token이면 Claims 인스턴스 반환
return claims.getSubject();
}
}
UserController에서 토큰을 생성하고 위조값을 판별할 TokenProvider를 사용해서 사용자 인증을 진행한다. 생성된 토큰값을 살펴보면 다음과 같다.
{
alg : HS512
}
.
{
sub=ff8081817ff98c3b017ff98c4b200000, iss=damo app provider, iat=1649159197, exp=1649245597
}
.
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJmZjgwODE4MTdmZjk4YzNiMDE3ZmY5OGM0YjIwMDAwMCIsImlzcyI6ImRhbW8gYXBwIHByb3ZpZGVyIiwiaWF0IjoxNjQ5MTU5MTk3LCJleHAiOjE2NDkyNDU1OTd9.OTYpC0qxOAZx8Hp3mW9zxbR8AJOKds6-ajJ4t0xRBL4RvUb1MkCzwunrEkASNVeD_FeybszMxANjesRRw62qcw
TokenProvider를 UserController에서 사용한다. 생성된 사용자 정보로 로그인을 하는 경우 사용자 정보를 기반으로 Token을 생성하고, 사용자 정보에 Token 값을 추가해준다.
UserController.java
package com.example.damo.controller;
import com.example.damo.dto.ResponseDTO;
import com.example.damo.dto.UserDTO;
import com.example.damo.model.UserEntity;
import com.example.damo.security.TokenProvider;
import com.example.damo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.coyote.Response;
import org.springframework.beans.factory.annotation.Autowired;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/auth")
public class UserController {
@Autowired
private UserService userService;
@Autowired
private TokenProvider tokenProvider;
@PostMapping("/sign")
public ResponseEntity<?> createUser(@RequestBody UserDTO dto){
try{
UserEntity user = UserEntity.builder()
.password(dto.getPassword())
.username(dto.getUsername())
.email(dto.getEmail())
.build();
UserEntity createdUser = userService.create(user);
UserDTO responseUserDTO = UserDTO.builder()
.username(createdUser.getUsername())
.password(createdUser.getPassword())
.id(createdUser.getId())
.email(createdUser.getEmail())
.build();
return ResponseEntity.ok().body(responseUserDTO);
}catch(Exception e){
ResponseDTO responseDTO = ResponseDTO.builder().error(e.getMessage()).build();
return ResponseEntity.badRequest().body(responseDTO);
}
}
@PostMapping("/signin")
public ResponseEntity<?> authenticator(@RequestBody UserDTO dto){
UserEntity user = userService.getByCredentials(dto.getEmail(), dto.getPassword());
if(user != null){
// 토큰 생성
final String token = tokenProvider.create(user);
final UserDTO response = UserDTO.builder()
.username(user.getUsername())
.email(user.getEmail())
.password(user.getPassword())
.id(user.getId())
// 토큰 생성 및 추가
.token(token)
.build();
return ResponseEntity.ok().body(response);
}else{
ResponseDTO response = ResponseDTO.builder().error("Login Failed").build();
return ResponseEntity.badRequest().body(response);
}
}
}
PostMan에서 사용자를 생성하는 POST 요청과 로그인을 위한 POST 요청을 보내면 정상적으로 token 생성 및 반환이 되는 것을 확인할 수 있다. UserController에서 로그인 로직을 담당하는 authenticator() 메소드가 수행될 때 TokenProvider에서 생성된 Token값이 사용자 정보에 정상적으로 추가된다.
사용자 생성
Token 생성 및 반환
스프링 시큐리티 && 서블릿 필터
토큰을 생성하고 사용자를 인증하는데 성공했다. 이제 사용자 API 요청을 할 때 마다 사용자를 인증하는 부분을 구현해야 한다. 즉 생성된 토큰을 받은 사용자가 다시 HTTP 요청을 할 때 Token을 붙여서 요청을 하게 되고 서버에서는 요청 안의 토큰이 유효한 값인지 검증하는 과정을 거쳐야 한다.
서블릿 필터란 서블릿이 실행되기 전 먼저 실행되는 클래스들을 의미한다. 스프링부트가 실행하는 디스패쳐 서블릿이 실행되기전에 서블릿 필터가 먼저 실행되는 것이다. 반면 스프링 시큐리티는 서블릿 필터들의 집합이라고 할 수 있다. 우리는 서블릿 필터를 먼저 구현해 놓고 서블릿 컨테이너가 서블릿 필터를 실행하도록 설정해준다.
스프링부트 서버 구성을 살펴보면 스프링부트내에서 비즈니스 로직을 실행하는 디스패쳐 서블릿이 실행되기 전, 서블릿 필터가 먼저 실행된다. 유효하지 않은 HTTP Request들은 모두 서블릿 필터에서 거절된다. 유효한 HTTP Request들은 이후 디스패쳐 서블릿이 실행되면서 비즈니스 로직에서 실행하고 클라이언트에게 HTTP 응답을 하게 된다.
서블릿 필터의 구조를 보면 HttpFilter / Filter 클래스를 상속하여 doFilter() 메소드를 오버라이딩한다. 서블릿 필터에서 필터링 된 HTTP Request들은 디스패쳐 서블릿으로 가지 않고 바로 리턴된다. 서블릿 필터를 구현한 후 어떤 URI 경로에 사용할지 지정해주면 서블릿 필터가 정상적으로 작동한다.
사실 서블릿 필터는 1개가 아니다. URI 별로 서블릿 필터를 생성할 수 있고, FilterChain을 통해 연속적으로 실행이 가능하다. 스프링 시큐리티를 프로젝트에 추가하면 스프링 시큐리티는 FilterChainProxy 필터를 서블릿 필터 사이에 끼워 넣는다. 이 FilterChainProxy 클래스 내부에서 필터를 실행시키게 된다. 짐작했겠지만 FilterChainProxy에서 실행하는 필터들이 바로 스프링이 관리하는 스프링 빈 필터(Bean Filter)들이다. 사용자가 서블릿 필터를 구현하기 위해 상속하는 필터는 스프링이 관리하는 빈 필터(Bean Filter) 중 OncePerRequestFilter다. 필터의 경로는 WebSecurityCongifurerAdapter에서 설정하게 된다.
스프링 시큐리티 && 서블릿 필터 구현
먼저 스프링 시큐리티 dependency를 build.gradle에 추가한다.
dependencies {
.
.
.
implementation 'org.springframework.boot:spring-boot-starter-security'
}
OncePerRequestFilter 클래스를 상속해 서블릿 필터를 생성한다. OncePerRequestFilter는 요청 한개당 한 번만 실행된다. 한번만 인증이 필요한 로직에서 사용된다. TokenProvider를 생성했던 security 패키지에 서블릿 필터 JwtAuthFilter를 생성한다.
JwtAuthFilter에서는 매개변수로 넘겨받은 요청에서 Header를 파싱하고 parseBearerToken() 메소드로 Bearer 토큰을 가져온다. TokenProvider의 validateAndGetUserId() 메소드로 토큰을 인증하여 유효한 사용자 ID를 가져온다. 유효한 사용자 ID를 토대로 UsernamePasswordAuthenticationToken()을 작성하고 SecurityContext에 인증된 사용자를 최종적으로 등록하게 된다. SecurityContext에 사용자를 저장해 놓아야만 요청을 처리하는 과정에서 사용자의 인증여부를 체크할 수 있기 때문이다.
SecurityContextHolder는 ThreadLocal에 저장된다. Thread별로 하나의 컨텍스트를 관리할 수 있으며, 동일 스레드 내에서는 어디에서든 SecurityContextHolder에 접근 가능하게 된다.
final class ThreadLocalSecurityContextHolderStrategy implements
SecurityContextHolderStrategy{
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();
}
JwtAuthFilter.java
package com.example.damo.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Autowired
private TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try{
// 사용자 요청에서 Token을 가져옴
String token = parserBearerToken(request);
log.info("Filter is running ... ");
// 토큰을 검사한다. JWT를 사용해서 인증서버를 건너뛰고 검증이 가능하다.
if(token != null && !token.equalsIgnoreCase("null")){
String userId = tokenProvider.validateAndGetUserId(token);
log.info("Authentication user ID : {}", userId);
// 인증이 완료된다. 인증된 사용자를 SecurityContextHolder에 등록한다.
// AbstractAuthenticationToken 인증정보를 저장
AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, AuthorityUtils.NO_AUTHORITIES);
// 요청을 인증정보에 기재
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// SecurityContext 설정
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authentication);
SecurityContextHolder.setContext(securityContext);
}
}catch(Exception e){
logger.error("Could not set user authentication in security context {}", e);
}
// 요청에 대한 인증 filterchain을 구성한다.
filterChain.doFilter(request, response);
}
private String parserBearerToken(HttpServletRequest request){
// HTTP 요청의 헤더를 파싱하여 Bearer 토큰을 리턴한다.
String bearerToken = request.getHeader("Authorization");
// ???
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")){
return bearerToken.substring(7);
}
return null;
}
}
스프링 시큐리티 Configuration
서블릿 필터를 구현하고 난 후 구현된 서블릿 필터를 사용하도록 스프링 시큐리티에 설정을 해준다. 크로스 오리진 문제를 해결하기 위해 생성했던 config 패키지 하위에 WebSecurityConfigurerAdapter을 상속하는 WebSecurityConfig클래스를 생성해준다. 매개변수로 넘겨받는 HttpSecurity 오브젝트는 시큐리티 관련 설정을 도와준다. cors, csrf, session, authorizeRequests 등 다양한 시큐리티 설정이 가능한 오브젝트다.
@EnableWebSecurity 어노테이션이 붙으면 SpringSecurityFilterChain이 자동으로 붙게 된다. 스프링에서 관리하는 FilterChain에 시큐리티 설정을 추가해주는 것이다.
주의할 점
CorsFilter은 반드시 springframework.web.filter.CorsFilter 라이브러리를 사용할 것. 자동 완성 기능을 사용하면서 apache.catalina.filters.CorsFilter을 import 해놓고 1시간 동안 코드를 해맸다.
WebSecurityConfig.java
package com.example.damo.config;
import com.example.damo.security.JwtAuthFilter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
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.web.filter.CorsFilter;
@EnableWebSecurity
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthFilter jwtAuthenticationFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
// http 시큐리티 빌더
http.cors() // WebMvcConfig에서 이미 설정했으므로 기본 cors 설정.
.and()
.csrf()// csrf는 현재 사용하지 않으므로 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
);
}
}
PostMan 테스팅
사용자를 생성하고 로그인을 진행하면 사용자 정보를 parsing + 전자서명 후 토큰을 반환하게 된다. 이 유요한 토큰값을 가지고 사용자 인증을 진행해본다. 해당 토큰값을 스프링 시큐리티가 실행한 서블릿 필터에서 검증한 후 토큰값에 해당한 정보를 반환해준다.
만약 유효하지 않은 토큰 값이 입력될 경우 에러를 반환한다. Status Code는 "403 Forbidden"을 반환한다.
스프링 시큐리티 프로젝트 적용
스프링 시큐리티와 서블릿 필터를 모두 구성하고, 기존의 CRUD 로직을 수정한다.
Create Todo List
create() 메소드는 매개변수로 @AuthenticationPrincipal String userId를 받고 있다. @AuthenticationPrincipal를 사용해서 가져온 userId는 UserDetailsService가 반환한 값인 현재 로그인한 사용자의 정보를 가져오게 된다. @AuthenticationPrincipal이 가져오는 String userId는 서블릿 필터 JwtAuthFilter에서 생성한 UsernamePasswordAuthenticationToken을 생성할 때 첫번째로 넘겨주는 것이 AuthenticationPrincipal이 된다. 이렇게 생성했던 authentication은 SecurityContext에 추가되었다.
CrudController.create()
@PostMapping
public ResponseEntity<?> create(@AuthenticationPrincipal String userId, @RequestBody CrudDTO dto){
try{
// 사용자에게 받은 DTO를 Entity로 변환한다.
CrudEntity entity = CrudDTO.toEntity(dto);
// 생성 당시에는 id가 없어야 한다. null로 초기화 한다.
entity.setId(null);
// 임시 UserId로 설정한다.
entity.setUserId(userId);
// 변환된 Entity로 비즈니스 로직을 작동한다.
// 반환값은 <CrudEntity> 타입의 배열이다.
List<CrudEntity> entities = crudService.create(entity);
// 자바 스트림을 사용한다.
// 반환된 Entity 배열을 DTO 배열로 변환한다.
List<CrudDTO> dtos = entities.stream().map(CrudDTO::new).collect(Collectors.toList());
// 반환된 DTO를 사용하여 ResponseDTO를 초기화 한다.
ResponseDTO<CrudDTO> response = ResponseDTO.<CrudDTO>builder().data(dtos).build();
return ResponseEntity.ok().body(response);
}catch(Exception e){
String error = e.getMessage();
ResponseDTO<CrudDTO> response = ResponseDTO.<CrudDTO>builder().error(error).build();
return ResponseEntity.badRequest().body(response);
}
}
Retrieve Todo List
@AuthenticationPrincipal 어노테이션을 사용해서 현재 로그인한 사용자 정보를 가져온다. 사용자가 등록한 TodoList를 검색한 후 ResponseEntity 형식의 데이터로 반환한다.
CrudController.retrieve()
@GetMapping
public ResponseEntity<?> retrieve(@AuthenticationPrincipal String userId){
List<CrudEntity> entities = crudService.retrieve(userId);
List<CrudDTO> dtos = entities.stream().map(CrudDTO::new).collect(Collectors.toList());
ResponseDTO<CrudDTO> response = ResponseDTO.<CrudDTO>builder().data(dtos).build();
return ResponseEntity.ok().body(response);
}
Update Todo List
@AuthenticationPrincipal을 사용해서 현재 로그인한 사용자 정보를 가져오고, HTTP 요청의 Body에 실린 데이터를 DTO로 받아와서 TodoList를 업데이트 하는 로직이다.
CrudController.update()
@PutMapping
public ResponseEntity<?> update(@AuthenticationPrincipal String userId, @RequestBody CrudDTO dto){
CrudEntity entity = CrudDTO.toEntity(dto);
entity.setUserId(userId);
List<CrudEntity> entities = crudService.update(entity);
List<CrudDTO> dtos = entities.stream().map(CrudDTO::new).collect(Collectors.toList());
ResponseDTO<CrudDTO> response = ResponseDTO.<CrudDTO>builder().data(dtos).build();
return ResponseEntity.ok().body(response);
}
Delete Todo List
@AuthenticationPrincipal을 사용해서 현재 로그인한 사용자 정보를 가져오고, 이를 토대로 현재 선택된 데이터를 DTO 오브젝트에 담아와 삭제하는 로직이다.
CrueController.delete()
@DeleteMapping
public ResponseEntity<?> delete(@AuthenticationPrincipal String userId, @RequestBody CrudDTO dto){
try{
CrudEntity entity = CrudDTO.toEntity(dto);
entity.setUserId(userId);
List<CrudEntity> entities = crudService.delete(entity);
List<CrudDTO> dtos = entities.stream().map(CrudDTO::new).collect(Collectors.toList());
ResponseDTO<CrudDTO> response = ResponseDTO.<CrudDTO>builder().data(dtos).build();
return ResponseEntity.ok().body(response);
}catch(Exception e){
String error = e.getMessage();
ResponseDTO<CrudDTO> response = ResponseDTO.<CrudDTO>builder().error(error).build();
return ResponseEntity.badRequest().body(response);
}
}
스프링은 컨트롤러에 정의된 메소드를 호출할 때 이미 @AuthenticationPrintipal이 존재하는 것을 알고 있다. SecurityContextHolder에서 SecurityContext::Authentication(UsernamePasswordAuthenticationToken)을 가져온다. 스프링은 이 오브젝트에서 AuthenticationPrincipal을 가져와서 컨트롤러 메소드로 넘겨주게 된다.
PostMan
사용자 1, 사용자 2를 생성한다. 첫번째 사용자로 로그인 후 Todo List를 하나 생성한다. 두번째 사용자로 로그인 후 Todo List를 생성한다. 첫번째 사용자의 토큰으로 Authorization을 설정 한 후 Retrieve 로직을 실행하면 첫번째 사용자가 생성한 Todo List만 출력되는 것을 확인할 수 있다.
사용자 생성
사용자 로그인
반환되는 Token값을 복사해 놓는다.
TodoList 생성
먼저 Authorization에서 Token값을 설정해준다. Token은 Bearer Token을 사용한다.
POST 메소드로 생성할 TodoList의 이름을 Body에 태워서 요청을 보낸다.
TodoList 확인
Authorization에 토큰값을 설정해주고 localhost:8080/crud URI로 GET 메소드를 보낸다. 사용자 2가 생성한 TodoList는 제외되고 사용자 1이 생성한 TodoList만 출력해준다.
Password Encryption
비밀번호는 반드시 암호화 되어 처리되어야 한다. 스프링 시큐리티에서 비밀번호 암호화를 위한 BCryptPasswordEncoder를 제공한다. 암호화된 패스워드를 인증하기 위해서 BCryptPasswordEncoder의 matches() 메소드를 사용한다. 의미없는 값인 salt를 붙여서 salting(인코딩 진행)하고 두 값이 일치 여부를 알려준다.
UserEntity를 생성할 때 password에 하드코딩된 password를 넣지 않는다. 보안에 취약하기 때문이다. passwordEncoder의 encode() 메소드를 사용해서 입력받은 password를 인코딩 한 후 UserEntity를 생성한다. 이 후 로그인 로직에서 UserService의 getByCredentials()에서 BCryptPasswordEncoder를 생성해서 패스워드 값의 유효성을 검사하는 로직을 만든다.
UserService.java
package com.example.damo.service;
import com.example.damo.model.UserEntity;
import com.example.damo.persistence.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public UserEntity create(final UserEntity entity){
// 사용자 미존재
if(entity == null || entity.getEmail() == null){
throw new RuntimeException("Invalid arguments");
}
final String email = entity.getEmail();
// 기존 이메일 존재 / 사용자 생성 실패
if(userRepository.existsByEmail(email)){
log.warn("Email Already Exists!, {}", email);
throw new RuntimeException("Email Already Exists!");
}
// DB에 사용자 정보 저장
return userRepository.save(entity);
}
public UserEntity getByCredentials(final String email, final String password, final PasswordEncoder encoder){
final UserEntity originalUser = userRepository.findByEmail(email);
if(originalUser != null && encoder.matches(password, originalUser.getPassword())){
return originalUser;
}
return null;
}
}
UserController의 authenticator() 메소드 내 UserEntity를 생성하는 로직에서 getByCredentials() 메소드의 매개변수로 passwordEncoder를 넘겨주면서 패스워드 검증을 실행한다.
UserController.java
package com.example.damo.controller;
import com.example.damo.dto.ResponseDTO;
import com.example.damo.dto.UserDTO;
import com.example.damo.model.UserEntity;
import com.example.damo.security.TokenProvider;
import com.example.damo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.coyote.Response;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/auth")
public class UserController {
@Autowired
private UserService userService;
@Autowired
private TokenProvider tokenProvider;
private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@PostMapping("/sign")
public ResponseEntity<?> createUser(@RequestBody UserDTO dto){
try{
UserEntity user = UserEntity.builder()
// password 암호화를 진행 후 DB에 저장한다.
.password(passwordEncoder.encode(dto.getPassword()))
.username(dto.getUsername())
.email(dto.getEmail())
.build();
UserEntity createdUser = userService.create(user);
// UserDTO에는 password를 제외한다.
UserDTO responseUserDTO = UserDTO.builder()
.username(createdUser.getUsername())
.id(createdUser.getId())
.email(createdUser.getEmail())
.build();
return ResponseEntity.ok().body(responseUserDTO);
}catch(Exception e){
ResponseDTO responseDTO = ResponseDTO.builder().error(e.getMessage()).build();
return ResponseEntity.badRequest().body(responseDTO);
}
}
@PostMapping("/signin")
public ResponseEntity<?> authenticator(@RequestBody UserDTO dto){
UserEntity user = userService.getByCredentials(dto.getEmail(), dto.getPassword(), passwordEncoder);
if(user != null){
// 토큰 생성
final String token = tokenProvider.create(user);
// 사용자를 인증 한 후 DTO에는 password를 제외한다.
final UserDTO response = UserDTO.builder()
.username(user.getUsername())
.email(user.getEmail())
.id(user.getId())
// 토큰 생성 및 추가
.token(token)
.build();
log.info(tokenProvider.validateAndGetUserId(token));
return ResponseEntity.ok().body(response);
}else{
ResponseDTO response = ResponseDTO.builder().error("Login Failed").build();
return ResponseEntity.badRequest().body(response);
}
}
}
인코딩 password 확인
password1은 사용자를 생성할 때 하드코딩 된 password다. password2는 인코딩 되어 실제 Repository에 저장된 password다. 위에서 알아본 것 처럼 BCryptPasswordEncoder()는 salting 과정을 통해 매번 변경되는 해시 함수의 리턴값을 반환하기 때문에 matches() 메소드를 통해서 실질적인 비교검증 과정을 거친다. matches() 함수의 결과값은 password1과 password2의 값이 동일하다고 출력한다.
password1 : password
password2 : $2a$10$eTDDOZ8dWCP5a.ximROAr.pTFAqlsROclc/uDZSavK5MpCS6v4eBu
PostMan 테스팅
사용자를 생성하고 로그인을 시도하면 비밀번호가 암호화 되어 있음에도 불구하고 사용자 인증에 성공하였고, 정상적으로 token이 반환되는 것을 확인할 수 있다.
백엔드 인증 구현 정리
먼저 사용자를 관리하기 위한 로직인 User 레이어(Repository, Entity, Service, Contorller, DTO)를 생성한다. 스프링 시큐리티를 이용해서 모든 요청에 한번의 인증을 진행하는 OncePerRequestFilter를 상속한 서블릿 필터 JwtAuthFilter를 생성한다.
WebSecurityConfigurerAdapter를 상속한 WebSecurityConfig을 생성해서 어떤 경로에서 인증이 진행되야 하는지 구성한다. 서블릿 컨테이너가 언제 어디서 서블릿 필터 JwtAuthFilter를 실행해야 하는지를 구성하는 과정이다.
마지막으로 패스워드 암호화를 위해서 BCryptPasswordEncoder을 사용해서 사용자의 패스워드를 암호화 한다. 암호화된 사용자 정보를 토대로 UserEntity를 생성하고 salting을 거친 암호가 유효한 값인지 비교하는 로직까지 완성한다.
더 읽을 콘텐츠
'Programming' 카테고리의 다른 글
No tests found for given includes 문제 발생시 스프링부트 해결방법 (0) | 2022.04.08 |
---|---|
스프링부트 SpringBoot 웹 애플리케이션 개발 #7 프론트엔드 인증 구현 (feat React.js) (0) | 2022.04.06 |
자바 스트림이란 What is Stream in JAVA ? (0) | 2022.04.04 |
스프링 부트 SpringBoot 웹 애플리케이션 개발 #5 백엔드 프론트엔드 통합 구현하기 (0) | 2022.04.02 |
스프링 부트 SpringBoot 웹 애플리케이션 개발 #4 프론트엔드 구현하기 (0) | 2022.04.01 |
스프링 부트 SpringBoot 웹 애플리케이션 개발 #3 CRUD 구현하기 (1) | 2022.03.31 |
댓글