Spring Security + JWT로 인증 인가 구현하기

ssongkim·2022년 4월 30일
12
post-thumbnail

해당 게시글은 인프런 Spring Boot JWT Tutorial을 보고 코드를 저에 맞게 변형하였습니다.

맨 마지막에 예제 프로젝트가 있으니 디렉토리를 참고하며 봐주시기 바랍니다

Overview

JWT(Json Web Token)이란?

JWT는 RFC7519 웹 표준으로 JSON 객체를 이용해 데이터를 주고받을 수 있도록한 웹 토큰입니다.

JWTheader, payload, signature로 구성되어 있으며 headersignature를 해싱하기 위한 알고리즘 정보가 담겨있고 payload는 실제로 사용될 데이터들이 담겨 있습니다.
signature는 토큰의 유효성 검증을 위한 문자열로 이 문자열을 통해 이 토큰이 유효한 토큰인지 검증 가능합니다.

JWT는 인터페이스이고 그 구현체인 JWS, JWE가 존재합니다. 해당 게시글에서는 JWS를 응용하며 JWT에 대한 자세한 게시글은 다음에 작성하도록 하겠습니다.

1. 스프링 프로젝트 생성

로컬 개발 환경 구성

먼저 자바21로 개발환경을 구성해주셔야 합니다!
윈도우/맥 터미널에 java --version 을 입력했을 때 자바 21이 출력되도록 환경구성을 해주세요.

또한 인텔리제이는 최소 23.3 버전으로 업데이트 해주셔야 합니다.

프로젝트 생성

그다음 spring initializer에서 스프링 프로젝트를 생성합니다.

의존성은 별도로 추가할 예정이오니 아무것도 추가 안하셔도 됩니다.

2. 의존성 설정

build.gradlespring WEB, JPA, H2, lombok,security, security-test, spring validation, jwt 의존성을 기입합니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    annotationProcessor 'org.projectlombok:lombok'

    runtimeOnly 'com.h2database:h2'

    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

3. application.yml 작성

spring:
  h2:
    console:
      enabled: true
      path: /h2

  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:test;mode=mysql
    username: sa
    password:

  jpa:
    show-sql: true
    generate-ddl: true
    hibernate:
      ddl-auto: create-drop

    properties:
      hibernate:
        format_sql: true

    defer-datasource-initialization: true

jwt:
  header: Authorization
  secret: rutyweorituwyerotiuweyrtoiuweyrtoweiurtywoeighdfsojkghsdfgsdofiguwyertouw | base64
YWprbGdoc2Rma2xnanNkaGZnbGprc2RmZ2hsc2
  access-token-validity-in-seconds: 600 # 10 min

jwt.secret은 랜덤의 문자열을 base64로 인코딩한 값을 사용하였습니다. 일정 길이 이상 되어야합니다. 짧으면 exception을 발생시키니 충분히 길게 설정합니다.

echo gfdfgdflkgjdlfkgjsasdjkhaskdjahsdkjahsd | base64

access-token-validity-in-seconds은 우리가 발급할 액세스토큰의 유효기간을 지정합니다.

entity 패키지 생성 및 작성

Authority

@Entity
@Table(name = "authority")
@Getter
@NoArgsConstructor
public class Authority {

    @Id
    @Column(name = "authority_name", length = 50)
    private String authorityName;

    @Builder
    public Authority(String authorityName) {
        this.authorityName = authorityName;
    }
}

Account

@Entity
@Table(name = "account")
@Getter
@NoArgsConstructor
public class Account {

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

    @Column(name = "username", length = 50, unique = true)
    private String username;

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

    @Column(name = "nickname", length = 50)
    private String nickname;

    @Column(name = "activated")
    private boolean activated;

