Spring Security, Jwt 이용한 로그인, 회원가입 구현하기
- 2주 챌린지 프로젝트팀에서 로그인, 회원가입 기능의 구현을 담당해서, 세부적인 학습의 필요성을 느낌
Spring Security
- Spring에서 인증(Authentication)과 인가(Authorization) 기능을 지원하는 보안 프레임워크
- Spring MVC 기반 애플리케이션에 보안을 적용하기 위한 표준
- Interceptor나 Servlet Filter를 이용해서 직접 Security를 구현할 필요 X
- 직접 구현 X -> Spring Security 이용
JWT
- 세션 기반 인증 방식은 사용자의 로그인 정보를 서버 측에서 관리하기 때문에, 서버에 부하가 발생할 수 있음
- REST API를 이용한 CSR 방식의 백엔드 서버를 개발할 것이기 때문에 무상태성을 유지하기 위해서는 세션 기반 인증 방식은 적절하지 X
- JWT -> 무상태성을 유지하면서 인증된 사용자의 자격을 증명 O
- 사용자가 누구인지 기억할 필요 X -> 토큰에 있는 정보에 접근 권한이 있는지만 체크
- But, 토큰이 탈취되면 사용자 정보를 그대로 노출 -> 민감한 정보는 토큰에 포함 X
- Access Token, Refresh Token 두 가지의 토큰으로 나누어 Access Token의 유효 기간을 짧게 -> 만료되면 Refresh Token을 통해 Access Token을 새로 발급하는 방식으로 안정성을 높임 (하지만 이 방식도 완벽하지 X)
Login
- 클라이언트에서 사용자의 ID, Password를 받아 서버에 로그인 요청
- 서버는 전달받은 ID, Password를 가진 User 객체를 조회 -> 존재 O -> Access Token, Refresh Token 생성
- 생성한 Access Token, Refresh Token을 DB 저장 후 -> HttpServletResponse Header에 담아 전달
- 클라이언트는 발급받은 Access Token을 HttpServletResponse Header에 담아 서버가 허용한 권한 범위 내에서 API 사용 가능
JwtTokenizer : JWT를 생성하고 검증
@Slf4j
@Component
public class JwtTokenProvider {
public static final String BEARER_TYPE = "Bearer";
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String REFRESH_HEADER = "Refresh";
public static final String BEARER_PREFIX = "Bearer ";
@Getter
@Value("${jwt.secret-key}")
private String secretKey;
@Getter
@Value("${jwt.access-token-expiration-millis}")
private long accessTokenExpirationMillis;
@Getter
@Value("${jwt.refresh-token-expiration-millis}")
private long refreshTokenExpirationMillis;
private Key key;
// Bean 등록후 Key SecretKey HS256 decode
@PostConstruct
public void init() {
String base64EncodedSecretKey = encodeBase64SecretKey(this.secretKey);
this.key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
}
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
public TokenDto generateTokenDto(CustomUserDetails customUserDetails) {
Date accessTokenExpiresIn = getTokenExpiration(accessTokenExpirationMillis);
Date refreshTokenExpiresIn = getTokenExpiration(refreshTokenExpirationMillis);
Map<String, Object> claims = new HashMap<>();
claims.put("role", customUserDetails.getRole());
String accessToken = Jwts.builder()
.setClaims(claims)
.setSubject(customUserDetails.getEmail())
.setExpiration(accessTokenExpiresIn)
.setIssuedAt(Calendar.getInstance().getTime())
.signWith(key, SignatureAlgorithm.HS256)
.compact();
String refreshToken = Jwts.builder()
.setSubject(customUserDetails.getEmail())
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(refreshTokenExpiresIn)
.signWith(key)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.authorizationType(AUTHORIZATION_HEADER)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 토큰 정보를 반환
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get("role") == null) {
throw new BusinessLogicException(ExceptionCode.NO_ACCESS_TOKEN);
}
String authority = claims.get("role").toString();
CustomUserDetails customUserDetails = CustomUserDetails.of(
claims.getSubject(),
authority);
log.info("# AuthMember.getRoles 권한 체크 = {}", customUserDetails.getAuthorities().toString());
return new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
}
// 토큰 검증
public boolean validateToken(String token, HttpServletResponse response) {
try {
parseClaims(token);
} catch (MalformedJwtException e) {
log.info("Invalid JWT token");
log.trace("Invalid JWT token trace = {}", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT token");
log.trace("Expired JWT token trace = {}", e);
Responder.sendErrorResponse(response, ExceptionCode.TOKEN_EXPIRED);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT token");
log.trace("Unsupported JWT token trace = {}", e);
Responder.sendErrorResponse(response, ExceptionCode.TOKEN_UNSUPPORTED);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.");
log.trace("JWT claims string is empty trace = {}", e);
Responder.sendErrorResponse(response, ExceptionCode.TOKEN_ILLEGAL_ARGUMENT);
}
return true;
}
private Date getTokenExpiration(long expirationMillisecond) {
Date date = new Date();
return new Date(date.getTime() + expirationMillisecond);
}
// Token 복호화 및 예외 발생(토큰 만료, 시그니처 오류)시 Claims 객체가 안만들어짐.
public Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
public void accessTokenSetHeader(String accessToken, HttpServletResponse response) {
String headerValue = BEARER_PREFIX + accessToken;
response.setHeader(AUTHORIZATION_HEADER, headerValue);
}
public void refresshTokenSetHeader(String refreshToken, HttpServletResponse response) {
response.setHeader("Refresh", refreshToken);
}
// Request Header에 Access Token 정보를 추출하는 메서드
public String resolveAccessToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
// Request Header에 Refresh Token 정보를 추출하는 메서드
public String resolveRefreshToken(HttpServletRequest request) {
String bearerToken = request.getHeader(REFRESH_HEADER);
if (StringUtils.hasText(bearerToken)) {
return bearerToken;
}
return null;
}
}
- JWT 생성 시 들어갈 정보
- setClaims() : JWT에 포함시킬 Custom Claims를 추가. 주로 인증된 사용자의 정보를 넣음
- setSubject() : JWT에 대한 제목
- setIssuedAt() : JWT 발행 일자 (파라미터 타입은 java.util.Date)
- setExpiration() : JWT의 만료기한을 정함 (파라미터 타입은 java.util.Date)
- signWith() : 서명을 위한 Key (java.security.Key) 객체를 설정
- compact() : JWT를 생성하고 직렬화
- getKeyFromBase64EncoedeKey : JWT 서명에 사용될 SecretKey를 생성. Decoders.BASE64.decode() 메서드를 통해 byte[]를 반환한 후, Keys.hmacShaKeyFor() 메서드로 HMAC 알고리즘을 적용한 Key 객체를 생성.
- validateToken : JWT에 포함된 Signature를 검증하여 위조 여부를 확인. 검증에 성공하면 JWT를 파싱 해서 Claims를 얻어온다
- parseClaims() : JWT를 파싱해서 Claims를 얻어온다.
- generateTokenDto : CustomUserDetials의 유저 정보를 기반으로 Access Token과 Refresh Token을 생성하는 메서드
TokenDto
@Data
@Builder
public class TokenDto {
private final String grantType;
private final String authorizationType;
private final String accessToken;
private final String refreshToken;
private final Long accessTokenExpiresIn;
}
JwtAuthenticationFilter
- JwtAuthenticationFilter 클래스는 로그인 검증 및 JWT 발급을 담당
- JwtAutheticationFilter가 상속받고 있는 UsernamePasswordAuthenticationFilter는 Spring Security에서 사용자 이름과 암호를 받아 인증을 시도하는 필터
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final AES128Config aes128Config;
private final MemberService memberService;
private final RedisService redisService;
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
// ServletInputStream을 LoginDto 객체로 역직렬화
ObjectMapper objectMapper = new ObjectMapper();
LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());
return authenticationManager.authenticate(authenticationToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException {
CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(customUserDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
jwtTokenProvider.accessTokenSetHeader(accessToken, response);
jwtTokenProvider.refresshTokenSetHeader(encryptedRefreshToken, response);
Member findMember = memberService.findMemberAndCheckMemberExists(customUserDetails.getId());
Responder.loginSuccessResponse(response, findMember);
// 로그인 성공시 Refresh Token Redis 저장 ( key = Email / value = Refresh Token )
long refreshTokenExpirationMillis = jwtTokenProvider.getRefreshTokenExpirationMillis();
redisService.setValues(findMember.getEmail(), refreshToken, Duration.ofMillis(refreshTokenExpirationMillis));
this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
}
}
- attemptAuthentication()
- 인증을 시도하는 메서드
- HttpServletRequest와 HttpServletResponse를 매개변수로 받아 사용자가 입력한 로그인 정보를 추출하고 AuthenticationManager를 사용해 인증을 시도
- 인증에 성공하면 Authentication 객체를 반환하고 인증이 실패하면 AuthenticationException을 throw
- 위 코드에서는 @SneakyThrows 애너테이션으로 발생한 예외를 throw
- successfulAuthentication()
- 사용자 인증이 성공했을 때 호출되는 메서드
- 인증 성공 후에 응답을 한다거나, 인증 정보를 저장하는 등의 추가 작업을 수행 O
- 위 코드에서는 로그인을 성공했다는 정보를 응답해 주고 생성한 Email과 RefreshToken을 key -value 값으로 Redis에 저장
CustomUserDetailService
- UserDetailsService를 구현한 클래스
- UserDetailsService 인터페이스는 Spring Security에서 인증 정보를 조회하기 위해 사용
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return memberRepository.findByEmail(email)
.map(this::createUserDetails)
.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
}
private UserDetails createUserDetails(Member member) {
return CustomUserDetails.of(member);
- loadByUsername()
- 사용자 이름(Email)을 입력받아 MemberRepository에서 사용자 정보를 조회
- 조회한 Member 객체가 존재하면 createUserDetails() 메서드를 사용해서 CustomUserDetails 객체를 생성하고 반환
CustomUserDetails
- UserDetails 인터페이스를 구현한 클래스
- Spring Security에서 관리하는 User 정보를 관리
@Getter
@NoArgsConstructor
@ToString
public class CustomUserDetails extends Member implements UserDetails {
private Long id;
private String email;
private String role;
private String password;
private CustomUserDetails(Member member) {
this.id = member.getId();
this.email = member.getEmail();
this.password = member.getPassword();
this.role = member.getRole();
}
private CustomUserDetails(String email, String role) {
this.email = email;
this.role = role;
}
private CustomUserDetails(String email, String password, String role) {
this.email = email;
this.password = password;
this.role = role;
}
public static CustomUserDetails of(Member member) {
return new CustomUserDetails(member);
}
public static CustomUserDetails of(String email, String role) {
return new CustomUserDetails(email, role);
}
public static CustomUserDetails of(String email, String password, String role) {
return new CustomUserDetails(email, password, role);
}
@Override
public List<GrantedAuthority> getAuthorities() {
return CustomAuthorityUtils.createAuthorities(role);
}
@Override
public String getUsername() {
return this.email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
- getAuthorities()
- 권한을 생성하고 List<GrantedAuthority> 타입으로 반환
- isAccountNonExpired() , isAccountNonLocked(), isCredentialsNonExpired(), isEnabled()
- 사용자 계정이 만료되지 않았는지, 잠금 상태인지, 인증 정보가 만료되지 않았는지, 활성화 상태인지 여부를 반환하는 메서드
- 모두 true를 반환하도록 구현
CustomAuthorityUtils
- 권한 정보를 생성하고 검증하는 유틸리티 클래스
@Slf4j
public class CustomAuthorityUtils {
public static List<GrantedAuthority> createAuthorities(String role) {
return List.of(new SimpleGrantedAuthority("ROLE_" + role));
}
public static void verifiedRole(String role) {
if (role == null) {
throw new BusinessLogicException(ExceptionCode.MEMBER_ROLE_DOES_NOT_EXISTS);
} else if (!role.equals(USER.toString()) && !role.equals(ADMIN.toString())) {
throw new BusinessLogicException(ExceptionCode.MEMBER_ROLE_INVALID);
}
}
}
- createAuthorities()
- 입력된 Role 값을 기반으로 권한 정보를 생성하여 List<GrantedAuthority> 타입으로 변환
- 권한 정보는 "ROLE_USER", "ROLE_ADMIN" 형식으로 생성
- verifiedRole()
- 입력된 Role 값이 유효한 권한인지 검증
SecurityConfiguration
- Spring Cecurity 설정을 담당하는 클래스
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors().configurationSource(corsConfigurationSource())
.and()
.formLogin().disable()
.httpBasic().disable()
.sessionManagement().sessionCreationPolicy(STATELESS)
.and()
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.apply(new CustomFilterConfigurer())
.and()
// TODO: 추후 권한 별 페이지 접근제어 설정 예정
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll());
return http.build();
}
- headers().frameOptions().sameOrigin() : X-Frame-Options 헤더 설정을 SAMEORIGIN으로 설정하여, 웹 페이지를 iframe으로 삽입하는 공격 방지를 위한 설정
- http.csrf().disable() : jwt를 사용하기 때문에 CSRF(Cross-Site Request Forgery) 공격 방지 기능을 사용하지 X
- http.cors().configurationSource(corsConfigurationSource()) : CORS(Cross-Origin Resource Sharing)를 활성화하고, 허용할 origin, method, header 등을 설정한 corsConfigurationSource() 메서드를 지정
- http.formLogin().disable() : 폼 기반 로그인 방식을 비활성화
- http.httpBasic().disable() : HTTP 기본 인증 방식을 비활성화
- http.sessionManagement().sessionCreationPolicy(STATELESS) : 인증에 사용할 세션을 생성하지 않도록 설정
- http.exceptionHandling() : 예외 처리를 설정
- authenticationEntryPoint(new CustomAuthenticationEntryPoint()) : 인증되지 않은 사용자가 보호된 리소스에 접근할 때 호출할 엔드포인트를 설정
- accessDeniedHandler(new CustomAccessDeniedHandler()) : 인가되지 않은 사용자가 보호된 리소스에 접근할 때 호출할 핸들러를 설정
- apply(new CustomFilterConfigurer()) : 사용자 정의 필터를 적용
- authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()) : 모든 HTTP 요청에 대해 접근을 허용
- corsConfigurationSource() : CORS를 설정하는 메서드
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE"));
configuration.setAllowCredentials(true);
configuration.addExposedHeader("Authorization");
configuration.addExposedHeader("Refresh");
configuration.addAllowedHeader("*");
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
- setAllowedOrigins(List.of("*")) : 모든 Origin에서 접근이 가능하도록 해놨다. 이후 Origin 이 확정되면 변경해줘야 한다.
- setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE")) : HTTP 요청 메서드 중 GET, POST, PATCH, DELETE 만 허용
- setAllowCredentials(true) : true로 설정하면 Access-Control-Allow-Credentials 헤더가 설정된다.
- addExposedHeader() : 클라이언트에게 노출할 헤더 값을 설정
- addAllowedHeader() : 클라이언트가 전송할 수 있는 헤더 값을 설정
- setMaxAge() : 클라이언트가 다시 preflight 요청을 보내지 않아도 되는 시간을 설정
- registerCorsConfiguration("/**", configuration) : CORS 구성을 등록, “/**” 는 모든 경로에서 CORS가 적용되도록 설정한다.
- CustomFilterConfigurer 클래스는 인증 및 권한 부여를 위해 사용되는 필터 체인을 구성하는 데 사용된다. AbstractHttpConfigurer 를 상속하며 configure() 메서드를 재정의한다
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
log.info("SecurityConfiguration.CustomFilterConfigurer.configure excute");
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager,
jwtTokenProvider, aes128Config, memberService, redisService);
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenProvider, redisService);
jwtAuthenticationFilter.setFilterProcessesUrl("/auth/login");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
jwtAuthenticationFilter.setAuthenticationFailureHandler(new LoginFailurHandler());
builder
.addFilter(jwtAuthenticationFilter)
.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
}
}
- setFilterProcessesUrl("/auth/login") : 로그인 URL을 설정
- setAuthenticationSuccessHandler() , setAuthenticationFailureHandler() : 로그인 성공 및 실패 시 호출되는 핸들러를 설정
- addFitler() 메서드를 사용하여 필터를 추가하고, addFilterAfter() 메서드를 사용하여 JwtAuthenticationFilter 다음에 JwtVerificationFilter 를 추가한다.
참조 https://green-bin.tistory.com/68