본문 바로가기
Spring/Spring Security

[Spring Security] JWT을 알아보자

by yoon_seon 2023. 7. 10.

Spring Boot JWT Tutorial

1. JWT 소개, 프로젝트 생성

2. Data 설정 추가

3. JWT 코드, Security 설정 추가

4. DTO, Repository, 로그인

5. 회원가입, 권한검증


📌 JWT 소개, 프로젝트 생성

JWT란 

JWT는 RFC 7519 웹 표준으로 지정이 되어있고 JSON 객체를 사용해서 토큰 자체에 정보들을 저장하고 있는 Web ToKen이다. 

JWT는 Header, Payload, Signature 3개의 부분으로 구성되어져 있다.

 

  • Header : Signature를 해싱하기 위한 알고리즘 정보가 담겨있다.
  • Payload : 서버와 클라이언트가 주고받는, 시스템에서 실제로 사용될 정보에 대한 내용이 담겨있다.
  • Signature : 토큰의 유효성을 검증하기 위한 문자열이 담겨있으며 이 문자열을 통해 서버에서 토큰이 유효한 토큰인지 검증한다.

 

JWT의 장점

  • 중앙의 인증서버, 데이터 스토어에 대한 의존성이 없으므로 시스템 수평 확장이 유리하다.
  • Base64 URL Safe Encoding을 이용하기 때문에  URL, Cookie, Header 모두 사용 가능한 범용성을 가지고 있다.

JWT의 단점

  • Payload의 정보가 많아지면 네트워크 사용량(트래픽)이 증가하므로 이를 고려해서 설계해야한다.
  • 토큰이 서버에 저장되지 않고 각 클라이언트에 저장되기 때문에 서버에서 클라이언트의 토큰을 조작할 수 없다.

 

 

프로젝트 생성

- Spring Boot 2.7.13

- Java 17

  • IntelliJ를 사용한다면 lombok 어노테이션을 사용하기 위해 Enable annotation processing 체크 필요

 


📌 Data 설정

예제에서는 User(사용자)와 Authority(권한) 엔티티를 N:M 매핑하여 진행한다.

 

Entity 생성

✔️ User 엔티티

package com.study.jwttutorial.entity;

import lombok.*;

import javax.persistence.*;
import java.util.Set;

@Entity
@Table(name = "`user`")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
    @Id
    @Column(name = "user_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

    @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(
            name = "user_authority",
            joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "user_id")},
            inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")})
    private Set<Authority> authorities;
}
  • 실무에서는 엔티티에 Setter를 열어두지 않지만 고려야하지만 예제이기 때문에 롬복 기능을 부담없이 사용했다.
  • @ManyToMany와 @JoinTable을 사용하여 일대다 다대일 관계의 조인 테이블을 정의했다.

 

✔️ Authority 엔티티

package com.study.jwttutorial.entity;

import lombok.*;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

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

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

 

✔️ data.sql을 사용해서 초기데이터 세팅

insert into "user" (username, password, nickname, activated) values ('admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin', 1);
insert into "user" (username, password, nickname, activated) values ('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_USER');
insert into user_authority (user_id, authority_name) values (1, 'ROLE_ADMIN');
insert into user_authority (user_id, authority_name) values (2, 'ROLE_USER');

  • 리소스 폴더 밑에 data.sql 생성 및 SQL 작성
  • 이후 부터는 data.sql 쿼리들이 서버 실행시 자동으로 실행된다.

 


📌 JWT 코드, Security 설정 추가

✔️ application.yml

JWT 설정 추가

spring:

  h2:
    console:
      enabled: true

  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
        show_sql: true
    defer-datasource-initialization: true
    
logging:
  level:
    me.silvernine: DEBUG
    
jwt:
  header: Authorization
  #HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
  #echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret'|base64
  secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
  token-validity-in-seconds: 86400
  • 이 예제에서  HS512 알고리즘을 사용하기 때문에 Secret Key는 64Byte 이상 되어야한다.
  • 토큰의 만료시간은 86400초로 설정
  • 참고로 secret 값은 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret' 을 Base64로 인코딩한 값으로 64Byte 이상을 만족하는 임의의 값으로 설정하면 된다.

 

