인증 흐름, 토큰 관리
인증 동작 흐름
우선 프론트 서버에서 요청이 들어오면 요청에 대한 검사를 진행시켰는데 이때 스프링 시큐리티를 사용했습니다.
들어온 요청에 대해 시큐리티 필터 체인을 이용하여 클릭 체킹 보안과 콜스 정책등을 1차적으로 체크하고, 체크가 완료되면 JWT를 정의해 놓은 사용자 정의 필터를 적용했습니다.
이 필터는 크게 두 가지 동작으로 이루 졌었는데,
클라이언트가 로그인 할 시 JWT가 데이터베이스에 저장된 클라이언트의 정보와 비교하고, 정보가 일치하면 엑세스 토킁을 발급하는 동작입니다.
클라이언트가 인증이 필요한 요청시 헤더에 담겨온 엑세스 토큰을 검사하고, 토큰의 정보를 토대로 엑세스 토큰을 만들어 클라이언트가 보낸 토큰과 일치하면 통과시키고 틀리면 반환을 시키는 작업을 했습니다.
JWT까지 통과하면 마지막으로 HTTP 요청에 대한 클라이언트의 권환을 확인하여 요청에 대한 통과 여부를 결정했습니다.
인증 흐름, 토큰 관리 구현
순서
SecurityConfig: 보안 구성 클래스를 통해 Spring Security 설정
JwtTokenizer: JWT 토큰 생성과 관련된 클래스를 분석하고, 토큰의 생성 및 검증 방법을 설명
CustomAuthorityUtils: 권한 관련 유틸리티 클래스를 다루며, 권한 설정 및 변환 메서드를 설명
MemberDetailsService: 사용자 정보를 로드하는 클래스로, Spring Security와 연동하여 사용자 인증 정보를 생성하는 방법을 설명
JwtAuthenticationFilter와 JwtVerificationFilter: 사용자 인증과 JWT 토큰 검증을 처리하는 필터 클래스를 분석하며, 보안 관련 필터의 역할과 작동 방식을 설명
GetAuthUserUtils: 현재 인증된 사용자 정보를 가져오는 유틸리티 클래스를 다루고, Spring Security의 SecurityContextHolder를 활용하는방법을 설명
SecurityConfig
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors().configurationSource(corsConfigurationSource())
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // (1) 추가
.and()
.formLogin().disable()
.httpBasic().disable()
.apply(new CustomFilterConfigurer())
.and()
.authorizeHttpRequests(authorize -> authorize
.antMatchers("/signup").permitAll()
.antMatchers("/login").permitAll()
.antMatchers("/regular/**").permitAll()
.antMatchers("/custom/submit/**").hasRole("USER")
.antMatchers("/custom/update/*").hasRole("USER")
.antMatchers("/custom/delete/*").hasAnyRole("USER", "ADMIN")
.antMatchers("/member/**").hasRole("USER")
.antMatchers("/bookmark/**").hasRole("USER")
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOrigins(Arrays.asList("http://resevilleage-bukit.s3-website.ap-northeast-2.amazonaws.com/"));
corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
corsConfiguration.setAllowedMethods(Arrays.asList("GET","POST","PATCH","DELETE"));
corsConfiguration.addExposedHeader("authorization");
corsConfiguration.addExposedHeader("refresh");
corsConfiguration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
jwtAuthenticationFilter.setFilterProcessesUrl("/login");
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);
builder.addFilter(jwtAuthenticationFilter)
.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
}
}
}
먼저 Security 설정을 위해 SecurityConfig
를 만들었습니다.
SecurityFilterChain
인터페이스를 사용하여 필터체인을 구성했는데, CustomFilterConfigurer
클래스를 사용하여 직접 보안 필터를 적용했습니다. 여기서 직접 jwt 인증을 /login 엔드포인트에서만 동작하도록 설정하고, jwtAuthenticationFilter
를 필터 체인에 추가했습니다.
passwordEncoder()
메서드 같은 경우에는 비밀번호 암호화를 위해 사용했고, 여기서 사용한 createDelegatingPasswordEncoder()
메서드는 다양한 비밀번호 인코딩 전략에서 적절한 전략을 선택하도록 도와주는 역할을 합니다.
CorsConfigurationSource
인터페이스를 활용하여 CORS 구성을 정의했습니다.
다음으로는 SecurityConfig
에서 DI 받는 JwtTokenizer
클래스를 설명하겠습니다.
JwtTokenizer
@Component
@RequiredArgsConstructor
public class JwtTokenizer {
@Getter
@Value("${jwt.key}")
private String secretKey;
@Getter
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes;
@Getter
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes;
private final MemberDetailsService memberDetailsService;
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base54EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base54EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
return claims;
}
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
public Date getTokenExpiration(int expirationMinutes) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
Date expiration = calendar.getTime();
return expiration;
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
}
JwtTokenizer
클래스 같은 경우 JWT를 생성하고 검증하는 역할을 합니다.
encodeBase64SecretKey()
메서드는 주어진 secretKey를 Base64로 인코딩 해줍니다.
generateAccessToken()
메서드 같은 경우 각 주어진 claims
, subject
, expiration
, Base64로 인코딩 된 시크릿 키를 사용하여 엑세스 토큰을 생성합니다.
마찬가지로 generateRefreshToken()
메서드도 같은 방식으로 리프레시 토큰을 생성합니다.
getClaims()
메서드를 사용해 JWT와 Base64로 인코딩 된 시크릿 키를 사용해서 클레임을 반환합니다.
verifySignature()
메서드는 서명을 검증하는데, 위와 동일한 방식으로 진행하여, JWT의 서명이 올바른지 확인합니다.
getTokenExpiration()
메서드는 주어진 만료 시간을 기반으로 현재 시간으로부터의 특정 시간 후의 만료 시간을 계산을 합니다.
마지막으로 getKeyFromBase64EncodedKey()
메서드는 Base64로 인코딩 된 시크릿 키를 바이트 배열로 디코딩하고, HMAC-SHA 알고리즘을 사용하여 Key 객체를 생성 했는데, 여기서 HMAC-SHA 알고리즘을 사용한 이유는 토큰을 생성할 때 사용한 키와 같은 키를 사용하여 토큰의 서명을 생성하고, 검증 시에도 같은 키를 사용하여 서명을 검증하기 때문입니다.
CustomAuthorityUtils
@Component
public class CustomAuthorityUtils {
@Value("${mail.address.admin}")
private String adminMailAddress;
private final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");
private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "USER");
private final List<String> USER_ROLES_STRING = List.of("USER");
public List<GrantedAuthority> createAuthorities(String email) {
if (email.equals(adminMailAddress)) {
return ADMIN_ROLES;
}
return USER_ROLES;
}
public List<GrantedAuthority> createAuthorities(List<String> roles) {
List<GrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
return authorities;
}
public List<String> createRole(String email) {
if (email.equals(adminMailAddress)) {
return ADMIN_ROLES_STRING;
}
return USER_ROLES_STRING;
}
}
CustomAuthorityUtils
클래스는 사용자의 권한과 역할을 처리하는 작업을 담당합니다.
createAuthorities(String email)
메서드를 사용하여 ADMIN_ROLES
목록 또는 USER_ROLES
목록을 적절하게 반환 시킵니다.
createAuthorities(List<String> roles)
메서드는 문자열 목록을 받아서 각 역할을 ROLE_
접두사와 함께 SimpleGrantedAuthority
로 변환한 후 목록으로 반환합니다. 즉, 역할 문자열을 권한 객체로 변환하는 데 사용되는 것 입니다. 문자열로 표현해주기 위해 SimpleGrantedAuthority
를 사용했습니다.
위에 두 메서드에서 사용한 GrantedAuthority
인터페이스 같은 경우에는 권한을 나타내는 인터페이스인데, 여기서는 권한을 부여하는데 사용했습니다.
createRole()
메서드는 사용자의 이메일 주소를 매개변수로 받아 해당 이메일이 관리자인 경우 ADMIN_ROLES_STRING
리스트를 반환하고, 그렇지 않은 경우 USER_ROLES_STRING
리스트를 반환합니다.
다음으로는 로그인 시 처리하는 JwtAuthenticationFilter
클래스를 설명하겠습니다.
MemberDetailsService
@Component
@RequiredArgsConstructor
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
private final CustomAuthorityUtils authorityUtils;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> optionalMember = memberRepository.findByEmail(username);
Member findMember = optionalMember.orElseThrow(() -> new CocktailException(CocktailRtnConsts.ERR401));
if(findMember.isDeleted() != false) {
throw new CocktailException(CocktailRtnConsts.ERR409);
}
return new MemberDetails(findMember);
}
private final class MemberDetails extends Member implements UserDetails {
MemberDetails(Member member) {
setId(member.getId());
setEmail(member.getEmail());
setPassword(member.getPassword());
setRoles(member.getRoles());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorityUtils.createAuthorities(this.getRoles());
}
@Override
public String getUsername() {
return getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
}
MemberDetailsService
같은 경우는 UserDetailsService
인터페이스를 구현하여 사용자의 인증과 관련된 로직을 처리합니다.
loadUserByUsername()
메서드는 주어진 이메일을 기반으로 사용자 정보를 제공합니다.
MemberDetails
클래스는 Member 클래스를 확장하며, UserDetails 인터페이스를 구현합니다.
해당 클래스에서 설명할 부분은 많진 않은데, MemberDetails()
를 통해 회원 정보를 받아서 필요한 정보를 초기화 시켰고, gerUsername()
을 @Override
를 통해 이메일로 재정의 시켰습니다.
JwtAuthenticationFilter
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenizer jwtTokenizer;
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
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) {
Member member = (Member) authResult.getPrincipal();
String accessToken = delegateAccessToken(member);
String refreshToken = delegateRefreshToken(member);
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh", refreshToken);
}
private String delegateAccessToken(Member member) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", member.getEmail());
claims.put("roles", member.getRoles());
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
return jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
}
private String delegateRefreshToken(Member member) {
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
return jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
}
}
JwtAuthenticationFilter
클래스는 UsernamePasswordAuthenticationFilter
를 상속 받아 사용자 이름과 비밀번호를 사용한 인증을 처리하게 만들었습니다.
attemptAuthentication()
메서드는 로그인 진행 시 호출되는데, 먼저 HTTP 요청에서 받은 JSON 데이터를 LoginDto
객체로 매핑하고, 사용자가 제공한 이메일과 비밀번호를 사용하여 UsernamePasswordAuthenticationToken
객체를 생성했습니다. 그리고 AuthenticationManager
를 통해 인증을 시도합니다.
successfulAuthentication()
메서드는 로그인에 성공하면 호출되는데, Authentication
을 이용해 인증된 사용자 정보를 가져옵니다. 그리고 액세스 토큰과 리프레시 토큰을 생성하고, HTTP 응답 헤더에 추가하여 반환합니다.
delegateAccessToken(), delegateRefreshToken()
이 메서드들은 각각의 토큰을 생성하기 위한 사용자 정보를 설정합니다.
다음은 JWT 토큰을 유효성 검사하는 JwtVerificationFilter
를 설명하겠습니다.
JwtVerificationFilter
@RequiredArgsConstructor
public class JwtVerificationFilter extends OncePerRequestFilter {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Map<String, Object> claims = verifyJws(request);
setAuthenticationToContext(claims);
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String authorization = request.getHeader("Authorization");
return authorization == null || !authorization.startsWith("Bearer");
}
private Map<String, Object> verifyJws(HttpServletRequest request) {
String jws = request.getHeader("Authorization").replace("Bearer ", "");
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();
return claims;
}
private void setAuthenticationToContext(Map<String, Object> claims) {
String username = (String) claims.get("username");
List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List) claims.get("roles"));
Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
먼저 해당 클래스는 HTTP 요청에서 JWT의 유효성을 검사하고, 검증된 토큰에서 사용자의 원한 정보를 추출하여 인증 객체를 생성하는 역할을 합니다. 간단하게 말해서 검증이 되면 사용자에게 접근 권한을 부여하는 것 입니다.
doFilterInternal()
메서드는 실제 필터 작업을 수행하는데, 과정을 살펴보면 verifyJws()
메서드를 호출하여 JWT 토큰을 검증하고, 검증된 클레임을 가져온다. 그다음 가져온 클레임에서 사용자 정보를 추출하고, 인증 객체를 생성하여 SecurityContextHolder
에 설정하고 인증이 처리된 후, 요청을 계속 진행합니다.
shouldNotFilter()
메서드 같은 경우는 필터를 적용할지 여부를 결정하는데, 헤더에 Bearer
로 시작하는 헤더 값이 없으면 적용하지 않도록 설정했습니다.
verifyJws()
메서드는 요청에 대한 JWT 토큰을 추출하고, 해당 토큰을 검증하는 역할을 하는데, Authorization
헤더에서 Bearer
부분을 제거하여 실제 JWT 토큰 문자열을 받습니다. 그리고 아까 설명한 JwtTokenizer
을 사용하여 JWT 토큰을 검증하고, 클레임 정보를 추출한 후 반환 시킵니다.
마지막으로 setAuthenticationToContext()
메서드는 클레임에서 사용자 이름과 권한 정보를 추출하여 인증 객체를 생성합니다. 사용자 이름은 username 클레임에서 가져오고, 권한 정보는 role 클레임에서 가져와 CustomAuthorityUtils
를 사용하여 권한 객체로 변환 시킵니다. 그리고 UsernamePasswordAuthenticationToken
을 생성해 사용자를 인증하고, SecurityContextHolder
에 설정하여 현재 스레드의 보안 컨텍스트에 인증 정보를 저장 시킵니다.
GetAuthUserUtils
public class GetAuthUserUtils {
public static Authentication getAuthUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.getName() == null || authentication.getName().equals("anonymousUser")){
throw new CocktailException(CocktailRtnConsts.ERR401);
}
authentication.getPrincipal();
return authentication;
}
}
해당 클래스는 현재 인증된 사용자의 정보를 확인하고, 인증되지 않은 사용자의 경우 예외 처리를 하는 역할을 합니다.
getAuthUser()
메서드를 통해 현재 사용자의 인증 정보인 Authentication
객체를 반환합니다. 그 다음 Authentication
객체에서 사용자 이름을 가져와 예외를 적용하여 처리합니다.
기능 동작
간단하게 회원 가입 후 로그인 동작을 살펴보겠습니다.
먼저 PasswordEncoder
가 잘 적용되는 것을 확인할 수 있습니다.
이제 해당 아이디로 로그인하고, 엑세스 토큰과 리프레시 토큰이 헤더에 잘 전달되는지 확인해 보겠습니다.
우선 로그인 같은 경우 SecurityConfig
의 CustomFilterConfigurer
에서 설정한 것처럼 localhost:8080/login
으로만 진행이 가능합니다.
그림을 보면 잘 전달되는 것을 확인할 수 있습니다.
S3를 설명할 때 이미지를 넣는 방식으로 설명했으니, 간단하게 회원 내용을 수정하는 API로 설명하겠습니다.
우선 로그인한 유저인지 인증이 필요한 회원 내용 수정을 위해 로그인 때 헤더로 받은 엑세스 토큰을 수정 요청할 때 넣어줍니다. 그림처럼 Bearer
를 같이 넣어도 구현한 JwtVerificationFilter
의 verifyJws()
를 통해 알아서 제거하여 넣어줍니다.
데이터베이스를 확인하면 요청된 수정이 적용된 걸 확인할 수 있습니다.
Last updated