JSP, Thymeleaf: 서버에서 모든 데이터를 만들어서 브라우저에 전송하는 SSR 방식
요즘은 이런 SSR 방식이 아닌, CSR(Client Side Rendering) 을 사용하고,
점차 하나의 단독적인 애플리케이션으로 동작하는 SPA(Single Page Application)의 형태로 변화 중이다.
따라서 최근의 서버는 클라이언트가 원하는 XML, JSON 데이터를 제공하는 API 서버 방식이 주류이다.
[API 서버]
요청받은 데이터만을 제공하는 서버
데이터만을 전달함으로써, 클라이언트 영역이 어떻게 구현되는지 상관 없이 구현할 수 있고,
클라이언트에서 자유롭게 전달 받은 데이터를 사용할 수 있어 유동성이 좋다.
이번 글에선 JSON을 이용하는 API 서버의 구성 방법에 대해 알아보자.
API 서버의 보안은 Spring Security로 처리하고, 인증은 JWT(JSON Web Token)을 사용하여 구현할 것이다.
우선 ClubMember라는 클럽 내의 사람들의 정보와 ManyTO One 관계를 가진 Note( 글 객체) 라는 엔티티를 만들었다.
그리고 해당 엔티티를 담을 DTO 객체를 생성한다.
이제 Rest방식의 컨트롤러를 작성해주자.
JSON 데이터를 수신하면, NoteDTO로 변환하여 작업을 처리하고, 처리완료된 note의 번호와 성공 메세지를 리턴한다.
Rest방식의 데이터 송, 수신에 대해 알아보기 위한 글이기에 Service와 Repository 구현은 스킵하겠다.
이제 잘 작동하나 확인해보자.
하지만 여기서 굳이 Thymeleaf 등의 통해 View를 완전히 구현할 필요 없이, Rest 방식의 테스트 도구를 사용해서 간단하게
요청 / 응답의 결과를 확인 할 수 있는 확장 프로그램을 사용하면 편하다.
아래와 같이 요청을 보내면
아래와 같이 생성되는 Notes의 num을 확인 가능하다.
이번엔 url에 담긴 num 정보를 통해 매칭되는 NoteDTO 객체를 반환하는 컨트롤러를 구현해보자.
이번에도 YARC를 사용해서 get 요청을 보냈지만, Get 방식은 Post와 다르게 그냥 브라우저에 url을 쳐도 확인 가능하기에 꼭 도구를 사용할 필요는 없다.
아까 Post방식의 요청을 통해 생성한 게시글을 조회해보자. ( num =1 로 생성되었다 )
그럼 아래와 같이, 해당 num을 가진 NoteDTO 객체가 Reponse된다.
이번엔 email 정보를 get 방식으로 전달받아 해당 email정보르 사용자를 식별하여 해당 사용자가 작성한 모든 글을 조회해보자. 파라미터의 String email은 @RequestParam 어노테이션이 생략되어 있기에, url에 들어온 email 정보가 파라미터 email에 들어가게 됩니다.
url에 "/all?email= 이메일" 을 더해 이메일 정보를 get 으로 보내면, getList 파라미터 email에 이메일 정보가 String 타입으로 들어가게 된다.
결과로, 해당 멤버가 작성한 모든 Note 정보가 JSON 형식으로 Response된다.
이번엔 Delete, Modify 작업을 처리해보자.
Response에 "Modified", "removed" 같은 문자를 출력할 것이기에, 응답 포맷은 단순한 문자열인
MediaType.TEXT_PLAIN_VALUE로 지정하자.
Delete
Modify
여태 작성한 '/notes' 라는 경로는 외부에서 데이터를 주고 받기 위한 경로인데, 이것을 외부 제약 없이 호출하는 것은
서버에 상당한 부하를 주기에, 인증을 거친 사용자에게 한정하여 이러한 서비스를 제공하도록 만들어보자.
웹 애플리케이션에서 쿠키, 세션을 사용하면, 동일한 사이트에서만 동작하기에, API 서버 처럼 외부에서 데이터를 자유롭게 주고 받지 못한다.
따라서 API 서버를 사용하고, 외부에서 API 호출시, 인증 키 혹은 인증 정보를 같이 전송하여 처리하면 된다.
예를 들어, 이전의 OAuth 게시글에서 OAuth API 를 사용할 때, 자신의 고유 key를 같이 전송하고,
이를 통해 해당 요청이 정상적인 사용자임을 인증하는 방식처럼 동작한다고 생각하면 된다.
이러한 인증 key는 Token이라고도 불리며, 이번 글에선 JWT(JSON Web Token)을 사용해서 처리할 것이다.
이것을 사용해서 외부에서 특정 API를 호출할 시, 인증에 사용할 토큰을 같이 전송하고, 서버에서 이를 검증하는데,
이 과정에서 특정 URL 호출 시 전달된 토큰을 검사할 Filter 가 필요하다.
이 Filter를 스프링 시큐리티를 통해 사용자가 작성하고, 시큐리티 동작의 일부로 추가하는 것으로 처리해보자.
[OncePerRequestFilter]
추상 클래스로 제공되는 필터이고, 매번 동작하는 기본적 필터이다.
해당 추상 클래스를 extends 하여 새로운 필터를 생성해보자.
OncePerRequestFilter를 상속받고, log를 출력한 뒤, filterChain.dofilter() 메서드를 통해 다음 필터로 넘어간다.
이제 생성한 필터를 SecurityConfig( @ Configuration 어노테이션으로 빈에 등록할 수 있게 만든 Config 클래스) 에 등록해보자.
이제 '/notes/2' 나 스프링 시큐리티에서 .permitAll() 을 적용한 URL 을 통해 URL을 호출하면,
아래와 같이 적용한 필터가 사용된 것을 확인할 수 있다.
로그를 보면 알겠지만, 현재는 생성한 APICheckFilter라는 필터는 맨 마지막에 적용되었다.
만약 이 필터의 순서를 조절하고 싶다면, 기존에 있던 특정 필터의 이전이나 다음에 동작하도록
전 후 관계를 설정할 수 있다.
예를 들어 유저의 아이디와 패스워드를 기반으로 동작하는 필터인 UsernamePasswordAuthenticationFilter의 이전에
동작하도록 만들고 싶다면, 다음과 같이 작성할 수 있다.
실행결과는 아래와 같다.
addFilterBefore 외에도, 아래와 같은 메소드들을 통해 생성한 필터의 동작 순서를 수정할 수 있다.
하지만 우리가 추가할 APICheckFIlter는 오직 "/notes/~" 로 시작하는 경우에만 동작하게 구현해야한다.
(기존 로그인과 다르게 토큰 기반으로 구현할 것이기에)
이를 위해, 우리는 AntPathMatcher라는 것을 사용할 것이다.
sql을 사용해본 사람이라면, Like "A%" 은 A~~~~~ 라는 문자열을 체크한다는 것을 알고 있을 것이다.
이것처럼 ?, *, ** 이 3가지 기호를 사용해서 어떠한 문자열에 패턴에 맞는기 검사하는 메소드이다.
아래는 생성한 필터에 AntPathMatcher를 추가한 코드이다.
이제 SecurityConfig 파일에 승인할 패턴을 String 타입 파라미터로 전달하는 추가된 생성자를 적용해보자.
? : 1개의 문자와 매칭
* : 0개 이상의 문자와 매칭
** : 0개 이상의 디렉토리와 매칭
으로 치환되기에, 위의 "/notes/**/*" -> /notes/~~~~ 인 url로 매칭되어 필터를 통과하게 된다.
만약 /notes/~~~ 의 형식이 아닌 url이 들어오면, antPathMatcher.match() 의 결과가 false로 도출되어
로그가 출력되지 않는다.
여기까지, "/notes/~~" 에 대해 동작하는 Filter에 대해 작성하였다.
이제 API를 통한 로그인, 그리고 인증 처리에 대해 살펴보자.
[ApiLoginFilter]
특정 URL로 외부에서 로그인 가능하고, 성공하면 클라이언트가 Authorization 헤더의 값으로 이용할 데이터를 전송하는 필터를 만들어보자. 이번엔 전과 다르게 AbstractAuthenticationProcessingFilter라는 추상 클래스를 상속받아 작성하자.
AbstractAuthenticationProcessingFilter는 이름에서 유추할 수 있듯이, 추상 클래스이고,
attemptAuthentication()이라는 추상 메서드와 문자열로 패턴을 받는 생성자가 기본으로 필요하다.
email 값을 파라미터로 받고, 만약 해당 값이 null( 존재하지 않는다) 이면 에러를 트리거한다.
이제 Config 파일에서 AbstractAuthenticationProcessingFilter를 사용하기 위해선 authenticationManager가 필요한데,
스프링 3.0 이전에는 WebSecurityConfigurationAdapater에 authenticationManger 변수를 사용하는 것으로 가능했다.
하지만, 스프링 3.0 이후, 해당 클래스가 deprecated 되었기에, 3.0 이후의 버전 사용자는 이제 아래와 같이 Builder를 통해
Build해주어야한다.
이제 /api/login url에 email 정보 없이 접근해보자.
만약 url에 email 이라는 값을 파라미터로 전달하면 아래와 같이 올바르게 진입 가능하다.
앞에서도 말했지만, 특정 API를 호출하는 클라이언트는 다른 서버나 Application으로 실행되기에 쿠키, 세션을 사용할 수 없기에 API를 호출하는 Request를 전송할 때, Http 헤더 메세지에 특별한 key값을 지정해서 전송하는 것으로 정상적인 사용자인지 판별한다. 이때 사용하는 헤더가 Authorization 헤더이다.
예를 들어, 아까 생성한 ApiCheckFilter에서 Authorization 헤더를 추출하고, 헤더의 값이 "1234" 인 경우에만 인증되게 만들고 싶다면 어떻게 할까?
이전에 생성한 ApiCheckFilter에 아래와 같은 메소드를 추가하여 Header의 값이 적절한지 확인해보겠다.
이후 해당 메소드에서 True 값이 나올때만 doFilter() 메소드를 통해 넘어가도록 구현해보자.
[결과]
Authenticatoin 헤더에 "1235" 라는 다른 인증 값을 넣었을 때, 혹은 Authentication 헤더가 없을 때는 아래와 같은 결과가 나온다.
데이터가 전달되지는 않지만, 분명 "에러" 를 검출해야 되는 상황에서도 200( 정상 ) 메세지가 출력된다.
이는 앞서 생성한 ApiCheckFilter가 스프링 시큐리티가 사용하는 쿠키, 세션을 사용하지 않기에 생기는 문제이다.
따라서 간단하게 ApiCheckFIlter에서 JSON 포맷으로 에러메세지를 전송하는 것으로 에러를 표기하는 것으로 Error Handling을 해주자.
[ApiCheckFilter]
이제 로그인 처리를 수행하는 ApiLoginFilter로 다시 돌아가서 올바르게 동작하도록 수정해보자.
정상적인 동작을 위해선, 내부적으로 AuthenticationManager를 가지고 동작하도록 수정해야하는데, 이 안의
authenticate() 는 파라미터와 리턴 타입 모두 Authentication 타입이다.
UsernamePasswordAuthenticationToken
ApiLoginFilter를 수정하자.
이메일 ,password 정보를 url 에서 받아오고, UsernamePasswordAuthenticationToken으로 토큰화 하여
AuthenticationManager의 authenticate() 메소드의 파라미터로 전달하는 것으로 로그인 인증을 진행한다.
이제 아래의 url 처럼 아이디, 비밀번호 정보를 파라미터로 전달해보자.
http://localhost:8080/api/login?email=user10@naver.com&pw=1111
결과화면이다. Mapping한 Controller를 설정하지 않아 no explicit mapping이 나오긴 하지만, 올바르게 로그인 정보가 들어갔다.
**여기서는 비밀번호까지 묶어서 토큰화하여 Get 방식으로 전달했지만, Get방식은 정보를 모두 노출하기에,
꼭 Post 방식을 사용하여 Body에 정보를 숨겨야하며, 비밀번호 파라미터의 암호화 역시 진행된 상태로 전달해야한다.
하지만 간편성을 위해 암호화, Post 방식 둘다 사용하지 않았다.
이제 ApiLoginFilter를 통한 직접 인증처리를 진행했으니, 이에 대한 인증 성공/ 실패에 대한 핸들러를 작성하여 처리해보자.
Fail/Success Handler를 별도의 클래스로 만들어 처리할 수 도 있고, 앞서 ApiLoginFilter가 상속받은
AbstractAuthenticationProcessingFilter의 fail,successhandler를 Override 하여 처리할 수 도 있기에
Fail의 핸들러는 별도의 Class로 구성하고, Success의 핸들러는 Override하여 구현해보겠다.
[FailHandler]
이제 올바르지 않은 인증 정보를 전달해보면, 아래와 같이 fail 상황을 handle한다.
[SuccessHandler]
이번엔 AbstractAuthenticationProcessingFilter 인터페이스에 존재하는 메소드를 Override하여 SuccessHandler를 구현했다.
아래와 같이 로그인 성공시의 상황을 log를 통해 확인하는 것으로, 로그인 성공시의 핸들러가 올바르게 동작함을
볼 수 있다.
** 만약 Failt Hander도 Override하고 싶은 경우,
unsuccessfulAuthentication
위의 메소드를 Override하면 되고, 파라미터로 들어온 "failed" 가 에러 메세지를 담고 있는 AuthenticationException 이다.
[JWT]
인증에 성공한 뒤, 사용자가 '/notes/xxx' 와 같은 API를 호출하면 적절한 데이터를 만들어서 인증 헤더에 넣는 것으로
인증을 수행하는데, 이 인증 헤더에 넣을 인증 값을 JWT를 통해 생성하겠다.
인증에 성공한 사용자에게 특수한 문자열(JWT)를 제공하고, API 호출 시 해당 문자열을 읽어 해당 Request가 정상적인이 확인하는 용도로 사용하는 것이다.
위 JWT 문자열을 보면 "." 으로 구분되어 있는데, 이 "." 을 통해 3개의 파트로 나누어진다.
- Header: 토큰 타입, 알고리즘을 의미 ( 주로 RSA, HS256 )
- Payload: name, value의 순서쌍인 Claim을 모아둔 객체이다.
- Signature 헤더의 인코딩 값, 정보의 인코딩 값을 합쳐 비밀키를 만든 뒤, 해시 함수로 처리된 결과
JWT는 Header와 Payload를 단순히 Base64를 통해 인코딩한 결과이기에, 누군가가 디코딩하여 내용물을 알아낼 여지가 있다.
따라서 마지막에 Signature를 이용한 암호화 값을 같이 사용하여 암호화할 때, "비밀 키"를 모르면 검증할 수 없는 점을 통하여 이러한 문제점을 방지한다.
JWT를 직접 구현하여 이용할 수 도 있고, Spring Security OAuth에서 제공하는 클래스를 사용해도 되지만,
이번에는 가장 쉬운 라이브러리인 io.jsonwebtoken:jjwt 를 사용하겠다.
implementation 'io.jsonwebtoken:jjwt:0.9.1'
위의 라인을 build.gradle에 추가한 후, JWT를 사용해보자.
아래는 새롭게 추가한 JWTUtil 클래스의 구현 코드다.
@Log4j2
public class JWTUtil {
private String secretKey="shyswy12345678";
private long expire=6*24*30; // 1달간 유효
//JWT 문자열이 노출되면 누구나 모든 내용 확인 가능하기에, 유효기간 설정
public String generateToken(String content) throws Exception{ //JWT 토큰 생성하기.
return Jwts.builder()
.setIssuedAt(new Date()) //시작점
.setExpiration(Date.from(ZonedDateTime.now().plusMinutes(expire).toInstant())) //만료 설정
.claim("sub",content) //name, value의 claim 쌍 여기에 이메일 등 저장할 정보를 "sub"라는 이름으로 추가
.signWith(SignatureAlgorithm.HS256,secretKey.getBytes("UTF-8")) //알고리즘, 비밀키 설정
.compact();
}
public String validateAndExtract(String tokenStr)throws Exception{ //인코딩된 문자열에서 원하는 값 추출
//"sub"의 이름으로 들어갔던 Content의 값을 추출한다.
String contentValue=null;
try{ //DefaultJws를 구하는 과정에서, 유효기간이 만료되었다면 Exception을 트리거한다.
DefaultJws defaultJws =(DefaultJws) Jwts.parser() //입력으로 들어온 인코딩된 String 해독하기.
.setSigningKey(secretKey.getBytes("UTF-8")) //해독에 필요한 비밀 키 넣기
.parseClaimsJws(tokenStr); //입력으로 들어온 인코딩된 String 넣기
log.info(defaultJws);
log.info(defaultJws.getBody().getClass());
DefaultClaims claims=(DefaultClaims) defaultJws.getBody(); //name,value의 claim쌍 추출
log.info("---------------------------------");
contentValue=claims.getSubject(); //content 값 추출
}catch (Exception e){
e.printStackTrace();;
log.error(e.getMessage());
contentValue=null; //초기화
}
return contentValue;
}
}
우리가 사용자를 식별하는 email 값을 content로 저장한다고 가정해보자.
generateToken() 메소드의 파라미터로 email 값을 저장한 String을 넘겨주면, 만료기간, 해독에 쓰일 비밀키,
그리고 저장할 content를 name, value의 Claim 순서쌍으로 만들어서 JWT 토큰으로 리턴해준다.
그리고 validateAndExtract를 통해 인코딩된 String을 파라미터로 넘겨주면, content를 추출하여 리턴한다.
이제 올바르게 JWT토큰이 생성되고, 해독되는지 Test 해보자.
public class JWTTest {
private JWTUtil jwtUtil;
@BeforeEach
public void beforeTest(){
jwtUtil=new JWTUtil();
}
@Test
public void testEncode() throws Exception{
String email="user10@naver.com";
String jwtString=jwtUtil.generateToken(email);
System.out.println("encode: "+jwtString);
String ans=jwtUtil.validateAndExtract(jwtString);
System.out.println("decode: "+ans);
}
}
아래는 결과이다.
encode: eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2NzUwNDE5MTAsImV4cCI6MTY3NTMwMTExMCwic3ViIjoidXNlcjEwQG5hdmVyLmNvbSJ9.vTjlHWh0yI9DOHJ0giLpxdcJ9zVFM4ga90Go1g1eXFY
10:25:10.510 [Test worker] INFO com.example.clubsite.security.util.JWTUtil - header={alg=HS256},body={iat=1675041910, exp=1675301110, sub=user10@naver.com},signature=vTjlHWh0yI9DOHJ0giLpxdcJ9zVFM4ga90Go1g1eXFY
10:25:10.510 [Test worker] INFO com.example.clubsite.security.util.JWTUtil - class io.jsonwebtoken.impl.DefaultClaims
10:25:10.510 [Test worker] INFO com.example.clubsite.security.util.JWTUtil - ---------------------------------
decode: user10@naver.com
generateToken을 통해 암호화가 완료되고, validateAndExtract로 올바르게 원래의 이메일 값이 해독되는 것을 볼 수 있다.
jwt.io를 통해 암호화된 JWT 토큰을 좀 더 자세히 분석해보자.
다음과 같이 설정한 값들이 올바르게 추출되는 것을 확인할 수 있다.
이번엔 만료기간에 대해 확인해보자.
우선 JWT 토큰의 만료기간을 임시적으로 1초로 설정하자.
이제 생성한 JWT 토큰이 올바르게 동작한다는 것을 알았으니,
이전에 생성한 ApiLoginFilter, ApiCheckFilter 등에 적용하여 구성한 API에 대한 인증 처리를 진행해보자.
우선 ApiLoginFilter에 JWT를 적용하자.
우리는 인증에 성공한 사용자에게 JWT토큰을 발행하여 content를 확인할 권한을 줄 것이기에,
SuccessHandler에서 Token을 발행하도록 처리한다.
로그인 인증 성공 시 JWT 토큰을 발행하는 것에 성공했으니, 이제 외부에서 접근 시, 발행 받은 JWT 토큰을 통해
접근을 허가 받는 작업을 ApiCheckFIlter를 통해 구현해보자.
기존에 ApiCheckerFilter에서 "1234" 라는 임의의 인증 헤더 값을 JWTUtil의 validateAndExtract로 Decode하는 것으로 변경하자.
이제 SecurityConfig에 JWTUtil을 추가하자.
이제 기능을 테스트 해보자.
1) 로그인 인증 성공 시, JWT 토큰을 발행하는가?
http://localhost:8080/api/login?email=user10@naver.com&pw=1111
위와 같이, DB에 존재하는 유저정보로 APILoginFilter를 사용하여 로그인 시도시 아래와 같이 JWT 토큰이 발행된다.
2) 발행받은 JWT토큰을 인증헤더에 넣어서 인증이 올바르게 이루어지는가?
ApiCheckFilter를 통해 "/notes/~" 에 접근 시도시, 인증 헤더가 올바르게 통과하고, 잘못된 인증 정보, 혹은 인증 정보가 없을 시는 접근이 반려되는지 테스트 해보자.
올바르게 인증정보를 추가 후, Request를 보낸다.
만약 JWT토큰을 바꾸거나, Authorization Header를 제거하면, 아래와 같이 오류가 발생한다.
이와 같은 방식으로 외부 API 서버에 대한 인증을, 인증 헤더에 JWT토큰을 넣는 것으로 해결할 수 있다.
이제 REST 방식의 테스트는 모두 성공했지만, 외부에서 Ajax를 이용해서 API를 사용하려면 마지막으로
CORS(Cross-Origin-Resource-Sharing) 을 처리해야한다.
CORS: Origin(브라우저) 에서 Cross-Origin(다른 출처)의 리소스를 공유하는 방식이다.
same-origin이란 scheme(프로토콜), host(도메인), 포트가 같다는 말이며, 이 3가지 중 하나라도 다르면 cross-origin이다.
이를 위해 CORS 필터를 앞선 ApiCheckFilter, ApiLoginFilter 처럼 클래스를 생성하여 처리하자.
이렇게 JSON을 이용하는 API 서버에서 CRUD를 구성하고, API 서버의 보안을 Spring Security로 처리하며, 인증을 JWT를 사용해서 구현해보았다.
'Spring boot' 카테고리의 다른 글
[JPA] Part2 (심화 매핑) (0) | 2023.03.10 |
---|---|
[JPA 기본] Part1 (영속성 컨텍스트, 기본 매핑 ) (0) | 2023.03.08 |
Spring Boot 컨트롤러와 Client간의 관계 정리 (0) | 2023.01.27 |
Spring 소셜 로그인 처리 [OAuth] (0) | 2023.01.26 |
Spring security란? (0) | 2023.01.25 |