🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [60]일차
🚀60일차에는 JWT를 통해 DB 혹은 쿠키에 토큰 저장, JWT Authentication, Filter 등을 적용해보는 실습을했다.
학습 목표 : JWT를 더 활용하여 쿠키 적용, JWT 인증에 필터 적용, 권한 부여 등을 학습 가능
학습 과정 : 회고를 통해 작성
토큰 정보 얻어오기
- Refresh Token :
서버가 저장하고 있다가 유저가 로그인하면 서버가 이 Refresh Token을 통해서 재로그인이 가능
Refresh Token과 Access Token은 일반적으로 같이 사용
public Claims parseToken(String token, byte[] secretKey){
return Jwts.parserBuilder()
.setSigningKey(getSigningKey(secretKey))
.build()
.parseClaimsJws(token)
.getBody();
}
- parseToken() :
JWT 토큰을 파싱하여 그 안의 정보를 가져오는 역할
클라이언트가 서버에 보낸 JWT를 복호화(디코딩)하여, 저장된 사용자 정보를 추출 - public Claims parseToken(String token, byte[] secretKey){ ... }
secretKey : JWT를 생성할 때 사용했던 시크릿 키
반환 타입 Claims : JWT 내부에서 payload을 추출하여 Claims 객체로 반환 - return Jwts.parserBuilder()
JWT 문자열을 파싱하기 위한 빌더 객체를 생성
JWT를 검증하기 위해 parserBuilder() 사용 - .setSigningKey(getSigningKey(secretKey))
서명 검증을 위해 시크릿 키를 설정하는 부분 - JWT는 생성될 때 서명이 포함 ➡️ 올바른 시크릿 키를 사용하지 않으면 검증 실패
- .build() : 설정이 완료된 JWT 파서를 최종적으로 생성하는 부분
- .parseClaimsJws(token) : token(JWT 문자열)을 해석하여 Claims 정보를 추출
- .getBody()
JWT의 payload(Claims) 부분을 가져옴
Claims 객체에는 사용자가 JWT 생성 시 넣었던 정보(username, userId, roles 등)이 포함
Bearer로 토큰 얻어오기
- 클라이언트는 Authorization 헤더에 "Bearer <JWT>" 형태로 토큰을 보냄
ex. "Authorization: Bearer eyJhbGciOiJIUzI1NiIsIn..." - Bearer :
➡️JWT 토큰을 HTTP 요청 헤더에서 전달할 때 사용하는 인증 방식
➡️"Bearer "(공백 포함) 다음에 JWT 토큰이 들어감
➡️인증 유형을 나타내며, 토큰을 안전하게 다루기 위한 일종의 관례(convention)
public Long getUserIdFromToken(String token){
if(token == null || token.isBlank()){
throw new IllegalArgumentException("JWT 토큰이 없습니다.");
}
if(token.startsWith("Bearer ")){ // Bearer 로 시작하는 토큰이 아닌 경우
throw new IllegalArgumentException("유효하지 않은 형식입니다.");
}
Claims claims = parseToken(token, accessSecret);
if(claims == null){
throw new IllegalArgumentException("유효하지 않은 형식입니다.");
}
Object userId = claims.get("userId"); // JWT 생성 시 claims.put("userId", id)로 저장된 값을 가져옴
if(userId instanceof Number){ // userId 타입이 Number인지 검사
return ((Number)userId).longValue(); // Number이면 long타입으로 형변환 후 리턴
}else{
throw new IllegalArgumentException("JWT 토큰에서 userId를 찾을 수 없습니다.");
}
}
- getUserIdFromToken() : JWT 토큰에서 userId 값을 추출하는 메소드
(=클라이언트가 JWT를 보내면, 토큰 안에 저장된 userId 값을 꺼냄) - if(token.startsWith("Bearer ")
➡️Bearer (공백 포함)로 시작하는 경우가 정상적인 JWT 토큰
이 코드에서는 Bearer 로 시작하면 예외를 발생시킴 - token = token.substring(7);
"Bearer " 제거 (7번째 문자부터 JWT 토큰만 사용) - Claims claims = parseToken(token, accessSecret);
➡️JWT 토큰을 해석하고 Claims 정보를 가져옴
➡️accessSecret(시크릿 키)로 서명 검증을 수행함 - if(userId instanceof Number)
return ((Number)userId).longValue();
➡️JSON에서 숫자는 Integer, Long, Double 등 여러 타입으로 변환될 수 있음
따라서 Object를 직접 Long으로 캐스팅하면 ClassCastException이 발생할 수 있음
Number 타입인지 먼저 확인한 후, longValue()를 사용해 안전하게 변환
(=JSON 변환 과정에서 Integer/Long 타입이 다를 수 있으므로 안전한 변환을 위해 사용)
❓instanceof Number를 사용하는 이유
➡️JWT Claims에서 userId 값이 Integer, Long, Double 등 다양한 Number 타입으로 저장될 가능성때문에 사용
➡️직접 캐스팅((Long) userId) 하면 ClassCastException 발생 가능
안전한 변환을 위해 instanceof Number 검사 후 longValue() 사용
Refresh Token 생성
- 리프레시 토큰(Refresh Token)을 DB에 저장하고 관리하기 위해 RefreshToken 엔티티를 정의
- JWT 인증의 흐름
1. 사용자가 로그인하면 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token)을 발급
2. 액세스 토큰이 만료되면, 리프레시 토큰을 이용해 새로운 액세스 토큰을 발급
3. 리프레시 토큰은 데이터베이스(DB)에 저장하여, 사용자가 요청할 때 유효성을 검증
@Entity
@Table(name = "refresh_token")
@Getter@Setter
public class RefreshToken{
@Id@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id")
private Long userId;
private String value;
}
- private String value;
리프레시 토큰 자체를 저장 - RefreshToken의 Repository에서 findByValue(String value)로 활용됨
(=JWT 인증 시 Refresh Token을 검증할 때 사용)
1. 사용자가 만료된 액세스 토큰을 리프레시 토큰과 함께 서버에 보냄
2. 서버는 value(리프레시 토큰 값)를 받아 DB에서 조회
3. DB에 해당 리프레시 토큰이 존재하는지 확인 후
존재하면 : 새로운 액세스 토큰 발급
존재하지 않으면 : 잘못된 토큰으로 판단하여 인증 실패 처리
반환타입은 Optional을 사용하는데 토큰이 존재할수도, 안할수도 있으므로 null처리를 확실하게 하기 위함➡️isPresent() 또는 orElseThrow() 등의 메소드를 사용하여 존재 여부를 간편하게 처리 가능
RefreshToken 서비스
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public RefreshToken addRefreshToken(RefreshToken refreshToken){
return refreshTokenRepository.save(refreshToken);
}
@Transactional
public void deleteRefreshToken(String refreshToken){
refreshTokenRepository.findByValue(refreshToken).ifPresent(refreshTokenRepository::delete);
}
@Transactional(readOnly = true)
public Optional<RefreshToken> findByRefreshToken(String refreshToken){
return refreshTokenRepository.findByValue(refreshToken);
}
}
- 리프레시 토큰을 추가, 삭제, 조회하는 메소드
- ifPresent(refreshTokenRepository::delete);
Optional 객체가 값을 가지고 있으면 (isPresent() == true)로 delete() 메소드를 실행하여 삭제
값이 없으면 (isPresent() == false)로 아무 동작도 하지 않음 (예외 발생 X) - (refreshTokenRepository::delete) = (refreshTokenRepository.delete(refreshToken))
람다표현식과 일반표현식 >> 이 둘은 같은 기능을 수행
UserApi Controller 정의
@RestController
@RequiredArgsConstructor
public class UserApiController {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
private final JwtTokenizer jwtTokenizer;
private final RefreshTokenService refreshTokenService;
@PostMapping("/login")
public ResponseEntity login(@RequestBody UserLoginDto userLoginDto, HttpServletResponse response){
// 1. username이 서버에 존재하는지 체크
User user = userService.findByUsername(userLoginDto.getUsername());
if(user == null){
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("존재하지 않는 아이디 입니다.");
}
// 2. 비밀번호 비교 { ... }
// 3. 유저, 비밀번호를 확인했다면 권한 얻어오기 { ... }
// 4. 토큰 발급 (만들어놓은 roles를 5번째 인자로 넣음) { ... }
// 5. Refresh Token을 데이터베이스에 저장 { ... }
// 6. 응답으로 보낼 값 준비 { ... }
return ResponseEntity.ok(loginResponseDto);
}
}
- 이 컨트롤러는 사용자의 로그인 요청을 처리하고, JWT(액세스 토큰 & 리프레시 토큰)를 발급하는 역할
- ➡️UserApiController는 로그인 시 RefreshTokenService를 사용하여 RefreshToken을 DB에 저장함
- @RestController : 클라이언트의 HTTP 요청을 처리
- @RequestBody
➡️HTTP 요청의 body에 포함된 JSON 데이터를 UserLoginDto 객체로 변환하기 위해 사용
ex. JSON 데이터로 요청하면
{
"username": "t3",
"password": "1234"
}
@PostMapping("/login")
public ResponseEntity login(@RequestBody UserLoginDto userLoginDto)
- @RequestBody 에서 JSON이 UserLoginDto 객체로 변환되어 매핑됨
❓UserLoginDto를 별도로 관리하는 이유
➡️UserLoginDto는 로그인 요청에서 필요한 필드(username, password)를 정의하는데
보안 관련 DTO를 분리하여 관리하기 위해서임 >> 유지보수성 및 확장성 향상
ex. CURL 테스트
curl -X POST <http://localhost:8080/login> \\
-H "Content-Type: application/json" \\
-d "{\\"username\\":\\"t3\\", \\"password\\":\\"1234\\"}"
- POST /login 요청
- JSON 데이터를 @RequestBody UserLoginDto로 변환
- 사용자 검증 → 비밀번호 검증 → JWT 생성 → 리프레시 토큰 저장
- JSON 응답 반환 (액세스 토큰 & 리프레시 토큰 포함)
SecurityConfig 추가
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.httpBasic(httpBasic -> httpBasic.disable())
.cors();
- csrf().disable() : CSRF는 세션 기반 인증에서 사용됨. JWT 인증은 세션을 사용하지 않기 때문에 불필요
- httpBasic().disable() : Bearer 토큰 인증을 사용하므로, HTTP 기본 인증을 비활성화
- sessionCreationPolicy(SessionCreationPolicy.STATELESS))
➡️Spring Security에서 세션 생성 정책을 정의하는 설정
ALWAYS : 항상 세션 설정, ... STATELESS : 세션을 사용하지 않음 (JWT 기반 인증 시 필수)
(=JWT를 활용한 무상태(stateless) 인증 방식) - HTTPBasic 인증
➡️HTTP 요청의 헤더(Authorization)에 username:password를 Base64 인코딩하여 인증하기때문에 보안이 취약
➡️HTTPS 환경에서만 사용하는 것이 권장
❓ HTTPS 환경에서는 HttpBasic을 사용해도 괜찮은 이유?
➡️HTTPS를 사용하면, 요청 데이터가 암호화되므로 username:password가 안전하게 전달됨
이 또한 보안이 취약할 수 있어, JWT 또는 OAuth를 더 많이 사용
정리하자면
HttpBasic, Bearer 인증
- HTTP에서 사용할 수 있는 인증 방식 중 일부
HttpBasic | ID/PW를 Base64 인코딩하여 인증 |
Bearer | JWT 또는 OAuth 토큰을 활용한 인증 |
Digest | HttpBasic보다 보안이 강화된 방식 |
OAuth | API 인증에 사용되는 대표적인 토큰 기반 인증 |
main 테스트
1. curl -X POST 테스트
2. 웹 API 테스트
3. curl -i -X POST (정보 포함) 테스트
쿠키 추가 - UserApiController
// 5-1) "JWT를 쿠키에 저장하여 클라이언트가 인증에 사용할 수 있도록 설정"
Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
// http에서만 사용가능 (보안 설정) = 쿠키 값을 자바스크립트 등에서는 접근 불가
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT/1000));
Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT/1000));
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
- accessTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT/1000));
➡️쿠키의 시간단위 : 초(sec) 단위, ACCESS_TOKEN_EXPIRE_COUNT는 Long타입에 밀리초(ms) 단위
Math.toIntExact : Long타입을 int타입으로 안전하게 바꿔줌 - accessTokenCookie.setHttpOnly(true);
HttpOnly 옵션을 설정하여 클라이언트의 JavaScript에서 쿠키에 접근하지 못하도록 함 (XSS 공격 방지)
❓ DB 저장 방식 vs 쿠키 저장 방식 차이점
쿠키 저장 (클라이언트 측 저장) | JWT를 쿠키에 저장 (HttpOnly) | XSS 공격 방어 가능 | CSRF 공격 가능성 |
DB 저장 (서버 측 저장) | 리프레시 토큰을 DB에 저장 | 보안이 강함 (서버에서 직접 관리) | 서버 부하 증가 |
- DB와 쿠키를 함께 사용하기도 함 (DB에서 토큰 검증 후, 쿠키에서 가져옴)
▶️실습 - curl 요청
curl -X POST <http://localhost:8080/login> \\
-H "Content-Type: application/json" \\
-d '{"username":"t3", "password":"1234"}'
[클라이언트] [서버]
| |
| --- 로그인 요청 ----------------------------> | (POST /login)
| |
| <--- `UserService.findByUsername()` 조회 ---- |
| |
| <--- `PasswordEncoder.matches()` 검증 ------- |
| |
| <--- JWT 생성 (Access & Refresh) ------------ |
| |
| <--- RefreshToken DB 저장 ------------------- |
| |
| <--- JWT 쿠키 저장 (`HttpOnly`) -- |
| |
| <--- 응답 (JWT 포함) ---------- |
1. curl -i -X POST 테스트
2. 웹 API 테스트
- 결과값으로 Set-Cookie 도 가져와지는 것을 확인 가능
JwtAuthenticationFilter
- JWT 인증을 위한 필터로, 클라이언트의 요청을 가로채서 인증을 수행
- Spring Security에서 인증이 필요한 요청이 들어오면, 이 필터가 JWT를 검증하여 SecurityContext에 저장
- public class JwtAuthenticationFilter extends OncePerRequestFilter { ... }
➡️OncePerRequestFilter 상속
➡️한 번의 요청당 한 번만 실행되는 필터 : 요청이 여러 개의 필터를 거쳐도, 한 요청에 대해 이 필터가 한 번만 실행 - CustomUserDetails customUserDetails = new CustomUserDetails(username, "", name, grantedAuthorities);
➡️Spring Security의 Authentication 객체 생성
➡️CustomUserDetails 객체를 만들어 Spring Security에서 사용할 수 있도록 함 - return new JwtAuthenticationToken(grantedAuthorities, customUserDetails, null);
➡️JwtAuthenticationToken 객체를 생성하여 반환
➡️credentials(비밀번호)는 JWT에서는 사용되지 않으므로 null
웹 API - Bearer 테스트
- [Bearer + 공백 + 토큰값] 형태로 Authorization을 보내줌 (Header에 포함)
JwtTokenizer - parseAccessToken() 추가
// 토큰 정보를 꺼내오는 메소드
public Claims parseToken(String token, byte[] secretKey){
return Jwts.parserBuilder()
.setSigningKey(getSigningKey(secretKey))
.build()
.parseClaimsJws(token)
.getBody();
}
public Claims parseAccessToken(String accessToken){
return parseToken(accessToken, accessSecret);
}
public Claims parseRefreshToken(String refreshToken){
return parseToken(refreshToken, refreshSecret);
}
- parseAccessToken()
➡️JWT를 해석하여 Claims(토큰 정보) 반환
➡️parseToken()을 호출하여 JWT의 payload를 파싱
➡️내부적으로 서명을 검증하여 변조되지 않은 JWT인지 확인
SecurityContextHolder 구조
+--------------------------+
| SecurityContextHolder |
| - SecurityContext |
| - Authentication |
| - Principal | (UserDetails - 사용자 정보)
| - Credentials | (비밀번호 or JWT)
| - Authorities | (GrantedAuthority - 권한)
+--------------------------+
- Principal은 현재 로그인한 사용자의 정보를 담고 있으며, UserDetails 객체가 그 역할을 수행
- AbstractAuthenticationToken이 Authentication을 구현한 대표적인 클래스
GrantedAuthority
- Spring Security에서 사용자의 권한을 표현하는 객체
- 사용자가 가진 권한(Role)을 SecurityContext에 저장
- 인가(Authorization) 과정에서 사용
- JWT에서 가져온 역할 정보를 Spring Security가 사용할 수 있도록 변환
addFilterBefore()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenizer), UsernamePasswordAuthenticationFilter.class)
- JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에서 실행하도록 설정
- JWT를 검증하고, SecurityContext에 Authentication 객체를 저장하는 역할을 수행
- Spring Security 필터 체인은 여러 개의 필터가 순서대로 실행되는 구조인데
addFilterBefore()를 사용하면 기존 필터(UsernamePasswordAuthenticationFilter)보다 먼저 실행되도록 설정 가능
🚀회고 결과 :
이번 회고에서는 JWT에서 Filter와 Tokenizer를 만드는 방법에 대해 다시 배울 수 있었다.
- JWT의 흐름을 이해
- curl 요청 명령 수행
느낀 점 :
어려운 점이 많았던 파트였다. 오늘 회고를 다시 검토하면서 공부해야겠다고 느꼈다.
향후 계획 :
- 이번 회고 복습 !
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_62일차_"OAuth2 연동" (0) | 2025.03.10 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_61일차_"Spring Security 예외" (0) | 2025.03.07 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_58일차_"Spring Security와 DB연결" (0) | 2025.03.04 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_57일차_"Spring Security - 권한, 쿠키" (0) | 2025.02.28 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_56일차_"ThreadLocal, Spring Security" (0) | 2025.02.27 |