    @ManyToMany
    @JoinTable( // JoinTable은 테이블과 테이블 사이에 별도의 조인 테이블을 만들어 양 테이블간의 연관관계를 설정 하는 방법
            name = "account_authority",
            joinColumns = {@JoinColumn(name = "account_id", referencedColumnName = "account_id")},
            inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")})
    private Set<Authority> authorities;

    @Builder
    public Account(String username, String password, String nickname, Set<Authority> authorities, boolean activated) {
        this.username = username;
        this.password = password;
        this.nickname = nickname;
        this.authorities = authorities;
        this.activated = activated;
        this.tokenWeight = 1L; // 초기 가중치는 1
    }

}

Authority는 인가에 사용되는 권한들을 DB로 관리하고자 생성한 엔티티입니다.
Account는 인증에 사용되는 계정 엔티티입니다.

data.sql

현재 embedded H2를 사용하며 JPAcreate-drop 설정에 따라 스프링 부트 애플리케이션이 실행될 때마다 데이터베이스에 있는 데이터들은 전부 날라갈 것입니다.

해당 게시글은 예제이므로 초기 데이터를 넣어주기 위해 resources/data.sql을 작성합니다. 실제로 개발환경에서 초기 권한데이터를 넣어줄 땐 flyway를 사용해보아요.

INSERT INTO USER (USER_ID, USERNAME, PASSWORD, NICKNAME, ACTIVATED) VALUES (1, 'admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin', 1);
INSERT INTO USER (USER_ID, USERNAME, PASSWORD, NICKNAME, ACTIVATED) VALUES (2, 'user', '$2a$08$UkVvwpULis18S19S5pZFn.YHPZt3oaqHZnDwqbCW9pft6uFtkXKDC', 'user', 1);

INSERT INTO AUTHORITY (AUTHORITY_NAME) values ('ROLE_USER');
INSERT INTO AUTHORITY (AUTHORITY_NAME) values ('ROLE_ADMIN');

INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_NAME) values (1, 'ROLE_MEMBER');
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_NAME) values (1, 'ROLE_ADMIN');
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_NAME) values (2, 'ROLE_USER');

JWT 설정

JwtProperties

@Data
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
    private String header;
    private String secret;
    private Long accessTokenValidityInSeconds;
}

application.yml에 기입한 정보를 객체로 매핑하여 사용하기 위해 선언합니다.

TokenProvider

// 토큰 생성, 검증
@Slf4j
public sealed class TokenProvider permits AccessTokenProvider {

    protected static final String AUTHORITIES_KEY = "auth";
    protected final String secret;
    protected final long tokenValidityInMilliseconds;
    protected Key key;

