앞선 시리즈에서 언급했듯이, 이 프로젝트에서는 인증/인가 과정에 Spring Security와 JWT(access + refresh)를 적용하여 백엔드를 구현하기로 하였다.
🛠️ 환경
spring boot 2.7 + jpa + redis + mysql
인증이 필요 없는 로그인/회원가입
인증이 필요한 api 호출 - api 호출 전에 jwt필터에서 access Token 유효성 검증
여기서 프론트에서는 access 토큰의 유효기간이 얼마 남지 않았을 경우 reissue를 요청하는 로직 정도가 추가 가능한 것 같다.
우선 이 (1)편에서는 로그인/회원가입이 아닌 인증이 필요한 api에 대한 사용자 인증처리를 알아보도록 하자!
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
@Configuration
public class WebSecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private static final String[] ALLOWED_URIS = {"/api/auth/**"};
/* filterChain 설정 */
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf().disable() // 토큰 사용 -> 비활성화
.and()
.sessionManagement() //세션 사용 X
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(ALLOWED_URIS).permitAll()
.antMatchers("/swagger-resources/**", "/swagger-ui/**", "swagger/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
회원가입/로그인의 경우 인증된 사용자의 정보가 필요 없으므로 permitAll()
로 허용해준다.
이렇게 하면, 허용된 URI에서는 인증된 사용자의 정보가 없더라도 security가 401 UnAuthorize를 반환하지 않고 정상 작동하게 된다.
⚠️주의
.permitAll()
이 등록한 필터를 타지 않게해주는 것이 아니다.
단지 인증된 객체가 없어도 요청을 허용해준다는 의미이다.
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private static final String DELIMS = " ";
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("JwtAuthFilter.doFilterInternal, Jwt 필터 인증 시작");
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
// 헤더에서 bearer 토큰인지 검증
String accessToken = isBearerToken(authorization);
if(StringUtils.hasText(accessToken) && jwtTokenProvider.validateToken(accessToken)) {
// access 토큰 정보로 Authentication 객체 생성 및 저장
Authentication authentication = jwtTokenProvider.extractAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String isBearerToken(String authorization){
return (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) ? authorization.split(DELIMS)[1].trim() : null;
}
}
Jwt Filter는 access Token을 통해 사용자를 인증한다.
주 로직은 다음과 같다.
이렇게 SecurityContextHolder에 넣어준 Authentication은,
다른 api에서 로그인된(인증된) 사용자의 정보를 가져오거나 권한을 검증하는 데 사용된다!
jwt provider에는 jwt토큰(access+refresh)을 생성/검증하는 모든 로직이 포함되어있다. 즉, 인증에 관한 모든 로직이 포함되어있기 때문에 꽤 복잡한 편이다.
우선 Jwt Filter에서만 사용되는 로직(access token 검증)을 살펴보면 다음과 같다.
@Component
public class JwtTokenProvider {
private final Key key; // 토큰을 암호화/복호화할 때 필요한 key
private final MemberDetailsService memberDetailsService;
private final RefreshTokenRepository refreshTokenRepository;
public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey, MemberDetailsService memberDetailsService, RefreshTokenRepository refreshTokenRepository) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.memberDetailsService = memberDetailsService;
this.refreshTokenRepository = refreshTokenRepository;
}
/* 토큰 유효성 검증 */
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
throw new JwtException("유효하지 않은 토큰입니다.");
} catch (ExpiredJwtException e){
throw new JwtException("만료된 토큰입니다.");
} catch (UnsupportedJwtException e){
throw new JwtException("지원되지 않는 유형의 토큰입니다.");
} catch (IllegalArgumentException e){
throw new JwtException("클레임이 비어있습니다.");
}
return false;
}
/* claim에 저장된 정보로 authentication 추출 */
public Authentication extractAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
// Authentication에 넘겨줄 Princiapal 생성
UserDetails memberDetails = memberDetailsService.loadUserByUsername(claims.getSubject());
return new UsernamePasswordAuthenticationToken(memberDetails, accessToken, memberDetails.getAuthorities());
}
/* 토큰 Parsing */
private Claims parseClaims(String accessToken) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
}
}
Authentication객체에 포함되는 정보는 Principal, Credentials, Authorities가 있다.
Principal
은 로그인된 사용자의 정보(아이디 등)를 알아올 수 있는 객체이다. UserDetails라는 security의 유저 객체가 해당된다.
Credentials
의 경우 비밀번호 등이 포함될 수 있는데, 보안 상 비밀번호를 포함하는 것은 추천하지 않는다.
Authorities
는 유저의 권한을 넣어 줄 수 있는데, api에서 바로 @PreAuthorize
등으로 간편하게 권한을 체크할 수 있다.
여기에서 UsernamePasswordAuthenticationToken을 생성하면, 생성 할 때 .setAuthenticated(true)
가 실행되어 security는 이 객체가 인증된 사용자의 객체임을 알 수 있다.
MemberDetails는 Security의 UserDetails를 구현한 커스텀 구현체이다.
여기서 Principal에 넣어줄 정보(아이디나 이메일, 비밀번호, 권한) 등을 커스텀할 수 있다.
@RequiredArgsConstructor
public class MemberDetails implements UserDetails {
private final Member member;
private static final String ROLE_PREFIX = "ROLE_";
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Stream.of(new SimpleGrantedAuthority(ROLE_PREFIX + member.getRole()))
.collect(Collectors.toList());
}
public Long getMemberNo() {
return member.getMemberNo();
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return member.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;
}
}
여기서 boolean 메소드들이 true를 리턴해야 Security에게 빈 Authentication이 아니라 인증된 Authentication임을 알려줄 수 있다.
security의 UserDetailsService를 구현한 구현체이다.
이것을 구현해주어야 security는 db에서 값을 가져왔다고 인식하는 듯 하다..ㅎㅎ
여기서 계정 검증을 해준다. (비밀번호 검증이 아니다. 비밀번호 검증은 로그인시 해준다.)
@RequiredArgsConstructor
@Service
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new AuthException("존재하지 않는 계정입니다."));
return new MemberDetails(member);
}
}
여기서 넘겨준 값을 Authentication의 Principal로 넘겨주었기 때문에, UserDetails를 커스텀한 MemberDetails를 반환해준다.
앞서 말했듯이, security에서 인증된 사용자에 대한 정보는 SecurityContextHolder에 담긴 Athentication객체를 통해 가져올 수 있다.
Security가 자동으로 주입해주기 때문에 Authentication 객체를 로직단에서 바로 받아올 수도 있지만, 다른 방법도 있다.
참고) 나는 앞에서 MemberDetails를 Principal로 넘겼다.
return new UsernamePasswordAuthenticationToken(memberDetails, accessToken, memberDetails.getAuthorities());
Principal
Authentication 객체로 넘긴 Principal의 getUsername() 메소드 값을 가져온다.
즉, 여기에서는 MemberDetails의 getUsername()을 가져온다.
public ...(Principal principal) {
String userEmail = principal.getName();
}
@AuthenticationPrincipal
Authentication 객체로 넘긴 Principal 자체를 가져온다.
public ... (@AuthenticationPrincipal MemberDetails memberDetails) {
String userEmail = memberDetails.getUsername();
int userNo = memberDetails.getUserNo();
String userAuthorities = memberDetails.getAuthorities().toString();
}
여기까지, 로그인된 사용자만 접근 가능한 api의 요청의 인증 플로우를 알아보았다.
짧게 요약하자면,
jwt토큰의 claims에 담겨있는 사용자에 대한 정보를 통해, SecurityContextHolder에 Authentication 객체를 설정해주면 api에서는 로그인된 사용자의 정보를 알아올 수 있다.
다음은 로그인/회원가입의 로직에 대해서 알아보자!!