- 현재 서버를 하나만 두었는데,
시간이 된다면 Nginx를 사용해 무중단배포도 해보고싶어
서버의 확장성을 고려해 인증은 토큰 방식으로 정했다- session 방식을 사용해서 redis를 session storage로 사용해볼까도 고민했는데,
만약 redis에 문제가 생겨 정보가 휘발되거나 응답시간이 늦춰진다면 그것 또한 문제라고 생각했다 - Cookie & Session vs JWT 공부 후 정리
- session 방식을 사용해서 redis를 session storage로 사용해볼까도 고민했는데,
- 비밀번호 암호화 방식은 단방향 암호화(BCrypt)를 선택했다
- 암호화 한 비밀번호를 복호화 할 일이 없어 단방향 암호화를 사용했다
- 그 중 BCrypt와 SCrypt를 고민하였다
SCrypt는 브루트 포스 공격도 막고 보안성은 더 좋지만 해싱하는데 더 많은 자원이 필요하다(메모리 오버헤드)
서비스의 한정된 자원을 고려했을 때, BCrypt도 충분히 보안성 있고 경제성 있어 효율적이라고 판단하여 선택했다- 보안성: 공격자가 암호화한 값을 알아내는데 걸리는 시간을 최대한 늦추어야 하는 궁극적인 목적을 얼마나 달성할 수 있는가?
- 경제성: 보안성을 얻기 위해 포기해야 하는 경제적 비용(메모리 오버헤드 or CPU 연산 속도 등)
Spring Security Setting
- dependency
//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
//JWT
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
- okta에서 만든 jjwt와 auth0에서 만든 java-jwt 라이브러리
- 둘 다 JWT를 인코딩하거나 디코딩하는 기능 제공
- 제공하는 기능은 유사, 구현 방식에서 조금씩 차이남
- jjwt가 상대적으로 더 많은 암호화 알고리즘 제공
=> jjwt와 java-jwt 라이브러리 중 jjwt가 github star 수와 fork 수가 더 높아서 더 많이 쓰인다고 판단했고
JWT 구현은 처음이다 보니 참고할 문서가 더 많을 jjwt 라이브러리로 선택했다
SecurityConfig.java
Spring Security 설정 파일
@Configuration
@EnableWebSecurity //Security filter(추가 설정) 등록
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final String[] apiUrls = {
"/docs", "/docs/index.html", "/docs/**", "/index.html", "/swagger-ui.html"
};
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
.antMatchers(apiUrls);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.httpBasic().disable()
.formLogin().disable()
.rememberMe().disable()
.logout().disable()
.cors()
.configurationSource(corsConfigurationSource())
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.antMatchers(apiUrls).permitAll()
.antMatchers("/api/v1/members/signup", "/api/v1/members/login").permitAll()
.and()
.exceptionHandling()
.authenticationEntryPoint(customAuthenticationEntryPoint())
.accessDeniedHandler(customAccessDeniedHandler())
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider()),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtExceptionFilter(objectMapper), JwtAuthenticationFilter.class);
return http.build();
}
}
Deprecated된 WebSecurityConfigurerAdapter
- 기존 : extends WebSecurityConfigurerAdapter 후 설정을 오버라이딩 하는 방식
- 변경 : Spring Security 5.7 버전부터는 WebSecurityConfigurerAdapter를 사용하지 않음
- 상속x 오버라이딩x -> 모두 Bean으로 등록
스프링 공식문서) Spring Security without the WebSecurityConfigurerAdapter
- 상속x 오버라이딩x -> 모두 Bean으로 등록
- 내가 현재 SpringBoot 2.7을 사용하는데 2.7부터 Spring Security 5.7버전 사용 중이다
따라서 SecurityFilterChain을 @Bean을 사용해 등록했다
WebSecurityCustomizer Bean으로 등록할 때 주의할 점
WebSecurity 요청을 무시하도록 구성하는건데
이 설정말고도 HttpSecurity 구성 시, authorizeHttpRequests에서 이 경로들을 permitAll 해주기
- 공식문서 : Spring Security에서 해당 경로를 무시하는 것이기 때문에 CSRF, XSS, Clickjacking 등에서 보호받지 못함
보호하기 위해 HttpSecurity에서 permitAll 하기
Custom한 JWT 객체
WumoJwt.java
토큰 관련 정보들은 가진 객체를 하나 만들어 사용했다
@Getter
public class WumoJwt {
private String grantType;
private String accessToken;
private String refreshToken;
@Builder
public WumoJwt(String grantType, String accessToken, String refreshToken) {
this.grantType = grantType;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
JWT 토큰 생성 및 인증 객체
로그인 과정에서 토큰을 생성하고
그 외의 요청 시에는 토큰이 유효한지 여부를 체크해주고
토큰에 담긴 정보에 맞는 authentication 객체를 반환해주는 객체를 만들어 사용했다
토큰관련해서는 모두 이 아이에게 맡기도록
JwtTokenProvider.java
- yml 파일
jwt:
header: Authorization
issuer: (issuer)
secret-key: (HS512 알고리즘 사용을 위해 64B(512bit) 이상이어야 함)
access-token-expire-seconds: 1800000 #30minute
refresh-token-expire-seconds: 604800000 #7days
@Slf4j
@Component
public class JwtTokenProvider {
private static final String BEARER_TYPE = "Bearer"; //JWT or OAuth에 대한 토큰 사용을 의미
private final String issuer;
private final String key;
private final long ACCESS_TOKEN_EXPIRE_SECONDS;
private final long REFRESH_TOKEN_EXPIRE_SECONDS;
private final SecretKey secretKey;
public JwtTokenProvider(
@Value("${jwt.issuer}") String issuer,
@Value("${jwt.secret-key}") String key,
@Value("${jwt.access-token-expire-seconds}") long accessSeconds,
@Value("${jwt.refresh-token-expire-seconds}") long refreshSeconds) {
this.issuer = issuer;
this.key = key;
this.ACCESS_TOKEN_EXPIRE_SECONDS = accessSeconds;
this.REFRESH_TOKEN_EXPIRE_SECONDS = refreshSeconds;
this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(key));
}
//유저정보(memberId)로 access token, refresh token을 만들어 CustomJwt 객체 생성
public WumoJwt generateToken(String memberId) {
Date currentDate = new Date();
return WumoJwt.builder()
.grantType(BEARER_TYPE)
.accessToken(generateAccessToken(memberId, currentDate))
.refreshToken(generateRefreshToken(currentDate))
.build();
}
private String generateAccessToken(String memberId, Date currentDate) {
Date expireDate = new Date(currentDate.getTime() + ACCESS_TOKEN_EXPIRE_SECONDS);
return Jwts.builder()
.setIssuer(issuer)
.setSubject(memberId)
.setIssuedAt(new Date())
.setExpiration(expireDate)
.signWith(secretKey)
.compact();
}
private String generateRefreshToken(Date currentDate) {
Date expireDate = new Date(currentDate.getTime() + REFRESH_TOKEN_EXPIRE_SECONDS);
return Jwts.builder()
.setExpiration(expireDate)
.signWith(secretKey)
.compact();
}
//JWT 토큰을 복호화해서 토큰에 들어있는 정보 꺼내서 Authentication 객체 생성
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
return new UsernamePasswordAuthenticationToken(claims.getSubject(), "", Collections.emptyList());
//token 만들 때 claims의 subject에 memberId 넣어서 생성했음
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException exception) {
log.info("Invalid JWT Token", exception);
} catch (ExpiredJwtException exception) {
log.info("Expired JWT Token", exception);
} catch (UnsupportedJwtException exception) {
log.info("Unsupported JWT Token", exception);
} catch (IllegalArgumentException exception) {
log.info("JWT claims string is empty.", exception);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
SecretKey secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(key));
- 알고리즘을 따로 명시해주지 않아도 된다
JWT 토큰 관련 필터
HttpRequest에서 토큰 읽어 정상토큰이면(JwtTokenProvider에서 체크)
JwtTokenProvider가 준 Authentication 객체(인증 완료, 권한 부여된)를 SecurityContext에 저장하도록 구현했다
의도한대로 토큰 관련해서는 모두 JwtTokenProvider에게서 정보를 얻어왔다
JwtTokenFilter.java
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION = "Authorization";
private static final String BEARER_TYPE = "Bearer";
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = extractToken((HttpServletRequest)request);
if (StringUtils.hasText(token)) {
jwtTokenProvider.validateToken(token);
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
}
로그인 과정
사용자가 입력한 email과 password를 가지고
데이터베이스에 있는 정보와 비교 후 일치하면 token을 생성해 반환함
이 때, refresh token은 서버 데이터베이스에도 저장
23.03.12) refresh token 저장위치는 redis로 변경했다
refresh token을 저장하기에 Redis가 적절하다고 생각한 이유
MemberService.java
@Transactional
public MemberLoginResponse login(MemberLoginRequest memberLoginRequest) {
Member member = memberRepository.findByEmail(new Email(memberLoginRequest.email()))
.orElseThrow(() -> new EntityNotFoundException("일치하는 회원이 없습니다."));
//BCryptPasswordEncoder 사용
if (member.isNotValidPassword(memberLoginRequest.password())) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
}
//DB의 정보와 일치하므로 JwtTokenProvider에게 토큰 생성 요청
WumoJwt wumoJwt = jwtTokenProvider.generateToken(String.valueOf(member.getId()));
//refreshToken는 Client뿐 아니라 DB도 저장
String refreshToken = wumoJwt.getRefreshToken();
member.updateRefreshToken(refreshToken);
return toMemberLoginResponse(wumoJwt);
}
- 참고) Hash는 단방향 암호화 기법, Encryption은 양방향 암호화 기법
- Hash는 평문을 암호화된 문장(텍스트)으로 만들어주는 기능 = 고정된 길이의 암호화된 문자열로 바꿔 버리는 것
- Encryption은 평문을 암호화된 문장(텍스트)으로 만들어주는 기능 + 암호화된 문장을 다시 평문으로 만드는 복호화 기능도 함
- 입력이 다른 값이지만 해시된 값은 같을 수 있음
- Hash 알고리즘은 특정 입력에 대해 항상 같은 해시 값을 리턴함(BCrypt 제외)
- BCrypt : 패스워드를 위해 탄생해서 아주 강력한 해시 알고리즘이 적용됨
- BCrypt 알고리즘은 SHA 알고리즘과 다르게 동일한 평문도 매번 다른 해시값으로 나타남
Postman으로 확인
access toekn 과 refresh token이 알맞게 보내졌다
token 만료 시 재발급하는 부분도 구현해야겠다
23.03.12) 구현 완료, 위와 동일한 정리!