    public TokenProvider(String secret, long tokenValidityInSeconds) {
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;

        //시크릿 값을 decode해서 키 변수에 할당
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // 토큰을 받아 클레임을 만들고 권한정보를 빼서 시큐리티 유저객체를 만들어 Authentication 객체 반환
    public Authentication getAuthentication(String token) {
        Claims claims = Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // 디비를 거치지 않고 토큰에서 값을 꺼내 바로 시큐리티 유저 객체를 만들어 Authentication을 만들어 반환하기에 유저네임, 권한 외 정보는 알 수 없다.
        User principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    // 토큰 유효성 검사
    public boolean validateToken(String token) {
        try {
            Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}

TokenProvider은 문자열 토큰을 검증하거나 문자열 토큰으로부터 스프링 시큐리티 Authentication 객체를 생성하는 역할을 수행합니다.

sealed class로 구성하여 액세스토큰프로바이더만 상속받을 수 있도록 합니다.(추후 RefreshTokenProvider를 생성하기 위해)

AccessTokenProvider

public final class AccessTokenProvider extends TokenProvider {

    public AccessTokenProvider(String secret, long tokenValidityInSeconds) {
        super(secret, tokenValidityInSeconds);
    }

    // 토큰 생성
    public String createToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMilliseconds);

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }
}

AccessTokenProvider는 TokenProvider를 상속받으며 Authentication 객체로부터 토큰을 생성하는 역할을 수행합니다.

JwtConfig(JWT 의존성 주입 설정파일)

@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class JwtConfig {

    // 액세스 토큰 발급용, 리프레시 토큰 발급용은 각각 별도의 키와 유효기간을 갖는다.
    @Bean(name = "accessTokenProvider")
    public TokenProvider accessTokenProvider(JwtProperties jwtProperties) {
        return new AccessTokenProvider(jwtProperties.getSecret(), jwtProperties.getAccessTokenValidityInSeconds());
    }
}

JwtConfig는 JWT 설정파일로 AccessTokenProvider에 의존성을 주입하고 빈을 생성하는 역할을 수행합니다.

CustomJwtFilter

@Component
public class CustomJwtFilter extends GenericFilterBean {

    private static final Logger logger = LoggerFactory.getLogger(CustomJwtFilter.class);

    public static final String AUTHORIZATION_HEADER = "Authorization";

    private final AccessTokenProvider accessTokenProvider;

    public CustomJwtFilter(AccessTokenProvider accessTokenProvider) {
        this.accessTokenProvider = accessTokenProvider;
    }

    // 실제 필터링 로직은 doFilter 안에 들어가게 된다. GenericFilterBean을 받아 구현
    // Dofilter는 토큰의 인증정보를 SecurityContext 안에 저장하는 역할 수행
    // 현재는 jwtFilter 통과 시 loadUserByUsername을 호출하여 디비를 거치지 않으므로 시큐리티 컨텍스트에는 엔티티 정보를 온전히 가지지 않는다
    // 즉 loadUserByUsername을 호출하는 인증 API를 제외하고는 유저네임, 권한만 가지고 있으므로 Account 정보가 필요하다면 디비에서 꺼내와야함
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        // 유효성 검증
        if (StringUtils.hasText(jwt) && accessTokenProvider.validateToken(jwt)) {
            // 토큰에서 유저네임, 권한을 뽑아 스프링 시큐리티 유저를 만들어 Authentication 반환
            Authentication authentication = accessTokenProvider.getAuthentication(jwt);
            // 해당 스프링 시큐리티 유저를 시큐리티 건텍스트에 저장, 즉 디비를 거치지 않음
            SecurityContextHolder.getContext().setAuthentication(authentication);
            logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
        } else {
            logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    // 헤더에서 토큰 정보를 꺼내온다.
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

실질적으로 액세스토큰을 검증하는 역할을 수행하는 GenericFilterBean을 상속받아 CustomJwtFilter를 작성합니다.
우리가 눈여겨보아야할 곳은 doFilter 메서드 영역입니다.
해당 코드는 필터 통과 시 토큰의 유효성을 검증하고, 토큰에서 식별자인 username과 해당 토큰에 부여된 권한을 뽑아 스프링 시큐리티 Authentication 객체를 생성하고 시큐리티 컨텍스트에 저장합니다.

이 말은 토큰 검증을 하며 데이터베이스에 사용자가 존재하는지 조회하지 않는다는 것입니다. doFilter을 통과시켜 시큐리티 컨텍스트에 저장된 authentication는 유저이름과 권한정보만 담고 있으므로 해당 사용자가 실제로 존재하는 지, 혹은 해당 사용자에 대한 다른 정보를 얻고싶다면 별도로 조회해야합니다.

시큐리티 설정 추가

CorsFilterConfig 작성

@Configuration
public class CorsFilterConfig {

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");

        source.registerCorsConfiguration("/api/**", config);
        return new CorsFilter(source);
    }

}

CorsFilter를 먼저 작성합니다.

JwtAuthenticationEntryPoint

AuthenticationEntryPoint는 인증 실패 시 동작하도록 시큐리티 설정파일 작성 시 지정할 예정입니다. 상속받아 구현합니다.

// 인증 실패 시
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401(인증 실패)
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

JwtAccessDeniedHandler

AccessDeniedHandler는 권한 체크 후 인가 실패 시 동작하도록 시큐리티 설정파일에 설정할 예정입니다.
AccessDeniedHandler를 상속받아 구현합니다.

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        //필요한 권한이 없이 접근하려 할때 403
       	response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

PasswordEncoderConfig 작성

@Configuration
public class PasswordEncoderConfig {

    // BCryptPasswordEncoder 라는 패스워드 인코더 사용
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

SecurityConfig 작성

@Configuration
@EnableWebSecurity // 기본적인 웹보안을 활성화하겠다
@EnableMethodSecurity // @PreAuthorize 어노테이션 사용을 위해 선언
@RequiredArgsConstructor
public class SecurityConfig {

    private final CorsFilter corsFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity, CustomJwtFilter customJwtFilter) throws Exception {
        httpSecurity
                .csrf(AbstractHttpConfigurer::disable) // token을 사용하는 방식이기 때문에 csrf를 disable
                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(exceptionConfig ->
                        exceptionConfig.authenticationEntryPoint(jwtAuthenticationEntryPoint)
                                .accessDeniedHandler(jwtAccessDeniedHandler)
                )
                .headers(headerConfig -> headerConfig.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
                .sessionManagement(httpSecuritySessionManagementConfigurer ->
                        httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(registry ->
                        registry.requestMatchers("/h2/**").permitAll()
                                .requestMatchers("/favicon.ico").permitAll()
                                .requestMatchers("/error").permitAll()

                )
                .authorizeHttpRequests(registry -> registry // actuator, rest docs 경로, 실무에서는 상황에 따라 적절한 접근제어 필요
                        .requestMatchers("/actuator/*").permitAll()
                        .requestMatchers("/docs/*").permitAll()
                )
                .authorizeHttpRequests(registry -> // api path
                    registry.requestMatchers("/api/hello").permitAll()
                            .requestMatchers("/api/v1/accounts/token").permitAll() // login
                            .requestMatchers("/api/v1/members").permitAll()
                )
                .authorizeHttpRequests(registry -> registry
                        .anyRequest().authenticated()) // 나머지 경로는 jwt 인증 해야함
                .addFilterBefore(customJwtFilter, UsernamePasswordAuthenticationFilter.class);

        return httpSecurity.build();
    }
}

@EnableMethodSecurity@PreAuthorize 어노테이션 사용을 위해 선언합니다.
@EnableWebSecurity는 기본적인 웹보안을 활성화하겠다는 어노테이션입니다.
나머지는 주석문을 참고해주시기 바랍니다.

AccountAdapter 작성

public class AccountAdapter extends User {
    private Account account;

    public AccountAdapter(Account account) {
        super(account.getUsername(), account.getPassword(), authorities(account.getAuthorities()));
        this.account = account;
    }

    public Account getAccount() {
        return this.account;
    }

    private static List<GrantedAuthority> authorities(Set<Authority> authorities) {
        return authorities.stream()
                .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
                .collect(Collectors.toList());
    }
}

뒤에서 구현할 인증 API 호출 시 그 과정에서 loadUserByUsername를 호출해 디비에서 사용자 정보를 꺼내오게될 것입니다.
인증 API 호출 시 엔티티 유저를 반환하여 사용하고 싶어 어댑터 패턴 사용합니다.
인증API 호출 과정에서authentication.getPrincipal()AccountAdapter 객체를 꺼내올 수 있습니다. -> JwtFilter을 통해 시큐리티 컨텍스트에 저장된 Authentication객체에서는 기본 User 객체만 꺼내집니다.

loadUserByUsername 재정의

@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final AccountRepository accountRepository;

    public CustomUserDetailsService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }
    @Override
    @Transactional
    public UserDetails loadUserByUsername(final String username) {
        Account account = accountRepository.findOneWithAuthoritiesByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));

        if(!account.isActivated()) throw new RuntimeException(account.getUsername() + " -> 활성화되어 있지 않습니다.");
        return new AccountAdapter(account);
    }
}

인증 API 호출 시에만 그 과정에서 재정의한 loadUserByUsername를 호출하여 디비에서 유저정보와 권한정보를 가져옵니다.
내가 만든 커스텀 AccountAdapterorg.springframework.security.core.userdetails.User 를 상속받았으므로 이걸 반환해도 됩니다.

액세스토큰 인증 API 구현

DTO 생성

	@Builder
    public record RequestLogin(
            @NotNull
            @Size(min = 3, max = 50)
            String username,
            @NotNull
            @Size(min = 5, max = 100)
            String password
    ) {
    }
	@Builder
    public record ResponseLogin(
            String accessToken,
    ) {
    }

repository 생성

public interface AccountRepository extends JpaRepository<Account, Long> {
    @EntityGraph(attributePaths = "authorities") // 엔티티그래프 통해 EAGER로 가져온다.
    Optional<Account> findOneWithAuthoritiesByUsername(String username); // user를 기준으로 유저를 조회할 때 권한정보도 가져온다.
}
public interface AuthorityRepository extends JpaRepository<Authority, String> {
}

service 구현

@Service
public class AccountService {
    private final AccessTokenProvider accessTokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final AccountRepository accountRepository;

    public AccountService(AccessTokenProvider accessTokenProvider, AuthenticationManagerBuilder authenticationManagerBuilder, AccountRepository accountRepository) {
        this.accessTokenProvider = accessTokenProvider;
        this.authenticationManagerBuilder = authenticationManagerBuilder;
        this.accountRepository = accountRepository;
    }

    // username 과 패스워드로 사용자를 인증하여 액세스토큰을 반환한다.
    public ResponseLogin authenticate(String username, String password) {
        // 받아온 유저네임과 패스워드를 이용해 UsernamePasswordAuthenticationToken 객체 생성
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(username, password);

        // authenticationToken 객체를 통해 Authentication 객체 생성
        // 이 과정에서 CustomUserDetailsService 에서 우리가 재정의한 loadUserByUsername 메서드 호출
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 인증 정보를 기준으로 jwt access 토큰 생성
        String accessToken = accessTokenProvider.createToken(authentication);

        return ResponseLogin.builder()
                .accessToken(accessToken)
                .build();
    }
}

Controller 구현

@RestController
@RequestMapping("/api")
public class AccountController {
    private final AccountService accountService;