✔️ build.gradle 

JWT 관련 라이브러리 추가

 

✔️ SecurityConfig 클래스 추가

Security 설정을 위한 클래스

package com.study.jwttutorial.config;

import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().antMatchers("/h2-console/**"
                , "/favicon.ico"
                , "/error");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeRequests()
                .antMatchers("/api/hello").permitAll()
                .anyRequest().authenticated();
                
        return httpSecurity.build();
    }


}
  • @Configuration : 스프링 설정파일로 등록
  • @EnableWebSecurity : 기본적인 Web 보안을 활성화 하겠다는 의미
  • 추가적인 설정을 위해 WebSecurityConfigurer를 implements 하거나 WebSecurityConfigurerAdapter를 extends 하는 방법이 있는데 여기서는 WebSecurityConfigurerAdapter을 extends 하여 진행 (Spring boot 2.7 이후 필요없음)
  • configure(WebSecurity web) 메서드 Override
    • web.ignoring().antMatchers("/h2-concole/**","favicon.ico")
      /h2-concole/ 하위 모든 요청과 파비콘은 모두 무시하는것으로 설정
  • configure(HttpSecurity http) 메서드를 Override
    • http
      .authorizeRequests() : HttpServletRequest를 사용하는 요청들에 대한 접근 제한을 설정한다는 의미
      .antMatchers("/api/hello").permitAll() : 이 요청은 인증없이 접근을 허용
      .anyRequest().authenticated() :  나머지의 모든 접근은 인증필요

 

✔️ TokenProvider 추가

→ 토큰의 생성, 토큰의 유효성 검증등을 담당하는 클래스

package com.study.jwttutorial.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Component
public class TokenProvider implements InitializingBean { // InitializingBean을 implements해서 afterPropertiesSet을 Override한 이유는 빈이 생성이 되고 의존성 주입을 받은 후에 secret 값을 Base64로 Decode해서 Key 변수에 할당하기 위함이다.

    private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
    private static final String AUTHORITIES_KEY = "auth";
    private final String secret;
    private final long tokenValidityInMilliseconds;
    private Key key;

    public TokenProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
    }

    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    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();
    }

    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());

        User principal = new User(claims.getSubject(), "", authorities);

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

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            logger.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            logger.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            logger.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            logger.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}
  • application.yml에 정의한 secret와 token-validity-in-seconds 값을 전역변수로 사용한다.
  • InitializingBean을 implements해서 afterPropertiesSet을 Override한 이유는 빈이 생성이 되고 의존성 주입을 받은 후에 secret 값을 Base64로 Decode해서 Key 변수에 할당하기 위함이다.
  • public String createToken(Authentication authentication)
    • Authentication 객체를 파라미터로 받아서 권한 및 토큰 유효시간을 설정하고 JWT 토큰을 반환한다.
  • public Authentication getAuthentication(String token)
    • Token을 파라미터로 받아서 Token을 클레임(Payload)으로 만들고 이를 이용해 User 객체를 만들어서 최종적으로 Authentication 객체를 반환한다.
  • public boolean validateToken(String token)
    • 토큰을 파라미터로 받아서 파싱을 해보고 발생하는 Exception들을 캐치, 문제가 있으면 false, 정상이면 true 반환한다.

 

✔️ JwtFilter 클래스 생성

→ JWT를 위한 커스텀 필터를 만들기 위한 클래스

package com.study.jwttutorial.jwt;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

public class JwtFilter extends GenericFilterBean {

