Java

[SpringSecurity] SpringSecurity6, SpringBoot3.x.x , JWT(access, refresh) - 2 구현(member, token, auth, config)

ride-dev 2024. 1. 24. 13:31

[SpringSecurity] SpringSecurity6, SpringBoot3.x.x , JWT(access, refresh) - 2 구현(member, token, auth, config)

[2024-06-26 ~ 내용 보완중]

member와 token 데이터를 config 기반으로 auth합니다.

따라서, 어떤(member, token) 자료형을 사용할 것인지 알아보고,

어떻게(config, auth) 사용할 것인지 순차적으로 구현하도록 하겠습니다.

 

1. member (디렉토리)

SpringSecurity의 인터페이스 중 하나인 UserDetails를 구현(implements)하겠습니다.

UserDetails를 구현(implements)하는 것은 사용자 인증 정보를 관리하는 데 매우 중요한 단계입니다.

SpringSecurity에서 사용자의 정보를 담기 위한 핵심 인터페이스로 UserDetails를 사용합니다.

 

DB에 저장할 Members 엔터티를 이 UserDetails의 구현체로 사용할 예정입니다.

사용자의 UserDetails에선 사용자 아이디와 비밀번호, 권한을 주로 관리하지만,

Members 엔터티를 구현체로 사용함으로써 이메일, 프로필 사진 등을 관리할 수 있습니다.

1.1 Member class

사용자 Entity는 User, Users, Member 등을 선택할 수 있습니다.

다만, DB에 따라 User나 Member가 예약어일 수 있으므로 잘 확인하고 명명합니다.

물론 설정을 통해, 클래스 이름은 User나 Member를 사용하고 DB Table 이름은 다르게 설정할 수 있습니다.

 

Spring Security를 사용하기 위해 필요한 필드 중 username과 password는 필수이지만,

get 메서드의 리턴값을 대체하는 것으로 username이나 password를,

@Override된 get 메서스의 반환값을 통해 다른 필드로 대체할 수 있습니다.

아래는 username을 email로 대체한 예입니다.

 

@Table(name ="members") 를 사용하여 db에 저장될 테이블이름을 변경할 수 있습니다.

@Entity 를 사용하여, 자바 객체를 통해 DB와 데이터를 주고 받을 수 있습니다.

package com.ride.rest_api_security.member;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Entity
@Table(name = "members")
public class Member implements UserDetails {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String email;
    private String name;
    @Setter
    private String password;
    @Enumerated(EnumType.STRING)
    private Role role;

    @OneToMany(mappedBy = "member")
    private List<Token> tokens;

    @Builder
    public Member(Long id, String email, String name, String password, Role role) {
        this.id = id;
        this.email = email;
        this.name = name;
        this.password = password;
        this.role = role;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() { // 사용자 권한 리스트
        return role.getAuthorities();
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public String getPassword() {
        return password;
    }

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

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

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

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

1.2 Role enum

사용자 별 권한을 나누어줍니다. 권한은 관리자, 사용자, 게스트로 나누었습니다.

관리자는 다른 모든 권한을 가지도록 설정했습니다.

package com.ride.rest_api_security.member;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import static com.ride.rest_api_security.member.Permission.*;

@Getter
@RequiredArgsConstructor
public enum Role {
    GUEST(Collections.emptySet()),
    ADMIN(
            Set.of(
                    ADMIN_READ,
                    ADMIN_CREATE,
                    ADMIN_UPDATE,
                    ADMIN_DELETE,
                    USER_READ,
                    USER_CREATE,
                    USER_UPDATE,
                    USER_DELETE
            )
    ),
    USER(
            Set.of(
                    USER_READ,
                    USER_CREATE,
                    USER_UPDATE,
                    USER_DELETE
            )
    );

    private final Set<Permission> permissions;

    public List<SimpleGrantedAuthority> getAuthorities() {
        var authorities = getPermissions()
                .stream()
                .map(permission -> new SimpleGrantedAuthority(permission.getPermission()))
                .collect(Collectors.toList());
        authorities.add(new SimpleGrantedAuthority("ROLE_" +  this.name()));
        return authorities;
    }
}

authorities에 권한을 ROLE_ 문자열을 접두어로 사용하여,

SpringSecurity에서 실제 권한과 역할을 분리할 수 있기 때문입니다.

역할을 분리하는 것을 통해 읽기, 쓰기 등을 안정적으로 핸들링할 수 있습니다.

권한: ADMIN

역할: ROLE_ADMIN_READ

1.3 Permission enum

package com.ride.rest_api_security.member;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Permission {

    ADMIN_READ("admin:read"),
    ADMIN_UPDATE("admin:update"),
    ADMIN_CREATE("admin:create"),
    ADMIN_DELETE("admin:delete"),

    USER_READ("user:read"),
    USER_UPDATE("user:update"),
    USER_CREATE("user:create"),
    USER_DELETE("user:delete");

    private final String permission;
}

 

(enum을 여럿 사용할 때, 쉼표( , )와 세미콜론( ; )을 신경써야합니다)

1.4 MemberRepository

이제 사용자 Entity와 DB Table간 데이터를 주고 받을 Repository를 생성합니다.

JpaRepository를 확장한 Repository인터페이스에 @Repository 어노테이션을 붙입니다.

이때 <사용할 Entity, Entity의 Id타입>를 기재합니다.

package com.ride.rest_api_security.member;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
}

또한, username을 대체한 필드에 대한 조회 메서드를 작성합니다.

findByEmail은 앞으로 SpringSecurity의 UserDetailsService에 사용됩니다.

2. token (디렉토리)

인증인가에 사용할 token 자료형을 정의합니다.

2.1 Token

Token을 사용하여 인증/인가를 구현할 때, 주의할 것이 있습니다.

Token의 유효기간을 변경할 수 없기 때문에,

따로 설정을 하지 않는 한, 한 번 발행하면 계속해서 액세스할 수 있습니다.

즉, 탈취로 인한 위험이 있습니다.

 

따라서 탈취당해도 사용하지 못하도록 유효기간을 짧게 설정하기도 합니다.

대신, 유효기간이 짧으면 재발급 과정을 거쳐야하기에 사용자에게 번거로운 경험을 제공할 수 있습니다.

그래서 등장한 것이 OAuth2.0의 access/refresh token입니다.

access token으로 사용자의 요청을 처리하고,

refresh token으로 accesstoken의 만료에 따라 재발급을 진행합니다.

사용자가 인증 과정(로그인 등)을 거치면,

서버는 Token을 발행합니다.

사용자는 Token을 받아 관리합니다.

이후 사용자는 요청마다 헤더의 Authorization에 "bearer "와 access token을 함께 보내고,

bearer sdkfljgnhvsadoiufj.JFIP3214JHR4980Q23T8W.SIDFUGHUYIDFSGIUOFDGHI

서버에서 필터를 통해 인증하고,

요청을 처리합니다.

만약 access token이 만료되었다면,

서버는 사용자 서버에게 만료 사실을 전달하고,

사용자 서버는 헤더의 Authorization에 "bearer "와 refresh token을 함께 보내고,

서버에서 필터를 통해 인증하고,

재발급 요청을 처리합니다.

만약 refresh token도 만료가 되었다면,

사용자에게 이 사실을 알려 다시 인증하도록(로그인 등) 요구합니다.

2.1 Token class (Access Token)

package com.ride.rest_api_security.token;


import com.ride.rest_api_security.member.Member;
import jakarta.persistence.*;
import lombok.*;


@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Setter
@Getter
@Builder
@Entity
public class Token {

    @Id
    @GeneratedValue
    private Integer id;
    private String token;
    @Enumerated(EnumType.STRING)
    private TokenType tokenType;
    @Setter
    private boolean expired; // db 만료 관리 (로그아웃)
    @Setter
    private boolean revoked; // db 취소 관리 (로그아웃)
    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;
}

2.2 TokenType enum

BEARER는 OAuth2.0에서 사용하는 양식입니다.

package com.ride.rest_api_security.token;

public enum TokenType {
    BEARER
}

2.3 AccessTokenRepository interface

Token 을 저장할 때 사용할 Repository를 생성합니다.

package com.ride.rest_api_security.token;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface AccessTokenRepository extends JpaRepository<Token, Integer> {

    @Query("select t " +
            "from Token t " +
            "inner join Member m " +
            "on t.member.id = m.id " +
            "where m.id = :memberId " +
            "and (t.expired = false or t.revoked = false)")
    List<Token> findAllValidTokensByMember(Integer memberId);

    Optional<Token> findByToken(String token);
}

Spring Data JPA에 NamedQuery를 사용하여 쿼리를 작성했습니다.

위의 findAllValidTokensByMember는 특정 유저의 토큰을 검색하여 모두 만료시키기 위한 메서드입니다.

 

토큰을 DB에 저장하는 것은 선택입니다.

DB에 저장하지 않으면, 그만큼 서버의 부하를 줄일 수 있습니다.

그러나 이미 발급된 토큰이 있다면 그 토큰이 만료되기 전까진 유효하게 사용됩니다.

(경우에 따라 사용자가 발급받은 토큰 중 유효한 토큰이 여럿 존재할 수 있습니다)

따라서 새로운 토큰을 발급받으면 기존의 토큰을 만료시키는 전략을 사용하기 위해

DB에 Token을 저장할 필요가 있습니다.

물론, AccessToken의 만료시간을 짧게 설정한다면 크게 신경쓰지 않아도 되는 부분입니다.

이 게시글에서는 여러 경우를 생각하여 DB에 저장할 것이라는 것을 감안해주시기 바랍니다.

2.4 RefreshToken class

 

refresh token은 redis에 저장합니다.

redis는 인메모리형 데이터 스토어로,

key, value 형식의 NoSQL입니다.

다양한 자료형을 지원하고, 2밀리세컨드 이하의 입출력 속도를 자랑합니다.

또한 쉽게 확장할 수 있어, 사용자가 많아져도 성능을 유지하기 쉽습니다.

또한 TimeToLive 설정을 통해, 시간의 경과에 따라 데이터가 자동으로 삭제되도록 할 수 있습니다.

(redis가 캐시 스토리지로 자주 사용되는 이유입니다)

의존성을 추가합니다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

redis를 사용하기 위한 방법은 크게 2가지가 있습니다.

다양한 기능과 가양한 자료형을 사용하기 위한 RedisTemplate을 Bean으로 등록하여 사용하는 것과,

CrudRepository를 사용하는 것입니다.

CrudRepository는 Jpa의 사용법과 상당히 유사합니다.

Entity의 @Id는 기본적으로 조회가 가능하며,

@Indexed를 사용하여 필드를 조회할 수 있습니다.

주의할 점이, Jpa와 유사하지만 다르기 때문에,

@Id 어노테이션을 참조하는 곳을 잘 확인하도록 합니다.

jakarta.persistence가 아닙니다.

package com.ride.rest_api_security.token;

import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;

import java.io.Serializable;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Getter
@Setter
@RedisHash(value = "refreshToken", timeToLive = 7 * 24 * 60 * 60 * 1000) // 7일
public class RefreshToken implements Serializable {

    @Id
    private String token;
    @Indexed
    private Integer memberId;
}

refreshToken이라는 key에 refresh token을 저장합니다.

time to live를 7일로 설정하였습니다.

2.5 RefreshTokenRepository interface

package com.ride.rest_api_security.token;

import org.springframework.data.repository.CrudRepository;

import java.util.Optional;

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
    Optional<RefreshToken> findByEmailAndToken(String memberId, String token);
}

2.6 RefreshTokenService class

package com.ride.rest_api_security.token;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    private final RefreshTokenRepository refreshTokenRepository;

    public RefreshToken findByToken(String refreshToken) {
        return refreshTokenRepository.findById(refreshToken).orElse(null);
    }

    public void deleteRefreshToken(String token) {
        refreshTokenRepository.deleteById(token);
    }

    public boolean isRefreshTokenPresent(String refreshToken) {
        return refreshTokenRepository.findById(refreshToken).isPresent();
    }
}

2.7 main class

스프링 컨테이너가 가동되는 main클래스에 @EnableRedisRepositories 어노테이션을 추가하여

추가적인 redis 설정을 합니다.

package com.ride.rest_api_security;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.redis.core.RedisKeyValueAdapter;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;

@SpringBootApplication
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
public class RestApiSecurityApplication {

    public static void main(String[] args) {
       SpringApplication.run(RestApiSecurityApplication.class, args);
    }

}

RefreshToken class에 사용된 @Indexed는 redis가 아니라 Spring에서 지원하는 어노테이션입니다.

따라서 redis의 Time To Live가 적용되지 않습니다.

 

@Indexed 필드는 삭제가 되지 않습니다.

이를 명시적으로 삭제해주는 코드를 추가합니다.

@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)

RedisTemplate을 구현한다면,

RedisTemplate이 있는 Configuration에 위 코드를 적용합니다.

3. config (디렉토리)

이제 Spring Security에 대한 구성을 세부적으로 작성하여,

요구사항에 맞게 인증 및 인가를 설정합니다.

SecurityConfig, ApplicationConfig, JwtAuthenticationFilter, JwtService에 대해 다룰 예정입니다.

(선택 - LogoutService)

이후 내부에서 사용할 객체(AccessToken, RefreshToken)를 정의하겠습니다.

 

필터를 대략적으로 표현하면 아래와 같습니다.

사용자와 서버가 요청과 응답을 주고받을 때,

필터는 그 중간에 있습니다.

예시를 들자면, 사용자가 "은행"을 방문"만" 한다고 하면,

필터는 은행의 "문"에 해당합니다.

들어오고(request) 나갈 때(response) 거쳐야 합니다.

각 요청과 응답마다 필터를 거치게 됩니다.

3.1 ApplicationConfig

여러 Bean을 생성하고 관리합니다.

주의 깊게 볼 점은 UserDetailsService입니다.

앞서 MemberRepository에서 작성한 findByEmail로 의존성을 주입합니다.

이를 통해 반복되는 코드를 줄이고 관심사를 분리하여,

사용자의 인증을 원활하게 진행할 수 있습니다.

package com.ride.rest_api_security.config;

import com.ride.rest_api_security.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {

    private final MemberRepository memberRepository;

    @Bean
    public UserDetailsService userDetailsService() {
        return username -> memberRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("Member not Found"));
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsService()); // UserDetailsService 설정
        authenticationProvider.setPasswordEncoder(passwordEncoder()); // PasswordEncoder 설정
        return authenticationProvider;
    }
    
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // 비밀번호 암호화를 위한 BCryptPasswordEncoder 사용
    }
}

3.2 SecurityConfiguration

SecurityFilterChain을 사용하여 csrf, url 경로에 따른 사용자 접근, session, logout 등의 설정을 합니다.

package com.ride.rest_api_security.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import static com.ride.rest_api_security.member.Permission.*;
import static com.ride.rest_api_security.member.Role.*;

@Configuration
@EnableWebSecurity // Spring Security 활성화
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private static final String[] WHITE_LIST_URL = {
            "/api/v1/auth/**",
            "configuration/ui",
            "configuration/security",
            "/webjars/**"
            };
    // 주입된 커스텀 JWT 인증 필터
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    // 사용자 정의 AuthenticationProvider
    private final AuthenticationProvider authenticationProvider;

    private final CustomLogoutHandler logoutHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                WHITE_LIST_URL
                        )
                        .permitAll()
                        .requestMatchers("/api/v1/auth/**").permitAll()// "/api/v1/auth/**" 경로는 인증 없이 허용
                        .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), USER.name())
                        .requestMatchers(HttpMethod.GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), USER_READ.name())
                        .requestMatchers(HttpMethod.POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), USER_CREATE.name())
                        .requestMatchers(HttpMethod.PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), USER_UPDATE.name())
                        .requestMatchers(HttpMethod.DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), USER_DELETE.name())
                        .anyRequest().authenticated()) // 그 외 모든 요청은 인증 필요
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션을 사용하지 않고, Stateless하게 설정
                .authenticationProvider(authenticationProvider) // 커스텀 AuthenticationProvider 설정
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // UsernamePasswordAuthenticationFilter 이전에 jwtAuthenticationFilter 추가
                .logout(logout -> logout
                        .logoutUrl("/api/v1/auth/logout") // Controller 엔드포인트 대신 사용
                        .addLogoutHandler(logoutHandler)
                        .logoutSuccessHandler(
                                (request, response, authentication) ->
                                        SecurityContextHolder.clearContext()
                        )
                );
        return http.build();
    }
}

requestMatchers를 통해 인증 수준에 따라 url의 접근을 제어합니다.

사용자 비밀번호 인증을 진행하기 전에 토큰(jwt)의 필터를 사용하여 유효성 검증을 먼저 하도록 합니다.

SpringSecurity에서는 logout에 대한 기능을 지원합니다.

Controller에 엔드포인트를 만들지 않고, Spring에서 지원해주는 logout을 활용합니다.

3.3 CustomLogoutHandler class

JWT를 활용할 때, 로그아웃은 선택의 문제입니다.

사용자가 로그인하면, 서버는 토큰을 발행해줍니다.

토큰은 서버에 상태를 저장하지 않아도 되지만, 유효기간이 고정적입니다.

따라서 로그인을 여럿 한다면, 토큰이 여럿 발행될 수 있습니다.

발행된 토큰들은 만료되기 전까지 모두 유효합니다.

이를 제어하기 위해 logout을 구현할 수 있습니다.

logout을 통해 기존에 발행된 토큰을 유효하지 않게 설정할 수 있습니다.

토큰을 db에 저장하고,

만료에 대한 Column을 만드는 것입니다.

토큰을 새로 발급받으면, 기존의 토큰은 만료됩니다.

이렇게 db에 access token을 저장할 수도 있지만, 저장하지 않아도 됩니다.

대신, accessToken의 탈취를 고려하여 유효기간을 짧게 설정하는 것을 권장합니다.

package com.ride.rest_api_security.config;

import com.ride.rest_api_security.token.AccessTokenRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class CustomLogoutHandler implements LogoutHandler {

    private final AccessTokenRepository accessTokenRepository;

    @Override
    public void logout(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) {
        final String authHeader = request.getHeader("Authorization"); // 'Authorization' 헤더 값 가져오기
        final String jwt; // JWT 토큰을 저장할 변수
        if (authHeader == null || !authHeader.startsWith("Bearer ")) { // 헤더 검증
            return;
        }
        jwt = authHeader.substring(7); // "Bearer " 제거 후 JWT 추출
        var storedToken = accessTokenRepository.findByToken(jwt)
                .orElse(null);
        if (storedToken != null) {
            storedToken.setExpired(true);
            storedToken.setRevoked(true);
            accessTokenRepository.save(storedToken);
        }
    }
}

3.4 JwtAuthenticationFilter

인증 필터 중 하나로, 토큰의 유효성을 검증합니다.

(userDetailsService는 앞서 ApplicationConfig에서 정의한 빈입니다)

package com.ride.rest_api_security.config;

import com.ride.rest_api_security.token.AccessTokenRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { // 요청당 한 번씩 실행되는 필터

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;
    private final AccessTokenRepository accessTokenRepository;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request, // 들어오는 요청
            @NonNull HttpServletResponse response, // 나가는 응답
            @NonNull FilterChain filterChain // 필터 체인
    ) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization"); // 'Authorization' 헤더 값 가져오기
        final String jwt; // JWT 토큰을 저장할 변수
        final String userEmail; // 사용자 이메일을 저장할 변수
        if (authHeader == null || !authHeader.startsWith("Bearer ")) { // 헤더 검증
            filterChain.doFilter(request, response); // 다음 필터로 요청 전달
            return;
        }
        jwt = authHeader.substring(7); // "Bearer " 제거 후 JWT 추출
        userEmail = jwtService.extractUsername(jwt); // JWT에서 사용자 이메일 추출
        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { // 사용자 이메일과 현재 인증이 없는 경우
            UserDetails member = this.userDetailsService.loadUserByUsername(userEmail); // 사용자 상세 정보 가져오기

            //db에 저장된 토큰 검증 정보
            var isTokenValid = accessTokenRepository.findByToken(jwt)
                    .map(t -> !t.isExpired() && !t.isRevoked())
                    .orElse(false);

            if (isTokenValid && jwtService.isTokenValid(jwt, member)) { // JWT 유효성 검증
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        member, // 사용자 상세
                        null, // 자격 증명
                        member.getAuthorities() // 권한
                );
                authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request) // 요청의 세부 정보 설정
                );
                SecurityContextHolder.getContext().setAuthentication(authToken); // 인증 정보를 보안 컨텍스트에 설정
            }
        }
        filterChain.doFilter(request, response); // 다음 필터로 요청 전달
    }
}

3.5 JwtService

JWT에 대한 전반적인 로직, 생성, 파싱, 유효성 검증 등을 담당합니다.

package com.ride.rest_api_security.config;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Service
public class JwtService {

    // 비밀 키, JWT 서명에 사용되는 비밀 키입니다.
    @Value("${application.security.jwt.secret-key}")
    private String SECRET_KEY;
    @Value("${application.security.jwt.expiration}")
    private long jwtExpiration;
    @Value("${application.security.jwt.refresh-token.expiration}")
    private long refreshExpiration;
    /**
     * JWT에서 username을 저장하고 있는
     * 페이로드의 SUB 내용 추출
     *
     * @param token
     * @return username
     */
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    /**
     * 토큰에서 특정 Claim을 추출
     * 이름, 권한, 만료시간 등을 추출
     *
     * @param token
     * @param claimsResolver - 객체를 받고, 필요한 타입(T)을 반환
     * @param <T>
     * @return claims
     */
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    /**
     * 토큰 생성 1
     * 간단한 토큰 생성을 위해 사용
     *
     * @param userDetails
     * @return token
     */
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    /**
     * 토큰 생성 2
     * 더 복잡한 구조의 토큰을 만들 수 있게 함
     * 사용자 정의 클레임을 추가할 수 있음
     *
     * @param extraClaims - 추가할 사용자 정의 클레임
     * @param userDetails - 사용자 정보
     * @return token
     */
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return buildToken(extraClaims, userDetails, jwtExpiration);
    }

    public String generateRefreshToken(UserDetails userDetails) {
        return buildToken(new HashMap<>(), userDetails, refreshExpiration);
    }
    private String buildToken(Map<String, Object> extraClaims, UserDetails userDetails, long expiration) {
        return Jwts
                .builder()
                .setClaims(extraClaims) // 추가 정보를 담을 객체
                .setSubject(userDetails.getUsername()) // Sub를 username으로 설정
                .setIssuedAt(new Date((System.currentTimeMillis()))) // 토큰 생성 시간
                .setExpiration(new Date(System.currentTimeMillis() + expiration)) // 토큰 만료 시간(현재부터 24시간)
                .signWith(getSignInKey(), SignatureAlgorithm.HS256) // 서명 알고리즘 및 키 설정
                .compact(); // 생성된 정보를 기반으로 토큰 문자열 생성
    }
    /**
     * 토큰 유효성 검증
     * 토큰의 username이 userDetails와 일치하고, 토큰이 만료되지 않았는지 확인
     *
     * @param token
     * @param member
     * @return isTokenValid - 토큰이 유효한지 여부
     */
    public boolean isTokenValid(String token, UserDetails member) {
        final String username = extractUsername(token);
        return (username.equals(member.getUsername())) && !isTokenExpired(token);
    }

    /**
     * 토큰 만료 여부 확인
     *
     * @param token
     * @return isTokenExpired - 토큰이 만료되었는지 여부
     */
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    /**
     * 토큰에서 만료 시간 추출
     *
     * @param token
     * @return expirationDate - 토큰의 만료 시간
     */
    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    /**
     * 토큰을 Claims로 파싱(분해)
     * 토큰의 서명을 검증하고, 클레임을 추출
     *
     * @param token
     * @return claims - 토큰의 클레임
     */
    private Claims extractAllClaims(String token) {
        return Jwts
                .parser()
                .verifyWith(getSignInKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    /**
     * 서명 키 생성 및 반환
     * SECRET_KEY를 기반으로 서명 키 생성
     *
     * @return Key - 서명에 사용될 키
     */
    private SecretKey getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

파싱을 통해 토큰을 분해하여, 토큰으로부터 필요한 정보를 가져옵니다.

토큰을 생성하고 파싱할 때, 사용하는 객체와 순서에 유의해야 합니다.

(Jwt와 Jwts 오타를 주의합니다)

    private String buildToken(Map<String, Object> extraClaims, UserDetails userDetails, long expiration) {
        return Jwts
                .builder()
                .setClaims(extraClaims) // 추가 정보를 담을 객체
                .setSubject(userDetails.getUsername()) // Sub를 username으로 설정
                .setIssuedAt(new Date((System.currentTimeMillis()))) // 토큰 생성 시간
                .setExpiration(new Date(System.currentTimeMillis() + expiration)) // 토큰 만료 시간(현재부터 24시간)
                .signWith(getSignInKey(), SignatureAlgorithm.HS256) // 서명 알고리즘 및 키 설정
                .compact(); // 생성된 정보를 기반으로 토큰 문자열 생성
    }

파싱할 때, 순서는 반대로 진행합니다.

private Claims extractAllClaims(String token) {
    return Jwts
            .parser()
            .verifyWith(getSignInKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
}

예컨대,

토큰을 발행하는 것은 금고에 물건을 넣고(setValue), 금고를 잠그는(signWith)것에 비유할 수 있습니다.

토큰을 파싱하는 것은 금고를 열고(verifyWith) 물건을 빼는 것(getPayload)에 비유할 수 있습니다.

4. auth

4.1 AuthenticationRequest class

4.2 AuthenticationResponse class

4.3 RegisterRequest class

4.4 Authentication Service class

4.5 Authentication Controller class

5. demo

5.1 AdminController

5.2 UserController

728x90