JWT
JSON (JSON Web Token)
1. JWT
- JSON으로 표현된 사용자 정보를 안전하게 주고받기 위한 Token의 일종.
- JWT 내부에 사용자 확인 인증정보가 담기며, JWT를 받으면 위변조가 되었는지 알아차릴 수 있기 때문에 Token 기반의 인증 시스템에서 많이 활용.
- 상태를 저장하지 않기 때문에 서버의 세션 관리가 불필요!!
- 토큰 소유가 곧 인증 >> 여러 서버에 걸쳐서 인증이 가능
cf) 쿠키는 요청을 보낸 클라이언트에 종속됨. / 토큰은 쉽게 첨부 가능(주로 header에)
- 로그인 "상태"라는 개념이 사라져서 로그아웃이 불가하다. (기본적으로는.. 로그아웃하려면 프론트에서 토큰 폐기.)
header.payload.signature
header : 이 JWT의 부수적인 정보(어떤 방식으로 암호화 되었는지 등)가 담긴다.
payload : 이 JWT가 실제로 전달하고자 하는 정보가 담긴 부분.
- claim : 정보
- sub(subject) : 사용자
- iat(issued at) : 발급일자
- exp(expires) : 만료 일자
signature : 이 JWT가 위조되지 않은 JWT인지를 판단하기 위한 부분.
- header, payload의 길이, 사전 공유된 암호키를 기반으로 signature를 계산해 JWT의 위변조를 감지.
- JJWT 라이브러리 추가
// build.gradle
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
2. JWT 발급
1) JwtTokenUtils
- 암호키 준비 : application.yaml에 작성
// application.yaml
// 커스텀 설정 (Spring내부 설정 아님)
jwt:
secret: aaaabbbsdifqbvaesoioegwaaaabbbsdifqbvaesoioegwaaaabbbsdifqbvaes
- Constructor에서 암호키 만들기
// JwtTokenUtils class
//JWT 자체와 관련된 기능을 만드는 곳
//@Value 어노테이션은 spring bean factory annotation을 선택(lombok 아님)
import org.springframework.beans.factory.annotation.Value;
@Slf4j
@Component
public class JwtTokenUtils {
private final Key signingKey;
//암호키 만들기
public JwtTokenUtils(
@Value("${jwt.secret}")
String jwtSecret
) {
log.info(jwtSecret);
this.signingKey
= Keys.hmacShaKeyFor(jwtSecret.getBytes());
}
}
- generateToken() : UserDetails를 받아서 JWT로 발급하는 메서드
- 일반적인 JWT 외의 정보를 포함하고 싶다면 Map.put() 사용 가능. (Claims 인터페이스는 Map<String, Object>를 상속받기 때문. )
// JwtTokenUtils class
//UserDetail를 받아서 JWT로 변환하는 메서드
public String generateToken(UserDetails userDetails) {
//JWT에 담고싶은 정보를 payload - Claim으로 만든다.
//sub, iat, exp
Instant now = Instant.now(); //호출되었을 현재 시각 epoch time
Claims jwtClaims = Jwts.claims()
.setSubject(userDetails.getUsername()) //누구인지
.setIssuedAt(Date.from(now)) //언제 발급 되었는지
.setExpiration(Date.from(now.plusSeconds(20L))); //언제 만료 예정인지 (20초후 만료되게 설정)
// 일반적인 JWT 외의 정보를 포함하고 싶다면 Map.put 사용 가능
// jwtClaims.put("test", "claim");
//JWT를 최종적으로 발급한다.
return Jwts.builder().
setClaims(jwtClaims). //지정된 클레임 설정
signWith(this.signingKey) //서명 키로 서명
.compact(); //최종 압축하여 문자열로 만들어줌
}
- JWT를 발급받을 엔드포인트 만들기 (컨트롤러 만들기)
- JwtTokenUtils : 실제로 JWT를 발급하기 위핸 필요한 Bean
- UserDetailsManager : 사용자 정보를 확인하기 위한 Bean
- PasswordEncoder : 사용자가 제공한 비밀번호를 비교하기 위한 암호화 Bean
- JwtRequestDto와 JwtResponseDto를 만들어 비밀번호를 확인해보고 일치하면 토큰을 발급한다.
//TokenController class
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("token")
public class TokenController {
// JWT를 발급하기 위한 Bean
private final JwtTokenUtils jwtTokenUtils;
// 사용자 정보를 회수하기 위한 Bean
private final UserDetailsManager manager;
// 사용자가 제공한 아이디 비밀번호를 비교하기 위한 클래스
private final PasswordEncoder passwordEncoder;
@PostMapping("/issue")
public JwtResponseDto issueJwt(@RequestBody JwtRequestDto dto) {
if (!manager.userExists(dto.getUsername())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
//사용자가 제공한 username, password가 저장된 사용자인지
UserDetails userDetails =
manager.loadUserByUsername(dto.getUsername());
//비밀번호 대조 (rawPassword, encodedPassword)
if (!passwordEncoder.matches(dto.getPassword(), userDetails.getPassword())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
// 일치하면 토큰 발급
JwtResponseDto jwtResponseDto = new JwtResponseDto();
jwtResponseDto.setToken(jwtTokenUtils.generateToken(userDetails));
return jwtResponseDto;
}
}
// JwtRequestDto
@Data
public class JwtRequestDto {
private String username;
private String password;
}
//JwtResponseDto
@Data
public class JwtResponseDto {
private String token;
}
- WebSecurityConfig : 인증이 필요 없는 상태로 설정.
// WebSecurityConfig class
@Configuration
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(
auth -> auth.requestMatchers("/no-auth",
"/users/home",
"/tests",
"/token/issue")
.permitAll()
.requestMatchers("/users/my-profile")
.authenticated()
.requestMatchers("/users/register",
"/users/login")
.anonymous()
)
.formLogin(
// ...
)
.logout(
// ...
);
return http.build();
}
}
>> 그러면 이렇게 나온다 token이 만들어지고, header.payload.signature 형태로 나온다.
>> jwt.io에서 얘를 확인해보면 이렇게 바로 decode되는데, 여기서 signature에서 내가 yaml에 설정해놓은 secret key를 넣어보면 Signature Verified로 바뀐다. 이런식으로 인증을 한다는 뜻이다. 즉, signingKey를 꼭 알아야지만 인증이 된다 = 그래서 위조인지 아닌지를 알게된다는 뜻.
3. JWT를 이용한 인증
- 토큰 기반 인증방식에서는 일반적으로 HTTP header에 Authorization Header를 추가해서 보낸다.
- JWT 비롯한 토큰을 활용해서 진행할 경우 > Bearer {token값} 형태로 추가해 보낸다 (Bearer Token 인증방식)
즉, 받는 요청에 헤더의 Authorization 값이 없다면 인증되지 않은 사용자, 값이 있다면 해당 Token이 유효한지 확인한 후 인증상태 판단.
- 결국 요청을 보내는 시점에 Header에 Authorization 값을 추가해주는 행위를 해야 한다 !! >> 서버(cookie/session)에서가 아닌 Frontend쪽에서 직접 진행해주어야 한다.
- 서버에서는 들어온 HTTP 요청에 Authorization Header 존재하는지, Bearer로 시작하는 값인지, 그 안의 Token이 유효한지 판단하는 필터를 만들어 확인한다.
1) JWT 토큰이 유효한지 확인하는 메서드 : JwtTokenUtils.validate()
- JwtParser : Key정보를 바탕으로 JWT 정보를 해석. >> 필드로 할당해준다. + constructor에 추가(signingKey를 포함해서 jwt를 해석하도록)
- jwtParser.parseClaimJws() : 암호화된 JWT String을 파싱해준다.
// JwtTokenUtils class
@Slf4j
@Component
public class JwtTokenUtils {
private final Key signingKey;
// Jwt를 해석하는 용도의 객체
private final JwtParser jwtParser;
//암호키 만들기
public JwtTokenUtils(
@Value("${jwt.secret}")
String jwtSecret
) {
log.info(jwtSecret);
this.signingKey
= Keys.hmacShaKeyFor(jwtSecret.getBytes());
this.jwtParser = Jwts.parserBuilder()
.setSigningKey(this.signingKey)
.build();
}
// 정상적인 JWT인지를 판단하는 메서드
public boolean validate(String token) {
//만약 정상적이지 않은 JWT라면 예외가 발생한다.
try {
jwtParser.parseClaimsJws(token);
return true;
} catch (Exception e) {
log.warn("invalid jwt");
}
return false;
}
}
- 혹은 예외에 따라 다르게 로그를 찍어줄 수도 있다.
// JwtTokenUtils class
public boolean validate(String token) {
try {
jwtParser.parseClaimsJws(token).getBody();
return true;
} catch (SecurityException | MalformedJwtException e){
log.warn("malformed jwt");
} catch (ExpiredJwtException e) {
log.warn("expired jwt presented");
} catch (UnsupportedJwtException e) {
log.warn("unsupported jwt");
} catch (IllegalArgumentException e) {
log.warn("illegal argument");
}
return false;
}
- 인증을 위해서 실제 데이터(Payload)를 반환하는 메서드도 만들어준다.
// JwtTokenUtils class
//실제 데이터(payload)를 반환하는 메서드
public Claims parseClaims(String token) {
return jwtParser
.parseClaimsJws(token) //파싱하면 Jws<Claims> 형태로 반환되어
.getBody(); // 그 Claim의 Body부분(payload)를 받는다
}
- Controller에서 실제로 인증하는 엔드포인트 만들기
// TokenController class
@GetMapping("/validate")
public Claims validateToken(
@RequestParam("token")
String token
) {
// 정상적인 jwt가 아니면 unauthorized
if (!jwtTokenUtils.validate(token)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
// 맞으면 payload부분 반환
return jwtTokenUtils.parseClaims(token);
}
- WebSecurityConfig에서 해당 url 허용하기
// WebSecurityConfig class
@Configuration
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(
auth -> auth.requestMatchers("/no-auth",
"/users/home",
"/tests",
"/token/issue",
"/token/validate")
.permitAll()
.requestMatchers("/users/my-profile")
.authenticated()
.requestMatchers("/users/register",
"/users/login")
.anonymous()
)
.formLogin(
// ...
)
.logout(
// ...
);
return http.build();
}
}
>> 결과 확인 : token 발급 받고 > 그 토큰을 고대로 parameter로 넣어줬더니 바로 payload부분을 확인 가능하다!~
2) JWT Filter 만들기
: 사용자가 포함시켜 보낸 JWT가 정당한지 아닌지 판단하는 필터
- OncePerRequestFilter를 상속받는 클래스 생성.
: 비동기 처리 또는 forward/redirect 등의 특정 상황에서 Filter의 기능이 한번만 사용되도록 보장하는 Filter 객체.
이 클래스를 상속받으면 doFilter 대신 doFilterInternal을 구현함.
** JwtTokenFilter를 Bean 객체로 등록할 경우 Spring container가 필터를 검색하고, Spring Security에서 필터를 다시 등록해서 같은 필터가 두번 등록될 수있다. >> 그러므로 여기서는 Bean 객체로 등록 X!!!!
// JwtTokenFilter
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 1. authorization 헤더를 회수
// 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
// 3. 존재하면 유효한 토큰인지
// 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
// 5. 다음 필터 호출
}
}
- Authorization 헤더를 회수
- request.getHeader("직접 Authorization 넣어주기"); 혹은 HttpHeaders에 상수로 설정된 값을 불러와도 된다. (자주 쓰이므로!)
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 1. authorization 헤더를 회수
// String authHeader = request.getHeader("Authorization");
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
// 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
// 3. 존재하면 유효한 토큰인지
// 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
// 5. 다음 필터 호출
}
}
- 헤더 존재하는지, Bearer로 시작하는지 확인 (반대 상황을 조건문으로 걸어주었다)
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 1. authorization 헤더를 회수
// String authHeader = request.getHeader("Authorization");
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
// 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
if (authHeader != null && authHeader.startsWith("Bearer ")) {
}
// 3. 존재하면 유효한 토큰인지
// 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
// 5. 다음 필터 호출
}
}
- 존재하면 유효한 토큰인지 확인
- 유효함을 확인하기 위해 JwtTokenUtils 클래스를 필드로 할당.
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
//토큰을 확인하는 메서드 (JwtTokenUtils.validate())를 위해
private final JwtTokenUtils jwtTokenUtils;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 1. authorization 헤더를 회수
// String authHeader = request.getHeader("Authorization");
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
// 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
if (authHeader != null && authHeader.startsWith("Bearer ")) {
// 3. 존재하면 유효한 토큰인지
// 헤더에서 Bearer 뒤의 토큰만 가져오기
String token = authHeader.split(" ")[1];
// 토큰을 확인하는 메서드 (JwtTokenUtils.validate()) boolean값 반환
if (jwtTokenUtils.validate(token)) {
} else {
log.warn("jwt validation failed!!");
}
}
// 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
// 5. 다음 필터 호출
}
}
- 토큰이 유효하다면 사용자 정보 등록
- 인증에 성공한 사용자를 SecurityContextHolder에 저장하면 >> Spring Security가 사용자를 인증된 사용자로 판단한다~~~~! 이 과정을 진행해주기.
- SecurityContext 생성
- 사용자 정보 회수 (JwtTokenUtils.parseClaims())
- 인증정보 생성 : 추상클래스 AbstractAuthenticationToken을 상속받은 UsernamePasswordAuthenticationToken으로 생성해준다. (인자로는 Object principal, Object Credentials, Collection<? extends GrantedAuthority> authorities 받아서 넘겨줌.)
- 인증정보 등록 : SecurityContext에 set해주고, 그 context를 SecurityContextHolder에 set해주기. (SecurityContextHolder 내부 메서드는 모두 static이므로 굳이 생성할 필요가 없다)
// JwtTokenFilter class
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
//토큰을 확인하는 메서드 (JwtTokenUtils.validate())를 위해
private final JwtTokenUtils jwtTokenUtils;
private final UserDetailsManager manager;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 1. authorization 헤더를 회수
// String authHeader = request.getHeader("Authorization");
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
// 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
if (authHeader != null && authHeader.startsWith("Bearer ")) {
// 3. 존재하면 유효한 토큰인지
// 헤더에서 Bearer 뒤의 토큰만 가져오기
String token = authHeader.split(" ")[1];
// 토큰을 확인하는 메서드 (JwtTokenUtils.validate()) boolean값 반환
if (jwtTokenUtils.validate(token)) {
// 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
// contextholder에서 context 만들고,
SecurityContext context = SecurityContextHolder.createEmptyContext();
// 토큰에서 사용자 정보 가져오기
String username = jwtTokenUtils.parseClaims(token).getSubject();
// 인증 정보 생성
AbstractAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
//principal : UserDetailsManager에서 실제 사용자 정보 가져오기
manager.loadUserByUsername(username),
// credentials : token
token,
// authorities
new ArrayList<>()
);
// 인증 정보 등록
context.setAuthentication(authenticationToken);
SecurityContextHolder.setContext(context);
log.info("=== set security context with jwt");
} else {
log.warn("jwt validation failed!!");
}
}
// 5. 다음 필터 호출
}
}
- 다음 필터 호출 꼭꼭~ filterChain.doFilter();
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
//토큰을 확인하는 메서드 (JwtTokenUtils.validate())를 위해
private final JwtTokenUtils jwtTokenUtils;
private final UserDetailsManager manager;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 1. authorization 헤더를 회수
// String authHeader = request.getHeader("Authorization");
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
// 2. 헤더가 존재하는지, Bearer로 시작하는지 확인
if (authHeader != null && authHeader.startsWith("Bearer ")) {
// 3. 존재하면 유효한 토큰인지
// 헤더에서 Bearer 뒤의 토큰만 가져오기
String token = authHeader.split(" ")[1];
// 토큰을 확인하는 메서드 (JwtTokenUtils.validate()) boolean값 반환
if (jwtTokenUtils.validate(token)) {
// 4. 유효하다면 해당 토큰을 바탕으로 사용자 정보를 SecurityContext에 등록
// contextholder에서 context 만들고,
SecurityContext context = SecurityContextHolder.createEmptyContext();
// 토큰에서 사용자 정보 가져오기
String username = jwtTokenUtils.parseClaims(token).getSubject();
// 인증 정보 생성
AbstractAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
//principal : UserDetailsManager에서 실제 사용자 정보 가져오기
manager.loadUserByUsername(username),
// credentials : token
token,
// authorities
new ArrayList<>()
);
// 인증 정보 등록
context.setAuthentication(authenticationToken);
SecurityContextHolder.setContext(context);
log.info("=== set security context with jwt");
} else {
log.warn("jwt validation failed!!");
}
}
// 5. 다음 필터 호출
filterChain.doFilter(request, response);
}
}
- 마지막으로 필터를 설정해주기 (WebSecurityConfig)
- 이제 formLogin을 사용할 필요가 없어졌다. >> .formLogin, .logout 제외하고 JWT 필터를 권한 필터 앞에 넣어주자.
- 또한, 세션에 저장할 필요가 없어졌기 때문에 보안 관련 세션을 해제해준다. (.sessionManagement)
// WebSecurityConfig class
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtTokenUtils jwtTokenUtils;
private final UserDetailsManager manager;
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) throws Exception {
http
// csrf 보안 해제
.csrf(AbstractHttpConfigurer::disable)
// url에 따른 요청 인가
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/no-auth",
"/users/home",
"/tests",
"/token/validate",
"/token/issue"
)
.permitAll()
.requestMatchers("/users/my-profile")
.authenticated()
.requestMatchers("/users/login",
"/users/register")
.anonymous()
.anyRequest().permitAll()
)
// JWT를 사용하기 때문에 보안 관련 세션 해제
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// JWT 필터를 권한 필터 앞에 삽입
.addFilterBefore(new JwtTokenFilter2(jwtTokenUtils, manager),
AuthorizationFilter.class)
;
return http.build();
}
// 비밀번호 암호화 클래스
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
* * 여기까지 하면 다 된줄 알았는데,, ㅎㅎㅎ 이러한 오류가 난다.
what's wrong...?
뭔가 순환된다는 이야기. 순환참조와 같이 된다 이유는
요렇게 두 클래스를 살펴보면 UserDetailsManager 인터페이스를 구현해놓은 CustomUserDetailsManager 클래스에서 생성시에 PasswordEncoder가 필요하다. 그러나, 이는 WebSecurityConfig에서 구현해두었고, 이 WebSecurityConfig를 생성하기 위해서는 UserDetailsManager가 필요하다. 그러므로, 닭이 먼저냐 달걀이 먼저냐...문제가 되면서 서로에게 의존적이기 때문에 application failed to start가 뜬다.
>> 이를 해결하려면 간단하게 PasswordEncoder 메서드를 그냥 따로 빼버린다.
// PasswordEncoderConfig class
@Configuration
public class PasswordEncoderConfig {
// 비밀번호 암호화 클래스
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
요렇게 빼주고 WebSecurityConfig에서는 삭제해준다. 그렇게 하면 정말로 실행이 된다! 이제 테스트해보면 된다.
토큰을 발행하고, 그 발행한 토큰을 만료 전에 Authorization headers에 넣어주고 실행!
하면 이렇게 잘 나와주고, 토큰 만료가 되고 실행하면 다시 로그인 창이 뜨게 된다. 와아아
그렇게 두려워하던 시큐리티와..... JWT.......드뎌 만났다.
아직은 직접 사용하기까지 걸리긴 하겠지만 적어둔대로 하나씩 구현해보아야겠다!!!
새로운 오류들을 만나고 해결하는 것이 재미있어진다.
'Programming > Spring, SpringBoot' 카테고리의 다른 글
OAuth2 - 소셜 로그인 (0) | 2024.01.30 |
---|---|
Authorization (0) | 2024.01.30 |
HandlerInterceptor & Filter / SecurityFilterChain (0) | 2024.01.27 |
Spring의 Dispatcher Servlet (3) | 2024.01.26 |
CustomUserDetailsManager과 CustomUserDetails 만들기 (1) | 2024.01.26 |