    private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
    public static final String AUTHORIZATION_HEADER = "Authorization";
    private TokenProvider tokenProvider;
    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @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) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.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을 extends 해서 doFilter를 Override하고 실제 필터링 로직은 doFilter내부에 작성한다.
  • doFilter는 토큰의 인증정보를 현재 실행중인 SecurityContext에 저장하는 역할을 수행한다.
  • JwtFilter는 전에 만들었던 TokenProvider를 주입받는다.
  • public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    • resolveToken을 통해 토큰을 받아와서 유효성 검증을 하고 정상 토큰이면 SecurityContext에 저장
  • private String resolveToken(HttpServletRequest request) {
    • Request Header에서 Token 정보를 꺼내온다.

 

✔️ JwtSecurityConfig 클래스 생성

→ TokenProvider, JwtFilter를 SecurityConfig에 적용할 때 사용할 클래스

package com.study.jwttutorial.jwt;

import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private TokenProvider tokenProvider;
    public JwtSecurityConfig(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void configure(HttpSecurity http) {
        http.addFilterBefore(
                new JwtFilter(tokenProvider),
                UsernamePasswordAuthenticationFilter.class
        );
    }
}
  • SecurityConfigurerAdapter를 extends하고 TokenProvider를 주입받아서 JwtFilter를 통해 Security 로직에 필터를 등록한다.

 

✔️ JwtAuthenticationEntryPoint 클래스 생성

→ 유효한 자격증명을 제공하지 않고 접근하려 할 때 401 Unauthorized 에러를 리턴할 클래스

package com.study.jwttutorial.jwt;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
  • AuthenticationEntryPoint을 implements 하고 HttpServletResponse.SC_UNAUTHORIZED를 반환하는 클래스이다.

 

✔️ JwtAccessDeniedHandler 클래스 생성

→ 필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 반환하는 클래스

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        //필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
  • AccessDeniedHandler을 implements 하고 HttpServletResponse.SC_FORBIDDEN를 반환하는 클래스이다.

 

✔️ SecurityConfig 클래스 수정

→ 지금까지 작성한 5개의 클래스를 추가

package com.study.jwttutorial.config;

import com.study.jwttutorial.jwt.JwtAccessDeniedHandler;
import com.study.jwttutorial.jwt.JwtAuthenticationEntryPoint;
import com.study.jwttutorial.jwt.TokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig{
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    public SecurityConfig(
            TokenProvider tokenProvider,
            JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
            JwtAccessDeniedHandler jwtAccessDeniedHandler
    ) {
        this.tokenProvider = tokenProvider;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }

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

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().antMatchers("/h2-console/**"
                , "/favicon.ico"
                , "/error");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // token을 사용하는 방식이기 때문에 csrf를 disable합니다.
                .csrf().disable()

                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                // enable h2-console
                .and()
                .headers()
                .frameOptions()
                .sameOrigin()

                // 세션을 사용하지 않기 때문에 STATELESS로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                .antMatchers("/api/hello").permitAll()
                .antMatchers("/api/authenticate").permitAll()
                .antMatchers("/api/signup").permitAll()

                .anyRequest().authenticated();

        return httpSecurity.build();
    }
}
  • @EnableGlobalMethodSecurity(prePostEnabled = true) : @PreAuthorize 어노테이션을 메서드 단위로 추가하기 위해서 적용
  • SecurityConfig는 생성자를 통해 이전에 만들었던 tokenProvider, jwtAuthenticationEntryPoint, jwtAccessDeniedHandler를 주입받는다.
  • PasswordEncoder는 BCryptPasswordEncoder을 사용하기 위해 Bean으로 등록
  • protected void configure(HttpSecurity httpSecurity)
    • .csrf().disable() : token을 사용하는 방식이기 때문에 csrf를 disable한다.
    • .exceptionHandling()
      .authenticationEntryPoint(jwtAuthenticationEntryPoint)
      .accessDeniedHandler(jwtAccessDeniedHandler) : exception을 Handling할 때 만들었던 jwtAuthenticationEntryPoint와 jwtAccessDeniedHandler를 클래스로 추가
    • .sessionManagement()
      .sessionCreationPolicy(SessionCreationPolicy.STATELESS) : 세션을 사용하지 않기 때문에 세션 설정을 STATELESS로 설정 
    • .authorizeRequests()
      .antMatchers("/api/authenticate").permitAll()
      .antMatchers("/api/signup").permitAll() : 
      토큰이 없는 상태에서 요청이 들어오는 해당 URI 들은 permitAll 설정
    • .anyRequest().authenticated() : 나머지의 모든 접근은 인증필요
    • .apply(new JwtSecurityConfig(tokenProvider)) : JwtFilter를 addFileterBefore로 등록했던 JwtSecurityConfig 클래스로 적용

 