    // 생성자주입
    public AuthController(AccountService accountService) {
        this.accountService = accountService;
    }

    @PostMapping("/authenticate") // Account 인증 API
    public ResponseEntity<ResponseLogin> authorize(@Valid @RequestBody RequestLogin loginDto) {

        ResponseLogin token = accountService.authenticate(loginDto.username(), loginDto.password());

        // response header 에도 넣고 응답 객체에도 넣는다.
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(CustomJwtFilter.AUTHORIZATION_HEADER, "Bearer " + token.accessToken());

        return new ResponseEntity<>(token, httpHeaders, HttpStatus.OK);
    }
}    

시큐리티 유틸 작성

인증 API를 직접 호출할 때를 제외하고 JwtFilter를 통과할 때 토큰에서 정보를 받아 스프링 시큐리티에 인증 정보를 저장할 때는 loadUserByUsername를 호출하지 않기에 실제로 디비에 사용자가 존재하는지 별도로 Account를 디비에서 조회해주어야 합니다.
이를 위해 편리하게 스프링 시큐리티에서 username을 꺼내주는 유틸을 작성하려고 합니다.
security context에 저장된 Authentication 객체를 이용해 username을 리턴해주는 유틸을 작성합니다.
security contextauthentication 객체가 저장되는 시점은 토큰 검증을 수행하는 JwtFilterdoFilter 영역입니다.

@Component
public class SecurityUtil {

    private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class);

    private SecurityUtil() {
    }

    public Optional<String> getCurrentUsername() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null) {
            logger.debug("Security Context에 인증 정보가 없습니다.");
            return Optional.empty();
        }

        String username = null;
        if (authentication.getPrincipal() instanceof UserDetails) {
            UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
            username = springSecurityUser.getUsername();
        } else if (authentication.getPrincipal() instanceof String) {
            username = (String) authentication.getPrincipal();
        }

        return Optional.ofNullable(username);
    }
}

유저 등록 API 구현

