| 인증 방식의 종류와 한계점
1. 인증 방식의 종류
데이터 | 저장 위치 | 방식 | |
Cookie | key-value 형태의 문자열 | 브라우저 (or 로컬 메모리) |
(1) 사용자가 로그인 요청을 보낸다. (2) 서버는 Cookie 정보를 header의 Set-Cookie에 담아 응답한다. (3) 사용자가 요청할 때마다 Cookie를 header에 담아 보낸다. (4) 서버는 쿠키에 담긴 정보로 클라이언트가 누군지 식별한다. |
Session | session id와 value가 든 세션 객체 * value는 map 형태로, 세션 생성 기간, 마지막 접근 기간, User 속성 등을 담는다. |
서버 (메모리, 로컬 파일, DB) |
(1) 사용자가 로그인 요청을 보내면 세션이 서버에 저장된다. 이때, 세션의 식별을 위한 Session Id가 발급된다. (2) 서버는 Session Id를 header의 Set-Cookie에 담아 응답한다. (3) 사용자가 요청할 때마다 Session Id를 header에 담아 보낸다. (4) 서버는 Session Id를 서버의 Session Id와 비교하여 인증한다. |
Token | 유일한 토큰 (JWT 등) * 쿠키와 세션이 없는 App에서 가장 많이 사용된다. |
클라이언트** | (1) 사용자가 로그인 요청을 보낸다. (2) 서버는 유일한 토큰을 발급하여 응답한다. (3) 사용자는 쿠키 및 브라우저에 해당 토큰을 저장하고, 요청할 때마다 토큰을 header에 담아 보낸다. (4) 서버는 토큰을 검증하고 요청에 응답한다. |
** Token은 쿠키, 세션 스토리지, 로컬 스토리지에 저장할 수 있다.
2. 한계점
한계점 | |
Cookie | - 보안에 취약하다. - 쿠키에는 용량 제한이 있어 많은 정보를 담기 어렵다. - 웹 브라우저마다 쿠키 지원형태가 달라 브라우저간 공유가 불가능하다. - 쿠키의 사이즈가 커질수록 네트워크 부하가 심해진다. |
Session | - 세션은 쿠키와 달리 요청이 외부에 노출되더라도, 세션 ID 자체만으로는 개인정보를 담고 있지 않는다. - 하지만, 해커가 세션 ID를 탈취하여 클라이언트인척 위장할 수 있다는 한계가 있다. - 다만, 이는 서버에서 IP특정을 통해 해결할 수 있다. - 서버의 별도의 저장공간을 사용하기 때문에 요청이 많으면 서버에 부하가 심해질 수 있다. |
Token | - 쿠키/세션과 달리 토큰 자체의 데이터 길이가 길어, 인증 요청이 많아질 수록 네트워크 부하가 심해진다. - Payload 자체는 누구든 해독할 수 있기 때문에 민감 정보는 담을 수 없다. - 토큰을 탈취당하면 대처하기 어렵다. (따라서, 사용 기간을 제한하는 것이 필요하다.) |
3. Session vs Token
세션(서버) 기반 인증 시스템 | 토큰 기반 인증 시스템 | |
저장 위치 | 서버의 로컬 메모리, DB 등 | 클라이언트의 브라우저, 로컬 메모리 |
상태 유지 | stateful | stateless * 보통 유효기간을 지정한다. |
인증 방식 | 요청의 session id와 서버의 session id 비교 | 요청의 token를 통해 사용자 여부 확인 |
한계점 | 서버에 부하 가능성 | 네트워크 부하 가능성 |
| JWT (Json Web Token)
- 인증에 필요한 정보들을 암호화시킨 JSON 타입의 토큰을 말한다.
- JSON 데이터를 Base64 URL-safe Encode를 통해 인코딩하여 바이트단위로 직렬화한 토큰이다.
- JWT의 구조는 Header, Payload, Signature로 이루어져 있다.
구조 | 내용 |
Header | 토큰 타입 및 알고리즘 등의 메타정보 |
Payload | 유저 정보, 유효 기간 등의 시스템에서 실제로 사용될 정보 - key-value 형식으로 이루어진 한 쌍의 정보를 Claim이라고 한다. |
Signature | secret key를 통한 서명 |
1. Payload의 Claim
- Claim은 미리 정의된 클레임(Registered Claim), 사용자 정의 공개용 클레임(Public Claims), 사용자 지정 보호된 클레임(Private Claims)로 구분할 수 있다.
- Registered Claim에는 Issuer(발행자), Expiration Time(만료시간), Subject(제목), IssuedAt(발행시간), JWI ID 가 있다.
2. Signature
- 헤더에서 적용한 알고리즘 방식(ex. HS256)을 활용해 데이터를 암호화하는 것을 말한다.
- 암호화 : (=인코딩) 평문(plain text)를 암호기술을 통해 암호문(ciphertext)로 변환하는 과정 - 복호화 : (=디코딩) 암호문을 다시 평문으로 복원하는 과정 |
- JWT 공식 사이트에 가면 암호화와 복호화를 직접 해볼 수 있다.
3. JWT 인증과정
(1) 사용자가 아이디와 비밀번호를 작성하여 로그인을 요청한다.
(2) 서버에서 JWT로 된 Access 토큰과 Refresh 토큰을 발급한 후 이를 쿠키에 담아 응답한다.
(3) 사용자는 Access 토큰을 통해 요청을 보낸다.
(4) Access 토큰에 문제가 없으면 정보를 응답한다.
(5) Access 토큰의 유효기간이 만료된 경우, Refresh 토큰을 통해 Access Token을 재발급한다.
4. Access Token과 Refresh Token
- JWT를 어디에 저장할 것인가? 에 대한 이슈를 해결하기 위해 여러 자료를 보았다.
- 클라이언트의 브라우저(쿠키) 또는 로컬 스토리지에 저장하는 방법이 크게 두가지 제시되었는데,
(1) 쿠키 : httpOnly를 통해 XSS를 막을 수 있지만 완전하지 않고, CSRF에 취약하다.
- 스프링 시큐러티와 함께 사용할 경우, csrf.disabled() 를 해야지만 쿠키 사용이 가능하다.
- 이렇게 되면 csrf를 막기 위해 스프링 시큐러티에서 제공하는 토큰을 사용할 수 없다는 단점이 있다.
(2) 로컬 스토리지 : 사용자단의 디스크에 JS파일로 저장하는 방식을 말하는 것 같다.(확실히는 해봐야알 것 같다.)
- 이 방식은 CSRF를 상대적으로 막을 수 있다는 장점은 있지만,
- js를 통한 XSS 공격은 막기 어려워진다.
- 그래서 대안적으로 추천하는 방식이, Access Token은 js에, Refresh Token은 cookie에 담는 방식이다.
- 이 부분은 사실상 더 배워서 프론트엔드와 협력해서 해야 하지 않나 싶어, 나는 코드 작성 시 더 깊게 들어가지는 않고,
Access Token을 쿠키에 일정 시간동안 저장하는 정도로 마무리했다..
Access Token | 실제 사용자 정보를 담고 있는 토큰 |
Refresh Token | Access Token을 재발급해주기 위한 토큰 |
| 스프링에서 시큐러티와 함께 JWT 사용하기
1. Signiture(서명)을 위한 Secret Key를 Base64를 통해 만든다.
(1) Shell을 통해서 인코딩
// Mac
echo 'your-secret-key' | base64
// Window
powershell "[convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('your-secret-key'))"
(2) Java에서 직접 작성
private final String signKey = Base64.getEncoder().encodeToString("petbutler".getBytes());
2. JwtTokenProvider 작성
package com.example.petbutler.security.authentication;
import com.example.petbutler.service.UserService;
import com.mysql.cj.util.StringUtils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
@Value("{spring.jwt.secret}")
public String secretKey;
public static final String TOKEN_HEADER = "Authorization";
public static final String TOKEN_PREFIX = "Bearer:";
public final static String KEY_ROLES = "roles";
public final static long TOKEN_EXPIRATION_TIME = 1000 * 60 * 60 * 5; // 5시간
private final UserService userService;
/**
* 토큰 생성
*/
public String generateToken(String email, List<String> roles){
Date now = new Date();
Date expiredDate = new Date(now.getTime() + TOKEN_EXPIRATION_TIME);
Claims claims = Jwts.claims().setSubject(email);
claims.put(KEY_ROLES, roles);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiredDate)
.signWith(SignatureAlgorithm.HS512, secretKey)
.compact();
}
//TODO : refresh token 별도 생성 필요
/**
* 토큰을 통해 Authentication 추출
*/
public Authentication getAuthentication(String token) {
UserDetails userDetails = userService.loadUserByUsername(getEmail(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
/**
* 토큰의 Claim에서 email 추출
*/
public String getEmail(String token) {
return parseClaim(token).getSubject();
}
/**
* Request Header의 Cookie에서 토큰 추출 후 이메일 반환
*/
public String getEmail(HttpServletRequest request){
String token = resolveTokenFromRequest(request);
if (!StringUtils.isNullOrEmpty(token)) {
return getEmail(token);
}
return null;
}
/**
* 토큰 유효성 확인
*/
public boolean validateToken(String token) {
if (StringUtils.isNullOrEmpty(token)) {
return false;
}
Claims claims = parseClaim(token);
return !claims.getExpiration().before(new Date());
}
/**
* 토큰의 payload에서 Claim 추출
*/
private Claims parseClaim(String token) {
try {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
} catch(ExpiredJwtException e) {
return e.getClaims();
}
}
/**
* Request Header의 Cookie에서 토큰 추출
*/
public String resolveTokenFromRequest(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (ObjectUtils.isEmpty(cookies)) {
return null;
}
Optional<String> token = Arrays.stream(cookies)
.filter(c -> c.getName().equals(TOKEN_HEADER))
.map(c -> c.getValue())
.findFirst();
if (token.isPresent()) {
return token.get().substring(TOKEN_PREFIX.length());
}
return null;
}
}
3. 컨트롤러 및 서비스 작성
(1) 회원 컨트롤러에서 로그인 인증 및 JWT 토큰 생성 작성
/**
* 로그인 (JWT 인증 토큰 생성)
*/
@PostMapping("/sign-in")
public String signIn(UserSignInForm userSignInForm, HttpServletResponse response){
// 아이디와 비밀번호 확인
User user = userService.authenticate(userSignInForm);
// 토큰 생성 후 반환
String token = jwtTokenProvider.generateToken(user.getEmail(), user.getUserRoles());
// 응답 헤더의 쿠키에 HttpOnly로 토큰 저장
Cookie cookie = new Cookie("Authorization", String.format("Bearer %s", token));
cookie.setPath("/");
cookie.setMaxAge((int)JwtTokenProvider.TOKEN_EXPIRATION_TIME);
cookie.setHttpOnly(true); // 서버만 쿠키에 접근
response.addCookie(cookie);
return "redirect:/";
}
(2) 회원 서비스에서 로그인 인증 코드를 작성
/**
* 로그인 시 아이디, 비밀번호 일치 확인
*/
@Override
public User authenticate(UserSignInForm userSignInForm) {
User user = userRepository.findByEmail(userSignInForm.getEmail())
.orElseThrow(() -> new ButlerUserException(ErrorCode.USER_NOT_FOUND));
if (!passwordEncoder.matches(userSignInForm.getPassword(), user.getPassword())) {
new ButlerUserException(ErrorCode.USER_PASSWORD_NOT_MATCH);
}
return user;
}
4. JwtAuthenticationFilter
스프링 부트 동작 과정 : 필터 -> Dispatcher Servlet -> 인터셉터 -> AOP -> 컨트롤러 |
- 사용자 요청 시 컨트롤러에 도달하기까지 총 네가지의 단계를 거친다. (필터, 서블렛, 인터셉터, AOP)
- 스프링 시큐러티 또한 일련의 필터들이 모인 FilterChain으로 구성되어 있는데,
사용자 요청에서 JWT 토큰이 있는지 확인하는 필터를 사전에 거치도록 우선선위를 정해주면,
JWT 토큰 관련 필터 후에 나머지의 스프링 시큐러티 필터를 진행하게 된다.
사용자 request -> JWT 토큰 필터 -> 스프링 시큐러티 필터 |
- 이 점을 상기한 상태에서 JWT 토큰 필터를 작성하면 다음과 같다.
(1) 먼저 JWT 토큰이 있는지 확인하고 해당 토큰의 유효기간이 남아있는지를 확인한다.
(2) JWT 토큰의 playload에서 username을 추출하여, 아래와 같은 과정을 통해 Authentication을 얻어낸다.
> UserDetailService의 loadUserByUsername메소드를 통해 UserDetails을 얻는다.
> 그런 뒤, UserDetails와 Role정보를 UsernamePasswordAuthenticationToken에 담는다.
* UsernamsPasswordAuthenticationToken은 AbstractAuthenticationToken을 상속하고,
AbstractAuthentictionToken은 Authentication과 CredentialsContainer를 구현한다.
> 위 과정을 하나의 메소드로 만들어 호출하도록 한다.
(3) SecurityContextHolder의 SecurityContext에 Authentication을 setting한다.
(4) FilterChain안에 doFilter(request, response)를 통해 필터 작업을 마무리한다.
package com.example.petbutler.security.authentication;
import com.mysql.cj.util.StringUtils;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 요청 헤더에서 jwt 토큰을 가져온다.
String token = jwtTokenProvider.resolveTokenFromRequest(request);
// 토큰 유효성 파악
if (!StringUtils.isNullOrEmpty(token) && jwtTokenProvider.validateToken(token)) {
// 유효할 경우 유저 정보를 받아온다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext에 Authentication 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
5. 스프링 시큐러티 작성
- 스프링 시큐러티는 기본적으로 쿠키-세션 방식으로 로그인 인증 토큰을 저장한다.
- 아래는 UsernamePasswordAuthenticationFilter가 어떻게 작업을 하는지 보여준다.
- 보고 또 봐도 어려운데,
> JWT필터를 UsernamePasswordAuthenticationFilter 전에 이루어지도록 하고,
> authenticationManagerBean() 을 Bean으로 등록하여
외부에서 AuthenticationManager로 Authentication을 만들 수 있게 하고,
> 스프링 시큐러티에서 http.authorizeRequests().loginForm().login()... 과 같은 별도의 설정 없이
컨트롤러에서 로그인 관련 처리를 해줄 경우 아래의 일련의 과정을 거치지 않고 로그인 인증이 가능했다.
- 스프링 시큐러티는 필터링되는 과정까지 모두 알 필요는 없다고, 공식문서에서 말한다는데,
어디서부터 어디까지 알아야 되는지 난해하다..
package com.example.petbutler.security;
import com.example.petbutler.model.constants.UserRole;
import com.example.petbutler.security.authentication.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
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.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Slf4j
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
/**
* Http Security
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 임시
http.csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.authorizeRequests()
.antMatchers(
"/",
"/user/sign-in",
"/user/sign-up",
"/user/email-auth",
"/user/email-auth/**"
)
.permitAll();
http.authorizeRequests()
.antMatchers("/admin/*", "/admin/**")
.hasAuthority(UserRole.ROLE_ADMIN.name());
http.exceptionHandling()
.accessDeniedPage("/error/access-denied");
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
/**
* Web Security
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers("/", "/*.html", "/**/*.html", "/*.png", "/**/*.png", "/*.jpg",
"/**/*.jpg", "/**/*.css", "/*.js", "/**/*.js");
super.configure(web);
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
[출처 및 참조]
부트캠프 수업 내용 참조
'Framework > 스프링 라이브러리' 카테고리의 다른 글
[Scraping] Jsoup으로 스크래핑하기 (0) | 2022.12.13 |
---|---|
[Scheduler] 스케줄러 사용하기 (feat. 쓰레드, 쓰레드풀) (0) | 2022.11.11 |
[스프링 시큐러티] 스프링 시큐러티 자료 모음 (수정중) (0) | 2022.10.29 |
[TEST] TDD 방식에 대한 자료 모음 (수정중) (0) | 2022.10.28 |
[JSON 파싱] ObjectMapper, Simple-json (0) | 2022.10.24 |