Spring Interceptor
와 JWT Token
을이용한 App(모바일) 인증 구현에 대해 알아보겠습니다.
이번 포스팅에서는 전체적인 App의 인증 과정에대한 시나리오를 알아보고, 사용자가 회원가입시 토큰을 저장하고, 로그인시 저장된 토큰을 받는 부분까지 구현해보도록 하겠습니다.
https://galid1.tistory.com/755
이 포스팅을 다 읽으셨다면 Refresh Token에 대해서도 공부해보시길 권장드립니다.
1. 환경
IDE
- WebStorm
- Intellij
Build tool
- gradle
FrameWork
- Spring
Language
- JAVA
2. 시나리오
전체 시나리오
- 사용자가 회원가입을 하면,
auth0 JWT
를 이용해Token
을 생성하여, 사용자의 데이터와 함께 저장합니다.
- 사용자가 ID, PW을 이용해 로그인을 하게되면, 서버에서 사용자의
Token
을 반환해줍니다.
- Client측에서, Token을 저장한뒤, 검증이 필요한 Server의 서비스를 이용할 때, 요청의
Header
에Token
을 포함시킵니다.
- Server에서
토큰을 검증
후, API 응답을 합니다.
서버의 토큰검증 시나리오
- 검증이 필요한(토큰이 필요한) API 이용시, Client의 요청 Header에 Token을 포함해 요청합니다.
- Spring의 Interceptor에 의해 요청이 Intercept 됩니다.
- 해당 사용자에게 제공되었던 Token과, 요청의 Header에 담긴 Token이 일치하는지 확인합니다.
- 마지막으로
auth0 JWT
를 이용해issuer, expire
를 검증합니다.
3. 실습
3.1 프로젝트 생성
앞서 말씀드렷듯이 Intellij
를 이용해서 Spring Boot 프로젝트를 생성합니다. build tool
은 gradle
을 이용하였고, java version
은 1.8
입니다.
Lombok
을 체크합니다.
spring web을 체크합니다.
jpa
와 h2
db를 선택합니다.
3.2 프로젝트 구조
빨간색, 주황색, 초록색 별로 같은 Depth에 위치한 디렉토리입니다.
- common : 어플리케이션에서 전체적으로 사용되는 기능들이 위치함
- config : Spring 설정파일들이 위치함
- domains : 도메인들이 위치함
- user(도메인 이름) : 각각의 도메인
- presentation : 사용자의 endpoint로 사용자와 소통하는 UI로직(Controller)들이 위치함
- service : application(service) 계층에 해당하는 로직들이 위치함
- domain : 도메인 계층에 해당하는 로직들이 위치함
- infra : 위의 3계층이 공통적으로 사용할 기능과 외부 모듈등이 위치함
- user(도메인 이름) : 각각의 도메인
저는 DDD시 위와 같은 디렉토리형태로 구조를 잡고 진행을 하는데요, 첫째로 common과 domains를 분리함으로써, 도메인로직들은 domains
디렉토리 하위에 존재함을 직관적으로 볼수 있기때문에, 도메인로직에 좀더 집중할 수 있습니다.
또한 domains
디렉토리에서 다시 어플리케이션에서 사용되는 domain
들이 나뉘고, 그 안에 layerd architecture
의 형태로 디렉토리 구조를 잡음으로써, ui 계층로직, service 계층로직, domain 계층 로직, infra 계층 로직을 다시 분리하여, 각각의 domain들의 도메인로직에 집중할 수 있기 때문에, 매우 효율적인 구조로 느껴졌기 때문입니다.
3.3 구현
domain 계층
x
access = AccessLevel.PROTECTED) (
public class User {
strategy = GenerationType.AUTO) (
private Long userId;
private String userEmail;
private String userPassword;
private String token;
public User(String userEmail, String userPassword, String token) {
this.userEmail = userEmail;
this.userPassword = userPassword;
this.token = token;
}
}
위의 구조를 가지는 User Entity를 생성합니다. user는 login시 사용될 email, password와 api 요청시 사용될 token
을 필드로 가집니다. (userEmail, userPassword, token
은 Credential
이라는 하나의 논리적인 Value 타입
으로 변경할 수도 있지만, 그렇게 하지 않았습니다. User가 현재 Application에서는 Login을 하는 User라는 것 이외의 특별한 역할을 하는 domain은 아니기 때문입니다.)
xxxxxxxxxx
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUserEmail(String email);
}
UserRepository 는 Interface로 JpaRepository<User, Long>
를 구현함으로써, User Entity
의 영속성 관리를 하며 User와 관련된 CRUD메소드를 자동으로 생성합니다.
또한 Optional<User>
객체를 반환하는 findByUserEmail()
메소드를 가지는데, 이 메소드는 사용자가 로그인시 DB에 저장된 User의 정보를 가져오기 위해 사용합니다.
Service(Application) 계층
xxxxxxxxxx
public class UserService {
private JwtUtil jwtUtil;
private UserRepository userRepository;
public void signUp(SignUpRequest signUpRequest) {
verifyDuplicatedUser(signUpRequest.getEmail());
User newUser = User.builder()
.userEmail(signUpRequest.getEmail())
.userPassword(signUpRequest.getPassword())
.token(jwtUtil.createToken())
.build();
userRepository.save(newUser);
}
private void verifyDuplicatedUser(String userEmail) {
if(userRepository.findByUserEmail(userEmail) != null)
throw new IllegalArgumentException("중복된 유저입니다.");
}
public SignInResponse signIn(SignInRequest signInRequest) {
User findUser = userRepository.findByUserEmail(signInRequest.getEmail())
.orElseThrow(() -> new IllegalArgumentException("없는 유저입니다."));
if (! findUser.getUserPassword().equals(signInRequest.getPassword()))
throw new IllegalArgumentException("암호가 일치하지 않습니다.");
return new SignInResponse(findUser.getToken());
}
}
UserService
클래스에는 Presentation계층의 UserController
에서 사용할 signUp(), signIn()
메소드가 존재합니다. Service
계층의 메소드들은 인자로, 자신들이 사용할 객체를 전달받습니다.
xxxxxxxxxx
access = AccessLevel.PROTECTED) (
public class SignUpRequest {
private String email;
private String password;
public SignUpRequest(String email, String password) {
this.email = email;
this.password = password;
}
}
먼저 signUp()
메소드는 SignUpRequest
를 인자로 전달받는데요, 사용자가 회원가입시 요청한 email, password가 담겨 있습니다. 우선, 요청한 email을 인자로 받는 verifyDuplicatedUser() 메소드를 호출하여, 중복된 email이 존재하는지 찾습니다.
중복되지 않는다면, jwtUtil
의 createToken()
메소드를 이용하여 token을 생성하여, 사용자가 요청한 정보와 함께 새로운 User Entity
를 생성하고, UserRepository를 이용해 이를 영속화 합니다.
이번 포스팅에서는, 사용자가 로그인 후, 토큰을 전달받는 과정까지만 해볼 것이므로, 회원가입시 사용자에게 전달될 정보가 없습니다. 따라서 signup의 반환형은 void로 합니다.
xxxxxxxxxx
access = AccessLevel.PROTECTED) (
public class SignInRequest {
private String email;
private String password;
public SignInRequest(String email, String password) {
this.email = email;
this.password = password;
}
}
signIn()
메소드는 SignInRequest
를 인자로 전달받습니다. 우선, 아까 전 UserRepository
에 정의한 findByUserEmail()
메소드를 이용해, 사용자의 요청에 담긴 Email에 해당하는 User가 서버에 존재하는지 확인합니다. 사용자가 존재한다면, 그 사용자의 Password와 비교하여 일치하는 경우, Token
을 가지는 SignInResponse 객체를 반환합니다. (원래 사용자 정보 보호법에 의해, 사용자의 비밀번호를 서버에 저장할 시에는 암호화를 한뒤 저장을 해야합니다. 암호화 시에는 단방향 해시 알고리즘을 통해 관리자또한 비밀번호를 확인할 수 없도록 해야하며, 이때는, 사용자의 요청을 같은 단방향 해시 알고리즘으로 해싱하여 해싱된 값과 저장된 값을 비교하여 로그인 검증을 합니다.)
xxxxxxxxxx
public interface JwtUtil {
String createToken();
void verifyToken(String givenToken);
}
JwtUtil은 UserService
에서 필요로하는 기능을 정의하고 있는 interface로, 구현체는 infra 계층
을 나타내는 디렉토리에 존재합니다. 이는 DIP
를 위한 설계로, Service계층
에 해당하는 로직이 infra 계층
의 로직에 의존하지 않고, infra계층의 로직이 Service계층
에 의존하도록 합니다. 이를 통해 infra 계층 코드의 변화로 부터 service 계층이 자유로울 수 있습니다.
Infra 계층
xxxxxxxxxx
public class JwtUtilImpl implements JwtUtil {
private String TEST_SIGN_KEY = "TESTKEY";
private Date EXPIRED_TIME = new Date(System.currentTimeMillis() + 1000 * 10);
private String ISSUER = "JJY";
public String createToken() {
return JWT.create()
.withIssuer(ISSUER)
.withExpiresAt(EXPIRED_TIME)
.sign(Algorithm.HMAC256(TEST_SIGN_KEY));
}
public void verifyToken(String givenToken) {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(TEST_SIGN_KEY))
.withIssuer(ISSUER)
.build();
verifier.verify(givenToken);
}
}
바로 위에서 말씀드린 JwtUtil의 구현체입니다. auth0 Jwt
라이브러리를 이용했습니다.
auth0 jwt를 사용하기 위해서는, build.gradle
의 dependencies 블록에 등록을 해주어야 합니다.
Presentation(UI) 계층
xxxxxxxxxx
public class UserController {
private UserService userService;
"/signUp") (
public String signUp(SignUpRequest signUpRequest) {
userService.signUp(signUpRequest);
return "Sign Up OK";
}
"/signIn") (
public SignInResponse signIn(SignInRequest signInRequest) {
return userService.signIn(signInRequest);
}
}
UserController
는 RestController로, 사용자의 요청에 따라 적절한 서비스를 실행한뒤, 응답을 반환합니다.
Interceptor
xxxxxxxxxx
public class JwtAuthInterceptor implements HandlerInterceptor {
private JwtUtil jwtUtil;
private UserRepository userRepository;
private String HEADER_TOKEN_KEY = "token";
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
User user = userRepository.findById(Long.parseLong(request.getHeader("userId")))
.orElseThrow(() -> new IllegalArgumentException("없는 유저 입니다."));
String givenToken = request.getHeader(HEADER_TOKEN_KEY);
verifyToken(givenToken, user.getToken());
return true;
}
private void verifyToken(String givenToken, String membersToken) {
if(! givenToken.equals(membersToken)){
throw new IllegalArgumentException("사용자의 Token과 일치하지 않습니다.");
}
jwtUtil.verifyToken(givenToken);
}
}
사용자가 Server에 요청시 특정 url을 제외한 거의 모든 기능은 Token을 필요로합니다. 따라서 이를 각각의 모든 handler에서 검증하는 것이 아니라, handler로 요청이 전달되기 직전인, Interceptor
에서 이를 검증한다면 훨씬 효율적입니다.
우선 HandlerInterceptor
를 구현합니다. 그 후, preHandle()
메소드를 override하고, 이곳에 token 검증로직을 추가하면 됩니다.
preHandle()
이 반환하는 boolean 값에따라 요청이 제어되는데요, verifyToken()
메소드에서 token을 검증하고, 실패한 경우에는 exception을 발생시킵니다. 모든 검증이 완료된 후, true
를 반환하게 되면, 이 요청이 원래의 목적 handler에게 전달됩니다.
Config
public class WebConfig implements WebMvcConfigurer {
private String[] INTERCEPTOR_WHITE_LIST = {
"/signUp/**",
"/signIn/**",
};
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JwtAuthInterceptor())
.addPathPatterns("/*")
.excludePathPatterns("/signUp", "/signIn");
}
}
WebMvcConfigurer
를 구현하여, WebMVC를 편하게 수정할 수 있도록 합니다. addInterceptors()
를 override 하여 앞서 생성한 JwtAuthInterceptor()를 등록하고, Intercept할 url pattern을 지정하고, 마지막으로 제외할 url pattern을 등록하면 끝입니다.
4. 다음으로
'FrameWork > Spring Boot' 카테고리의 다른 글
Spring Boot - Profile에 따른 properties(yml) 파일 설정하기 (2) | 2019.11.28 |
---|---|
Spring Boot - Timezone을 설정한 채로 배포하기(-Duser.timezone을 사용하지 않고) (2) | 2019.10.05 |
SpringBoot - KAKAO AccessToken 발급 받기 (자세한 설명) (3) | 2019.08.31 |
SpringBoot - Let's Encrypt로 무료 SSL인증서를 발급받아 SpringBoot에 적용하기(Https) (3) | 2019.08.08 |
SpringBoot - SpringBoot H2 연결방법(H2 웹콘솔) (4) | 2019.08.05 |