USER_ROLE을 부여하여 Account를 생성하는 유저 등록 API를 구현하겠습니다.

DTO 생성

    @Builder
    public record RequestUserRegister(
            @NotNull
            @Size(min = 3, max = 50)
            String username,

            @NotNull
            @Size(min = 5, max = 100)
            String password,

            @NotNull
            @Size(min = 5, max = 100)
            String nickname
    ) {
    }
    @Builder
    public record ResponseUserRegister(
            String username,
            String password,
            String nickname,
            Long tokenWeight,
            Set<String> authoritySet
    ) {

        public static ResponseUserRegister of(Account account) {
            if(account == null) return null;

            return ResponseUserRegister.builder()
                    .username(account.getUsername())
                    .password(account.getPassword())
                    .nickname(account.getNickname())
                    .tokenWeight(account.getTokenWeight())
                    .authoritySet(account.getAuthorities().stream()
                            .map(authority -> authority.getAuthorityName())
                            .collect(Collectors.toSet()))
                    .build();
        }
    }

예시이므로 비밀번호까지 전부 응답으로 한번 보내보겠습니다.

AccountService 멤버 생성 메서드 구현

@Service
@RequiredArgsConstructor
public class AccountService {
    private final AccountRepository accountRepository;
    private final PasswordEncoder passwordEncoder;

......
....

    @Transactional
    @Override
    public ResponseUserRegister registerMember(RequestUserRegister registerMemberDto) {
        Optional<Account> accountOptional = accountRepository.findOneWithAuthoritiesByUsername(
                registerMemberDto.username());

        if (accountOptional.isPresent()) {
            throw new ApplicationException(CommonErrorCode.CONFLICT, "이미 가입되어있는 유저");
        }

        // 이 유저는 권한이 ROLE_MEMBER
        // 이건 부팅 시 data.sql에서 INSERT로 디비에 반영한다. 즉 디비에 존재하는 값이여야함
        Authority authority = Authority.builder()
                .authorityName("ROLE_MEMBER")
                .build();

        Account user = Account.builder()
                .username(registerMemberDto.username())
                .password(passwordEncoder.encode(registerMemberDto.password()))
                .nickname(registerMemberDto.nickname())
                .authorities(Collections.singleton(authority))
                .activated(true)
                .build();

        // DB에 저장하고 그걸 DTO로 변환해서 반환, 예제라서 비번까지 다 보낸다. 원랜 당연히 보내면 안댐
        return ResponseUserRegister.of(accountRepository.save(user));
    }

}    

ROLE_USERAuthority 테이블에 존재하는 값이여야 합니다.
현재는 data.sql에서 애플리케이션 부팅 시 insert 해줍니다.

유저 파사드 구현

AccountService를 가져다 사용하기 위해 파사드 패턴을 사용합니다.
파사드 패턴에 대해 이해가 부족하신 분은 UserService라고 명칭해도 됩니다.

@Service
@RequiredArgsConstructor
public class UserFacadeService {

    private final AccountService accountService;

    @Override
    public ResponseUserFacadeInformation signup(RequestUserFacadeRegister registerDto) {
        ResponseUserFacadeInformation response = accountService.registerMember(RequestAccount.RegisterMember.builder()
                        .nickname(registerDto.nickname())
                        .username(registerDto.username())
                        .password(registerDto.password())
                .build());

        return ResponseUserFacadeInformation.builder()
                .authoritySet(response.authoritySet())
                .nickname(response.nickname())
                .tokenWeight(response.tokenWeight())
                .password(response.password())
                .username(response.username())
                .build();
    }
}

controller 생성

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserController {
    private final UserFacadeService userFacadeService;

    // user 등록 API
    @PostMapping("/user/signup")
    public ResponseEntity<ResponseUserFacadeRegister> signup(
            @Valid @RequestBody RequestUserFacadeRegister registerDto
    ) {
        ResponseUserFacadeRegister userInfo = userFacadeService.signup(registerDto);

        return ResponseEntity.ok(ResponseUserRegister);
    }
}

