Just Do IT!
JWT + spring security 로그인 구현 실습 본문
728x90
반응형
토큰 기반 인증이란?
- 토큰 기반 인증은 토큰을 사용하는 방법이다.
- 토큰 : 서버에 요청을 받을 때, 요청을 보낸 클라이언트를 구분하기 위한 유일한 값
- 서버가 토큰을 생성해서 클라이언트에게 제공하면, 클라이언트는 해당 토큰을 보관하고 있다가 여러 요청을 토큰과 함께 보내게 된다.
- 그러면 서버는 토큰을 보고 해당 클라이언트가 유효한 사용자인지 검증하고, 요청을 처리해주게 된다.
JWT?
- 발급받은 JWT를 이용해 인증을 하려면 HTTP 요청 헤더 중에 Authorization 값에 Bearer + JWT 토큰값을 넣어서 보내야 한다.
- JWT는 .을 기준으로 헤더(Header), 내용(Payload), 서명(Signature)으로 구성되어 있다.
- 헤더 : 토큰의 타입과 해싱 알고리즘을 지정하는 정보
- 내용 : 토큰과 관련된 정보로 내용의 한 덩어리를 클레임(Claim)이라고 부르며 키와 값의 한 쌍으로 이뤄져 있다.
- 등록된 클레임은 토큰에 대한 정보를 담는데 사용된다.
- 서명 : 해당 토큰이 조작되었거나 변견되지 않았음을 확인하는 비밀키
직접 실습해보기 [spring security + jwt]
WebSecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final UserDetailsService userDetailsService;
private final JwtProperties jwtProperties;
// JWT PROVIDER bean 생성
@Bean
JwtProvider jwtProvider() {
return new JwtProvider(jwtProperties, userDetailsService);
};
private TokenUtils tokenUtils() {
return new TokenUtils(jwtProvider());
}
private JwtAuthenticationService jwtAuthenticationService() {
return new JwtAuthenticationService(tokenUtils());
}
// 인증 관리자 (Authenticaiton Manager) 설정
@Bean
AuthenticationManager authenticationManager() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(bCryptPasswordEncoder());
return new ProviderManager(authProvider);
}
// 암호화 빈
@Bean
BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
// HTTP 요청에 따른 보안 구현
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 경로에 대한 권한 설정
http.authorizeHttpRequests(auth ->
// 특정 URL 경로에 대해서는 인증 없이 접근 가능
auth.requestMatchers(
// 로그인 관련 로직은 LoginCustomAuthenticationFilter에 존재
new AntPathRequestMatcher("/img/**"), // image는 전부 다 보이도록 설정
new AntPathRequestMatcher("/api/auth/signup"), // 회원가입
new AntPathRequestMatcher("/api/auth/duplicate"), // email 중복 체크,
new AntPathRequestMatcher("/api/post/**", "GET") // 전체 게시글 리스트는 전부 보이도록
).permitAll()
// AuthController 중 나머지는 ADMIN만 접근 가능한 페이지
.requestMatchers(new AntPathRequestMatcher("/api/auth")).hasRole("ADMIN")
// 그 밖의 다른 요청들은 인증을 통과한 사용자라면(=로그인한 사용자) 접근 가능
.anyRequest().authenticated());
// session 관리는 이제 더 이상 하지 않는다
// 무상태성 세션 관리
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 특정 경로(로그인)에 대한 필터 추가
http.addFilterBefore(new LoginCustomAuthenticationFilter(authenticationManager(), jwtAuthenticationService()),
UsernamePasswordAuthenticationFilter.class);
// (토큰을 통해 검증할 수 있도록) filter 추가
// http.addFilterBefore(추가할 필터, 다른필터)
// jwt 인증 필터를 추가
http.addFilterBefore(new JwtAuthenticationFilter(jwtProvider()), UsernamePasswordAuthenticationFilter.class);
// HTTP 기본 설정
http.httpBasic(HttpBasicConfigurer::disable);
// CSRF 비활성화
http.csrf(AbstractHttpConfigurer::disable);
// CORS 비활성화
http.cors(corsConfig -> corsConfig.configurationSource(corsConfigurationSource()));
return http.getOrBuild();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
return request -> {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedHeaders(Collections.singletonList("*"));
config.setAllowedMethods(Collections.singletonList("*"));
config.setAllowedOriginPatterns(Collections.singletonList("http://localhost:3000"));
config.setAllowCredentials(true);
return config;
};
}
}
Bean 생성
- JWT Provider : JWT 토큰을 생성하고 검증
- TokenUtils : JWT를 처리하는 유틸리티 클래스
- JwtAuthenticationService : JWT를 이용한 인증 서비스
- AuthenticationManager ; DaoAuthenticationProvider를 사용하여 사용자 인증을 처리, 비밀번호 암호화
- BCryptPasswordEncoder : 비밀번호 암호화
Security Filter Chain
- 권한 설정
- 세션 관리 : STATELESS 로 설정해서 무상태
- 필터 추가 : LoginCustomAuthentication (로그인 필터), JwtAuthenticationFilter (JWT를 통한 인증 필터)
- HTTP 기본 인증 비활성화
- CRSF 비활성화
- CORS 설정 : 특정 출처(localhost:3000)에서의 요청을 허용
CORS 설정
- 요청 헤더, 메서드, 출처를 허용
JwtProvider.java
@Slf4j
@Service
@RequiredArgsConstructor
public class JwtProvider {
// jwt 설정 정보 객체 주입
private final JwtProperties jwtProperties;
private final UserDetailsService userDetailsService;
// jwt access token 생성
public String generateAccessToken(User user) {
log.info("[generateAccessToekn] 토큰을 생성합니다");
Date now = new Date(); // 현재 날짜
Date expiredDate = new Date(now.getTime() + jwtProperties.getAccessDuration()); // 만료일
return makeToken(user, expiredDate);
}
// 토큰 생성 공통 메소드 (실제로 jwt 토큰 생성)
private String makeToken(User user, Date expiredDate) {
String token = Jwts.builder().header().add("typ", "JWT") // jwt 타입을 명시
.and().issuer(jwtProperties.getIssuer()) // 발행자 정보 설정
.issuedAt(new Date()) // 발행일시 설정
.expiration(expiredDate) // 만료일 설정
.subject(user.getEmail()) // 토큰의 주제(subject) 설정 >> 사용자 이메일
.claim("id", user.getId()) // claim 설정
.claim("role", user.getRole()) // claim 설정
.claim("role", user.getRole().name()) // user role의 name claim 설정
.signWith(getSecretKey(), Jwts.SIG.HS256) // 비밀키와 해시 알고리즘 사용하여 토큰 설명값 설정
.compact(); // 토큰 정보를 최종적으로 압축해서 문자열로 반환
log.info("[makeToken] 완성된 토큰: {}", token);
return token;
}
// 비밀키 만들기 메소드
private SecretKey getSecretKey() {
return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes());
}
// 토큰이 유효한지 검증하는 메소드
public boolean validateToken(String token) {
log.info("[validateToken] 토큰 검증을 시작합니다.");
try {
Jwts.parser().verifyWith(getSecretKey()) // 비밀키로 서명 검증
.build().parseSignedClaims(token); // 서명된 클레임을 파싱
return true;
} catch (Exception e) {
}
log.info("토큰 검증 실패");
return false;
}
// 토큰에서 정보(Claim) 추출 메소드
private Claims getClaims(String token) {
return Jwts.parser().verifyWith(getSecretKey()) // 비밀키로 서명 검증
.build().parseSignedClaims(token) // 새로운 클레임을 파싱
.getPayload(); // 파싱된 클레임에서 페이로드(실제 클레임)을 반환
}
// 토큰에서 인증정보 반환하는 메소드
public Authentication getAuthenticationByToken(String token) {
log.info("[getAuthenticationByToken] 토큰 인증 정보 조회");
String userEmail = getUserEmailByToken(token);
User user = (User) userDetailsService.loadUserByUsername(userEmail);
Authentication authentication = new UsernamePasswordAuthenticationToken(user, token, user.getAuthorities());
return authentication;
}
// 토큰에서 사용자 email만 추출하는 메소드
public String getUserEmailByToken(String token) {
log.info("[getUserEmailByToken] 토큰 기반 회원 정보 추출");
Claims claims = getClaims(token);
String email = claims.get("sub", String.class);
return email;
}
}
generateAccessToken(User usre)
- 사용자 정보를 바탕으로 JWT accessToken 생성
- 현재 날짜와 만료 날짜를 설정
- makeToken 메소드를 호출하여 실제 JWT 생성
makeToken
- JWT를 생성하는 실제 로직 처리
- JWT의 헤더, 발행자, 발행일, 만료일, 주제(subject), 클레임(claim)을 설정
- getSecretKey 메소드를 사용하여 비밀키를 얻고, HS256 알고리즘으로 서명
- 최종적으로 JWT를 문자열로 압축해서 반환
getSecretKey
- JWT 서명키를 위한 비밀키 생성
- jwtProperties에서 비밀키를 가져와 SecretKey 객체로 변환
- key byte array를 기반으로 적절한 HMAC 알고리즘을 적용한 Key(java.security.Key) 객체를 생성
validateToken
- JWT의 유효성 검증
- Jwts.parser()로 JWT를 파싱하고 비밀키로 서명을 검증
- 예외가 발생하면 토큰이 유효하지 않다고 판단
getClaims
- JWT에서 클레임을 추출
- JWT를 파싱하고 클레임(payload)를 반환함
getAuthenticationByToken
- JWT에서 인증 정보를 추출하여 Authentication 객체 생성
- getUserEmailByToken 메소드로 사용자 이메일을 추출하고, UserDetailsService를 사용하여 사용자 정보 로드
- UsernamePasswordAuthenticationToken을 생성하여 인증 정보를 반환
getuserEmailByToken
- JWT에서 사용자 이메일 추출
JwtAuthenticationFilter
- HTTP 요청이 들어올 때마다 실행
- 요청 헤더에서 JWT를 추출하고 이를 검증하여 인증 정보를 설정
- HEADER_AUTHORIZATION : JWT를 포함한 HTTP 헤더 이름
- TOKEN_PREFIX : JWT가 포함된 헤더 값의 접두사 (일반적으로 'Bearer '로 시작)
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final static String HEADER_AUTHORIZATION = "Authorization";
private final static String TOKEN_PREFIX = "Bearer ";
// HTTP 요청이 들어올 때마다 실행되는 필터
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader(HEADER_AUTHORIZATION);
// header에서 token값 가져오기
String token = getAccessToken(header);
if (token != null && jwtProvider.validateToken(token)) {
// 유효한 토큰인 경우
Authentication authentication = jwtProvider.getAuthenticationByToken(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 그 다음 요청 처리 체인을 이어서 진행
filterChain.doFilter(request, response);
}
private String getAccessToken(String header) {
log.info("[getAccessToken] token값 추출, {}", header);
if (header != null && header.startsWith(TOKEN_PREFIX)) {
return header.substring(TOKEN_PREFIX.length());
}
return null;
}
}
LoginCustomAuthenticationFilter
- AbstractAuthenticaitonProcessingFilter를 확장하여 로그인 필터를 정의
- 사용자가 로그인 요청을 할 때 인증을 처리하고, 성공적인 인증 후 JWT를 생성하여 클라이언트에 반환하는 역할
- LOGIN_PATH : 로그인 요청의 URL 경로('/api/auth/login') 경로의 POST 요청만 처리
@Slf4j
public class LoginCustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private JwtAuthenticationService jwtAuthenticationService;
private static final AntPathRequestMatcher LOGIN_PATH = new AntPathRequestMatcher("/api/auth/login", "POST");
protected LoginCustomAuthenticationFilter(AuthenticationManager authenticationManager,
JwtAuthenticationService jwtAuthenticationService) {
super(LOGIN_PATH);
setAuthenticationManager(authenticationManager);
this.jwtAuthenticationService = jwtAuthenticationService;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
// POST '/api/auth/login'에 요청이 들어오면 진행되는 곳
// 1. Body에 있는 로그인 정보 { email: "", password: "" } 가져오기
LoginRequest loginRequest = null;
try {
log.info("[attemptAuthentication] 로그인 정보 가져오기");
ObjectMapper objectMapper = new ObjectMapper();
loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequest.class);
} catch (IOException e) {
throw new RuntimeException("로그인 요청 파라미터 이름 확인 필요 (로그인 불가)");
}
// 2. email, password를 기반으로 AuthenticationToken 생성
log.info("[attemptAuthentication] Authentication 생성");
UsernamePasswordAuthenticationToken uPAToken = new UsernamePasswordAuthenticationToken(loginRequest.getEmail(),
loginRequest.getPassword());
// 3. 인증 시작
// (AuthenticationManager이 authentication 메소드가 동작할 때 >> loadUserByUsername 실행)
log.info("[attemptAuthentication] 인증 시작");
Authentication authenticate = getAuthenticationManager().authenticate(uPAToken);
return authenticate;
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
log.info("[successfulAuthentication] 로그인 성공 > 토큰 생성");
jwtAuthenticationService.successAuthentication(response, authResult);
}
}
LoginCustomAuthenticationFilter
- LoginCustomAuthenticationFilter의 생성자
- super(LOGIN_PATH): AbstractAuthenticationProcessingFilter의 생성자를 호출하여 로그인 요청 경로를 설정
- setAuthenticationManager(authenticationManager): AuthenticationManager를 설정하여 인증 작업을 처리할 수 있게
- JWT 관련 작업을 위한 서비스 객체를 설정
attemptAuthentication
- 로그인 요청을 처리하며 Authentication 객체를 생성하고 인증을 시도
- request.getInputStream()을 통해 요청 본문에서 로그인 정보 추출
- ObjectMapper를 사용하여 JSON 형태의 데이터를 LoginRequest로 변환
- 추출된 이메일과 비밀번호를 사용하여 UsernamePasswordAuthenticationToken 생성
- AuthenticationManager를 사용하여 인증 시도 > 성공 시 Authentication 반환
successfulAuthentication
- 인증이 성공한 후 호출되며 JWT를 생성하고 응답에 추가
JwtAuthenticationService
- 사용자 인증이 성공한 후 JWT를 생성하고 HTTP 응답에 포함시키는 역할
- 이 클래스를 통해 인증 성공 시 JWT를 클라이언트에게 반환
// 인증 관련 서비스 진행
@Service
@RequiredArgsConstructor
public class JwtAuthenticationService {
private final TokenUtils tokenUtils;
void successAuthentication(HttpServletResponse response, Authentication authResult) throws IOException {
User user = (User) authResult.getPrincipal(); // authResult의 유저 정보 가져오기
// tokenUtils에 user 넣어서 토큰 생성
Map<String, String> tokenMap = tokenUtils.generateToken(user);
String accessToken = tokenMap.get("accessToken");
// loginRespnse에 token 담아서 응답
LoginResponse loginResponse = LoginResponse.builder().accessToken(accessToken).build();
tokenUtils.writeResponse(response, loginResponse);
}
}
- 인증 성공 후 JWT를 생성하고 이를 HTTP 응답으로 반환
- authResult에서 User 객체를 가져오기 >> getPrincipal()은 인증된 사용자 정보 반환
- TokenUtils 사용하여 사용자 정보를 기반으로 JWT 생성
- generateToken 메소드는 사용자 정보를 입력으로 받아 JWT를 생성하고 accessToken 등을 포함한 맴븡ㄹ 반환
- 생성된 JWT 중 accessToken 추출
- LoginResponse 객체를 생성하여 응답에 포함될 엑세스 토큰을 설정
- TokenUtils를 사용하여 응답에 JWT를 포함시킨다
- writeResponse 메소드는 HttpServletResponse 객체와 LoginResponse 객체를 사용하여 클라이언트에게 응답을 작성한다.
TokenUtils.java
@Component
@RequiredArgsConstructor
public class TokenUtils {
private final JwtProvider jwtProvider;
// token 생성
public Map<String, String> generateToken(User user) {
String accessToken = jwtProvider.generateAccessToken(user);
Map<String, String> tokenMap = new HashMap<String, String>();
tokenMap.put("accessToken", accessToken);
return tokenMap;
}
// JSON 응답 전송
public void writeResponse(HttpServletResponse response, LoginResponse loginResponse) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
String jsonResponse = objectMapper.writeValueAsString(loginResponse);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(jsonResponse);
}
}
generateToken
- JwtProvider의 generateAccessToken 메소드를 호출하여 사용자 정보 기반으로 accessToken 생성
- Map 객체를 생성하여 JWT 추가
- 액세스 토큰을 토함한 Map 객체 반환
writeResponse
- 로그인 응답을 JSON 형식으로 HTTP 응답에 작성
- ObjectMapper를 통해 객체를 JSON으로 변환
- HTTP 응답의 콘텐츠 타입을 application/json으로 설정
- HTTP 응답의 문자 인코딩을 UTF-8로 설정
- 변환된 JSON의 문자열을 응답에 작성하여 클라이언트에 전송
로그인 화면 확인해보기
또다시 글이 길어지고 말았다...
jwt 실습하는데 코드 따라치기도 바빠서 꼭 하나씩 생각해보고 싶었는데 정리를 해도 아직 100% 이해되지 않았다(ㅋㅋ)
역시 로그인 로직이 가장 어렵다고 (개인적으로) 생각한다.
우선은 실습 내용을 정리해두고...꼭 이해해야겠다.
728x90
'개발 공부 > Spring' 카테고리의 다른 글
[Spring Boot] H2 Database를 사용하여 테스트 코드 작성하기 (1) | 2024.10.17 |
---|---|
JWT에서 Access Token, Refresh Token이 필요한 이유 (1) | 2024.09.11 |
[Spring Boot] 프로젝트 빌드해서 jar 파일 생성/실행해보기 (0) | 2024.08.30 |
[Spring] Spring Security 기본 개념 정리 & 예시 (0) | 2024.08.23 |
[JPA] @EnableJpaAuditing 사용하기 (0) | 2024.08.22 |