• 현재 서버를 하나만 두었는데,
    시간이 된다면 Nginx를 사용해 무중단배포도 해보고싶어
    서버의 확장성을 고려해 인증은 토큰 방식으로 정했다
    • session 방식을 사용해서 redis를 session storage로 사용해볼까도 고민했는데,
      만약 redis에 문제가 생겨 정보가 휘발되거나 응답시간이 늦춰진다면 그것 또한 문제라고 생각했다
    • Cookie & Session vs JWT 공부 후 정리


  • 비밀번호 암호화 방식은 단방향 암호화(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를 사용하지 않음
  • 내가 현재 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));   
  • 알고리즘을 따로 명시해주지 않아도 된다

KakaoTalk_20230225_222934589

KakaoTalk_20230225_222937571

KakaoTalk_20230225_223106244



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으로 확인

postman_token

access toekn 과 refresh token이 알맞게 보내졌다
token 만료 시 재발급하는 부분도 구현해야겠다
23.03.12) 구현 완료, 위와 동일한 정리!


업데이트: