Spring Security 권한 여러 개 (학생/강사 테이블 분리된 인증 서비스 구현)

2025. 1. 27. 00:10·develop

✅ 상황

현재 진행 중인 프로젝트의 요구사항을 보니 학생, 강사로 테이블이 분리되어 있는 상황이다.

평소에 User 테이블로 ROLE_USER 의 권한 구현이 전부 였지만, 처음으로 2개의 권한을 구현하게 되었다.

ROLE_STUDENT와 ROLE_INSTRUCTOR의 권한에 맞게 구현해야한다 !

 

🧠 계획 및 생각 정리

일반적으로 CustomUserDetailsService를 구현하게 되면 loadUserByUsername메서드에서 UserRepository로 loginId를 통해 해당 사용자를 찾아 UserDetails 객체를 만든다.

하지만 현재 구현 해야하는 서비스는 StudentRepository와 InstructorRepository에서 loginId를 통해 해당 사용자를 찾아 UserDetails 객체를 만들어야한다.

그렇다면 2개의 CustomUserDetailsService를 제작하여, 관리한다면 되지 않을까 생각했다.

해당 Student, Instructor의 테이블을 보면 대부분 공통된 row를 가지고 있다.

따라서, 공통되는 부분을 관리하는 추상클래스를 작성하기로 했다.

또한 요구 사항이 언제든 변하고 추가될 수 도 있기 때문에 추상클래스를 적극 활용하여 유동성을 키우는 전략을 가지기로 했다.

 

추상클래스 활용

추상클래스를 이용하여 Student, Instructor 관리를 하는 이유는 다음과 같다.

  1. Entity에서 가지고 있는 공통된 필드가 많다.
  2. 추상클래스로 객체를 받아 활용한다면 UserDetails 객체 생성에 편리성(유동성)을 제공받는다.
  3. Protected 접근 제한자를 사용하여 캡슐화를 보장한 공용 메서드 생성할 수 있다.

따라서 User라는 추상 클래스로 작성하고 Student, Instructor를 상속받아서 사용하도록 결정하였다 !

 

User

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@MappedSuperclass
public abstract class User extends BaseTimeEntity {

    @Column(name = "login_id", length = 20, nullable = false, unique = true)
    private String loginId;

    @Column(name = "password", length = 100, nullable = false)
    private String password;

    @Column(name = "email", length = 50, nullable = false, unique = true)
    private String email;

    @Column(name = "name", length = 20, nullable = false)
    private String name;

    @Enumerated(EnumType.STRING)
    @Column(name = "role", length = 20, nullable = false)
    private Role role;

    @Column(name = "is_deleted", nullable = false)
    private Boolean isDeleted;

    protected User(String loginId, String password, String email, String name, Role role) {
        this.loginId = loginId;
        this.password = password;
        this.email = email;
        this.name = name;
        this.role = role;
        this.isDeleted = false;
    }

    public void updatePassword(String password) {
        this.password = password;
    }

    protected void updateUser(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

 

 

 

Student

@Entity
@Table(name = "student")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Student extends User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "student_id")
    private Long id;

    @OneToMany(mappedBy = "student", cascade = ALL)
    private List<Registration> registrations = new ArrayList<>();

    @OneToMany(mappedBy = "student", cascade = ALL)
    private List<Answer> answers = new ArrayList<>();

    @OneToMany(mappedBy = "student", cascade = ALL)
    private List<QuizGrade> quizGrades = new ArrayList<>();

    @OneToMany(mappedBy = "student", cascade = ALL)
    private List<AssignmentGrade> assigmentGrades = new ArrayList<>();

    @Builder
    private Student(String loginId, String password, String email, String name) {
        super(loginId, password, email, name, Role.STUDENT);
    }

    public static Student of(String loginId, String password, String email, String name) {
        return Student.builder()
                .loginId(loginId)
                .password(password)
                .email(email)
                .name(name)
                .build();
    }

    public void update(String name, String email) {
        super.updateUser(name, email);
    }
}

 

 

Instructor

package com.example.lms.domain.instructor.entity;

import com.example.lms.domain.teaching.entity.Teaching;
import com.example.lms.domain.user.enums.Role;
import com.example.lms.domain.user.entity.User;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

import static jakarta.persistence.CascadeType.ALL;

@Entity
@Table(name = "instructor")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Instructor extends User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "instructor_id")
    private Long id;

    @Column(name = "instructor_description", nullable = false)
    private String description;

    @OneToMany(mappedBy = "instructor", cascade = ALL)
    private List<Teaching> teachings = new ArrayList<>();

    @Builder
    private Instructor(String loginId, String password, String email, String name, String description) {
        super(loginId, password, email, name, Role.INSTRUCTOR);
        this.description = description;
    }

    public static Instructor of(String loginId, String password, String email, String name, String description) {
        return Instructor.builder()
                .loginId(loginId)
                .password(password)
                .email(email)
                .name(name)
                .description(description)
                .build();
    }

    public void update(String name, String description, String email) {
        super.updateUser(name, email);
        this.description = description;
    }
}

 

만약 요구사항의 변경이 되어 필드가 추가된다면 공통으로 사용해야되는 필드는 User에, 별도의 필드는 각 상속받은 Entity에 정의하면서 관리하면 된다.

 

CustomUserDetailsService 구현

해당 서비스에서는 loadUserByUsername메서드를 오버라이드할 때 UserRepository가 아닌 StudentRepository, InstructorRepository에서 로그인하는 사용자를 식별해야한다.

따라서 CustomStudentDetailsService, CustomInstructorsDeatilsService 2개를 구현하여 해결하였다.

 

CustomStudentDetailsService

@Component
@RequiredArgsConstructor
public class CustomStudentDetailsService implements UserDetailsService {

    private final StudentRepository studentRepository;

    @Override
    public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
        return studentRepository.findByLoginIdAndNotDeleted(loginId)
                .map(student -> new CustomUserDetails(student, student.getId()))
                .orElseThrow(() -> new UsernameNotFoundException("학생 정보를 찾을 수 없습니다. : " + loginId));
    }

    public UserDetails loadUserByStudentId(String studentId) throws UsernameNotFoundException {
        return studentRepository.findByIdAndNotDeleted(Long.valueOf(studentId))
                .map(student -> new CustomUserDetails(student, student.getId()))
                .orElseThrow(() -> new UsernameNotFoundException("학생 정보를 찾을 수 없습니다. : " + studentId));
    }
}

 

CustomInstructorDeatilsService

@Component
@RequiredArgsConstructor
public class CustomInstructorDetailsService implements UserDetailsService {

    private final InstructorRepository instructorRepository;

    @Override
    public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
        return instructorRepository.findByLoginIdAndNotDeleted(loginId)
                .map(instructor -> new CustomUserDetails(instructor, instructor.getId()))
                .orElseThrow(() -> new UsernameNotFoundException("강사 정보를 찾을 수 없습니다. : " + loginId));
    }

    public UserDetails loadUserByInstructorId(String instructorId) throws UsernameNotFoundException {
        return instructorRepository.findByIdAndNotDeleted(Long.valueOf(instructorId))
                .map(instructor -> new CustomUserDetails(instructor, instructor.getId()))
                .orElseThrow(() -> new UsernameNotFoundException("강사 정보를 찾을 수 없습니다. : " + instructorId));
    }
}

 

2개로 나누는 이유는 개인적인 생각으로 요구사항이 변경되어 새로운 권한을 추가할 때 간편하게 등록할 수 있을 것 이라고 생각했다.

위 코드의 loadUserBy**Id 메서드는 loginId가 id값 보다 민감정보라고 판단되어 해당 서비스의 AccessToken subject 값을 id로 세팅하여 관리하였기 때문에 별도로 추가해주었다.

 

 

CustomUserDetails 작성

User를 추상 클래스로 작성했기 때문에 CustomUserDeatils는 Student, Instructor 객체를 모두 수용할 수 있게 되었다.

 

CustomUserDetails

public class CustomUserDetails implements UserDetails {

    private final User user;
    private final Long id;

    public CustomUserDetails(User user, Long id) {
        this.user = user;
        this.id = id;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(() -> user.getRole().getAuthority());
        return authorities;
    }

