jwt 토큰을 이용한 다중서버 로그인 유지
AuthServer-CorsConfig
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
//새로운 UrlBasedCorsConfigurationSource 객체를 생성합니다. 이 객체는 URL 패턴과 함께 CORS 설정을 등록하는 데 사용됩니다.
CorsConfiguration config = new CorsConfiguration();
//새로운 CorsConfiguration 객체를 생성합니다. 이 객체는 CORS 설정을 정의하는 데 사용됩니다.
config.setAllowCredentials(true);
//이 줄은 CORS 요청에서 자격 증명(쿠키, 인증헤더 등)을 사용할 수 있도록 설정합니다.
config.addAllowedOrigin("*");
//이 줄은 모든 도메인에서의 CORS 요청을 허용합니다. (주의: 실제로는 필요한 도메인만 허용하는 것이 보안상 좋습니다.)
//클라이언트가 어떤게 되도 상관없다
config.addAllowedHeader("*");
//이 줄은 모든 헤더에 대해 CORS 요청을 허용합니다.
config.addAllowedMethod("*");
//이 줄은 모든 HTTP 메서드(GET, POST, PUT, DELETE 등)에 대해 CORS 요청을 허용합니다.
source.registerCorsConfiguration("/api/**", config);
//이 줄은 /api/** URL 패턴에 대해 앞서 정의한 CorsConfiguration 을 등록합니다.
return new CorsFilter(source);
//새로운 CorsFilter 객체를 생성하고 반환합니다. 이 필터는 앞서 설정한 CORS 설정을 사용합니다.
}
}
@configuration : 설정 클래스 -> 빈정의, 외부파일 설정 로딩
@Bean : IoC(Inversion of Control) 컨테이너가 관리하는 객체->DI(dependency injection) 되는것들
사용할려면 직접 new 한뒤에 사용해야하지만 스프링에서 띄우기 때문에 편하게 사용가능
CORS(Cross-Origin Resource Sharing) : 보안상의 이유로 다른 출처의 자원에서 접근을 막source.registerCorsConfiguration("/api/**", config);를 통해 /api/로 시작하는 모든 요청에 대해
위에서 구성한 CORS 설정이 적용됩니다. 나머지는 접근 불가
AuthServer-SecurityConfig
@EnableWebSecurity
//스프링 시큐리티를 활성화해서 웹 보안 구성을 할 수 있도록 해줍니다
@Configuration
@RequiredArgsConstructor
//롬복에서 제공하는 어노테이션으로 final 필드를 가지는 생성자를 자동으로 생성합니다
//없으면 아래처럼 직접 생성자를 만들어야 한다
/*
public SecurityConfig(TokenProvider tokenProvider, CorsFilter corsFilter, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler) {
this.tokenProvider = tokenProvider;
this.corsFilter = corsFilter;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
}
*/
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
//TokenProvider, CorsFilter, JwtAuthenticationEntryPoint, JwtAccessDeniedHandler 주입받기
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
//BCryptPasswordEncoder 인스턴스를 반환하는 Bean을 정의, 비밀번호 인코딩 및 매칭을 처리하는 데 사용
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//filterChain 메서드는 SecurityFilterChain 객체를 생성하는 Bean을 정의하며, HTTP 보안 구성을 설정합니다.
http
// CSRF 설정을 기본으로 Configuration을 통해 비활성화
.csrf(csrf -> csrf.disable())
// 로그인 인증필터 보다 CORS 필터가 더 앞에 있어야 한다
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
// 인증 실패 시->JwtAuthenticationEntryPoint
// 접근 거부 경우->JwtAccessDeniedHandler
.exceptionHandling(exceptionHandling ->
exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
//X-Frame-Options : 보안강화
.headers(headers ->
headers.frameOptions(frameOptions -> frameOptions.sameOrigin())
)
// STATELESS -> JWT쓸거니까
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// /auth는 기본적으로 허용해야한다
// 나머지는 인증되야만 사용가능
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
);
// JwtFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
http.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
//보안 구성 끝
return http.build();
}
}
-현재 필터 순서 cors->jwt->usernamepassword
AuthServer-AuthController
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/signup")
//request받는거랑 response받는거랑 형식이 다르다
public ResponseEntity<MemberResponseDto> signup(@RequestBody MemberRequestDto memberRequestDto) {
//authService.signup()호출해서 성공하면 200리턴
return ResponseEntity.ok(authService.signup(memberRequestDto));
}
@PostMapping("/login")
public ResponseEntity<TokenDto> login(@RequestBody MemberRequestDto memberRequestDto) {
return ResponseEntity.ok(authService.login(memberRequestDto));
}
@PostMapping("/reissue")
public ResponseEntity<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
return ResponseEntity.ok(authService.reissue(tokenRequestDto));
}
}
-ResponseEntity : 사용이유
- HTTP 응답 형태 제어: ResponseEntity를 사용하면 HTTP 응답 코드, 헤더, 본문 데이터 등을 세밀하게 제어할 수 있습니다.
- 유연한 응답: ResponseEntity는 다양한 상황에서 유연한 응답 생성을 제공하여, API 요청에 따라 다른 형태의 응답을 제공할 수 있습니다.
- HTTP 응답 코드 설정: ResponseEntity를 통해 HTTP 응답 코드를 설정하여 성공, 실패 또는 다른 상태를 클라이언트에게 전달할 수 있습니다.
AuthServer-MemberRequestDto
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class MemberRequestDto {
private String email;
private String password;
//service에서 회원가입할때 필요
public Member toMember(PasswordEncoder passwordEncoder) {
return Member.builder()
.email(email)
.password(passwordEncoder.encode(password))
//기본적으로 역할은 USER
.authority(Authority.ROLE_USER)
.build();
}
//service에서 login할때 사용 -> emial,password를 받아서 auth토큰 객체 형성
//service에서 login을 하면 사용자정보가 일치한지 확인하고 jwt토큰을 발행하는데
//사용자 정보가 일치한지 확인할때 사용된다
public UsernamePasswordAuthenticationToken toAuthentication() {
return new UsernamePasswordAuthenticationToken(email, password);
}
}
- UsernamePasswordAuthenticationToken : SpringSecurity에서 사용되는 기본 인증
https://brio-sw.tistory.com/98
AuthServer-MemberResponseDto
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class MemberResponseDto {
private String email;
//멤버를 받아서 responseDto로 변환 해주는 of메소드
public static MemberResponseDto of(Member member) {
return new MemberResponseDto(member.getEmail());
}
}
-of : 특정객체를 다른객체 형태로 변환할때 사용
AuthServer-Member
@Entity
@Getter
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String email;
private String password;
@Enumerated(EnumType.STRING)
private Authority authority;
@Builder
//다른 클래스에서멤버객체를 사용할때 필요
public Member(String email, String password, Authority authority) {
this.email = email;
this.password = password;
this.authority = authority;
}
}
-@Builder : 다른 클래스에서 멤버 객체를 사용할때 필요하다, 생성자에 적용한
AuthServer-RefreshToken
@Getter
@NoArgsConstructor
@Entity
public class RefreshToken {
@Id
@Column(name = "rt_key")
private String key;
@Column(name = "rt_value")
private String value;
@Builder
public RefreshToken(String key, String value) {
this.key = key;
this.value = value;
}
//service에서 토큰 재발행 할때 사용된다
public RefreshToken updateValue(String token) {
this.value = token;
return this;
}
}
AuthServer-JwtFilter
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
//헤더 작업
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
// 실제 필터링 로직은 doFilterInternal 에 들어감
// JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
//서블릿으로 넘기기전에 다음 필터로 전달한다 : 다음필터는 매개변수인 filterchain에의 해 결정된다
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.split(" ")[1].trim();
}
return null;
}
}
- @RequiredArgsConstructor : final에 대해서 자동으로 생성자 생성
https://brio-sw.tistory.com/99
jwt토큰을 헤더에 담아서 들고오는 클라이언트의 요청을 여기서 토큰이 유효한지 검사하고 스레드의 context에 저장한다
그리고 서블릿에 넘기는게 아니라 다음 필터로 넘긴
https://brio-sw.tistory.com/100
AuthServer-TokenProvider
@Slf4j
//로거 자동 생성
@Component
//컴포넌트로 등록
public class TokenProvider {
//토큰에 포함되는 정보 중 키값을 정의
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일
//jwt토큰 서명에 사용되는 키
//토큰을 검증할 때, 서버는 토큰의 Header와 Payload 부분을 추출하고, 동일한 비밀 키로 서명을 생성한다
private final Key key;
//jwt토큰은 base64로 디코딩 후 HMAC-SHA 알고리즘에 사용후 키 생성
public TokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
//controller(memberdto)->service(authentication=인증확인)->여기
public TokenDto generateTokenDto(Authentication authentication) {
// getAuthorities()로 가져온 컬렉션을 stream() 스트림으로 변환
String authorities = authentication.getAuthorities().stream()
//사용자 권한 확인 ROE_USER
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
//JwtBuilder 인스턴스 생성
String accessToken = Jwts.builder()
.setSubject(authentication.getName()) // payload "sub": "name"
.claim(AUTHORITIES_KEY, authorities) // payload "auth": "ROLE_USER"
.setExpiration(accessTokenExpiresIn) // payload "exp": 151621022 (ex)
.signWith(key, SignatureAlgorithm.HS512) // header "alg": "HS512" ,JWT서명 설정
.compact(); // MAKE
// Header: { "alg": "HS512", "typ": "JWT" }
// Payload: { "sub": "name", "auth": "ROLE_USER", "exp": 151621022 }
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
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(","))
//스트림의 각 요소를 SimpleGrantedAuthority 객체로 변환하고
//SimpleGrantedAuthority는 GrantedAuthority의 구현체로, 권한 정보를 담는 역할을 한다
//스트림의 각 권한 문자열("ROLE_USER", "ROLE_ADMIN")에 대해
//SimpleGrantedAuthority 생성자를 호출해서 객체를 만듭니다
.map(SimpleGrantedAuthority::new)
//SimpleGrantedAuthority 객체들이 리스트(List<SimpleGrantedAuthority>)로 변환
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
// 시큐리티 자체적으로 UserDetails의 구현체인 User를 사용한다
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | 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.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
-컴포넌트 : @Bean은 사용자가 직접 객체를 생성하고 반환한다면, @Component는 스프링이 대신 해준다고 생각하자
https://brio-sw.tistory.com/100