권한 테스트

HelloController를 작성하여 인증과 권한에 따른 인가가 잘 작동하는지 테스트해봅니다. 먼저 AccountService에 기능을 몇 개 추가하겠습니다.

service

// AccountService
@Transactional(readOnly = true)
    @Override
    public ResponseAccountInformation getAccountWithAuthorities(String username) {
        Account account = accountRepository.findOneWithAuthoritiesByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username + "-> 찾을 수 없습니다"));
        return ResponseAccountInformation.of(account);
    }

    // 현재 시큐리티 컨텍스트에 저장된 username에 해당하는 정보를 가져온다.
    @Transactional(readOnly = true)
    @Override
    public ResponseAccountInformation getMyAccountWithAuthorities() {
        Account account = securityUtil.getCurrentUsername()
                .flatMap(accountRepository::findOneWithAuthoritiesByUsername)
                .orElseThrow(() -> new UsernameNotFoundException("security context로부터 찾을 수 없습니다"));
        return ResponseAccountInformation.of(account);
    }

위의 메서드는 username을 받아 해당 Account의 정보를 반환합니다.
아래의 메서드는 위에서 제작한 시큐리티 유틸을 통해 시큐리티 컨텍스트의 인증정보에 저장된 username에 해당하는 Account의 정보를 반환합니다.

HelloController

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class HelloController {
    private final AccountService accountService;

    @GetMapping("/hello")
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("hello");
    }

    // redirect test
    @PostMapping("/test-redirect")
    public void testRedirect(HttpServletResponse response) throws IOException {
        response.sendRedirect("/api/user");
    }

    // 인가 테스트
    // Authorization: Bearer {AccessToken}
    // @AuthenticationPrincipal를 통해 JwtFilter에서 토큰을 검증하며 등록한 시큐리티 유저 객체를 꺼내올 수 있다.
    // JwtFilter는 디비 조회를 하지 않기에 유저네임, 권한만 알 수 있음
    // Account 엔티티에 대한 정보를 알고 싶으면 당연 디비 조회를 별도로 해야함
    @GetMapping("/user")
    @PreAuthorize("hasAnyRole('USER','ADMIN')") // USER, ADMIN 권한 둘 다 호출 허용
    public ResponseEntity<ResponseUserInfo> getMyUserInfo(@AuthenticationPrincipal User user) {
        System.out.println(user.getUsername() + " " + user.getAuthorities());
        return ResponseEntity.ok(accountService.getMyUserWithAuthorities());
    }

    @GetMapping("/user/{username}")
    @PreAuthorize("hasAnyRole('ADMIN')") // ADMIN 권한만 호출 가능
    public ResponseEntity<ResponseUserInfo> getUserInfo(@PathVariable String username) {
        return ResponseEntity.ok(accountService.getUserWithAuthorities(username));
    }
}

전체 예제 보기

https://github.com/suhongkim98/spring-security-jwt-ssongplate
위 깃허브를 통해 예제를 확인할 수 있습니다.

블로그에서는 액세스토큰만 설명드렸지만 예제에는 리프레시토큰 예제도 포함되어 있으며 계속 리팩토링되고 있는 레포지토리입니다.

profile
鈍筆勝聰✍️

3개의 댓글

comment-user-thumbnail
2023년 2월 9일

잘 봤습니다. 다만 궁금한 점이 있어서 댓글 남겨요!

제가 알기로 필터에서는 사용자의 인가처리보다는 잘못된 요청이나 비정상적인 사용자 접근을 처리해주는게 맞다고 생각했습니다. 하지만 코드를 보다보니, 필터에서 인가처리를 해주는 것 같아서 궁금해서 여쭤봅니다.

제가 스프링 시큐리티를 아직 사용해보지 않았기 때문에 그럴수도 있어서 혹시 스프링 시큐리티를 사용하면 필터에서 처리를 해줘야하는 건가요? 아니라면 인터셉터가 아닌 필터에서 인가 처리를 하신 이유가 있을까요?

2개의 답글