📌 DTO, Repository, 로그인

✔️ LoginDto 클래스 생성

package com.study.jwttutorial.dto;

import lombok.*;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoginDto {

    @NotNull
    @Size(min = 3, max = 50)
    private String username;

    @NotNull
    @Size(min = 3, max = 100)
    private String password;
}

 

✔️ TokenDto 클래스 생성

package com.study.jwttutorial.dto;

import lombok.*;

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {

    private String token;
}

 

✔️ AuthorityDto 클래스 생성

package com.study.jwttutorial.dto;

import lombok.*;

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthorityDto {
    private String authorityName;
}

 

✔️ UserDto 클래스 생성

package com.study.jwttutorial.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.study.jwttutorial.entity.User;
import lombok.*;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.Set;
import java.util.stream.Collectors;

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {

    @NotNull
    @Size(min = 3, max = 50)
    private String username;

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @NotNull
    @Size(min = 3, max = 100)
    private String password;

    @NotNull
    @Size(min = 3, max = 50)
    private String nickname;

    private Set<AuthorityDto> authorityDtoSet;

    public static UserDto from(User user) {
        if(user == null) return null;

        return UserDto.builder()
                .username(user.getUsername())
                .nickname(user.getNickname())
                .authorityDtoSet(user.getAuthorities().stream()
                        .map(authority -> AuthorityDto.builder().authorityName(authority.getAuthorityName()).build())
                        .collect(Collectors.toSet()))
                .build();
    }
}

 

✔️ UserRepository 인터페이스 생성

package com.study.jwttutorial.repository;

import com.study.jwttutorial.entity.User;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    @EntityGraph(attributePaths = "authorities")
    Optional<User> findOneWithAuthoritiesByUsername(String username);
}
  • findOneWithAuthoritiesByUsername 메서드는 username을 기준으로 user 정보를 가져올때 권한 정보도 같이 가져온다.
  • @EntityGraph은 쿼리가 수행될 때 Lazy 조회가 아니고 Eager 조회로 authorities 정보를 같이 가져오게 된다.

 

✔️ CustomUserDetailsService 클래스 생성

package com.study.jwttutorial.service;

import com.study.jwttutorial.entity.User;
import com.study.jwttutorial.repository.UserRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

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

@Component("userDetailsService")
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    @Transactional
    public UserDetails loadUserByUsername(final String username) {
        return userRepository.findOneWithAuthoritiesByUsername(username)
                .map(user -> createUser(username, user))
                .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
    }

    private org.springframework.security.core.userdetails.User createUser(String username, User user) {
        if (!user.isActivated()) {
            throw new RuntimeException(username + " -> 활성화되어 있지 않습니다.");
        }

        List<GrantedAuthority> grantedAuthorities = user.getAuthorities().stream()
                .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
                .collect(Collectors.toList());

        return new org.springframework.security.core.userdetails.User(user.getUsername(),
                user.getPassword(),
                grantedAuthorities);
    }
}
  • UserDetailService를 implements하고 UserRepository를 주입받는다. loadUserByUsername 메서드를 오버라이드해서 로그인시 DB에서 유저정보와 권한정보를 가져오게 된다. 해당 정보를 기반으로 userdetials.User 객체를 생성해서 리턴한다.

 

✔️ AuthController 클래스 생성

package com.study.jwttutorial.controller;

