응답 body (클라이언트가 가져올 수 있음)
응답 header (클라이언트가 가져올 수 있음)
쿠키 (클라이언트가 가져올 수 없음)
inminute에서는 Cookie에 JWT 토큰을 담았다. 서버가 토큰을 쿠키에 담아서 클라이언트에게 넘겨준다. 클라이언트에서 쿠키에 접근은 못하지만 브라우저가 쿠키를 관리하기 때문에 클라이언트 사이드에서 서버에 요청을 하면 브라우저가 자동으로 서버에게 쿠키를 전달한다. 그래서 서버는 그 전달된 쿠키를 검증해서 사용자 인가 작업을 완료한다.
Refresh 토큰이 탈취되는 경우 대응 방법
서버가 JWT를 응답 body에 담아서 클라이언트에게 전달하는 방식
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
장점: 클라이언트가 쉽게 가져와서 localStorage나 sessionStorage 등에 저장 가능.
단점: XSS 공격에 취약하여 브라우저에 저장하는 것은 보안상 위험함.
JWT를 HTTP 응답 헤더에 포함하여 전달하는 방식
HTTP/1.1 200 OK
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
장점: 응답 body를 깔끔하게 유지할 수 있고, 클라이언트가 Authorization 헤더에서 쉽게 추출 가능.
단점: 클라이언트가 직접 헤더에서 추출해야 하므로 구현이 필요함.
✅ 클라이언트에서 JWT 토큰을 응답 헤더에서 가져오려면 백엔드에서 CORS 설정을 올바르게 적용해야 함.
Access-Control-Allow-Origin: https://your-frontend.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Authorization
브라우저 보안 정책으로 인해, Authorization 헤더를 직접 읽으려면 Access-Control-Expose-Headers
가 필요함.
인미닛에서 처음에 쿠키를 썼다가 응답 헤더를 썼다가 쿠키를 썼는데 ...
쿠키에 저장을 하면 내가 별도의 웹소켓 인가 작업을 거치지 않아도 브라우저가 쿠키에 이미 담겨있으니까 브라우저가 웹소켓 연결을 할 때 쿠키에 JWT를 담아서 요청을 보내면 된다. 즉, 다른 인증 필터 추가 구현 없이 바로 한 큐에 끝낼 수 있다 ! 그래서 처음에 쿠키를 써봤다.
그런데 막상 코드는 헤더처럼 구현을 해버려서 + 쿠키로는 클라이언트에서 토큰을 담아서 못 보내주기 때문에 웹소켓 구현이 힘들었던 것이다(사실 되는데, 토큰을 담아 보내줘야만 되는 줄 알았던 것이다.)
그래서 헤더로 바꿨더니 Access-Control-Expose-Headers: Authorization
이런 설정을 안 한건지 클라이언트에서 헤더에 접근을 못했다.
그래서 다시 쿠키로 했더니 웹소켓 세션에 사용자 정보가 담겨있는 걸 확인해버렸다. 충격. 바로 그냥 되는구나. 바보였구나. ㅋ ㅋ 그래서 인미닛은 쿠키 기반 인증 로직으로 !! 최종 땅땅땅.
근데, 헤더 기반으로 요청을 했다면 헤더에서 값을 꺼내서 클라이언트가 값을 담아서 보내줘야 한다. 웹소켓 인가를 할 때도 필터뿐만 아니라 또 다른 인가 로직을 만들어야 한다. (깔로그래밍은 헤더 인증 방식이라 이 방식으로 ,,, ㅠㅠ)
JWT를 Set-Cookie 헤더를 통해 쿠키에 저장하는 방식
HTTP/1.1 200 OK
Set-Cookie: accessToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...; HttpOnly; Secure; SameSite=Strict
장점:
HttpOnly 설정 시 XSS 공격 방어 가능.
자동으로 브라우저에서 요청마다 쿠키를 포함하므로 편리함.
단점:
CSRF 공격 방지를 위해 SameSite=Strict 또는 SameSite=Lax 설정 필요.
프론트엔드에서 쿠키를 직접 조작하기 어려움.
(API 하나 요청용)일회용 세션
이 발급된다.
- 인증(Authorization) : 로그인. 로그인 정보랑 DB 정보를 비교(=인증)해서 인증에 성공하면 토큰이 발급된다.
- 인가(Authentication) : 발급된 토큰을 가지고 000(api든 ..) 요청을 하면 그 토큰을
검증(Validation)
한다. 검증에 성공하면 사용자 인가 작업이 완료된다.
브라우저 웹서버
------------------------->
Set-Cookie: role=user; ⇐ 다음 요청 처리 시 서버가 필요로 하는 값을
<------------------------- Set-Cookie 응답 헤더로 전달
Cookie: role=user; ⇐ 동일 서버로 다음 요청 시 Cookie 요청 헤더로 서버로 전달
------------------------->
^
|
+-- 요청와 응답 헤더를 통해서 주고 받기 때문에
쉽게 노출 및 유출될 수 있으며, 쉽게 위변조 가능하다는 단점
⇒ 쿠키에 중요 정보를 포함해서 전달하면 안 됨
🚨 쿠키를 로컬에서 자바스크립트로 직접 핸들링하면 안 된다. 악의적인 행동이 된다. 서버가 자동으로 주는 것이다. 요청 파라미터를 써야 한다.
쿠키도 set-cookie 라는 응답 헤더로 넣어진다.
쿠키에도 토큰을 넣을 수 있으나 client에서 접근을 못한다.
쿠키의 취약점을 개선하기 위해서 나온 개념
기존 cookie는 사용자 정보를 바로 보냈다면 session으로 한 번 감싸서 session_id로 보낸다. session에 대한 정보는 서버에 있는 것이다 !
브라우저 웹서버
login.do?id=aaa&pw=bbb
-------------------------> 사용자 인증 후 다음 처리에 필요한 값을 서버 사이드에 기록하고,
정보를 식별할 수 있는 ID를 발급해서 전달
Set-Cookie: SID=1234
<-------------------------
Cookie: SID=1234
-------------------------> 전달된 세션 ID를 이용해서 서버 사이드에 기록된 내용을 조회해서
사용자 맞춤 서비스를 제공
⇒ 중요 정보가 서버 사이드에 저장되어 있으므로,
정보에 대한 직접적인 노출, 유출, 위변조가 불가능
⇒ 세션 ID를 도용해 정보의 소유자인 것 처럼 서버를 속이는 것이 가능
⇒ 세션 ID 생성 및 관리가 중요
1. 세션 ID 추측 취약점 방어 => 세션 ID 생성 시 생성 규칙을 유출할 수 없도록 해야 함 ⇒
~~~~~~~~~~
세션 ID 생성 규칙을 유추해 앞으로 생성될 세션 ID을 미리 설정해서 사용
2. 세션 ID 고정 취약점 방어 => 인증 후 세션 ID를 재발행
~~~~~~~~~~
인증 전후에 동일한 세션 ID를 유지
3. 세션 ID 탈취 방어 => XSS 공격을 방어, HttpOnly
~~~~~~~~~~
XSS(크로스 사이트 스크립트) 공격을 통해서 브라우저에 저장된 세션 ID를 탈취
# 30 minutes
server.servlet.session.timeout=30m
// changeSessionId() : 기본값. 현재 세션 ID를 새 세션 ID로 변경 (기존 세션 속성은 그대로 유지)
// newSession() : 새 세션을 생성하고 기존 세션은 무효화 (기존 세션 속성은 복사되지 않음)
// migrateSession() : 새 세션을 생성하고 기존 세션의 속성을 새 세션으로 복사 (기존 세션은 무효화 처리)
// none() : 세션 고정을 방어할 수 없음 (권장하지 않음)
http.sessionManagement(auth -> auth
.sessionFixation(ses -> ses.newSession()));
return http.build();
// 다중 로그인 방어 ⬇️
// maximumSessions(int) : 하나의 아이디에 대해 다중 로그인 허용 개수
// maxSessionsPreventsLogin(boolean) : 다중 로그인 개수를 초과한 경우 처리 방법
// - true : 초과 시 새로운 로그인을 차단
// - false : 초과 시 기존 세션 하나를 삭제
http.sessionManagement(auth -> auth
.sessionFixation(ses -> ses.newSession())
.maximumSessions(1)
.maxSessionsPreventsLogin(true));
package board.controller;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Controller
public class LogoutController {
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) throws Exception {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
new SecurityContextLogoutHandler().logout(request, response, authentication);
}
return "redirect:/home";
}
}
CSRF 기능을 차단하면
POST
방식으로 로그아웃을 해야 하므로, 만약GET
방식으로 로그아웃을 처리하려면 아래와 같은 설정을 추가해야 함SecurityConfiguration
http.logout(auth -> auth.logoutUrl("/logout").logoutSuccessUrl("/"));
session 방식
에 대한 대안이 JWT(AccessToken, RefreshToken)
일반적으로 토큰은 Authorization: <type> <credentials>
요청 헤더를 통해서 전달
Authentication VS Authorization
<type>
Basic : 사용자 아이디(username)와 패스워드(password)를 BASE64로 인코딩한 값을 토큰으로 사용
~~~~~~~
(보안적으로 안전하지 않다. 보안 프로토콜(https)과 함께 써야한다.)
Bearer : JWT 또는 OAuth에 대한 토큰 사용
Digest : 서버에서 난수 데이터 문자열을 클라이언트로 보내면, 클라이언트는 사용자 정보와 nonce를 포함하는
해시값을 사용해서 응답
- HEADER : type과 토큰 암호화 알고리즘
- PAYLOAD : 정보
- SIGNITURE : 토큰의 위변조를 검증해줄 수 있는 곳
이름: 값
형식의 정보를 담고 있는 영역 ex) "sub": "user10001"implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.6'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.6'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.6'
# 24H = 24 * 60 * 60 * 1000 = 86,400,000 ms
token.expiration-time: 86400000
# 1 2 3
# 12345678901234567890123456789012
token.secret: My JWTToken's Secret is p@ssw0rd
package board.security;
import java.io.IOException;
import java.util.Date;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import board.entity.UserEntity;
import board.repository.UserRepository;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private UserRepository userRepository;
@Autowired
private Environment env;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
UserDetails userDetails = (UserDetails) authentication.getPrincipal(); // 인증에 성공하면 authentication에 사용자 인증 정보가 담기고 그걸 시큐리티에 있는 UserDetails로 캐스팅해줘서 사용자 정보로 접근이 가능해진다.
UserEntity userEntity = userRepository.findByUsername(userDetails.getUsername()); // userDetails에서 가져온 사용자 정보를 DB와 비교해서 회원 찾기
// 토큰 발행 시간과 만료 시간 설정에 사용할 값
Date now = new Date();
Long expirationTime = Long.parseLong(env.getProperty("token.expiration-time"));
// JWT 토큰을 생성해서 로그로 기록
String jwtToken = Jwts.builder()
.claim("name", userEntity.getName())
.claim("email", userEntity.getEmail())
.subject(userEntity.getUsername())
.id(String.valueOf(userEntity.getSeq()))
.issuedAt(now)
.expiration(new Date(now.getTime() + expirationTime))
.compact();
log.debug(jwtToken);
request.getSession().setAttribute("user", userEntity);
response.sendRedirect("/");
}
}
Invalid Signature -> 파란색 부분 만들어야 함 !
// 서명에 사용할 키를 생성
String secret = env.getProperty("token.secret");
SecretKey hmacKey = Keys.hmacShaKeyFor(secret.getBytes());
...
.signWith(hmacKey, Jwts.SIG.HS256)
response.setHeader("token", jwtToken);
위 코드를 추가
package board.security;
import java.io.IOException;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import board.entity.UserEntity;
import board.repository.UserRepository;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private UserRepository userRepository;
@Autowired
private Environment env;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
UserEntity userEntity = userRepository.findByUsername(userDetails.getUsername());
// 토큰 발행 시간과 만료 시간 설정에 사용할 값
Date now = new Date();
Long expirationTime = Long.parseLong(env.getProperty("token.expiration-time"));
// 서명에 사용할 키를 생성
String secret = env.getProperty("token.secret");
SecretKey hmacKey = Keys.hmacShaKeyFor(secret.getBytes());
// JWT 토큰을 생성해서 로그로 기록
String jwtToken = Jwts.builder()
.claim("name", userEntity.getName())
.claim("email", userEntity.getEmail())
.subject(userEntity.getUsername())
.id(String.valueOf(userEntity.getSeq()))
.issuedAt(now)
.expiration(new Date(now.getTime() + expirationTime))
.signWith(hmacKey, Jwts.SIG.HS256) // 여기에 붙이기
.compact();
log.debug(jwtToken);
// 생성한 토큰을 응답 헤더를 통해 전달
response.setHeader("token", jwtToken);
request.getSession().setAttribute("user", userEntity);
response.sendRedirect("/");
}
}
token.secret(시크릿 키) 넣어주기
이처럼 토큰과 시크릿 키가 있으면 웹사이트에서 인증이 가능하다.
=> 분산화된 어플리케이션
에서 인증할 때 토큰
을 사용한다.
앞에서 만들었던 것을 util로 뺀 것
package board.common;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import board.entity.UserEntity;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class JwtUtils {
private SecretKey hmacKey;
private Long expirationTime;
public JwtUtils(Environment env) {
this.hmacKey = Keys.hmacShaKeyFor(env.getProperty("token.secret").getBytes());
this.expirationTime = Long.parseLong(env.getProperty("token.expiration-time"));
}
public String generateToken(UserEntity userEntity) {
Date now = new Date();
String jwtToken = Jwts.builder()
.claim("name", userEntity.getName())
.claim("email", userEntity.getEmail())
.subject(userEntity.getUsername() + "'s token")
.id(String.valueOf(userEntity.getSeq()))
.issuedAt(now)
.expiration(new Date(now.getTime() + this.expirationTime))
.signWith(this.hmacKey, Jwts.SIG.HS256)
.compact();
log.debug(jwtToken);
return jwtToken;
}
private Claims getAllClaimsFromToken(String token) {
Jws<Claims> jwt = Jwts.parser()
.verifyWith(this.hmacKey)
.build()
.parseSignedClaims(token);
return jwt.getPayload();
}
private Date getExpirationDateFromToken(String token) {
Claims claims = getAllClaimsFromToken(token);
return claims.getExpiration();
}
private boolean isTokenExpired(String token) {
Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}
private String getSubjectFromToken(String token) {
Claims claims = getAllClaimsFromToken(token);
return claims.getSubject();
}
public boolean validateToken(String token, UserEntity userEntity) {
// 토큰 유효기간 체크
if (isTokenExpired(token)) {
return false;
}
// 토큰 내용을 검증
String subject = getSubjectFromToken(token);
String username = userEntity.getUsername();
return subject != null && username != null && subject.equals(username + "'s token");
}
}
package board.security;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import board.common.JwtUtils;
import board.entity.UserEntity;
import board.repository.UserRepository;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private UserRepository userRepository;
@Autowired
private JwtUtils jwtUtils;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
UserEntity userEntity = userRepository.findByUsername(userDetails.getUsername());
String jwtToken = jwtUtils.generateToken(userEntity);
response.setHeader("token", jwtToken);
request.getSession().setAttribute("user", userEntity);
response.sendRedirect("/");
}
}
컨트롤러 앞단에서 필터를 거쳐야 한다. (인터셉터와 비슷)
package board.security;
import java.io.IOException;
import java.util.Arrays;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import board.common.JwtUtils;
import board.entity.UserEntity;
import board.repository.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
// OncePerRequestFilter 를 상속받음
@Component
@Slf4j
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
private UserRepository repository;
@Override // 오버라이드
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String jwtToken = null;
String subject = null;
// Authorization 요청 헤더 포함 여부를 확인하고, 헤더 정보를 추출
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwtToken = authorizationHeader.substring(7);
subject = jwtUtils.getSubjectFromToken(jwtToken);
} else {
log.error("Authorization 헤더 누락 또는 토큰 형식 오류");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Invalid JWT Token");
response.getWriter().flush();
return;
}
UserEntity userEntity = repository.findByUsername(subject);
if (!jwtUtils.validateToken(jwtToken, userEntity)) {
log.error("사용자 정보가 일치하지 않습니다. ");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Invalid JWT Token");
response.getWriter().flush();
return;
}
// 인가 성공했으니 다음 필터로 넘어가라
filterChain.doFilter(request, response);
}
// '/login' , '/join '등 모두가 들어와야 하는 페이지이므로 Filter 제외
// '/'는 Guest, Hong 등등 누군지 알아야 하는 부분이 있음. 그러므로 이 함수에 없어야 함.
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String[] excludePath = { "/login", "/loginProc", "/join", "/joinProc" };
String uri = request.getRequestURI();
return Arrays.stream(excludePath).anyMatch(uri::startsWith);
}
}
검증 필터 앞에 만들었던 JwtRequestsFilter 등록 (필터 등록 위치에 따라 오류 여부 바뀜)
package board.configuration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import board.security.CustomAuthenticationSuccessHandler;
import board.security.JwtRequestFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Autowired
private CustomAuthenticationSuccessHandler successHandler;
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/home", "/join", "/joinProc").permitAll()
.requestMatchers("/board", "/board/**", "/api/**").hasAnyRole("ADMIN", "USER")
.anyRequest().authenticated()
);
http.formLogin(auth -> auth
.loginPage("/login")
.loginProcessingUrl("/loginProc")
.permitAll()
.successHandler(successHandler)
);
http.csrf(auth -> auth.disable());
http.sessionManagement(auth -> auth
.sessionFixation(ses -> ses.newSession())
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
);
// ⭐️JWT -> 세션을 STATELESS하게 설정 (세션을 일회용으로 쓰겠다. JWT로 쓰겠다. 세션 미사용 표기)
http.sessionManagement(auth -> auth.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.logout(auth -> auth.logoutUrl("/logout").logoutSuccessUrl("/"));
// 검증 필터 앞에 만들었던 JwtRequestsFilter 등록 (필터 등록 위치에 따라 오류 여부 바뀜)
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
Talend API Tester를 이용해서 로그인 성공 시 전달받은 토큰을 이용해서 게시판 조회를 요청
Authorization 헤더가 없기 때문에 401을 반환
import axios from "axios";
import { useState } from "react";
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const changeUsername = e => setUsername(e.target.value);
const changePassword = e => setPassword(e.target.value);
const handleSubmit = e => {
e.preventDefault();
axios({
method: "POST",
url: "http://localhost:8080/loginProc",
data: { username, password },
headers: {"Content-Type": "application/x-www-form-urlencoded"}
})
.then(res => {
console.log(res.headers.token);
// JWT 토큰을 세션 스토리지에 저장
sessionStorage.setItem("token", res.headers.token);
navigate("/list");
})
.catch(err => console.log(err));
};
return (
<>
<h1>로그인 페이지</h1>
<form onSubmit={handleSubmit}>
Username: <input type="text" value={username} onChange={changeUsername} />
<br/>
Password: <input type="text" value={password} onChange={changePassword} />
<br/>
<button type="submit">로그인</button>
</form>
</>
);
}
import { createBrowserRouter, Outlet, RouterProvider, Link } from 'react-router-dom';
import './App.css';
import BoardList from './board/BoardList';
import BoardDetail from './board/BoardDetail';
import BoardWrite from './board/BoardWrite';
import Login from './user/Login';
const Layout = () => (
<>
<nav>
<Link to="/">로그인</Link>:<Link to="/list">게시판 목록</Link>:
<Link to="/detail/8">게시판 상세</Link>:<Link to="/write">게시판 글쓰기</Link>
</nav>
<Outlet />
</>
);
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ path: '', element: <Login /> },
{ path: 'list', element: <BoardList /> },
{ path: 'detail/:boardIdx', element: <BoardDetail /> },
{ path: 'write', element: <BoardWrite /> },
],
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
=> WebMvcConfiguration 파일에 /loginProc 허용 정책을 추가
package board.configuration;
import board.interceptor.LoggerInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoggerInterceptor());
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE");
registry
.addMapping("/loginProc")
// .allowedHeaders("content-type")
.exposedHeaders("token") // 커스텀 헤더의 값을 사용할 수 있도록 허용
.allowedOrigins("http://localhost:5173")
.allowedMethods("POST");
}
}
인미닛 인증 방식이 헤더였으면 .exposedHeaders("token")
이런 설정도 넣어줘야했지 않았을까..? 싶다.
=> SecurityConfiguration 파일에 WebMvcConfigurer의 CORS 설정을 허용하도록 수정
package board.configuration;
import board.security.JwtRequestFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import board.security.CustomAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Autowired
private CustomAuthenticationSuccessHandler successHandler;
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/home", "/join", "/joinProc").permitAll()
.requestMatchers("/board", "/board/**", "/api/**").hasAnyRole("ADMIN", "USER")
.anyRequest().authenticated()
);
http.formLogin(auth -> auth
.loginPage("/login")
.loginProcessingUrl("/loginProc")
.permitAll()
// .defaultSuccessUrl("/board")
.successHandler(successHandler)
);
// 개발단계에서 임시적으로 Disable
http.csrf(auth -> auth.disable());
// 세션 고정 방어 ⬇️
// changeSessionId() : 기본값. 현재 세션 ID를 새 세션 ID로 변경 (기존 세션 속성은 그대로 유지)
// newSession() : 새 세션을 생성하고 기존 세션은 무효화 (기존 세션 속성은 복사되지 않음)
// migrateSession() : 새 세션을 생성하고 기존 세션의 속성을 새 세션으로 복사 (기존 세션은 무효화 처리)
// none() : 세션 고정을 방어할 수 없음 (권장하지 않음)
// 다중 로그인 방어 ⬇️
// maximumSessions(int) : 하나의 아이디에 대해 다중 로그인 허용 개수
// maxSessionsPreventsLogin(boolean) : 다중 로그인 개수를 초과한 경우 처리 방법
// - true : 초과 시 새로운 로그인을 차단
// - false : 초과 시 기존 세션 하나를 삭제
http.sessionManagement(auth -> auth
.sessionFixation(ses -> ses.newSession())
.maximumSessions(1)
.maxSessionsPreventsLogin(true));
http.logout(auth -> auth.logoutUrl("/logout").logoutSuccessUrl("/"));
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
// 이 부분 !! ⬇️
http.cors(Customizer.withDefaults());
return http.build();
}
@Bean
BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
import { useEffect, useState } from "react";
import axios from "axios";
import { Link } from "react-router-dom";
export default function BoardList() {
const [datas, setDatas] = useState([]);
useEffect(() => {
const token = sessionStorage.getItem("token");
axios
.get("http://localhost:8080/api/v2/board", {
headers: {
"Authorization": `Bearer ${token}`
}
})
.then(res => {
console.log(res);
res && res.data && setDatas(res.data);
})
.catch(err => console.log(err));
}, []);