스프링
JWT_사용자 정의 로그인
infobox503
2025. 1. 31. 10:24
<요약>
- 목적
- JWT 로그인 인증을 REST API 형식으로 구축한다.
- 과정
- 프론트 : 로그인 요청
- 백엔드 : 로그인 인증 및 JWT 쿠키 전달
- 프론트 : 각 요청 헤더에 JWT를 담아서 백엔드에게 API 요청
- (전달받은 JWT 쿠키는 자동으로 클라이언트에 저장됨)
- 백엔드 : JwtFilter를 통해 요청 헤더의 JWT를 검증. 그 후, 인증된 사용자에게는 API 제공
<코드 설명>
1. SecurityConfig
- 스프링 시큐리티
- 각 요청에 필터를 적용 할 수 있음
- (검증된 요청자에게만 API 제공)
- 목적
- 각 API 요청마다 JwtAuthenticationFilter를 적용 시키는 것
- (JwtAuthenticationFilter는 요청 헤더에 있는 JWT 토큰을 검증함)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomUserDetailsSuccessHandler customUserDetailsSuccessHandler;
@Autowired
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
CustomUserDetailsSuccessHandler customUserDetailsSuccessHandler){
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.customUserDetailsSuccessHandler = customUserDetailsSuccessHandler;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors
.configurationSource(CorsConfig.corsConfigurationSource())) // CORS 허용
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(request -> request
.requestMatchers(
new AntPathRequestMatcher("/"),
new AntPathRequestMatcher("/api/auth/**"),
new AntPathRequestMatcher("/signup")
).permitAll()
.requestMatchers("/security/user").hasRole("USER")
.requestMatchers("/security/admin").hasRole("ADMIN")
.anyRequest().authenticated()
)
// .formLogin(form -> form
// .loginPage("/login")
// .defaultSuccessUrl("/", true)
// .failureUrl("/login?error=true")
// .usernameParameter("email")
// .successHandler(customUserDetailsSuccessHandler)
// .permitAll()
// )
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
2. JwtAuthenticationFilter
- 목적
- 각 API 요청마다 담긴 헤더의 JWT 토큰 검증
- 과정
- 요청받은 헤더에서 JWT 토큰을 추출한다.
- resolveToken 함수를 통해 헤더에 있는 JWT 토큰 추출
- JWT 토큰을 검증한다.
- JwtTokenProvider 객체를 통해 JWT 토큰을 검증하는 로직을 적용한다.
- 검증된 토큰이면 Authentication에 사용자 정보를 저장한다.
- Authentication에 사용자 정보를 담아서 인증된 사용자임을 증명한다.
- (다음에 진행되는 필터는 Authentication에 담긴 사용자 정보를 보고 인증된 사용자임을 확인 할 수 있다) - QnA에서 자세히 진행
- 그 다음 필터를 진행시킨다.
- JwtAuthenticationFilter의 다음 필터를 진행시킨다.
- Authentication에 사용자 정보를 담아 놓았기 때문에 해당 사용자는 인증을 받을 수 있다.
- 요청받은 헤더에서 JWT 토큰을 추출한다.
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Autowired
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider){
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
//토큰 유효 및 만료 기간 검증
if(token != null && jwtTokenProvider.isValid(token)){
this.setAuthentication(token);
}
log.info("token : {}", token);
filterChain.doFilter(request, response);
}
private void setAuthentication(String token){
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
3. JwtTokenProvider
- 목적
- JWT 토큰 생성 및 검증
- 방법
- jsonwebtoken(12.6)은 jwt를 생성하고 검증할 수 있는 라이브러리를 제공한다.
@Getter
@Slf4j
@Component
public class JwtTokenProvider {
@Value("${jwt.key}")
private String KEY;
private SecretKey SECRET_KEY;
private final long ACCESS_EXPIRE_TIME = 1000 * 60 * 30L;
private final long REFRESH_EXPIRE_TIME = 1000 * 60 * 60L * 24 * 7;
private final String KEY_ROLE = "role";
private final String HEADER_TYPE = "typ";
private final String HEADER_JWT_TYPE = "JWT";
private final String ALGORITHM = "HmacSHA256";
private final String JWT_COOKIE_NAME = "JwtCookie";
@PostConstruct
private void setSecretKey() {
if(KEY.length() < 32){
log.info("JWT 키의 문자열 길이는 32 이상이어야 합니다.");
throw new IllegalArgumentException("The secret key must be at least 32 bytes long.");
}
SECRET_KEY = Keys.hmacShaKeyFor(KEY.getBytes());
}
public Cookie createJwtCookie(String username, Role role){
String jwtToken = createToken(username, role);
Cookie cookie = new Cookie(JWT_COOKIE_NAME, jwtToken);
cookie.setHttpOnly(false); // 클라이언트에서 JavaScript로 접근 불가
// cookie.setSecure(true); // HTTPS에서만 전송
cookie.setPath("/"); // 모든 경로에서 쿠키에 접근 가능
cookie.setMaxAge((int)ACCESS_EXPIRE_TIME);
return cookie;
}
public String createToken(String username, Role role) {
Date beginDate = new Date();
Date endDate = new Date(beginDate.getTime() + ACCESS_EXPIRE_TIME);
return Jwts.builder()
.header()
.add(HEADER_TYPE, HEADER_JWT_TYPE)
.and()
.subject(username)
.claim(KEY_ROLE, "ROLE_"+role)
.issuedAt(beginDate)
.expiration(endDate)
.signWith(new SecretKeySpec(SECRET_KEY.getEncoded(), ALGORITHM))
.compact();
}
public String createToken(Authentication authentication) {
Date beginDate = new Date();
Date endDate = new Date(beginDate.getTime() + ACCESS_EXPIRE_TIME);
//authentication.getName() is username
log.info("authentication.getName() : {}", authentication.getName());
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining());
return Jwts.builder()
.header()
.add(HEADER_TYPE, HEADER_JWT_TYPE)
.and()
.subject(authentication.getName())
.claim(KEY_ROLE, authorities)
.issuedAt(beginDate)
.expiration(endDate)
.signWith(new SecretKeySpec(SECRET_KEY.getEncoded(), ALGORITHM))
.compact();
}
public boolean isValid(String token) {
Claims payload = tokenToClaims(token);
try{
if(payload != null && payload.getExpiration().after(new Date())){
return true;
}
}catch(NullPointerException e){
log.error("payload || getExpiration() is null");
return false;
}
return false;
}
// token을 파싱하는 과정에서 오류가 나지 않으면 검증이 된 것
public boolean validateToken(String token) {
try {
Claims payload = Jwts.parser()
.verifyWith(SECRET_KEY)
.build()
.parseSignedClaims(token)
.getPayload();
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public Authentication getAuthentication(String token) {
Claims claims = tokenToClaims(token);
if(claims == null){
return null;
}
List<SimpleGrantedAuthority> authorities = getAuthorities(claims);
log.info("authorities : {}", authorities);
User principal = new User(claims.getSubject(), "",authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
private List<SimpleGrantedAuthority> getAuthorities(Claims claims) {
return Collections.singletonList(new SimpleGrantedAuthority(
claims.get(KEY_ROLE).toString()));
}
private Claims tokenToClaims(String token){
try {
return Jwts.parser()
.verifyWith(SECRET_KEY)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (JwtException | IllegalArgumentException e) {
return null;
}
}
}
4. AuthController
- 목적
- 로그인 요청 검증 및 Jwt 토큰을 쿠키로 발급
- 방법
- 로그인 요청을 받는다.
- DB에 회원이 있는지 검사한다.
- 검증된 회원은 Jwt 토큰을 쿠키 형태로 반환한다.
@Slf4j
@Controller
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
private final JwtTokenProvider jwtTokenProvider;
@Autowired
public AuthController(AuthService authService, JwtTokenProvider jwtTokenProvider) {
this.authService = authService;
this.jwtTokenProvider = jwtTokenProvider;
}
// login 검증 및 JWT 토큰 발급
@PostMapping("/login")
public ResponseEntity<String> loginRequest(@RequestBody UserDto userDto, HttpServletResponse response) {
log.info("userDto : {}", userDto);
userDto = authService.findByUser(userDto.getEmail());
if(userDto == null){
return ResponseEntity.badRequest().body("조회되지 않는 회원입니다.");
}
response.addCookie(jwtTokenProvider.createJwtCookie(userDto.getEmail(), userDto.getRole()));
return ResponseEntity.ok("Login successful");
}
}
<코드 설명 - 기타 등등>
1. CorsConfig
- CORS(Cross Origin Resource Sharing)
- CORS 정책
- 서로 다른 Origin으로의 접근을 막음
- Origin : Protocal + Host + Port
- CORS 정책
- 목적
- 프론트와 연동 과정에서 발생하는 CORS 문제 해결
- 방법
- 스프링 시큐리티는 CORS 정책 수정이 가능하다.
- 즉, Front Origin 주소에 대한 접근을 허용하면 된다.
- 세팅 방법
- 접근 허용 Origin 지정
- CorsConfiguration.setAllowedOrigins()
- 허용 HTTP Method 지정
- CorsConfiguration.setAllowedMethods()
- 요청 헤더 허용
- CorsConfiguration.setAllowedHeaders
- Credentials 허용
- CorsConfiguration.setAllowCredentials
- Credentials?
- 인증을 위해 사용되는 정보
- (쿠키 및 Authorization 헤더 등)
- CORS 정책에서는 Credentials의 정보가 서로 다른 Origin에서 오고 가는 것을 금지한다.
- 따라서 쿠키를 자동으로 Request받고 싶다면 양쪽 Origin에서 Credentials의 사용을 허가해야한다.
- Csrf 공격에 취약해지므로 Credentials의 허가는 신중히 결정해야한다.
- (사용자가 쿠키를 지닌 상태에서 악의적인 Request가 이뤄진다면 민감한 Response 정보가 탈취될 수 있음)
- 쿠키를 받고 싶다면 Credentials 외에, 프론트가 보내는 Request에 직접적으로 쿠키 내용을 저장하는 방법도 있다.
- Csrf 공격에 취약해지므로 Credentials의 허가는 신중히 결정해야한다.
- 인증을 위해 사용되는 정보
- 접근 허용 Origin 지정
@Configuration
public class CorsConfig {
public static CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
//리소스를 허용할 URL 지정
ArrayList<String> allowedOriginPatterns = new ArrayList<>();
allowedOriginPatterns.add("http://localhost:3000");
allowedOriginPatterns.add("http://127.0.0.1:3000");
configuration.setAllowedOrigins(allowedOriginPatterns);
//허용하는 HTTP METHOD 지정
ArrayList<String> allowedHttpMethods = new ArrayList<>();
allowedHttpMethods.add("GET");
allowedHttpMethods.add("POST");
allowedHttpMethods.add("PUT");
allowedHttpMethods.add("DELETE");
configuration.setAllowedMethods(allowedHttpMethods);
configuration.setAllowedHeaders(Collections.singletonList("*"));
// configuration.setAllowedHeaders(List.of(HttpHeaders.AUTHORIZATION, HttpHeaders.CONTENT_TYPE));
//인증, 인가를 위한 credentials 를 TRUE로 설정
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
SecurityConfig에 적용
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors
.configurationSource(CorsConfig.corsConfigurationSource())) // CORS 허용
...
return http.build();
}
}
2. UsernamePasswordAuthenticationFilter
- UsernamePasswordAuthenticationFilter
- /login 경로로 오는 요청(로그인 요청)에 대해서만 실행
- 인증 방법
- AuthenticatoinManager → ProviderManager →AuthenticationProvider → UserDetailsService
- AuthenticatoinManager
- ProviderManager의 부모 객체
- ProviderManager
- 여러 개의 AuthenticationProvider 객체를 관리
- 각 인증 방식(OAuth2, 사용자 정의 로그인 폼, ..)에 맞는 AuthenticatoinProvider를 수행시킴
- AuthenticationProvider
- 로그인 인증 수행
- UserDetailsService.loadByUsername(username)으로 DB에서 실제 사용자 정보를 가져옴
- 실제 사용자 정보와 Authentication에 있는 사용자 정보를 매칭시킴
- 서로 정보가 동일하면 실제 사용자가 맞으므로, 아래 정보 반환
return UsernamePasswordAuthenticationToken(username, password, userDetails.getAuthorities());
----------
//UsernamePasswordAuthenticationToken
//해당 토큰은 Authentication을 부모 타임으로 가짐
//해당 토큰을 이용하면 Authentication을 사용함과 동시에, 아래와 같이 인증된 사용자로 등록 가능
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
(출처 : https://assu10.github.io/dev/2023/11/25/springsecurity-authrorization-1/)
/**
* `AuthenticationProvider` 계약을 구현하는 클래스
*/
@Component // 이 형식의 인스턴스를 컨텍스트에 포함시킴
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
public CustomAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
}
/**
* 인증 논리 구현
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
// UserDetails 를 가져오기 위해 UserDetailsService 구현 이용
// 사용자가 존재하지 않으면
// loadUserByUsername() 는 AuthenticationException 예외 발생시킴
// 인증 프로세스가 중단되고 HTTP 필터는 401 리턴
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 사용자가 존재하면 matches() 로 암호 확인
if (passwordEncoder.matches(password, userDetails.getPassword())) {
// 암호가 일치하면 AuthenticationProvider 는
// 필요한 세부 정보가 담긴 Authentication 계약의 구현을 '인증됨' 으로 표시한 후 반환함
return new UsernamePasswordAuthenticationToken(username, password, userDetails.getAuthorities());
} else {
// 암호가 일치하지 않으면 AuthenticationException 형식의 예외 발생
throw new BadCredentialsException("BadCredentialsException...!!");
}
}
/**
* AuthenticationProvider 가 어떤 종류의 Authentication 인터페이스를 지원할 지 결정
* 이는 authenticate() 메서드의 매개 변수로 어떤 형식이 제공될지에 따라서 달라짐
* <p>
* AuthenticationFilter 수준에서 아무것도 맞춤 구성하지 않으면(지금은 이런 케이스) UsernamePasswordAuthenticationToken 클래스가 형식을 정의함
*/
@Override
public boolean supports(Class<?> authentication) {
// UsernamePasswordAuthenticationToken 는 Authentication 인터페이스의 한 구현이며,
// 사용자 이름과 암호를 이용하는 표준 인증 요청을 나타냄
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
- UserDetailsService
- AuthenticationProvider가 실제 DB에서 사용자 정보를 가져오기 위해 사용하는 객체
- Authentication 정보를 가지고 DB에서 실제 사용자 정보를 가져오면 됨
- AuthenticationProvider는 해당 실제 사용자 정보를 통해 Authentication의 유저가 실제 있는지 확인 가능
- (따라서, Authentication의 username은 고유 식별자여야 함. 해당 username을 통해 DB에서 값을 조회하는 구조이기 때문에, 굳이 username이 아니더라도 Authentication의 username칸은 고유 식별자 값이 올 수 있도록 해야함)
@Service
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Autowired
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
System.out.println("email : " + email);
log.info("email : {}", email);
Users users = userRepository.findByEmail(email);
if(users == null){
log.error("user is null");
throw new UsernameNotFoundException("User not found with email: " + email);
}
//******** 수정 변경 사항 1.10 *************
// getUsername -> getEmail로 수정바람
UserDetailsDto userDetailsDto = new UserDetailsDto(userToUserDto(users));
return new Principal(userDetailsDto);
}
private UserDto userToUserDto(Users users){
return UserDto.builder()
.id(users.getId())
.email(users.getEmail())
.password(users.getPassword())
.username(users.getUsername())
.phoneNumber(users.getPhoneNumber())
.role(users.getRole())
.birthday(users.getBirthday())
.build();
}
}
<QnA>
1) Authentication으로 사용자 정보를 검증하는 과정?
- 사용자 정보는 2개로 분리된다.
- DB 정보, 권한
- DB 정보
- 요청받은 Authentication의 정보가 DB User로 있는지 확인한다.
- UsernamePasswordAuthenticationFilter 또는 jwtAuthenticationFilter가 그 역할을 담당한다.
- 위 필터들은 DB에서 사용자를 조회 후, SecurityContext에 저장함으로써 인증된 사용자임을 증명한다.
- 권한
- DB 정보로 조회된 사용자 권한(ROLE_USER, ROLE_ADMIN, …)이 해당 API에 접근 가능한지 확인한다.
- AuthorizationFilter는 접근 가능한 권한을 가졌는지 확인한다.
public class AuthorizationFilter extends GenericFilterBean {
...
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws ServletException, IOException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
...
try {
AuthorizationResult result = this.authorizationManager.authorize(this::getAuthentication, request);
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, result);
if (result != null && !result.isGranted()) {
throw new AuthorizationDeniedException("Access Denied", result);
}
chain.doFilter(request, response);
} finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}
}
}
2) jwtAuthenticationFilter는 왜 UsernamePasswordAuthenticationFilter 이전에 실행되어야 하나?
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
- 추정
- JwtAuthenticationFilter는 UsernamePasswordAuthenticationFilter의 역할을 대신하기 때문이다.
- UsernamePasswordAuthenticationFilter
- UsernamePasswordAuthenticationFilter는 주어진 Authentication의 정보로 DB에 있는사용자 정보를 조회한다.
- 조회된 사용자는 Authentication 형태로 SecurityContext에 저장된다.
- 즉, UsernamePasswordAuthenticationFilter는 SecurityContext에 실제 사용자 정보를 넣는 역할을 한다.
- JwtAuthenticationFilter
- JwtAuthenticationFilter는 UsernamePasswordAuthenticationFilter와 역할이 같다.
- JwtAuthenticationFilter는 사용자로부터 JWT 토큰을 받는다.
- 해당 JWT 토큰을 통해 실제 사용자 정보를 DB에서 가져온다.
- 조회된 사용자는 Authentication 형태로 SecurityContext에 저장된다.
- 즉, JwtAuthenticationFilter는 SecurityContext에 실제 사용자 정보를 넣는 역할을 한다.
- ⇒ /login 요청에 대해서는 UsernamePasswordAuthenticationFilter가 SecurityContext에 DB 사용자를 넣는다.
- ⇒ JWT 토큰이 있을때는 JwtAuthenticationFilter가 SecurityContext에 DB 사용자를 넣는다.
- JwtAuthenticationFilter 또는 UsernamePasswordAuthenticationFilter를 통해 만들어진 SecurityContext의 Authentication의 DB 사용자 정보를 토대로 나머지 필터링이 진행가능해진다.
- UsernamePasswordAuthenticationFilter
- JwtAuthenticationFilter는 UsernamePasswordAuthenticationFilter의 역할을 대신하기 때문이다.
※ 용어
- SecurityContextHolder
- SecurityContext 객체를 지님
- SecurityContext
- Authentication 객체를 지님
- ⇒ SecurityContextHolder를 호출하여 현재 Authentication 정보를 확인 가능
3. Front는 JWT 쿠키를 어떻게 사용해야하나?
- 방법
- CORS 정책 수정
- Credentials(인증 정보)가 오고가는 것을 허용
- (쿠키 사용 및 Authorization 헤더 사용을 위함)
- Credentials(인증 정보)가 오고가는 것을 허용
- Cookie에서 JWT 추출
- Cookies.get(JwtCookie)
- JWT를 Authorization 헤더에 담아서 요청 보냄
- CORS 정책 수정
const response = await fetch(`${BASE_URL}${LOGIN_ENDPOINT}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwtCookie}`
},
//include 시, 쿠키가 자동으로 request에 포함됨
//하지만 이미 헤더에 jwt를 담았으므로 필요없음
//credentials: 'include'
});
예시 코드
import React from 'react';
import styles from '../css/login.module.css'
import Cookies from 'js-cookie';
const Login = () => {
return (
<div>
<LoginForm/>
</div>
);
};
const LoginForm = () => {
const BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8080';
const LOGIN_ENDPOINT = '/security/user';
const JwtCookie = 'JwtCookie';
const getJwtCookie = () => {
return Cookies.get(JwtCookie);
};
// 폼 제출 처리 함수
const handleSubmit = async (e) => {
e.preventDefault(); // 기본 폼 제출 방지
// 폼 데이터를 수집
const formData = new FormData(e.target);
const json = Object.fromEntries(formData);
const jwtCookie = getJwtCookie();
try {
// POST 요청 보내기
const response = await fetch(`${BASE_URL}${LOGIN_ENDPOINT}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwtCookie}`
},
});
if (response.ok) {
alert('조회 성공' + response.text());
} else {
alert(jwtCookie);
alert('조회 실패');
}
} catch (err) {
console.error(err);
alert('오류가 발생했습니다. 나중에 다시 시도해주세요.');
}
};
return (
<>
<form onSubmit={handleSubmit} className={styles['login-form']}>
<div className={styles.heading}>로그인</div>
<label htmlFor="email" className={styles.label}>이메일</label>
<input
type="email"
id="email"
name="email"
className={styles.input}
required
placeholder="이메일을 입력하세요"
/>
<label htmlFor="password" className={styles.label}>비밀번호</label>
<input
type="password"
id="password"
name="password"
className={styles.input}
required
placeholder="비밀번호를 입력하세요"
/>
<input type="submit" value="로그인" className={styles['submit-button']}/>
</form>
</>
);
};
export default Login;
4) JWT 인증 방식 수정계획
- 요약
- 현재 방식
- 백엔드 → 프론트
- 쿠키 전달
- 쿠키 : 쿠키 읽기 가능, Credentials 비허용
- 프론트 → 백엔드
- Request Header에 jwt 담아서 전달
- 백엔드
- 헤더 파싱하여, jwt 검증
- 백엔드 → 프론트
- 적절한 방법인가?
- No
- Xss 공격에 취약함
- 대응 방안
- LocalStorage : Access Token
- 해당 토큰은 생명 주기를 30분 미만으로 설정
- Cookie : Refresh Token
- 해당 토큰은 생명 주기를 12시간으로 설정
- 쿠키 : 읽기 불가, Credentials 허용
- LocalStorage : Access Token
- 현재 방식
- private variable
- 결론
- 로그인 등의 상태 유지가 어려워서 private variable을 사용하지 않음
- 시나리오
- 백엔드 → 프론트
- JWT 전달
- 프론트
- private variable로 jwt 저장
- 백엔드 → 프론트
- 장점
- XSS, CORS 공격에 상대적으로 안전
- 새로고침, 페이지 이동에 의해 변수에 있는 JWT 값이 사라지기 때문에 JWT 탈취가 어려움
- XSS, CORS 공격에 상대적으로 안전
- 단점
- JWT로 상태 유지가 어려움
- 새로고침, 페이지 이동에 의해 JWT가 사라지므로, 로그인 유지 등이 어려움
- JWT로 상태 유지가 어려움
- 결론
- SessionStorage
- LocalStorage
- JWT를 LocalStorage에 저장
- 장점
- CSRF 공격에 비교적 안전
- 쿠키처럼 request 요청에 자동으로 JWT가 포함되지 않으므로 CSRF 공격에 안전함
- (쿠키는 Credentials를 프론트, 백엔드 둘 다에서 허용 시, 쿠키가 자동으로 request에 포함됨)
- 쿠키처럼 request 요청에 자동으로 JWT가 포함되지 않으므로 CSRF 공격에 안전함
- CSRF 공격에 비교적 안전
- 단점
- XSS 공격에 취약함
- 사용자가 어느 웹페이지에 있든 LocalStorage에 내용은 보존된다.
- 따라서, 공격자가 어떻게든 XSS 공격에 성공하기만 하면 LocalStorage에 저장된 내용을 탈취 가능
- XSS 공격에 취약함
- 쿠키
- JWT를 쿠키에 저장
- 장점
- XSS 공격에 방어 가능
- 쿠키는 HttpOnly 설정이 있다.
- 해당 설정을 도입하면, JS 코드로 쿠키를 읽는 것이 불가능하다.
- (하지만 프론트에서도 쿠키를 이용할 수 없는 단점이 생김)
- CSRF 공격에 취약
- Credentials 허용 시, 쿠키가 자동으로 request에 포함된다.
- 백엔드가 해당 쿠키를 인증에 사용할 시, CSRF 공격에 취약하게 된다.
- XSS 공격에 방어 가능
※ XSS
- 상대방 PC에 내가 정의한 JS 코드를 실행시키는 것
- 해당 JS 코드를 통해, LocalStorage나 쿠키 등을 조회하여 상대방 정보 탈취 가능
- 예시(localStorage 탈취)
var stolenToken = localStorage.getItem("authToken");
※ CSRF
- 사용자가 원하지 않는 Request를 보내게 하는 것
- (로그인 된 사용자가 Request를 보내게 해서 사용자 정보가 담긴 Response를 획득하는 방법)
- 시나리오
- 사용자는 로그인 된 상태
- 사용자는 공격자의 웹 사이트에 접속
- 공격자는 사용자가 원치 않는 실행 코드를 실행시킴
//사용자는 의도치 않게 img를 누르면 다음과 같은 명령이 실행됨
<img src="http://bank.com/transfer?to=attacker&amount=1000">
사용자가 로그인 된 상태에서 해당 Request를 보낼 시, 공격자는 Request에 대한 Response 정보 탈취 가능
(https://github.com/goForItkang/teamproject_1_backend/tree/choeyoungju)