import com.study.jwttutorial.dto.LoginDto;
import com.study.jwttutorial.dto.TokenDto;
import com.study.jwttutorial.jwt.JwtFilter;
import com.study.jwttutorial.jwt.TokenProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

@RestController
@RequestMapping("/api")
public class AuthController {
    private final TokenProvider tokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    public AuthController(TokenProvider tokenProvider, AuthenticationManagerBuilder authenticationManagerBuilder) {
        this.tokenProvider = tokenProvider;
        this.authenticationManagerBuilder = authenticationManagerBuilder;
    }

    @PostMapping("/authenticate")
    public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto) {

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String jwt = tokenProvider.createToken(authentication);

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);

        return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
    }
}
  • AuthController는 TokenProvider, AuthenticationManagerBuilder를 주입받는다. 로그인 API 경로는 /api/authenticate 이고 Post 요청을 받는다.
  • LoginDto의 username, password를 파라미터로 받고 이를 이용해 UsernamePasswordAuthenticationToken을 생성한다.
  • authenticationToken을 이용해서 Authentication객체를 생성하려고 authenticate메서드가 실행이 될 때CustomUserDetailsService에서 오버라이드한 loadUserByUsername 메서드가 실행된다.
  • 결과값을 가지고 Authentication객체를 생성하고 이를 SecurityContext에 저장하고 Authentication 객체를 createToken 메서드를 통해서 JWT Token을 생성한다.
  • JWT Token을 Response Header에도 넣어주고 TokenDto를 이용해서 Response Body에도 넣어서 리턴하게 된다.

 

✔️ 로그인 API 테스트

 


📌 회원가입, 권한검증

✔️ SecurityUtil 클래스 생성

package com.study.jwttutorial.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Optional;

public class SecurityUtil {

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

    private SecurityUtil() {}

    public static 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);
    }
}
  • getCurrentUsername메서드의 역할은 Security Context의 Authentication 객체를 이용해 username을 리턴해주는 간단한 유틸성 메서드다.
  • Security Context에 Authentication 객체가 저장되는 시점은 JwtFilter의 doFilter 메서드에서 Request가 들어올 때 SecurityContext에 Authentication 객체를 저장해서 사용하게 된다.

 

✔️ UserService 클래스 생성

package com.study.jwttutorial.service;

import com.study.jwttutorial.dto.UserDto;
import com.study.jwttutorial.entity.Authority;
import com.study.jwttutorial.entity.User;
import com.study.jwttutorial.repository.UserRepository;
import com.study.jwttutorial.util.SecurityUtil;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Collections;

@Service
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @Transactional
    public UserDto signup(UserDto userDto) {
        if (userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername()).orElse(null) != null) {
            throw new RuntimeException("이미 가입되어 있는 유저입니다.");
        }

        Authority authority = Authority.builder()
                .authorityName("ROLE_USER")
                .build();

        User user = User.builder()
                .username(userDto.getUsername())
                .password(passwordEncoder.encode(userDto.getPassword()))
                .nickname(userDto.getNickname())
                .authorities(Collections.singleton(authority))
                .activated(true)
                .build();

        return UserDto.from(userRepository.save(user));
    }

    @Transactional(readOnly = true)
    public UserDto getUserWithAuthorities(String username) {
        return UserDto.from(userRepository.findOneWithAuthoritiesByUsername(username).orElse(null));
    }

    @Transactional(readOnly = true)
    public UserDto getMyUserWithAuthorities() {
        return UserDto.from(
                SecurityUtil.getCurrentUsername()
                        .flatMap(userRepository::findOneWithAuthoritiesByUsername)
                        .orElseThrow(() -> new RuntimeException("Member not found"))
        );
    }
}
  • UserService는 UserRepository, PasswordEncoder를 주입 받는다.
  • signup 메서드는 회원가입 로직을 수행하는 메서드이다. username이 DB에 존재하지 않으면 Authority와 User 정보를 생성해서 UserRepository의 save메서드를 통해 DB에 정보를 저장한다.
  • 여기서 중요한 점은 signup 메서드를 통해 가입한 회원은 USER ROLE을 가지고 있고, data.sql에서 자동 생성되는 admin 계정을  USER, ADMIN ROLE을 가지고 있다. 이 차이를 통해 권한 검증 부분을 테스트 하도록 하겠다.
  • UserService에는 유저, 권한정보를 가져오는 메서드가 2개 있다.
    getUserWithAuthorities는 username을 기준으로 정보를 가져온다.
    getMyUserWithAuthorities는 SecurityContext에 저장된 username의 정보만 가져온다.
    이 두가지 메서드의 허용권한을 다르게 해서 권한 검증에 대한 테스트를 하도록 하겠다.

 

