큰일이다. 너무 어렵다! 정말 너무 어려워서 오늘은 반드시 오늘 배운 내용을 정리를 잘 하고 넘어가야겠다.
우선 오늘은 로그인/회원가입을 하는 프로젝트를 어떻게 잘 구현하는지에 대해 배웠다. 기존에 하던 로그인 방식은 DB에 아이디와 비밀번호를 저장하고, 클라이언트로부터 로그인 요청이 들어오면 DB에서 클라이언트에서 받아온 아이디와 비밀번호가 일치하면 로그인이 된다. 이렇게 로그인 된 상태에서 다른 작업을 수행한다~ 이런 간단한 방식으로 구현이 됐었다.
개선된 회원가입/로그인 방식의 개선방향을 간략하게 따져보자
1. DB에 비밀번호가 그대로 저장된다면 보안에 취약하다. DB에는 비밀번호가 평문이 아닌, 암호화된 형태로 존재해야 한다. 외부의 공격으로 부터 비밀번호를 지켜야 하고, 추가적으로 DB에 접근하는 관계자들도 사용자의 비밀번호를 알아선 안되기 때문!
2. 로그인 상태를 유지하는건 서버에 많은 부담을 준다. 따라서 로그인 상태를 쭉 유지 하는 것이 아닌, 한번의 인증을 거친 후 클라이언트에게 인증받은 사용자라는 토큰을 던지고, 클라이언트는 그 토큰을 다시 서버에게 던지면서 통신을 하게 된다. 이렇게 되면 요청시에만 클라이언트와 서버거 연결이 되기 때문에 서버에 부하가 적어진다.
3. 한단계를 더 나아가서 이제는 인증을 담당하는 필터를 하나 씌운다. 이제는 서버에서 토큰이 유효한 토큰인지 인증을 하는 것이 아닌, 필터단에서 토큰의 유효성 인증을 하고, 인증이 된 토큰을 갖는 클라이언트들만 서버로 연결이 될 수 있도록 필터를 추가한다!
4. 회원가입도 제한적으로 받으려고 한다. 예를들어 회원가입시 아이디는 반드시 적어줘야 하며, 이메일은 이메일의 형식(~@~.~)을 갖췄을 때만 회원가입을 승인해주는 것. 만약 요구된 형식대로 정보가 입력 되지 않는다면, 로그인 페이지로 다시 클라이언트를 보내버린다.
5. 마지막으로 관리자 권한이 있는 사용자만 들어갈 수 있는 페이지를 만든다. 이때 관리자가 아닌 일반 사용자가 접근을 할 때는 다른 페이지로 보내도록 한다.
[비밀번호 암호화]
위에 다뤘던 첫번째 문제 사용자는 비밀번호를 평문처럼 작성하지만, DB에서는 암호화되어 저장되어야 한다. 이때 스프링부트의 PasswordEncoder 클래스를 사용하면 된다. 이때 PasswordEncoder는 보통 config 클래스에서 사용되며 Bean으로 관리된다. 비밀번호를 암호화 할 때는
String password = passwordEncoder.encode(requestDto.getPassword());
이런식으로 사용한다. encode()의 인자는 내가 암호화 하고 싶은 값을 넣어주면 된다.
그렇다면 암호화 된 데이터는 $2a$10$PUjbGiUEPt9Oi1EpTjE2o.XP/ZaxQ1rZq5RZUaQhEvTxLJ9Fqhc8O 과 같이 알수 없는 값으로 나온다. 참고로 이 암호는 "1111"이었다. 그렇다면 암호화된 값을 다시 평문으로 복호화 할 수 있을까? 방법은 있겠지만 우리는 복호화가 되지 않는 방식으로 사용한다. 이유는 복호화는 할 필요가 없다. 그렇다면 어떻게 평문의 비밀번호와 암호화된 비밀번호를 비교할까?
if(!passwordEncoder.matches("사용자가 입력한 비밀번호", "저장된 비밀번호")) {
throw new IllegalAccessError("비밀번호가 일치하지 않습니다.");
}
passwordEncoder의 matches() 메서드를 사용해주면 된다. 이렇게 암호화된 비밀번호와, 평문인 비밀번호를 비교하여 비밀번호가 일치하면 true/ 비밀번호가 일치 하지 않을 땐 false를 반환한다.
[JWT]
2.번 개선방향에서 얘기 했듯 서버에선 로그인 상태를 유지하는것은 부담스럽다. 한번 로그인 인증이 완료되면 그후부턴 "인증이 됏다 치고!" 클라이언트에게 새로운 권한을 주게 된다. 이때 그 방식으로는 JWT를 사용했다. 이 방식은 서버에 로그인 정보를 넘기지 않는다. 로그인된 정보를 클라이언트에게 JWT 암호화하여 쿠키에 담아 던진다. 그리고 클라이언트가 JWT암호화가 된 쿠키를 요청하면 "JWT Security Key"를 통해 인증한다. 이런방식은 서버에 로그인 정보를 담을 필요가 없어 진다. (쿠키 -세션 방식의 세션역할을 하지 않아도 됨)
그렇다면 실제 구현 방식에 대해 알아보자. JWT 인증방식의 흐름은 다음과 같다.
클라이언트의 로그인 성공시 ->JWT 생성 -> 서버에서 로그인 정보를 JWT로 암호화 해 쿠키에 담아서 응답. -> 쿠키 저장소에 JWT 로 암호화된 데이터가 담긴 쿠키 저장 -> 클라이언트에서 JWT 정보가 담긴 쿠키를 서버에 요청 -> 서버에서 해당 JWT 값을 갖고 있는 Secret Key를 통해 찾아서 인증 -> 인증된 쿠키에서 사용자 정보 획득.
이때의 JWT는 어떻게 구성이 되어있는지 알아보자.
Header에는 JWT 암호화된 방식의 알고리즘과 토큰 타입이 들어있다. Signature에는 토큰을 인코딩 및 인증검사를 하기 위해 사용되는 키코드가 담겨있다. Payload에는 로그인 정보를 담고 있다.
우선 JWT를 사용하려면 Gradle에 등록해야 한다.
// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
또한 JWT의 Secret Key를 application. properties에 등록을 해주어야 한다.
jwt.secret.key=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg==
우선 JWT을 생성하는 방식에 대해 알아보자.
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
토큰은 String으로 되어 있으니 반환값은 String으로 되어 있다. Jwts.builder() 메서드를 통해 토큰을 생성한다.
setSubject()은 토큰의 제목을 설정해준다. 우리는 토큰의 제목을 사용자 이름으로 지정했다.
claim()은 Payload에 들어갈 정보들의 조각들이 들어간다. 우리는 여기에 사용자 권한을 넣어준다.(관리자 or 일반 유저)
setExpiration()은 토큰의 만료기간을 정해준다.
setIssueAt()은 토큰의 발급일을 정해준다.
compact()메서드가 호출이 되면 토큰은 발행이 된다.
이때 return 값 앞의 BEARER_PREFIX는 토큰의 종류를 구분해준다고 했다. Bearer 인증방식은 쉽게 설명하면 카카오톡이나 구글아이디같은 외부의 아이디로 로그인할 때 인증을 가져오기 위해 사용한다고 생각하면 된다.
따라서 토큰의 앞에는 Bearer 이 들어가있다.(공백까지 포함) 따라서 나중에 토큰을 인증할 때 이 앞에 Bearer 을 떼어주는 메서드도 작성해줘야 한다.
생성된 JWT를 쿠키에 넣는 방법은 다음과 같다.
public void addJwtToCookie(String token, HttpServletResponse res) {
try {
token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
logger.error(e.getMessage());
}
}
우선 토큰의 값들을 URL 규칙에 맞게 인코딩을 해줘야 한다. 따라서 URLEncoder.encode()를 통해 token을 URL에 사용가능하게 인코딩 해주자.
JWT를 담을 쿠키를 생성자를 통해 생성해주자. 이때 생성자에 들어가는 인자에는 토큰의 KEY-VALUE값으로 들어가게 된다.
Cookie의 setPath("URL")메서드는 특정 URL에서만 쿠키를 전송 할 수 있게 해주는 메서드다. 우리는 /를 넣어주어 모든 URL에 전송 할 수 있게 된다. 그리고 HttpServletResponse 객체에 쿠키를 담아서 응답한다.
[필터]
필터는 서버에 들어가기 전 클라이언트의 요청에서 최초와 최종적으로 거쳐가며 정보를 수정/ 부가적인 기능 추가를 해주게 된다. 주로 범용적인 기능들을 넣어준다. 예를들어 사용자 인증(앞으로 로그인은 필터단에서 할게 될 것), 서버의 상태를 로그로 찍기등을 한다.
이때 필터는 한개가 아닌 여러 겹으로 이루어질 수 있다. 필터별로 각각의 기능을 나누어 여러 필터를 씌운다. 이를 Filter Chain이라고 한다.
@Slf4j(topic = "LoggingFilter")
@Component
@Order(1)
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 전처리
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
log.info(url);
chain.doFilter(request, response); // 다음 Filter 로 이동
// 후처리
log.info("비즈니스 로직 완료");
}
}
@Order(1) 어노테이션은 필터의 위치를 말한다.1은 가장 바깥쪽에 위치한 필터라는 뜻
이 필터 클래스는 Filter 인터페이스를 구현한 것. 이필터의 목적은 요청이 들어왔을 때, 요청을 HttpServletRequest로 캐스팅을 해준다. 그리고 요청 URL을 로그로 찍어준다. 그리고 chain.doFilter로 다음 필터로 넘기게 된다. 그리고 다음 필터들과 서버들의 결과 값을 받은 후 마지막에 클라이언트에게 전해주기 전 ("비즈니스 로직 완료")로그를 찍어준다.
@Slf4j(topic = "AuthFilter")
@Component
@Order(2)
public class AuthFilter implements Filter {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
public AuthFilter(UserRepository userRepository, JwtUtil jwtUtil) {
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
if (StringUtils.hasText(url) &&
(url.startsWith("/api/user") || url.startsWith("/css") || url.startsWith("/js"))
) {
// 회원가입, 로그인 관련 API 는 인증 필요없이 요청 진행
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
// 나머지 API 요청은 인증 처리 진행
// 토큰 확인
String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest);
if (StringUtils.hasText(tokenValue)) { // 토큰이 존재하면 검증 시작
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if (!jwtUtil.validateToken(token)) {
throw new IllegalArgumentException("Token Error");
}
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
User user = userRepository.findByUsername(info.getSubject()).orElseThrow(() ->
new NullPointerException("Not Found User")
);
request.setAttribute("user", user);
chain.doFilter(request, response); // 다음 Filter 로 이동
} else {
throw new IllegalArgumentException("Not Found Token");
}
}
}
}
<여기서부터 정신이 오락가락>
@Order(2)이므로 클라이언트로부터 2번째로 위치한 필터. 이 필터의 목적은 사용자 인증이다. 위에서 언급했듯 이제는 필터단에서 토큰 인증을 마친다. 따라서 인증이 필요 없는 회원가입/로그인 페이지는 인증하지 않는다(인증 토큰을 날리기 전).인증하지 않는 방법은 해당 URL을 그냥 지나칠 수 있도록 doFilter()을 해버린다.
인증이 필요한 URL로 진입하려고 하면(인가되지 않은 URL이면)요청받은 Dto에서 토큰을 찾아주고, 토큰 속에 jwt를 찾는다. JwtUtil에서 선언한 validateToken()메서드로 유효한 토큰인지 확인한다. 유효한 토큰이라면 토큰의 정보를 통해 사용자를 찾아서 다음 필터로 사용자 정보를 담아 넘겨준다.
오늘 정말 어려웠다. 사실 필터까지는 이해가 쉬웠는데 뒤에가 너무 어려웠다. 오늘 마무리 하고 싶었지만 도저히 머리가 돌지 않는다.. 내일 아침 다시 Spring security와 valid까지 한번씩 코드를 이해하고 넘어가도록 하자.
스프링을 배우면서 가장 크게 느끼는게 스프링을 일종의 재료를 어떻게 쓸지를 이해하는 것. 자바를 배울 때는 도구를 배우는 느낌이었다. 이것들이 어떻게 작동하는지에 초점을 두었다면 이제는 어떤 것들을 어떻게 가져와서 쓸지, 가져와서 쓸 때 그것들은 어떤 규칙을 가지고 있는지, 어떤 장점과 단점을 갖는지를 파악하고, 상황에 맞는 재료들을 가져다가 잘 구축하는 것이 스프링을 쓰는 방식인 것 같다. 그래서 스프링을 프레임워크라고 하는구나! 라고 느꼈다. 스프링의 정의가 정말 잘 와닿는 하루였다. 이제서야 로그인 페이지를 만드는데, 아직도 너무 어렵고 이해해야 할 것들이 많다. 하지만 그래도 오늘 마무리를 지으며 정리를 하니 머리속에 그림이 그려진다. 앞으로 어려운 것이 나오면 이렇게 천천히 분석해보자.