제가 공부한 내용을 정리하는 블로그입니다.
아직 많이 부족하고 배울게 너무나도 많습니다. 틀린내용이 있으면 언제나 가감없이 말씀해주시면 감사하겠습니다😁
스프링부트와 리액트를 활용해 로그인을 구현 프로젝트를 진행합니다. 순서는
1. 프로젝트 초기화
2. 쿠키를 이용한 로그인 구현
3. 세션을 이용한 로그인 구현
4. JWT 토큰을 활용한 로그인 구현
입니다.
네번째는 Security + JWT Token 로그인 구현입니다.
JwtProvider
Spring Security에서 JWT를 생성, 검증, 토큰 해석을 하는 클래스 입니다.
사용자 로그인이 완료되면 JWT 토큰을 생성하거나 인증정보를 확인합니다.
생성자
public JwtProvider(@Value("${hiro.jwtSecret}") String jwtSecret,
@Value("${hiro.jwtAccessExpiration}") int jwtAccessExpiration,
@Value("${hiro.jwtRefreshExpiration}") int jwtRefreshExpiration
) {
this.jwtSecret = jwtSecret;
this.jwtAccessExpiration = jwtAccessExpiration;
this.jwtRefreshExpiration = jwtRefreshExpiration;
this.key = Keys.hmacShaKeyFor(this.jwtSecret.getBytes());
}
- @Value 어노테이션을 통해 설정 파일(application.properties 또는 application.yml)에 저장된 JWT 관련 설정 값을 주입합니다.
- hiro.jwtSecret → JWT 서명을 위한 비밀키
- hiro.jwtAccessExpiration → 액세스 토큰 만료 시간(ms)
- hiro.jwtRefreshExpiration → 리프레시 토큰 만료 시간(ms)
- 해당 값들은 노출되면 안되기에 환경변수로 저장하는 것이 안전합니다.
- HMAC SHA 알고리즘을 사용하여 JWT 서명에 사용할 키를 생성
generateJwtToken
public String generateJwtToken(Authentication authentication) {
String roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
return Jwts.builder()
.setSubject(authentication.getName()) // JWT의 subject 설정 (사용자명)
.claim("role", roles) // 사용자의 역할(권한) 저장
.setIssuedAt(new Date()) // 발급 시간
.setExpiration(new Date((new Date()).getTime() + jwtAccessExpiration)) // 만료 시간 설정
.signWith(key, SignatureAlgorithm.HS256) // 서명 알고리즘 사용
.compact();
}
Jwts 클래스는 Java에서 JWT를 생성하고 파싱하는데 사용하는 JJWT의 클래스입니다.
빌더 패턴을 이용하여 토큰을 생성하고 토큰을 검증하고 해석할 때에는 Jwts.parserBuilder()를 이용합니다.
JWT 구조
header.payload.signature
- Header
- 사용화 알고리즘과 토큰 유형
- Payload(Claims)
- sub (Subject): 토큰을 발급받은 사용자 ID
- role: 사용자의 역할 정보 (ex: ROLE_USER, ROLE_ADMIN)
- iat (Issued At): 토큰 발급 시간 (Unix Timestamp)
- exp (Expiration): 토큰 만료 시간 (Unix Timestamp)
- Signature(서명)
- 서명(Signature)은 JWT의 무결성을 보장하기 위해 사용됩니다.
주요 필드 설명
필드 | 설명 |
setSubject(String sub) | 사용자의 고유 식별자(일반적으로 username 또는 user ID) |
claim(String key, Object value) | 추가적인 사용자 정의 정보(예: 역할, 이메일, 권한 등) |
setIssuedAt(Date iat) | 토큰 발급 시간 |
setExpiration(Date exp) | 토큰 만료 시간 |
signWith(Key key, SignatureAlgorithm alg) | 토큰의 무결성을 보장하기 위한 서명 |
- authentication.getAuthorities() → 사용자의 권한(ROLE_USER, ROLE_ADMIN 등)을 가져옴.
- setSubject(authentication.getName()) → JWT의 subject(보통 사용자 ID/username) 설정.
- claim("role", roles) → 사용자 역할 정보를 JWT의 Claims에 추가.
- setExpiration(...) → 액세스 토큰의 만료 시간을 설정.
- signWith(key, SignatureAlgorithm.HS256) → HMAC SHA256 알고리즘을 사용하여 JWT 서명.
getAuthentication
public Authentication getAuthentication(String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("role").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new UsernamePasswordAuthenticationToken(claims.getSubject(), token, authorities);
} catch (MalformedJwtException e) {
log.error("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.error("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.error("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty: {}", e.getMessage());
}
return null;
}
- Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody()
→ JWT를 파싱하여 Claims(토큰의 정보)를 추출. - claims.get("role").toString().split(",")
→ JWT에 저장된 사용자 권한(role)을 가져와서 리스트로 변환. - new UsernamePasswordAuthenticationToken(claims.getSubject(), token, authorities);
→ JWT에서 가져온 사용자 정보를 기반으로 Authentication 객체 생성.
-> UsernamePasswordAuthenticationToken은 Authentication 객체의 구현체
validateToken
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
- parseClaimsJws(token)을 호출하여 토큰이 유효한지 검사.
- 예외 처리:
- SecurityException | MalformedJwtException: 토큰이 변조되었거나 잘못된 형식인 경우.
- ExpiredJwtException: 토큰이 만료된 경우.
- UnsupportedJwtException: 지원되지 않는 JWT 형식.
- IllegalArgumentException: 토큰이 비어있거나 유효하지 않은 경우.
JwtFilter
@Component
public class JwtFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
public JwtFilter(JwtProvider jwtProvider) {
this.jwtProvider = jwtProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null) {
filterChain.doFilter(request, response);
} else {
String jwtToken = authHeader.split(" ")[1];
if (jwtProvider.validateToken(jwtToken)) {
Authentication authentication = jwtProvider.getAuthentication(jwtToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
}
}
OncePerRequestFilter
oncePerRequestFilter는 Spring Security에서 제공되는 필터로 HTTP 요청마다 단 한번만 실행되는 필터를 구현할 때 사용됩니다.
(여러번 실행되지 않도록 보장)
따라서 JWT 검증하는 filter로 적절합니다.(요청 때 한번만 검증)
- HttpServletRequest에서 Header("Authorization")을 확인
- 클라이언트가 보낸 HTTP 요청에서 Authorization 헤더를 가져옵니다.
- JWT는 일반적으로 "Bearer <토큰값>" 형식으로 전달됩니다.
- authHeader == null
- 다음 필터로 넘김.
- 로그인, 회원가입, 공개 API 요청 등
- else => 토큰 값 읽어오기
- Bearer <토큰값> 형식에서 공백(" ")을 기준으로 split하여 토큰만 추출.
- authHeader.split(" ")[0] → "Bearer"
- authHeader.split(" ")[1] → "<토큰값>"
- jwtProvider로 validateToken()를 통해 Token을 검증
- 유효하지 않으면(만료됨, 변조됨 등) 인증을 진행하지 않고 요청을 그대로 통과.
- 인증 객체 생성 및 설정
- jwtProvider.getAuthentication(jwtToken)을 호출하여 JWT에서 사용자 정보를 추출하고 Authentication 객체를 생성.
- SecurityContextHolder.getContext().setAuthentication(authentication);
→ Spring Security의 SecurityContext에 인증 객체를 저장하여 이후 요청에서 인증된 사용자로 처리.
SecurityConfig
SecurityConfig는 Security의 보안 설정을 구성하는 역할을 합니다.
사용자의 권한에 따라서 API 접근 권한을 설정할 수 있고(RBAC) HttpMethod에 따라서도 설정이 가능합니다.
해당 클래스를 위해서는 @EnableWebSecurity 어노테이션이 필요합니다
@EnableWebSecurity
Spring Security를 활성화하고, 보안 구성을 정의할 수 있도록 해주는 어노테이션입니다.
- Spring Security의 기본 보안 설정을 비활성화하고, 개발자가 직접 설정을 정의할 수 있도록 함.
- SecurityFilterChain을 통해 보안 규칙을 커스터마이징할 수 있도록 해줌.
PasswordEncoder
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
PasswordEncoder는 Spring Security에서 비밀번호를 안전하게 암호화하고, 검증하는 인터페이스입니다.
비밀번호를 평문(Plain Text)으로 저장하는 것은 보안적으로 위험하기 때문에, 해싱(Hashing) 알고리즘을 사용하여 비밀번호를 암호화하고, 로그인 시 입력된 비밀번호와 저장된 해시 값을 비교하여 검증하는 역할을 합니다.
- BCryptPasswordEncoder는 BCrypt 알고리즘을 사용하여 비밀번호를 안전하게 암호화합니다.
- @Bean을 사용하여 Spring 컨텍스트에서 PasswordEncoder 빈으로 관리하도록 설정함.
BCryptPasswordEncoder의 특징
- Salt를 자동으로 추가하여 해싱
- 동일한 비밀번호라도 해싱 결과가 항상 다르게 생성됨 → Rainbow Table 공격 방어 가능
- 반복 연산(Work Factor) 조절 가능
- 기본적으로 10번의 해싱 작업 수행 (BCryptPasswordEncoder(10))
- 연산 횟수를 증가시켜 보안성을 강화할 수 있음
- 비밀번호 검증 기능 제공
- matches(rawPassword, encodedPassword): 입력된 비밀번호와 암호화된 비밀번호 비교
AuthenticationManager
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
AuthenticationManager는 Spring Security에서 인증(Authentication) 요청을 처리하는 핵심 인터페이스입니다.
- 사용자의 로그인 요청을 받아 인증을 수행하고, 결과를 반환하는 역할을 합니다.
- 인증 과정에서 UserDetailsService를 이용해 사용자 정보를 조회하고, 비밀번호를 검증하는 등의 작업을 수행합니다.
- 해당 AuthenticationManager가 CustomDetailsService()를 호출하게 됩니다.
- Spring Security의 AuthenticationManager 빈을 생성하는 역할
- AuthenticationConfiguration을 통해 Spring Security의 기본 인증 설정을 가져와 AuthenticationManager를 반환
- Spring Security에서 AuthenticationManager의 기본 구현체는 DaoAuthenticationProvider 입니다.
- 따라서 여기서는 Spring Security가 자동으로 생성한 ProviderManager 인스턴스를 반환합니다.
SecurityFilterChain
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.headers(headersConfigurer -> headersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> {
auth.requestMatchers(permitAllUrl).permitAll();
auth.requestMatchers(permitAdminUrl).hasRole("ADMIN");
auth.anyRequest().authenticated();
})
.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
.addFilter(webConfig.corsFilter())
.build();
}
해당 메소드는 Spring Security의 SecurityFilterChain을 구성하여 애플리케이션의 보안 규칙을 설정합니다.
- CSRF 보호 비활성화 (csrf().disable())
- REST API는 주로 JWT를 사용하여 상태(State) 없이 인증을 수행하므로 CSRF 보호가 필요 없음
- CSRF는 세션 기반 인증에서 주로 필요하지만, JWT 인증을 사용할 경우 불필요
- X-Frame-Options 비활성화 (frameOptions().disable())
- H2 데이터베이스 콘솔 같은 프레임 기반 UI를 허용하기 위해 설정
- 폼 로그인 비활성화 (formLogin().disable())
- JWT 기반 인증을 사용하기 때문에 기본 로그인 폼을 사용하지 않도록 설정
- HTTP Basic 인증 비활성화 (httpBasic().disable())
- HTTP Basic 인증(아이디 & 비밀번호를 요청 헤더에 포함하여 전송하는 방식)을 비활성화
- JWT 인증 방식을 사용할 것이므로 필요 없음
- SessionCreationPolicy.STATELESS
- 세션을 사용하지 않는 Stateless 인증 방식 적용
- SessionCreationPolicy.STATELESS → Spring Security가 세션을 생성하거나 저장하지 않도록 설정
- authorizeHttpRequest -> HTTP 요청에 대한 권한 설정
- requestMatchers(permitAllUrl).permitAll();
→ 특정 URL(로그인, 회원가입, Swagger 등)은 인증 없이 접근 허용 - requestMatchers(permitAdminUrl).hasRole("ADMIN");
→ 관리자 페이지(예: /status/admin)는 "ADMIN" 역할이 있는 사용자만 접근 가능 - anyRequest().authenticated();
→ 그 외 모든 요청은 인증이 필요함
- requestMatchers(permitAllUrl).permitAll();
- JWT Filter 및 CORS 필터 추가
- addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
→ JWT 필터(JwtFilter)를 UsernamePasswordAuthenticationFilter 앞에 추가- Spring Security의 기본 로그인 방식(폼 로그인, 세션 로그인 등)보다 먼저 JWT를 확인하도록 설정
- .addFilter(webConfig.corsFilter())
→ CORS 필터 추가
- addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
UserDetails
@RequiredArgsConstructor
public class UserDetailsImpl implements UserDetails {
@Getter
private final Long id;
private final String username;
private final String password;
private final Collection<? extends GrantedAuthority> authorities;
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public static UserDetailsImpl build(Member member) {
List<GrantedAuthority> authorities = member.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
.collect(Collectors.toList());
return new UserDetailsImpl(
member.getId(),
member.getUsername(),
member.getPassword(),
authorities
);
}
}
커스텀한 UserDetails 클래스를 생성합니다.
UserDetailsImpl이 필요한 이유
- Spring Security는 UserDetailsService를 통해 사용자 정보를 불러오며,
이때 UserDetails를 구현한 클래스(UserDetailsImpl)가 필요 - UserDetailsImpl은 Spring Security에서 인증된 사용자의 정보를 저장하는 역할을 함.
UserDetailsService
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByUsername(username);
return UserDetailsImpl.build(member);
}
}
UserDetailsService는 Spring Security의 UserDetailsService 인터페이스를 구현한 클래스로,
Spring Security가 로그인 요청을 처리할 때 사용자 정보를 불러오는 역할을 합니다.
- Spring Security가 로그인 요청을 받을 때 자동으로 실행되는 메서드
- 해당 메소드는 AuthenticationManager가 로그인 요청을할 때 UserDetailsService를 호출합니다.
- username을 이용해 DB에서 사용자 정보를 조회 (memberRepository.findByUsername(username))
- 조회된 Member 엔티티를 Spring Security에서 사용할 수 있도록 UserDetailsImpl로 변환
- UserDetailsService를 구현해야 Spring Security가 사용자 인증을 수행할 수 있습니다.
AuthService
public JwtResponse loginMember(String username, String password) {
if (!repository.existsByUsername(username)) {
throw new RuntimeErrorException(new Error("not Found Member"));
}
log.info("login Member");
final Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password)
);
final String jwtAccessToken = jwtProvider.generateJwtToken(authentication);
final String jwtRefreshToken = null;
final UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList();
return new JwtResponse(jwtAccessToken, jwtRefreshToken, userDetails.getUsername(), roles);
}
로그인 화면
다음와 같이 정상 동작하는 것을 확인할 수 있습니다.
프론트에서는 다음에 헤더에 accessToken을 추가하면 Security가 설정된 프로젝트와 통신을 할 수 있습니다.
깃허브 및 참조
'spring' 카테고리의 다른 글
[로그인구현] Spring-React 로그인 구현하기(Security) (1) | 2025.01.18 |
---|---|
[Spring] Swagger 적용하기 (0) | 2025.01.05 |
[로그인구현] Spring-React를 이용한 로그인 구현(세션) (4) | 2024.12.24 |
[로그인구현] Spring-React 로그인 구현하기(쿠키) (2) | 2024.12.18 |
[로그인구현] Spring-React 로그인 구현하기 (프로젝트 초기화) (2) | 2024.12.06 |