    @Override
    public String getUsername() {
        return id.toString();
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

 

LoginFilter 구현

Student, Instructor에 대한 Loginfilter를 등록하기 위해 UsernamePasswordAuthenticationFilter를 커스텀하여 CustomUsernamePasswordAuthenticationFilter를 작성해야한다.

물론 CustomUsernamePasswordAuthenticationFilter내에서 URL을 구분하여 원하는 AuthenticationManager를 주입해 주어도 되겠지만 요구사항이 변경되어 서로 다른 로그인 인증 절차를 가지게 된다면 확실하게 분리를 해줘야된다고 판단했다.

따라서 CustomUsernamePasswordAuthenticationFilter를 추상 클래스로 제작하여, StudentLoginFilter, InstructorLoginFilter를 제작하기로 했다.

 

먼저 로그인에 필요한 LoginRequestDto를 작성한다.

 

LoginRequestDto

@Getter
@NoArgsConstructor
@Schema(name = "LoginRequestDto : 로그인 요청 Dto")
public class LoginRequestDto {
    @Schema(description = "아이디는 입력되어야 합니다. 아이디는 영어 소문자와 숫자만 사용하여 4~20자리입니다", example = "helloworld")
    private String loginId;

    @Schema(description = "비밀번호는 입력되어야 합니다. 비밀번호는 8~16자리 수 입니다. 영문 대소문자, 숫자, 특수문자를 포함합니다.", example = "helloworld1234@")
    private String password;

    @Builder
    public LoginRequestDto(String loginId, String password) {
        this.loginId = loginId;
        this.password = password;
    }
}

 

CustomUsernamePasswordAuthenticationFilter

@Slf4j
public abstract class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final TokenProvider tokenProvider;
    private final ObjectMapper objectMapper;

    public CustomUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager, TokenProvider tokenProvider, ObjectMapper objectMapper) {
        this.authenticationManager = authenticationManager;
        this.tokenProvider = tokenProvider;
        this.objectMapper = objectMapper;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        LoginRequestDto loginReqDto;
        try {
            String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
            loginReqDto = objectMapper.readValue(messageBody, LoginRequestDto.class);
            validateLoginRequestDto(loginReqDto);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        UsernamePasswordAuthenticationToken authToken =
                new UsernamePasswordAuthenticationToken(loginReqDto.getLoginId(), loginReqDto.getPassword(), null);
        return authenticationManager.authenticate(authToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException {
        handleSuccessAuthentication(response, authentication);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
        handleFailureAuthentication(response);
    }

    private void handleSuccessAuthentication(HttpServletResponse response, Authentication authentication) throws IOException {
        String id = authentication.getName();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        String role = authorities.stream()
                .findFirst()
                .map(GrantedAuthority::getAuthority)
                .orElseThrow(() -> new RuntimeException("권한이 식별되지 않은 사용자 입니다. : " + id));

        log.info("{}-{}: login ({})", id, role, new Date());
        ResponseCookie refreshTokenCookie = CookieUtil.createCookie(
                REFRESH_TOKEN_COOKIE_NAME,
                tokenProvider.createRefreshToken(id, role, new Date()),
                tokenProvider.getRefreshTokenExpirationSeconds()
        );

        response.setHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + tokenProvider.createAccessToken(id, role, new Date()));
        response.addHeader(COOKIE_PREFIX, refreshTokenCookie.toString());
        response.setStatus(HttpServletResponse.SC_OK);
        response.setCharacterEncoding("utf-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(null));
    }

    private void handleFailureAuthentication(HttpServletResponse response) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setCharacterEncoding("utf-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString("아이디 혹은 비밀번호가 올바르지 않습니다."));
    }

    protected abstract void validateLoginRequestDto(LoginRequestDto loginReqDto);
}

validateLoginRequestDto를 추상 메서드로 정의하여 상속받은 각 권한 별 로그인 필터에서 작업하도록 설계했다.

 

StudentLoginFilter

@Slf4j
public class StudentLoginFilter extends CustomUsernamePasswordAuthenticationFilter {

    public StudentLoginFilter(AuthenticationManager authenticationManager, TokenProvider tokenProvider, ObjectMapper objectMapper) {
        super(authenticationManager, tokenProvider, objectMapper);
        this.setFilterProcessesUrl("/api/students/login");
    }

    @Override
    protected void validateLoginRequestDto(LoginRequestDto loginReqDto) {
        if (loginReqDto.getLoginId() == null || !loginReqDto.getLoginId().matches("^[a-z0-9]{4,20}$")) {
            log.error("401error -> 올바르지 않은 학생 로그인 아이디 요청");
            throw new IllegalArgumentException("올바르지 않은 학생 로그인 아이디 요청");
        }
        if (loginReqDto.getPassword() == null || !loginReqDto.getPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)(?=.*[$@$!%*#?&])[A-Za-z\\d$@$!%*#?&]{8,16}$")) {
            log.error("401error -> 올바르지 않은 학생 로그인 비밀번호 요청");
            throw new IllegalArgumentException("올바르지 않은 학생 로그인 비밀번호 요청");
        }
    }
}

 

InstructorLoginFilter

@Slf4j
public class InstructorLoginFilter extends CustomUsernamePasswordAuthenticationFilter {

    public InstructorLoginFilter(AuthenticationManager authenticationManager, TokenProvider tokenProvider, ObjectMapper objectMapper) {
        super(authenticationManager, tokenProvider, objectMapper);
        this.setFilterProcessesUrl("/api/instructors/login");
    }

    @Override
    protected void validateLoginRequestDto(LoginRequestDto loginReqDto) {
        if (loginReqDto.getLoginId() == null || !loginReqDto.getLoginId().matches("^[a-z0-9]{4,20}$")) {
            log.error("401error -> 올바르지 않은 강사 로그인 아이디 요청");
            throw new IllegalArgumentException("올바르지 않은 강사 로그인 아이디 요청");
        }
        if (loginReqDto.getPassword() == null || !loginReqDto.getPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)(?=.*[$@$!%*#?&])[A-Za-z\\d$@$!%*#?&]{8,16}$")) {
            log.error("401error -> 올바르지 않은 강사 로그인 비밀번호 요청");
            throw new IllegalArgumentException("올바르지 않은 강사 로그인 비밀번호 요청");
        }
    }
}

/api/instructors/login, /api/students/login 의 URL로 들어오는 요청에 대한 로그인 절차를 수행하도록 작성했다.

 

이제 SpringConfig에 정의를 해야한다.

구현 흐름에 맞게 해당 필터에 필요한 AuthenticationManager를 주입을 해주는 과정을 중점으로 진행하겠다.

 

SpringConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final TokenProvider tokenProvider;
    private final ObjectMapper objectMapper;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   @Qualifier("studentAuthenticationManager") AuthenticationManager studentAuthenticationManager,
                                                   @Qualifier("instructorAuthenticationManager") AuthenticationManager instructorAuthenticationManager) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(SWAGGER_PATTERNS).permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/students/signup", "/api/instructors/signup").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/users/reissue").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/course/**").hasAuthority(Role.INSTRUCTOR.getAuthority())
                        .requestMatchers(HttpMethod.POST, "/api/course/**").hasAuthority(Role.INSTRUCTOR.getAuthority())
                        .requestMatchers(HttpMethod.DELETE, "/api/course/**").hasAuthority(Role.INSTRUCTOR.getAuthority())
                        .requestMatchers(HttpMethod.GET, "/api/course/**").permitAll()
                                .requestMatchers(HttpMethod.GET, "/api/students/check-login-id/{loginId}", "/api/students/check-email/{email}").permitAll()
                                .requestMatchers(HttpMethod.GET, "/api/students/managements").hasAuthority(Role.INSTRUCTOR.getAuthority())
                        .requestMatchers("/api/students", "/api/students/**").hasAuthority(Role.STUDENT.getAuthority())
                        .requestMatchers(HttpMethod.GET, "/api/instructors/check-login-id/{loginId}", "/api/instructors/check-email/{email}").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/instructors/{instructorId}").authenticated()
                        .requestMatchers("/api/instructors", "/api/instructors/**").hasAuthority(Role.INSTRUCTOR.getAuthority())
                        .requestMatchers("/api/teachings", "/api/teachings/**").hasAuthority(Role.INSTRUCTOR.getAuthority())
                                .requestMatchers(HttpMethod.POST, "/api/registrations/courses/{courseId}").hasAuthority(Role.STUDENT.getAuthority())
                                .requestMatchers(HttpMethod.PUT, "/api/registrations/{registrationId}/courses/{courseId}/cancel").authenticated()
                                .requestMatchers(HttpMethod.PUT, "/api/registrations/{registrationId}/courses/{courseId}/approve").hasAuthority(Role.INSTRUCTOR.getAuthority())
                                .requestMatchers(HttpMethod.GET, "/api/registrations/student/history").hasAuthority(Role.STUDENT.getAuthority())
                                .requestMatchers(HttpMethod.GET, "/api/registrations/history/courses/{courseId}").hasAuthority(Role.INSTRUCTOR.getAuthority())
                        .anyRequest().authenticated()
                )
                .addFilterAt(new StudentLoginFilter(studentAuthenticationManager, tokenProvider, objectMapper), UsernamePasswordAuthenticationFilter.class)
                .addFilterAt(new InstructorLoginFilter(instructorAuthenticationManager, tokenProvider, objectMapper), UsernamePasswordAuthenticationFilter.class)
                .addFilterAfter(new JwtAuthenticationFilter(tokenProvider), CustomUsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new CustomLogoutFilter(tokenProvider, objectMapper), LogoutFilter.class)
                .exceptionHandling((exceptionHandling) -> exceptionHandling
                        .authenticationEntryPoint(new CustomAuthenticationEntryPoint(objectMapper))
                        .accessDeniedHandler(new CustomAccessDeniedHandler(objectMapper)));
        return http.build();
    }

    @Bean
    @Primary
    public AuthenticationManager studentAuthenticationManager(
            CustomStudentDetailsService studentDetailsService,
            BCryptPasswordEncoder bCryptPasswordEncoder
    ) {
        DaoAuthenticationProvider studentAuthProvider = new DaoAuthenticationProvider();
        studentAuthProvider.setUserDetailsService(studentDetailsService);
        studentAuthProvider.setPasswordEncoder(bCryptPasswordEncoder);

        return new ProviderManager(List.of(studentAuthProvider));
    }

    @Bean
    public AuthenticationManager instructorAuthenticationManager(
            CustomInstructorDetailsService instructorDetailsService,
            BCryptPasswordEncoder bCryptPasswordEncoder
    ) {
        DaoAuthenticationProvider instructorAuthProvider = new DaoAuthenticationProvider();
        instructorAuthProvider.setUserDetailsService(instructorDetailsService);
        instructorAuthProvider.setPasswordEncoder(bCryptPasswordEncoder);

        return new ProviderManager(List.of(instructorAuthProvider));
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    private static final String[] SWAGGER_PATTERNS = {
            "/swagger-ui/**",
            "/actuator/**",
            "/v3/api-docs/**",
    };
}

 

 

이전에 만들었던 StudentDetailsService, InstuctorDetailsService를 사용하기 위해 studentAuthenticationManager, instructorAuthenticationManager 를 따로 제작하여 빈으로 등록한다.

 

StudentAuthenticationManager

@Bean
    @Primary
    public AuthenticationManager studentAuthenticationManager(
            CustomStudentDetailsService studentDetailsService,
            BCryptPasswordEncoder bCryptPasswordEncoder
    ) {
        DaoAuthenticationProvider studentAuthProvider = new DaoAuthenticationProvider();
        studentAuthProvider.setUserDetailsService(studentDetailsService);
        studentAuthProvider.setPasswordEncoder(bCryptPasswordEncoder);

        return new ProviderManager(List.of(studentAuthProvider));
    }

 

InstructorAuthenticationManager

@Bean
    public AuthenticationManager instructorAuthenticationManager(
            CustomInstructorDetailsService instructorDetailsService,
            BCryptPasswordEncoder bCryptPasswordEncoder
    ) {
        DaoAuthenticationProvider instructorAuthProvider = new DaoAuthenticationProvider();
        instructorAuthProvider.setUserDetailsService(instructorDetailsService);
        instructorAuthProvider.setPasswordEncoder(bCryptPasswordEncoder);

        return new ProviderManager(List.of(instructorAuthProvider));
    }

 

이제 작성했던 StudentLoginFilter, InstructorLoginFilter에 맞는 AuthenticationManager를 주입하여 필터체인에 등록해야한다.

.addFilterAt(new StudentLoginFilter(studentAuthenticationManager, tokenProvider, objectMapper), UsernamePasswordAuthenticationFilter.class)
                .addFilterAt(new InstructorLoginFilter(instructorAuthenticationManager, tokenProvider, objectMapper), UsernamePasswordAuthenticationFilter.class)

 

 

 

번외) 로그아웃 필터

현재 서비스처럼 JWT를 이용한 인증 시스템에서는 로그아웃 기능을 구현하기 위해 서버가 인증과 인가에 대한 모든 책임을 져야 한다.
이는 REST API의 stateless 원칙을 어기고 stateful한 상태를 유지해야 함을 의미한다.

즉, Refresh Token을 프론트엔드의 로컬 스토리지에서 관리하는 대신, 서버 측 DB에 저장하고, 쿠키를 통해 토큰을 관리해야 한다.
이 방식은 서버가 토큰을 저장 및 관리하고, 브라우저 쿠키를 삽입하거나 삭제하는 작업을 수행할 수 있기 때문이다.

 

로그아웃 == DB토큰 삭제 + 브라우저 쿠키(토큰) 초기화

 

CustomLogoutFilter

@Slf4j
public class CustomLogoutFilter extends GenericFilterBean {

    private final TokenProvider tokenProvider;
    private final ObjectMapper objectMapper;

    public CustomLogoutFilter(TokenProvider tokenProvider, ObjectMapper objectMapper) {
        this.tokenProvider = tokenProvider;
        this.objectMapper = objectMapper;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        if (!request.getRequestURI().equals("/api/users/logout") || !request.getMethod().equals("POST")) {
            filterChain.doFilter(request, response);
            return;
        }

        String accessToken = tokenProvider.resolveAccessToken(request);
        Claims claimsByAccessToken = tokenProvider.getClaimsByAccessToken(accessToken);

        String id = claimsByAccessToken.getSubject();
        String role = claimsByAccessToken.get(AUTHORITIES_KEY).toString();

        tokenProvider.invalidateRefreshToken(role, id);

        log.info("{}-{}: logout ({})", id, role, new Date());
        response.addHeader(COOKIE_PREFIX, CookieUtil.createCookie(REFRESH_TOKEN_COOKIE_NAME, null, CookieUtil.COOKIE_EXPIRATION_DELETE).toString());
        response.setStatus(HttpServletResponse.SC_OK);
        response.setCharacterEncoding("utf-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(null));
    }
}

'develop' 카테고리의 다른 글

WebRTC - EC2 Coturn 서버 구축  (0) 2025.04.03
네이버 로그인 검수 승인받는 법  (0) 2025.04.03
새로운 팀원들 / 코드 리뷰, 테스트 코드를 경험하다.  (1) 2024.12.24
Spring Batch 정리  (2) 2024.10.01
교차 출처 리소스 공유 CORS 정리  (0) 2024.09.30
'develop' 카테고리의 다른 글
  • WebRTC - EC2 Coturn 서버 구축
  • 네이버 로그인 검수 승인받는 법
  • 새로운 팀원들 / 코드 리뷰, 테스트 코드를 경험하다.
  • Spring Batch 정리
VANEL
VANEL
break;
  • VANEL
    VANEL의 블로그
    VANEL
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 오류
      • develop
      • 과거 기록
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 이전 블로그
  • 공지사항

  • 인기 글

  • 태그

    restdocs
    코드리뷰
    WebRTC
    테스트코드
    coturn
    JWT
    Spring
    spring security
    Spring boot
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
VANEL
Spring Security 권한 여러 개 (학생/강사 테이블 분리된 인증 서비스 구현)
상단으로

티스토리툴바