인증/인가 로직을 구현 할 때, 모든 기능을 직접 구현하는 것보다 Sprign security를 사용하면 간편하게 구현이 가능합니다.
저는 인가로직은 사용하지 않았고, Spring Security환경에서 JWT를 사용해 인증 로직을 어떻게 구현했는지 과정을 기록하려고 합니다.
0️⃣ 프로젝트 환경
- Spring Boot 2.7
- Gradle
- Java 11, JPA
- Rest API
✔️ 로그인 설계
- 개인 서버를 이용하는 로그인
- 일반 회원 로그인 / 관리자 로그인
- oauth 서버를 사용하는 로그인
- kakao 로그인/ google 로그인 / naver 로그인
일반 회원과 관리자는 개인 서버, 즉 우리의 DB를 사용하기 때문에 인증/인가에 대한 보안 전략이 필요합니다.
이 때 아이디/패스워드와 같은 민감한 회원 정보를 다루는 방법으로, JWT를 사용하겠습니다.
💡 인증
유저의 신원을 입증하는 과정이다.
💡 인가
권한에 대한 허가로서, 인증된 사용자에 대한 자원 접근 권한을 확인한다.
1️⃣ 인증 흐름
✔️ 인증이 필요하지 않은 API
회원가입이나 로그인, 그리고 단순한 조회용 API의 경우, 로그인한 사람의 정보가 없으므로 사용자 정보를 알아올 필요가 없습니다.
- 회원가입
- 회원가입 API요청
- 회원가입 로직
- 성공 시 201 Created 상태코드 반환
- 로그인
- 로그인 API 요청
- 아이디/패스워드 검증
- Access/Refresh Token 생성 + Refresh Token DB에 저장
- 토큰 정보 응답
- 그 외 단순 조회 API
✔️ 인증이 필요한 API
게시글을 작성하거나, 수정하는 API는 로그인한 사용자에 대한 정보가 필요합니다.
이를 알아내기 위해 API 호출 전, Security Filter에서 등록한 JWT Filter에서 Access Token의 유효성을 검증하도록 합니다.
- 인증 성공 시
- 토큰 정보로 Authentication 객체를 생성해 SecurityContext에 넣어줌
- API 응답
- 인증 실패 시
- [BE] 유효하지 않은 토큰이므로 에러 반환
- [FE] Refresh Token으로 Access/Refresh Token Reissue API 요청
- [BE] 저장소에 해당 사용자의 Refresh Token이 있는지 확인
- [BE] Refresh Token이 없으면 만료된 사용자임을 알려주며 에러 반환 → [FE] 로그인 api 요청
- [BE] Refresh Token이 있으면 Access/Refresh Token 재생성 후 토큰 정보 반환
현재 프로젝트에서는 백엔드만 다루기 때문에 프론트 로직은 구현하지 않습니다.
여기서 프론트에서는 Access Token의 유효기간이 얼마 남지 않았을 경우, Token을 재발급하는 API를 요청하는 로직 정도를 추가하면 될 것 같습니다.
전체적인 인증의 흐름을 알아보기 위해 로그인/로그아웃로직보다 먼저 인증이 필요한 API를 호출할 때 로직을 구현해보겠습니다.
2️⃣ Security 설정
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
@Configuration
public class WebSecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private static final String[] ALLOWED_URIS = {"/api/auth/**", "/api/member/register", "/api/oauth2/**"};
/* filterChain 설정 */
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors().and()
.csrf().disable() // 토큰 사용 -> 비활성화
.httpBasic().disable()
.forLogin().disalbe()
.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)
.addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class);
return http.build();
}
}
Security는 UsernamePasswordAuthenticationFilter 에서 인증된 사용자의 정보를 Authentication 객체로 만들어 SecurityContextHolder에 넣어줌으로써 인증 과정을 끝마칩니다.
이 과정을 직접 구현해야 하기 때문에, UsernamePasswordAuthenticationFilter 전에 JwtFilter가 작동하도록 등록한 뒤, 위의 과정을 대신 거치도록 구현할 것입니다.
이 때, 회원가입/로그인에 사용되는 API의 경우(ALLOWED_URIS) 인증된 사용자의 정보가 필요 없으므로 permitAll()로 허용해줍니다.
이 설정으로 허용된 URI에서는 인증된 사용자의 정보가 없더라도 security가 401 UnAuthorize를 반환하지 않고 정상 작동하게 됩니다.
⚠️주의
.permitAll()은 등록한 필터를 타지 않게해주는 것이 아닙니다.
단지 인증된 객체가 없어도 요청을 허용해준다는 의미입니다.
3️⃣ 인증 필터
✔️ Jwt Filter
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private static final String DELIMS = " ";
private final JwtProvider jwtProvider;
@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) && jwtProvider.validateToken(accessToken)) {
// Access Token 정보로 Authentication 객체 생성 및 저장
Authentication authentication = jwtProvider.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을 검증한 뒤, Access Token에서 추출한 사용자정보값으로 Security에서 요구하는 인증된 사용자 객체를 설정해주는 역할을 합니다.
순서는 다음과 같습니다.
- RequestHeader로 넘어온 Authorization의 토큰값을 검사합니다.
- 토큰이 bearer 타입이며 JWT 알고리즘에 의해 유효한 값인지 검증합니다.
- 유효성 검증을 통과하면, Access Token에서 추출한 사용자의 정보로 Authentication이라는 유저 정보를 생성하여 SecurityContextHolder에 넣어줍니다.
이렇게 SecurityContextHolder에 넣어준 Authentication은, 인증 후 로직(API)에서 사용자의 정보를 가져오고 권한을 검증하는 데 사용됩니다.
✔️ Jwt Provider
Jwt Filter에서 사용된 Jwt Provider는 JWT와 관련된 로직을 담당합니다.
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가 있습니다. ****Security는 이 값을 통해 사용자 정보를 알아옵니다.
- Principal
- 로그인된 사용자의 정보(아이디 등)를 알아올 수 있는 객체입니다. UserDetails라는 Security의 유저 객체가 해당됩니다.
- Credentials
- 비밀번호 등 민감한 정보를 포함할 수 있는데, 이후 동작하는 API에서도 접근할 수 있기 때문에 보안 상 민감한 정보는 포함하지 않는 것이 좋습니다.
- Authorities
- 유저의 권한을 넣어줄 수 있습니다. API에서 호출할 때 Controller에 @PreAuthorize 로 사용자 권한을 체크할 수 있습니다.
💡 Claims
JWT의 Claims는 토큰의 복호화값, 즉 사용자에 대한 정보가 들어있습니다. subject, claim, expiration 등의 설정이 들어있는 객체입니다.
💡 UsernamePasswordAuthenticationToken
Authentication의 구현체로서, 생성자에서 .setAuthenticated(true)가 실행되기 때문에 Security는 이 객체가 인증된 사용자의 객체임을 알 수 있습니다.
✔️ MemberDetailsService
Security의 UserDetailsService 구현체입니다.
사용자의 정보가 실제로 저장소에 존재하는 지 검증하는 단계입니다.
@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);
}
}
여기서 UserDetails를 반환해주면 되는데, 저는 UseDetails를 커스텀하였기에 MemberDetails라는 구현체를 반환하였습니다.
✔️ MemberDetails (선택)
MemberDetails는 Security의 UserDetails를 구현한 커스텀 구현체입니다.
Authentication 객체의 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임을 알려줄 수 있습니다.
이제 인증 과정은 모두 끝났습니다.
3️⃣ 인증 과정을 거친 API에서 사용자 정보 가져오기
앞서 말했듯이, Security에서 인증된 사용자에 대한 정보는 SecurityContextHolder에 담긴 Athentication객체를 통해 가져올 수 있습니다.
로직에서 SecurityContextHolder를 호출해 정보를 가져올 수도 있지만, Security는 ArgumentResolver를 통해 Principal을 주입해줍니다.
따라서 Controller에서 간편하게 Principal을 파라미터로 받으면 정보를 가져올 수 있습니다.
💡 참고로 저는 UserDetails 대신 MemberDetails를 Principal로 넘겼습니다.
✔️ Principal
Principal 인터페이스를 ****그대로 파라미터로 받아올 수 있습니다.
public ...(**Principal** principal) {
String userEmail = principal.getName();
}
✔️ @AuthenticationPrincipal
어노테이션을 사용해 Principal로 설정해준 클래스(예:UserDetails)를 파라미터로 받아올 수 있습니다.
public ... (**@AuthenticationPrincipal MemberDetails** memberDetails) {
String userEmail = memberDetails.getUsername();
int userNo = memberDetails.getUserNo();
String userAuthorities = memberDetails.getAuthorities().toString();
}
✅ 요약
Security Filter Chain에서 어떤 주소에 인증된 정보가 필요한지 설정해줍니다.
이후 인증이 필요한 API 주소를 요청할 때, JWT Filter를 거치면서 Request Header의 Authorization값에 담긴 Access Token을 검증합니다.
검증 성공 시
- Access Token을 바탕으로 사용자 정보 (아이디, 권한 등)을 추출해 인증된 Authentication 객체를 생성합니다.
- 이 객체를 SecurityContextHolder에 넣어주면 JWT Filter의 역할은 끝납니다.
- 이후 원래 호출한 API에서는 저장된 Authentication 객체를 바탕으로 사용자 정보를 알아올 수 있습니다.
검증 실패 시
- 예외를 던져 API 요청을 중단시킵니다.
'프로젝트 Project' 카테고리의 다른 글
추상 클래스로 예외 규격 설정하기 (1) | 2024.08.06 |
---|---|
static 내부 클래스로 DTO 관리하기 (0) | 2024.08.06 |
멀티 모듈로 프로젝트 구성하기 (0) | 2024.08.05 |
Ehcache로 캐싱하기 (0) | 2024.08.02 |
쿼리 튜닝하기 (0) | 2024.08.02 |