이제 본격적으로 로그인/회원가입 기능을 코드를 구현해보려한다.
Task: 유저는
username
,password
를 입력해서 회원가입을 한다.
•password
로 로그인을 해서 토큰을 받고, 로그인 성공 시username
으로 서비스를 이용한다.
• 이 때, 이메일 양식과 중복 확인은 필수이다.
• 기본 권한은 member로 부여하고 추후 추가 기능으로 admin 권한을 부여할 것이다.
추후 계획: 로그아웃, 회원 탈퇴, 관리자는 챌린지 요구사항을 반영 후 도입할 계획이다.
큰 흐름은 아래와 같다.
• 스프링 시큐리티를 통해 비밀번호를 암호화하여 DB에 저장 및 DB에 저장된 사용자의 계정과 비밀번호로 로그인
• JWT를 사용하여 로그인한 사용자에게 토큰 발급
• 인가된 토큰의 권한에 따라 API 접근 권한 제어
a. 요청 처리 및 관리
서버로 들어오는 모든 요청을 JwtFilter
가 처리한다.
스프링 시큐리티
는 JwtFilter
와 연계되어 사용자의 인증 및 권한 관리를 담당한다.
• 인증 및 권한 관리 / 예외 처리(유효성 검사 과정에서의 예외 처리) / 보안 설정(접근 권한) / 필터 설정
b. 토큰 생성 및 유효성 검사
TokenProvider
가 토큰 생성, 유효성 검사, 인증 정보 추출을 하고,
• 만약 토큰 유효성을 통과하지 못했다면 예외를 발생시키고, JwtFilter
에서 예외를 처리한다.
• 만약 토큰 유효성을 통과했다면, TokenProvider
에서 인증 정보를 추출해 시큐리티 컨텍스트에 저장하고 로그인 인증 과정으로 넘어간다.
c. 로그인 인증 과정
SecurityContext에 저장된 인증 정보를 기반으로 사용자의 인증 상태와 권한을 확인하는데,
• AuthenticationEntryPoint
(인증되지 않은 사용자 401)
• AccessDeniedHandler
(권한 없는 사용자 403)
이 예외가 발생하지 않는다면 로그인 인증에 성공!
왜 레디스를 사용했는가?
로그인한 사용자의 요청이 들어올 때마다 토큰을 검증해야 하기 때문에, 레디스를 사용해서 빠르게 검색하고, 토큰이 탈취될 경우를 대비해 도입하였다.
왜 레디스에 refreshToken만 저장하는가?
토큰이 탈취되거나 악용될 경우를 대비해서 서버 측에서 해당 토큰을 무효화할 수 있는 수단을 갖기 위함
보안
: accessToken
이 탈취당하더라도, refreshToken
이 없다면 새로운 accessToken
을 발급받을 수 없기 때문에 refreshToken
을 안전하게 보관하기 위해 레디스에 저장하였다.
토큰 재발급 관리
: accessToken
이 만료되었을 때, 서버는 저장해둔 refreshToken
과 클라이언트가 보내는 refreshToken
을 비교하여 유효성을 검사하고 새로운 accessToken
을 발급한다. 이를 위해 refreshToken
은 서버에 저장되어 있어야 한다.
그럼 refreshToken이 만료되었을 경우, 레디스에서 어떻게 삭제하는가?
→ TTL(Time To Live) 설정으로 해결
만약 refreshToken
이 만료된 경우, 사용자는 재로그인을 통해 새로운 accessToken
과 refreshToken
을 받게 된다. 이때 새로운 refreshToken
은 레디스에 저장되며, 기존 refreshToken
은 자동으로 만료된다.
이때, 레디스에서 키-값 쌍에 대해 TTL(Time To Live) 설정을 해서 TTL이 만료되면 해당 키-값 쌍은 자동으로 삭제되도록 구현하였다.
적용한 코드
로그아웃과 블랙리스트 처리를 구현할 때 RedisUtil 에 설정을 추가해주겠지만 현재는 로그인해서 발급받은 refreshToken
을 email을 key값으로 하여 저장하는 흐름이기에 생각보다 간단하게 구현이 가능하다.
@RedisHash(value = "MemberToken", timeToLive = 3600 * 24 * 14)
@AllArgsConstructor
@Getter
@Setter
public class Redis {
@Id
private String email;
private String refreshToken;
}
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName(redisProperties.getHost());
config.setPort(redisProperties.getPort());
return new LettuceConnectionFactory(config);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
@Component
@Getter
@PropertySource("application.yml")
public class RedisProperties {
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.host}")
private String host;
}
@Component
@RequiredArgsConstructor
public class RedisUtil {
private final RedisTemplate<String, String> redisTemplate;
public void save(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}
}
JwtFilter
는 사용자 요청을 받아 토큰의 유효성을 검증 및 사용자 정보 추출을 담당하고,
스프링 시큐리티
는 이를 바탕으로 인증 및 권한 관리, 예외 처리, 보안 설정 등의 기능을 제공하도록 할 계획이기에 관련 코드를 작성해준다.
• 필터 체인에 세션 방식을 사용하는 것이 아니기 때문에 CSRF 공격 방어 비활성화하고, 시큐리티가 기본적으로 세션 방식을 사용하기 때문에 STATELESS 설정을 해주었다.
• JwtFilter와 연계할 수 있도록 인증/인가 관련 예외 핸들링과 커스텀 컨피그 파일을 필터 체인에 추가해주었다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception -> {
exception.accessDeniedHandler(jwtAccessDeniedHandler);
exception.authenticationEntryPoint(jwtAuthenticationEntryPoint);
})
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers("/api/v2/admin/**").hasAuthority("ROLE_ADMIN") // 관리자 페이지 role 추가
.requestMatchers("/auth/**") // 로그인, 회원가입은 열어주기
.permitAll()
.anyRequest().authenticated()
)
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.with(new JwtSecurityConfig(tokenProvider), customizer -> customizer.getClass());
return http.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
인증 및 인가 | JwtAccessDeniedHandler & JwtAuthenticationEntryPoint
로그인 인증 과정에서 SecurityContext에 저장된 인증 정보를 기반으로 사용자의 인증 상태와 권한을 확인해야하기 때문에 예외 처리 클래스를 추가했다.
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
// 접근 권한 없을 때 403 에러
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
// 인증 정보 없을 때 401 에러
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
서버 환경에서 자원 공유 | CorsConfig
현재는 백엔드 개발에 중점을 두었지만 추후 확장성을 고려해서 cors 설정을 해주었다.
서로 다른 서버 환경에서 자원을 공유할 때 발생할 오류에 대응하기 위함이 목적이다.
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
JwtSecurityConfig
토큰을 생성하는 TokenProvider 와 JwtFilter를 SecurityConfig에 필터 등록할 때 사용된다.
// 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
참고
→ apply 대신 with 사용 코드 (https://www.inflearn.com/questions/1186827/스프링-시큐리티-6-2-버전-이후로-apply-메서드를-이용한-jwtauthenticationfilter-가-등록이-안됩니다?commentId=320431)
Spring Security에서 사용자 인증을 처리하기 위해 사용된다. 즉, 로그인 시 사용자의 정보를 로드하고, 해당 정보를 기반으로 인증을 수행하는 역할을 한다.
로그인 과정에서 이 과정이 없다면 시큐리티가 사용자 정보를 로드할 수 없기에 인증을 할 수 없고 토큰도 생성할 수 없게 된다.
동작 과정
- 유저가 로그인을 시도할 때, 이메일을 기반으로 DB에서 유저의 정보를 조회한다.
loadUserByUsername
메서드가 호출되어 DB에서 이메일로 유저를 찾는다.
a. 해당 유저가 존재하지 않으면UsernameNotFoundException
을 던집니다.
b. 해당 유저가 존재하면,createUserDetails
메서드를 통해UserDetails
객체를 생성한다.
CustomUserDetailService
이메일을 key 값으로 가질 것이기 때문에 이메일로 유저를 찾도록 하였다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return memberRepository.findByEmail(email)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(email + " -> 데이터베이스에서 찾을 수 없습니다."));
}
// DB에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
private UserDetails createUserDetails(Member member) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());
return new User(
member.getEmail(), // 사용자 식별자로 이메일을 사용
member.getPassword(),
Collections.singleton(grantedAuthority)
);
}
}
TokenProvider
로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져올 수 있다.
JwtFilter
는 Request 앞단에 붙일 커스텀 필터로, 인증 처리를 담당한다.
유저의 인증 정보를 바탕으로 JWT 토큰을 생성하고, 이를 검증하는 역할을 한다.
즉, JWT 토큰 관련된 암호화, 복호화, 검증 로직이 모두 이 곳에서 이루어진다.
각 메소드를 좀 더 보면
토큰 생성 |generateTokenDto
• 인증된 사용자의 정보를 받아서accessToken
과refreshToken
을 생성한다.
• 생성된 토큰들은 TokenDto 객체에 담겨 반환되고 유저는 유효한 토큰을 얻게 된다.
재발급 |reissueAccessToken
• 기존refreshToken
을 검증하고 유효한 경우 새로운accessToken
과refreshToken
을 생성한다. → 이 때accessToken
으로 토큰을 재발급하려는 시도가 보이면 예외를 던진다.
• 만약refreshToken
이 만료되면 함께 생성하여 TokenDto 객체에 담겨 반환된다.
인증 객체 생성 |getAuthentication
• 스프링 시큐리티에서 사용할 수 있는 인증 객체를 생성하는 메소드이다.
• UserDetails 객체를 생성해서 UsernamePasswordAuthenticationToken 형태로 리턴하는데 SecurityContext를 사용하기 위한 절차이다.
•accessToken
을 통해 해당 토큰에 담긴 사용자 인증 정보를 Authentication 객체로 반환한다.
토큰 검증 |validateToken
• 토큰의 유효성을 검증한다.
@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일
private final Key key;
private final RedisUtil redisUtil;
public TokenProvider(@Value("${custom.jwt.secret}") String secretKey, RedisUtil redisUtil) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.redisUtil = redisUtil;
}
public TokenDto generateTokenDto(Authentication authentication) {
// 권한들 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
String accessToken = generateAccessToken(authentication.getName(), authorities);
String refreshToken = generateRefreshToken(authentication.getName(), authorities);
long now = (new Date()).getTime();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(new Date(now + ACCESS_TOKEN_EXPIRE_TIME).getTime())
.refreshToken(refreshToken)
.build();
}
public TokenDto reissueAccessToken(String refreshToken) {
// 리프레시 토큰에서 사용자 정보 추출 -> 클레임 확인
Claims claims = parseClaims(refreshToken);
// Refresh Token 검증 및 클레임에서 Refresh Token 여부 확인
if (!validateToken(refreshToken) || claims.get("isRefreshToken") == null || !Boolean.TRUE.equals(claims.get("isRefreshToken"))) {
throw new InvalidTokenException("유효하지 않은 리프레시 토큰입니다.");
}
String email = claims.getSubject();
String authorities = claims.get(AUTHORITIES_KEY).toString();
String newAccessToken = generateAccessToken(email, authorities);
String newRefreshToken = generateRefreshToken(email, authorities);
redisUtil.save(email, newRefreshToken);
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(newAccessToken)
.accessTokenExpiresIn(new Date((new Date()).getTime() + ACCESS_TOKEN_EXPIRE_TIME).getTime())
.refreshToken(newRefreshToken)
.build();
}
private String generateAccessToken(String email, String authorities) {
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
return Jwts.builder()
.setSubject(email)
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
private String generateRefreshToken(String email, String authorities) {
long now = (new Date()).getTime();
return Jwts.builder()
.setSubject(email)
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.claim("isRefreshToken", true) // refreshToken 임을 나타내는 클레임 추가
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
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());
// UserDetails 객체를 만들어서 Authentication 리턴
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();
}
}
}
JwtFilter
필터는 Spring Security를 활용하여 JWT 토큰을 검증하고, 유효한 토큰의 경우 사용자의 인증 정보를 SecurityContext에 저장하는 역할을 한다. 이를 통해 인증된 사용자의 요청만 처리할 수 있도록 한다.
OncePerRequestFilter를 상속받은 이유
이 필터가 요청당 한 번씩만 실행되도록 하기 위해서이다.
doFilterInternal
메서드가 실제 필터링 로직을 구현하는 곳으로, 요청이 들어올 때 마다 실행된다.
필터링 과정
Authentication
객체를 SecurityContextHolder의 SecurityContext에 저장하여, 다음 필터나 요청 처리 과정에서 현재 사용자가 인증되었음을 알린다.filterChain.doFilter
를 호출하여 요청 및 응답을 다음 필터로 넘기거나 리소스에 도달한다.@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 2. validateToken 으로 토큰 유효성 검사
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
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;
}
}
이렇게 시큐리티를 통한 인증과 JWT 토큰을 통한 인가 그리고, refreshToken을 저장하는 레디스를 활용해서 서비스 로직은 어떻게 구현할 수 있을까
회원 가입
회원 가입 로직은 간단하지만, 한 가지 이메일 중복 체크 로직에서 고민이 되었던 부분을 정리해보았다.
이 때 existsByEmail
로 이메일이 존재하는지 여부만 확인하는 것이 추후 유지보수성이 떨어지지 않을까? 라는 생각에 findByEmail
해서 orElseThrow
로 커스텀 예외를 던지려고 하였다. 그러나 현재 서비스 단계에서 수정할 여부가 없을 것으로 판단하여 굳이 엔티티 조회를 하는 것이 아닌, boolean 으로 체크하도록 하였다.
@Transactional
public void signup(SignUpDto signUpDto) {
if (memberRepository.existsByEmail(signUpDto.getEmail())) {
throw new MultipleLoginException("이미 가입되어 있는 유저입니다");
}
Member member = Member.createMember(signUpDto, passwordEncoder);
memberRepository.save(member);
}
로그인
AuthenticationToken
을 생성해주고, loadUserByUsername
가 실행되면서 검증이 이루어진다.JWT 토큰을 생성(generateTokenDto)
해주고,refreshToken
을 저장한다. @Transactional
public TokenDto signIn(SignInDto signInDto) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(signInDto.getEmail(), signInDto.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
// Redis에 리프레시 토큰 저장
redisUtil.save(signInDto.getEmail(), tokenDto.getRefreshToken());
return tokenDto;
}
토큰 재발급
refreshToken
의 유효성을 검사한다.InvalidRefreshTokenException
예외를 던진다. @Transactional(readOnly = true)
public String resolveRefreshToken(String refreshToken) {
if (refreshToken == null || !refreshToken.startsWith("Bearer ")) {
throw new InvalidRefreshTokenException("리프레시 토큰이 누락되었거나 올바르지 않습니다.");
}
return refreshToken.substring(7);
}
회원가입
로그인
토큰재발급
이렇게 스프링 시큐리티와 JWT, Redis를 사용해서 회원가입/로그인/토큰 재발급 API를 구현해보았다.
다음 포스트에서 챌린지 요구사항을 구현해 볼 것이다.