spring/게시판 api

Spring boot 게시판 API 서버 제작 (9) - 로그인 - 인증 로직

얼킴 2023. 2. 20. 21:11

이번에는 로그인 관련하여 인증 로직을 추가해보겠습니다.

 

전체적인 인증 로직은 다음과 같습니다.

  1. 클라이언트가 API 를 요청한다. 요청시에 로그인해서 발급받은 access token을 HTTP Authorization 헤더에 담아서 보내준다.
  2. JwtAuthenticationFilter에서 토큰을 검증하고, 토큰으로 요청한 사용자 정보를 데이터베이스에서 조회해서 SecurityContext에 저장한다.
  3. 요청한 API url에 따라서 접근 허용 여부를 검사한다.
  4. 접근에 성공하면 요청한 API url에 따라 Controller에서 작업을 수행한다.
  5. 접근에 실패하면
    1. 인증되지 않은 사용자의 경우 401응답을 내려주는 곳으로 redirect
    2. 요청한 자원에 접근 권한이 없는 경우 403응답을 내려주는 곳으로 redirect

SecurityConfig 설정

먼저 SecurityConfig 설정에 위 로직에 필요한 내용을 추가하겠습니다. SecurityConfig를 다시 정리를 해보자면, SecurityConfig 클래스는 WebSecurityConfigurerAdapter 클래스를 상속하여 구현한 클래스입니다.

Spring Security는 어플리케이션이 시작될 때 @EnableWebSecurity어노테이션이 붙은 클래스를 찾아 웹 보안 구성을 위한 필터 체인을 생성합니다. 그리고 해당 클래스의 configure메서드들을 호출하여 필터 체인에 필요한 보안 구성을 추가합니다. 

이제 코드를 보면서 수정한 내용들을 살펴 보겠습니다.

@Configuration
@EnableWebSecurity //Spring Security 를 사용하는 웹 어플리케이션을 활성화
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final TokenService tokenService;
    private final CustomUserDetailsService userDetailsService;


    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().mvcMatchers("/exception/**"); // /exception/** 패턴의 요청은 보안 검사를 거치지 않는다
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
            http
                .httpBasic().disable() // HTTP Basic 인증을 사용하지
                .formLogin().disable() // Form 로그인을 사용하지 않음
                .csrf().disable() // CSRF 공격으로부터 보호하지 않음
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 사용하지 않음
                .and()
                    .authorizeRequests() // 요청에 대한 인가를 구성
                        .antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up").permitAll()// 회원가입과 로그인 POST 요청은 인증 없이 허용
                        .antMatchers(HttpMethod.GET, "/api/**").permitAll() // /api/** Get 요청은 인증 없이 허용
                        .antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)") //회원 삭제 요청은 MemberGuard.check 검사를 통해 권한이 있는 사용자만 허용
                        .anyRequest().hasAnyRole("ADMIN")
                .and()
                    .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler()) // 엑세스 거부 예외
                .and()
                    .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .and()
                    .addFilterBefore( // JwtAuthenticationFilter 를 UsernamePasswordAuthenticationFilter 앞에 등록
                            new JwtAuthenticationFilter(tokenService, userDetailsService) //JWT 토큰 기반 인증
                            , UsernamePasswordAuthenticationFilter.class
                    );
    }

    @Bean
    public PasswordEncoder passwordEncoder()    {
        return new BCryptPasswordEncoder();
    }
}