✔️ UserController 클래스 생성

package com.study.jwttutorial.controller;

import com.study.jwttutorial.dto.UserDto;
import com.study.jwttutorial.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.io.IOException;

@RestController
@RequestMapping("/api")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

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

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

    @PostMapping("/signup")
    public ResponseEntity<UserDto> signup(
            @Valid @RequestBody UserDto userDto
    ) {
        return ResponseEntity.ok(userService.signup(userDto));
    }

    @GetMapping("/user")
    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    public ResponseEntity<UserDto> getMyUserInfo(HttpServletRequest request) {
        return ResponseEntity.ok(userService.getMyUserWithAuthorities());
    }

    @GetMapping("/user/{username}")
    @PreAuthorize("hasAnyRole('ADMIN')")
    public ResponseEntity<UserDto> getUserInfo(@PathVariable String username) {
        return ResponseEntity.ok(userService.getUserWithAuthorities(username));
    }
}
  • signup 메서드는 UserDto를 파라미터로 받아 UserService의 signup 메서드를 호출한다.
  • getMyUserInfo 메서드는 @PreAuthorize를 통해서 USER, ADMIN 두가지 권한 모두 허용했고, getUserInfo 메서드는 ADMIN 권한만 호출할 수 있도록 설정했다.
  • getMyUserInfo 메서드는 UserService에서 만들었던 username 파라미터를 기준으로 유저정보와 권한정보를 리턴한다.

 

✔️ 회원가입 API 테스트

Postman

  • 회원가입 성공하고 tester 유저는 USER 권한을 소유한다.

 

H2 Console

  • tester 유저는 USER 권한을 가지고 있고 admin 유저는 ADMIN, USER 두가지 권한을 소유하고 있다.
  • 두 계정을 가지고 허용권한이 달랐던 두개의 API를 테스트 해보겠다.

 

✔️ 허용권한 테스트 - admin 계정 권한의 토큰으로 접근 테스트

ADMIN 권한을 가진 토큰만 API에 호출이 가능한지 검증하는 테스트이다.

  • admin 유저의 토큰을 발급

 

  • admin 계정의 토큰으로 tester 계정의 정보를 가져올 수 있다.

 

  • tester 유저의 토큰 발급

 

  • tester 유저로 발급받은 토큰은 /user/{username} API를 호출하는 권한이 없기때문에 거부된 것을 확인할 수 있다.

 

✔️ 허용권한 테스트 - user 계정 권한의 토큰으로 접근 테스트

USER, ADMIN 권한을 가진 토큰 모두  API에 호출이 가능한지 검증하는 테스트이다.

  • USER 권한으로 호출 가능한 api/user/ API의 접근이 성공한 것을 확인할 수 있다.

 


해당 글은 인프런의 [Spring Boot JWT Tutorial] 강의를 정리한 내용입니다.

 

[무료] Spring Boot JWT Tutorial - 인프런 | 강의

Spring Boot, Spring Security, JWT를 이용한 튜토리얼을 통해 인증과 인가에 대한 기초 지식을 쉽고 빠르게 학습할 수 있습니다., [사진] 본 강의는 Spring Boot, Spring Security를 이용해서 JWT 인증과 인가를 쉽

www.inflearn.com

 

댓글