auth.jwt
package com.frombooktobook.frombooktobookbackend.auth.jwt;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER= "Authorization";
public static final String BEARER_PREFIX="Bearer";
private final JwtTokenProvider tokenProvider;
// 실제 필터링 로직은 doFilterInternal에서 수행
// JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext에 저장하는 역할 수행
// 가입/로그인/재발급을 제외한 모든 Request 요청은 이 필터를 거침 -> 토큰 정보가 없거나 유효하지 않으면 정상적으로 수행되지 않음
// 요청이 정상적으로 Controller까지 도착했다면 SecurityContext에 UserId가 존재한다는 것이 보장됨
// 직접 DB를 조회한 것이 아니라 Access Token에 있는 ID를 꺼낸 것이므로 탈퇴로 인해 DB에 ID가 없는 경우 등의 예외 상황은
// Service 단에서 따로 고려해야 함
@Override
protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
// 1. Request Header에서 토큰을 꺼낸다.
String jwt = resolveToken(request);
// 2. validataToken으로 유효성을 검사한다.
// 정상 토큰이면 해당 토큰으로 Authentication을 가져와서 SecurityContext에 저장한다.
if(StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
package com.frombooktobook.frombooktobookbackend.auth.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import java.security.Key;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Slf4j
@Component
public class JwtTokenProvider {
// Jwt 토큰에 관련된 암호화, 복호화, 검증 로직이 이루어지는 클래스
private static final String JWT_SECRET_KEY = "secretkey1085737618374629";
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "bearer";
private static final long ACCESS_TOKEN_EXP_TIME = 1000 * 60 * 60; // 1시간 설정
private static final long REFRESH_TOKEN_EXP_TIME = 1000 * 60 * 60 * 24 * 7; // 7일 설정
private final Key key;
public JwtTokenProvider() {
byte[] keyBytes = Decoders.BASE64.decode(JWT_SECRET_KEY);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenDto generateToken(Authentication authentication) {
// 권한들 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Date now = new Date();
Date accessTokenExpiresIn =new Date(now.getTime() + ACCESS_TOKEN_EXP_TIME);
String accessToken = Jwts.builder()
.setHeaderParam(Header.TYPE,Header.JWT_TYPE)
.setIssuer("FromBookToBook")
.setIssuedAt(now)
.setExpiration(accessTokenExpiresIn) // payload "exp" : 1248383 (예시)
.setSubject(authentication.getName()) // payload "sub" : "name"
.claim(AUTHORITIES_KEY,authorities) // payload "auth" : "ROLE_USER"
.signWith(SignatureAlgorithm.HS256,key) //header "alg" : "HS256"
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXP_TIME))
.signWith(SignatureAlgorithm.HS256,key)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
// .refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 토큰에 있는 정보를 꺼낸다.
public Authentication getAuthentication (String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if(claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// SecurityContext는 Authentication 객체를 저장함
// UserDetails 객체를 만들어서 Authentication 리턴 -> SecurityContext 를 사용하기 위한 절차
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal,"",authorities);
}
// 토큰 정보를 검증한다. Jwts 모듈이 Exception을 던져준다.
public boolean validateToken(String token) {
try{
Jwts.parser().setSigningKey(key).parseClaimsJws(token);
return true;
} catch(MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch( ExpiredJwtException e) {
log.info("만료된 JWT 서명입니다.");
} catch( UnsupportedJwtException e ) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e ) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
private Claims parseClaims (String accessToken) {
try {
return Jwts.parser().setSigningKey(key).parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
package com.frombooktobook.frombooktobookbackend.auth.jwt;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenDto {
private String grantType;
private String accessToken;
// private String refreshToken;
private Long accessTokenExpiresIn;
}
auth.springSecurity
package com.frombooktobook.frombooktobookbackend.auth.springSecurity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest reauest, HttpServletResponse response, AccessDeniedException accessDeniedException)
throws IOException, ServletException {
// 유저 정보는 있으나 필요한 권한이 없을 경우 403 응답
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
package com.frombooktobook.frombooktobookbackend.auth.springSecurity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence (HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException)
throws IOException {
// 유효한 유저 정보 없이 접근시 401 응답
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
System.out.println("401 error occured.");
}
}
package com.frombooktobook.frombooktobookbackend.auth.springSecurity;
import com.frombooktobook.frombooktobookbackend.auth.jwt.JwtFilter;
import com.frombooktobook.frombooktobookbackend.auth.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
// 직접 만든 TokenProvider와 JwtFilter를 SecurityContext에 적용
private final JwtTokenProvider tokenProvider;
// TokenProvider를 주입받아서 JwtFilter를 통해 Security 로직에 필터를 등록
@Override
public void configure(HttpSecurity http)
{
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
package com.frombooktobook.frombooktobookbackend.auth.springSecurity;
import com.frombooktobook.frombooktobookbackend.auth.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
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;
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
// h2 database 테스트를 위해 관련 API 무시 처리
@Override
public void configure(WebSecurity web) {
web.ignoring()
.antMatchers("/h2-console/**","/favicon.ico");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF 설정 Disable
http.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// h2-console을 위한 설정
.and()
.headers()
.frameOptions()
.sameOrigin()
// 시큐리티는 기본적으로 세션을 이용하나 지금은 세션을 이용하지 않으므로 세션 설정을 Stateless로
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 permitAll 설정
.and()
.authorizeRequests()
.antMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
// JwtFilter를 addfilterBeforefh 등록했던 JwtSecuirtyConfig 클래스 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));
}
}
package com.frombooktobook.frombooktobookbackend.auth.springSecurity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
@Slf4j
public class SecurityUtil {
private SecurityUtil() {}
// SecurityContext 에 유저 정보가 저장되는 시점
// Request 가 들어올 때 JwtFilter의 doFilter에서 저장
public static Long getCurrentUserId() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication==null || authentication.getName() ==null) {
throw new RuntimeException("Security Context에 인증 정보가 없습니다.");
}
return Long.parseLong(authentication.getName());
}
}
'Project > FromBookToBook' 카테고리의 다른 글
[FBTB] 4. 로그인 유지 기능 구현 (OAuth2/JWT 관련 API) (0) | 2022.04.22 |
---|---|
[FBTB] 4. 로그인 유지 기능 구현 [1] (0) | 2022.04.21 |
[FBTB] 3. 로그인 기능 구현 (with Oauth2) (0) | 2022.04.07 |
[FBTB] 2. 독후감 목록 기능 구현 (0) | 2022.04.01 |
[FBTB] 1. 독후감 작성 기능 구현 (0) | 2022.03.30 |