(아직은 spring security 를 매번 볼 때 마다 헷갈려서 주석처리를 통해 내용 정리를 하는중입니다)

  • @EnableWebSecurity : 웹 보안 구성을 활성화합니다.
  • tokenService : 토큰을 통해 사용자를 인증하기 위해 JwtAuthenticationFilter에서 필요한 의존성입니다.
  • userDetailsService : 토큰에 저장된 subject로 사용자의 정보를 조회하는데 사용됩니다.
  • authorizeRequests 메서드를 호출하여 요청에 대한 인가를 구성합니다.
    • POST (/api/sign-in, /api/sign-up) : 인증 없이 허용
    • GET (/api/**) : 인증 없이 허용
    • DELETE(/api/members/{id}/**) : MemberGuard.check 검사를 통해 권한이 있는 사용자만 허용
  • exceptionHandling : 엑세스 거부 및 인증 예외를 처리하는 handler를 등록
  • addFilterBefore : JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 등록합니다. 이 필터는 JWT 토큰 기반 인증을 구현합니다.

다음으로 configure메서드에서 호출하는 클래스를 추가해보겠습니다.

 

TokenService.java

토큰을 검증하기 위한 기능을 추가했습니다.

@Service
@RequiredArgsConstructor
public class TokenService {
    private final JwtHandler jwtHandler;

    @Value("${jwt.max-age.access}") // Access 토큰의 만료시간을 지정하는데 사용됩니다.
    private long accessTokenMaxAgeSeconds;

    @Value("${jwt.max-age.refresh}") // Refresh 토큰의 만료시간을 지정하는데 사용됩니다.
    private long refreshTokenMaxAgeSeconds;

    @Value("${jwt.key.access}") // Access 토큰을 생성할 때 사용되는 비밀키를 지정하는데 사용됩니다.
    private String accessKey;

    @Value("${jwt.key.refresh}") // Refresh 토큰을 생성할 때 사용되는 비밀키를 지정하는데 사용됩니다.
    private String refreshKey;

    public String createAccessToken(String subject) {
        return jwtHandler.generateJwtToken(accessKey, subject, accessTokenMaxAgeSeconds);
    }

    public String createRefreshToken(String subject) {
        return jwtHandler.generateJwtToken(refreshKey, subject, refreshTokenMaxAgeSeconds);
    }

    public boolean validateAccessToken(String token) { // access token 검증
        return jwtHandler.validate(accessKey, token);
    }

    public boolean validateRefreshToken(String token) { // refresh token 검증
        return jwtHandler.validate(refreshKey, token);
    }

    public String extractAccessTokenSubject(String token) { // access token 추출
        return jwtHandler.extractSubject(accessKey, token);
    }

    public String extractRefreshTokenSubject(String token) { // refresh token 추출
        return jwtHandler.extractSubject(refreshKey, token);
    }

}

TokenService 테스트

TokenService에 메서드가 추가되면서 테스트도 추가 하였습니다. (설명은 생략)

더보기
@Test
    void validateAccessTokenTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(true);

        // when, then
        assertThat(tokenService.validateAccessToken("token")).isTrue();
    }

    @Test
    void invalidateAccessTokenTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(false);

        // when, then
        assertThat(tokenService.validateAccessToken("token")).isFalse();
    }

    @Test
    void validateRefreshTokenTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(true);

        // when, then
        assertThat(tokenService.validateRefreshToken("token")).isTrue();
    }

    @Test
    void invalidateRefreshTokenTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(false);

        // when, then
        assertThat(tokenService.validateRefreshToken("token")).isFalse();
    }

    @Test
    void extractAccessTokenSubjectTest() {
        // given
        String subject = "subject";
        given(jwtHandler.extractSubject(anyString(), anyString())).willReturn(subject);

        // when
        String result = tokenService.extractAccessTokenSubject("token");

        // then
        assertThat(subject).isEqualTo(result);
    }

    @Test
    void extractRefreshTokenSubjectTest() {
        // given
        String subject = "subject";
        given(jwtHandler.extractSubject(anyString(), anyString())).willReturn(subject);

        // when
        String result = tokenService.extractRefreshTokenSubject("token");

        // then
        assertThat(subject).isEqualTo(result);
    }

CustomUserDetailsService.java

이번에는 사용자 인증을 위해 UserDetailsService 인터페이스를 구현할 클래스인 CustomUserDetailsService를 살펴보겠습니다. 

@Component
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
    private final MemberRepository memberRepository;
    @Override
    public CustomUserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        Member member = memberRepository.findById(Long.valueOf(userId))
                .orElseGet(() -> new Member(null, null, null, null, List.of()));
        return new CustomUserDetails(
                String.valueOf(member.getId()),
                getAuthorities(member)
        );
    }

    private Set<GrantedAuthority> getAuthorities(Member member) {
        return member.getRoles().stream().map(memberRole -> memberRole.getRole())
                .map(role -> role.getRoleType())
                .map(roleType -> roleType.toString())
                .map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
    }
}

loadUserByUsername 메서드는 UserDetailServicve인터페이스의 추상 메서드로, 인자로 전달된 userId를 사용하여 memberRepository를 통해 해당유저 정보를 조회합니다. 이때, 조회된 Member 객체는 CustomUserDetails로 변환하여 반환합니다.

 

CustomUserDetails 는 UserDetails 인터페이스를 구현한 사용자 정보 객체로,  사용자 인증에 필요한 정보를 제공합니다. CustomUserDetails객체에는 사용자 ID, 사용자 권한 정보, 패스워드 등이 저장됩니다.

CustomUserDetails.java

@Getter
@AllArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final String userId;
    private final Set<GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities; // 사용자가 가지고 있는 권한 정보를 반환
    }
    @Override
    public String getUsername() {
        return userId;
    }
    @Override
    public String getPassword() {
        throw new UnsupportedOperationException();
    }
    @Override
    public boolean isAccountNonExpired() {
        throw new UnsupportedOperationException();
    }
    @Override
    public boolean isAccountNonLocked() {
        throw new UnsupportedOperationException();
    }
    @Override
    public boolean isCredentialsNonExpired() {
        throw new UnsupportedOperationException();
    }
    @Override
    public boolean isEnabled() {
        throw new UnsupportedOperationException();
    }
}
  • getAuthorities() : 사용자가 가지고 있는 권한 정보를 반환합니다.
  • getUsername() : 사용자 Id를 반환합니다.

예외 처리

이번에는 예외 처리를 해보겠습니다. SecurityConfig에서의 코드를 잠시 살펴보면 /exception으로 요청이 들어오면 바로 컨트롤러로 이동합니다.

@Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().mvcMatchers("/exception/**"); // /exception/** 패턴의 요청은 보안 검사를 거치지 않는다
    }

그리고 예외를 처리하는 부분도 같이 살펴 보면,

.and()
    .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler()) // 엑세스 거부 예외
.and()
    .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())

이제 Excpetion Handler가 어떻게 Exception을 처리하는지 알아보겠습니다. 

Handler 클래스를 보면

public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendRedirect("/exception/access-denied");
    }
}
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendRedirect("/exception/entry-point");
    }
}

CustomAccessDeniedHandler와 CustonAuthenticationEntryPoint는 /exception/{예외항목}으로 redirect합니다. 그렇게 되면 /exceptrion/{예외항목}을 처리하는 Controller에서 예외를 발생시켜 최종적으로는 ExceptionAdvice에서 관리가 가능해집니다.

 

그렇다면 ExceptionController를 살펴보겠습니다.

@RestController
public class ExceptionController {
    @GetMapping("/exception/entry-point") //인증되지 않은 사용자가 요청 하는 경우
    public void entryPoint() {
        throw new AuthenticationEntryPointException();
    }
    @GetMapping("/exception/access-denied") //사용자가 요청에 대한 접근 권한이 없는 경우
    public void accessDenied() {
        throw new AccessDeniedException();
    }
}

 

redirect된 /exception/{예외항목} 요청은 ExceptionController에 들어와 각 항목에 해당하는 예외를 발생 시킵니다. 그렇게 되면 ExceptionAdvice에서 해당 예외 항목을 찾아 Response로 반환합니다.

@ExceptionHandler(AuthenticationEntryPointException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Response authenticationEntryPoint() {
    return Response.failure(-1001, "인증되지 않은 사용자입니다.");
}

@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Response accessDeniedException() {
    return Response.failure(-1002, "접근이 거부되었습니다.");
}

ExceptionAdvice에 추가된 내용에 대해서 테스트를 진행하겠습니다. (설명 생략)

더보기
@ExtendWith(MockitoExtension.class)
class ExceptionControllerAdviceTest {
    @InjectMocks ExceptionController exceptionController;
    MockMvc mockMvc;

    @BeforeEach
    public void beforeEach(){
        mockMvc = MockMvcBuilders.standaloneSetup(exceptionController).setControllerAdvice(new ExceptionAdvice()).build();
    }

    @Test
    void entryPointTest() throws Exception{
        // given, when, then
        mockMvc.perform(
                get("/exception/entry-point"))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.code").value(-1001));
    }

    @Test
    void accessDeniedTest() throws Exception {
        // given, when, then
        mockMvc.perform(
                        get("/exception/access-denied"))
                .andExpect(status().isForbidden())
                .andExpect(jsonPath("$.code").value(-1002));
    }

}

 

회원 삭제

이번에는 회원 삭제에 대해서 알아보겠습니다. 회원 삭제는 DELETE /api/members/{id} 요청에 대해서 처리하는 로직입니다.

//회원 삭제 요청은 MemberGuard.check 검사를 통해 권한이 있는 사용자만 허용
.antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)")

Delete 요청은 사용자 본인이거나 관리자인 경우에만 허용됩니다. 위 코드에서 권한 검사는 MemberGuard.check 반환 결과에 따라 결정됩니다. 

access 함수는 "@<빈이름>.<메소드명>(<인자, #id로하면 URL에 지정한 {id}가 매핑되어서 인자로 들어감>)"  를 인자로 받습니다.

 

MemberGuard

@Component
@RequiredArgsConstructor
@Slf4j
public class MemberGuard {
    
    private final AuthHelper authHelper;
    
    public boolean check(Long id) {
        return authHelper.isAuthenticated() 
                && authHelper.isAccessTokenType() 
                && hasAuthority(id);
    }
    
    private boolean hasAuthority(Long id) {
        Long memberId = authHelper.extractMemberId();
        Set<RoleType> memberRoles = authHelper.extractMemberRoles();
        return id.equals(memberId) || memberRoles.contains(RoleType.ROLE_ADMIN);
    }
}
  • authHelper.isAuthenticated() : 요청한 사용자가 인증되었는지
  • authHelper.isAccessTokenType() : access token을 통한 요청인지
  • hasAuthority() : 접근 권한을 가지고 있는지 (본인 or 관리자)

AuthHelper

검증을 도와주는 authHelper 클래스를 살펴보겠습니다.

@Component
@Slf4j
public class AuthHelper {

    public boolean isAuthenticated() {
        log.info("1. {}", getAuthentication());

        return getAuthentication() instanceof CustomAuthenticationToken &&
                getAuthentication().isAuthenticated();
    }

    public Long extractMemberId() {
        return Long.valueOf(getUserDetails().getUserId());
    }

    public Set<RoleType> extractMemberRoles() {
        return getUserDetails().getAuthorities()
                .stream()
                .map(authority -> authority.getAuthority())
                .map(strAuth -> RoleType.valueOf(strAuth))
                .collect(Collectors.toSet());
    }

    public boolean isAccessTokenType() {
        log.info("isAccessTokenType");
        return "access".equals(((CustomAuthenticationToken) getAuthentication()).getType());
    }

    public boolean isRefreshTokenType() {
        return "refresh".equals(((CustomAuthenticationToken) getAuthentication()).getType());
    }

    private CustomUserDetails getUserDetails() {
        return (CustomUserDetails) getAuthentication().getPrincipal();
    }
    private Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }
}

검증 작업 이전에 사용자의 인증 정보는 Spring Security에서 관리해주는 컨텍스트에 저장됩니다. 저장은 JwtAuthenticationFilter에서 진행 됩니다. 

AuthHelper는 검증을 위해 사용자 정보로부터 요청자의 id, 인증 여부, 권한 등급, 요청 토큰의 타입을 추출합니다. 

 

JwtAuthenticationFilter

방금 언급한 Spring Security에서 관리해주는 컨텍스트에 사용자 정보를 어떻게 저장하는지 알아보겠습니다.

다시 SecurityConfig에서 JwtAuthenticationFilter 설정 부분을 보자면

.addFilterBefore( // JwtAuthenticationFilter 를 UsernamePasswordAuthenticationFilter 앞에 등록
        new JwtAuthenticationFilter(tokenService, userDetailsService) //JWT 토큰 기반 인증
        , UsernamePasswordAuthenticationFilter.class
)

JwtAuthenticationFilter는 usernamePassowrdAuthenticationFilter 이전에 등록됩니다. usernamePassowrdAuthenticationFilter 는 자신이 처리할 요청이 들어오면 다음 필터를 거치지 않기 때문에, 그 이전에 필터를 등록해야 정상적으로 인증을 수행할 수 있습니다.

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final TokenService tokenService;
    private final CustomUserDetailsService userDetailsService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = extractToken(request); //JWT 추출
        if(validateAccessToken(token)) { // access token
            setAccessAuthentication("access", token);
        } else if(validateRefreshToken(token)) { // refresh token
            setRefreshAuthentication("refresh", token);
        }
        chain.doFilter(request, response); // 다음 필터 또는 서블릿으로 요청을 전달.
    }

    private String extractToken(ServletRequest request) {
        return ((HttpServletRequest)request).getHeader("Authorization");
    }

    private boolean validateAccessToken(String token) {
        return token != null && tokenService.validateAccessToken(token);
    }

    private boolean validateRefreshToken(String token) {
        return token != null && tokenService.validateRefreshToken(token);
    }

    private void setAccessAuthentication(String type, String token) {
        String userId = tokenService.extractAccessTokenSubject(token);
        CustomUserDetails userDetails = userDetailsService.loadUserByUsername(userId);
        SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationToken(type, userDetails, userDetails.getAuthorities()));
    }

    private void setRefreshAuthentication(String type, String token) {
        String userId = tokenService.extractRefreshTokenSubject(token);
        CustomUserDetails userDetails = userDetailsService.loadUserByUsername(userId);
        SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationToken(type, userDetails, userDetails.getAuthorities()));
    }
}

JwtAuthenticationFilte 클래스는 GenericFilterBean을 상속받기 때문에 자동으로 @Bean으로 등록됩니다. 

먼저 필드에 대해서 알아 보겠습니다. CustomAuthenticationToken는 CustomUserDetailsService를 이용하여 조회된 사용자의 정보 CustomUserDetails와 요청 토큰 타입을 저장합니다.

  • doFilter : request로부터 token 을 추출하고 token이 유효하다면 spring security에서 관리하는 컨텍스트에 사용자 정보를 저장합니다.

CustomAuthenticationService

@Component
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
    private final MemberRepository memberRepository;
    @Override
    public CustomUserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        Member member = memberRepository.findById(Long.valueOf(userId))
                .orElseGet(() -> new Member(null, null, null, null, List.of()));
        return new CustomUserDetails(
                String.valueOf(member.getId()),
                getAuthorities(member)
        );
    }

    private Set<GrantedAuthority> getAuthorities(Member member) {
        return member.getRoles().stream().map(memberRole -> memberRole.getRole())
                .map(role -> role.getRoleType())
                .map(roleType -> roleType.toString())
                .map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
    }
}

CustomAuthenticationToken

public class CustomAuthenticationToken extends AbstractAuthenticationToken {
    private String type;
    private CustomUserDetails principal;

    public CustomAuthenticationToken(String type, CustomUserDetails principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.type = type;
        this.principal = principal;
        setAuthenticated(true);
    }

    @Override
    public CustomUserDetails getPrincipal() {
        return principal;
    }

    @Override
    public Object getCredentials() {
        throw new UnsupportedOperationException();
    }

    public String getType() {
        return type;
    }
}

Spring security에서 제공해주는 추상 클래스 AbstractAuthenticationToken을 상속받아 사용자를 인증하기 위한 정보를 저장합니다.


궁금한신점이나 잘못된 부분이 있으면 자유롭게 댓글 달아주세요.

github : https://github.com/jaeyeon423/spring_board_api