Spring Boot JWT Tutorial

젤리젤링텀·2024년 1월 17일
0

Spring, SpringBoot

목록 보기
5/8
post-thumbnail

이 게시글은 정은구님의 inflearn 강의 Spring Boot JWT Tutorial를 기반으로 작성되었습니다.

JWT?

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

장점

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

단점

  • Payload의 정보가 많아지면 네트워크 사용량이 커질 수 있기 때문에, 이를 고려한 데이터 설계가 필요하다.
  • 토큰이 서버에 저장되지 않고 각 클라이언트에 저장되기 때문에, 서버에서는 각 클라이언트에 저장된 토큰 정보를 직접 조작할 수 없다.

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

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

실습

프로젝트 환경 설정

initializr 설정

Lombok 설정

롬복을 사용하기 위해서 Window 환경 IntelliJ 기준으로,
Settings -> Annotation Processors -> Enable annotation processing 을 체크 해준다.

간단한 Rest api 작성

권한 테스트를 위해서 Controller 하나를 작성해준다.

Controller/HelloController.java

@RestController
@RequestMapping("/api")
public class HelloController {
    @GetMapping("/hello")
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("hello");
    }
}

Rest api 테스트는 Postman에서 진행할 것이다.
지금 get 요청을 해보면, Unauthorized 에러가 발생한다.

이 401 unauthorized 에러를 해결하기 위해 Security 설정을 해보자.

Security 설정

기본적인 Security 설정을 위해서 SecurityConfig 클래스를 만든다.

Config/SecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable) //token을 쓰는 방식이므로 csrf를 disable

                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests //HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다.
                        .requestMatchers("/api/hello").permitAll() // 해당 주소에 대한 요청은 인증 없이 접근 허용.
                        .anyRequest().authenticated() //나머지 요청들에 대해서는 인증을 받야아 한다.
                );
        return http.build();
    }
}

@EnableWebSecurity 어노테이션은 기본적인 Web 보안을 활성화 하겠다는 의미이다.
강의에서는 WebSecurityConfigurerAdapter를 extends 하는 방식으로 구현했지만, 버전이 업데이트 되면서 해당 어댑터를 사용할 수 없어서, 위 코드를 사용하였다.

위 코드를 작성하고, 다시 Get 요청을 보내보자.

hello 문자열이 잘 응답 되었다.

본격적인 JWT 튜토리얼을 위해서 Entity 폴더에 User, Authority 엔티티를 만들어보자.

Entity 생성

Entity/User.java

import lombok.*;
import jakarta.persistence.*;
import java.util.Set;

@Entity
@Table(name = "users")
@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;
}

Entity/Authority.java

import lombok.*;

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

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

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

@Getter, @Setter, @...Constructor 등의 Lombok의 기능들은 튜토리얼이니까 부담없이 사용하셨다고 한다. 실무에서는 고려해야되는 점들이 조금 있으니 주의해서 사용해야 한다!

H2 데이터베이스 연결

H2 데이터베이스를 연결하기 위해서 yml파일을 설정해주자.

application.yml

spring:
  h2:
    console:
      enabled: true

  datasource:
    url: jdbc:h2:tcp://localhost/~/jwttestdb
    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
  sql:
    init:
      mode: always

logging:
  level:
    com.example: DEBUG

sql:init:mode: always는 곧 만들 sql 파일을 실행시키기 위해 넣었다.

resources 폴더 밑에 data.sql file을 만들어준다.

insert into users (username, password, nickname, activated) values ('admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin', 1);
insert into users (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 쿼리들이 자동실행된다.

이제, h2-console에 접근을 원활하게 하기 위해서 Security 설정을 추가해주자.

Config/SecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable) //token을 쓰는 방식이므로 csrf를 disable

                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                        .requestMatchers("/api/hello").permitAll() 
                        .requestMatchers(PathRequest.toH2Console()).permitAll() //<-이 부분 추가
                        .anyRequest().authenticated() 
                );
        return http.build();
    }
}

h2-console 하위 모든 요청들은 SpringSecurity 로직을 수행하지않고 접근할 수 있게 해주었다.

H2 데이터베이스 결과

엔티티가 잘 들어간 것을 확인했고, SQL 쿼리도 잘 들어간 것을 확인했다.

기본적인 Security 설정과 Data 설정이 완료되었다.
이제, JWT 관련 코드들을 만들어보자.

JWT 코드 개발

build.gradle

implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

JWT 관련 의존성을 추가해준다.

application.yml

jwt:
  header: Authorization
  secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
  token-validity-in-seconds: 86400
  #토큰의 만료시간은 86400초

JWT 관련 설정을 추가해준다.

  • HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
  • echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret'|base64
  • 위 명령어를 입력하면, 문자열을 base64로 인코딩한 값이 나온다. 그것을 secret key로 사용.
  • secret key는 토큰을 발급할 때 사용되는 중요한 키 값이다. 따라서 키 문자열이 yml에 그대로 보이는 것이 신경쓰여서 인코딩을 했다고 하시는데.. 디코딩 해버리면 보이지 않나..? 더 알아봐야겠다. 어쨌든 실무에서는 비공개 해야하는 정보이다!!

Jwt/TokenProvider.java

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 한다.

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

    //주입받은 secret값을 Base64 Decode해서 key변수에 할당
    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    //Authentication 객체의 권한정보를 이용해서 토큰을 생성하는 createToken 메소드
    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); //yml에서 설정했던 토큰 만료시간

        //JWT 토큰 생성 후 리턴
        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }

    //토큰을 파라미터로 받아서 토큰에 담긴 정보를 이용해 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());

        //권한정보를 이용해 User 객체를 만든다.
        User principal = new User(claims.getSubject(), "", authorities);
        //User객체, 토큰, 권한정보를 이용해 최종적으로 Authentication 객체를 리턴한다.
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    //토큰을 파라미터로 받아서 토큰의 유효성 검증을 수행하는 메소드
    public boolean validateToken(String token) {
        try {
            //받은 토큰으로 파싱을 해보고 발생하는 예외들을 잡는다.
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            //정상이면 true 문제가 있으면 false
            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;
    }
}

토큰의 생성과 토큰의 유효성 검증을 담당할 클래스이다.

  • InitializingBean을 implements해서 afterPropertiesSet을 Override한 이유는 빈이 생성이 되고 의존성 주입까지 받은 후에 주입받은 secret값을 Base64 Decode해서 key변수에 할당하기 위함이다.
  • afterPropertiesSet 메소드에서는 우리가 임의의 문자열로 인코딩해놓은 secret을 디코딩해서, key라는 변수에 담는 메소드이다. 이 key는 JWT토큰을 생성할 때 secret값으로 사용된다.
  • ${jwt.secret}는 yml에서 설정한 secret key이다.
  • ${jwt.token-validity-in-seconds}는 yml에서 설정한 토큰의 유효기간이다.

위 코드들을 JWT 토큰 구조로 살펴보면 아래와 같다.

Jwt/JwtFilter.java

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 jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;

public class JwtFilter extends GenericFilterBean {
    //GenericFilterBean을 extends

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

    //JwtFilter는 TokenProvider를 주입받는다.
    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }


    //JWT 토큰의 인증 정보를 현재 실행중인 SecurityContext에 저장하는 메소드
    //실제 필터링 로직을 작성하는 메소드이다.
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        //resolveToken으로 토큰을 받아온다.
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        //이 토큰의 유효성 검증을 한다.
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            //토큰이 정상적이면 토큰에서 Authentication 객체를 받아와서 SecurityContext에 저장해줌.
            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);
    }

    //필터링을 하기 위해서 토큰 정보가 필요.
    //Request Header에서 토큰 정보를 꺼내오기 위한 메소드
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        return null;
    }
}

JWT를 위한 커스텀 필터 클래스이다. doFilter 메소드에서 Request가 들어올때, SecurityContext에 Authentication 객체를 저장해놓는다.

Jwt/JwtSecurityConfig.java

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> {
    //SecurityConfigurerAdapter를 extends하고,
    private final TokenProvider tokenProvider;
    //TokenProvider를 주입받아서,
    public JwtSecurityConfig(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    //JwtFilter를 Security 로직에 필터로 등록한다.
    @Override
    public void configure(HttpSecurity http) {
        http.addFilterBefore(
                new JwtFilter(tokenProvider),
                UsernamePasswordAuthenticationFilter.class
        );
    }
}

TokenProvider, JwtFilter를 SecurityConfig에 적용할 때 사용할 Config이다.

Jwt/JwtAuthenticationEntryPoint.java

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

import jakarta.servlet.http.HttpServletRequest;
import jakarta.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);
    }
}

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

Jwt/JwtAccessDeniedHandler.java

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

import jakarta.servlet.http.HttpServletRequest;
import jakarta.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);
    }
}

필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 리턴해주는 클래스이다.

이렇게 만든 JWT관련 5개의 클래스를 SecurityConfig에 추가해야한다.

Config/SecurityConfig.java

import com.example.jwttutorialinflearn.Jwt.JwtAccessDeniedHandler;
import com.example.jwttutorialinflearn.Jwt.JwtAuthenticationEntryPoint;
import com.example.jwttutorialinflearn.Jwt.JwtSecurityConfig;
import com.example.jwttutorialinflearn.Jwt.TokenProvider;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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.HeadersConfigurer;
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
@EnableMethodSecurity
public class SecurityConfig {
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    //생성자 주입. 만들었던 JWT관련 클래스를 주입해준다.
    public SecurityConfig(
            TokenProvider tokenProvider,
            JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
            JwtAccessDeniedHandler jwtAccessDeniedHandler
    ) {
        this.tokenProvider = tokenProvider;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }

    //PasswordEncoder로는 BCryptPasswordEncoder를 사용한다.
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable) //token을 쓰는 방식이므로 csrf를 disable

                //예외를 핸들링할때, 각 예외에 맞게 만들었던 클래스를 추가해준다.
                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .accessDeniedHandler(jwtAccessDeniedHandler) //필요한 권한이 존재하지 않는 경우
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint) //유효한 자격증명을 제공하지 않고 접근하려할 경우
                )

               //HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다. 
               .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests 
                		//(로그인API, 회원가입API)는 토큰이 없는 상태에서 요청이 들어오므로 모두 허용.
                        .requestMatchers("/api/hello", "/api/authenticate", "/api/signup").permitAll() 
                        .requestMatchers(PathRequest.toH2Console()).permitAll()
                        .anyRequest().authenticated() //나머지 요청들에 대해서는 인증을 받야아 한다.
                )

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

                // enable h2-console
                .headers(headers ->
                        headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
                )
                
                //JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig 클래스도 적용해줌.
                .with(new JwtSecurityConfig(tokenProvider), customizer -> {});
        return http.build();
    }
}

이것으로 JWT설정 추가, JWT 관련 코드 개발, Security 설정 추가하는 작업이 완료되었다.

로그인 API

이제 DB와 연결하는 Repository를 만들고, 로그인 API를 구현해보자.

DTO

먼저, 외부와의 통신에 사용할 DTO 클래스를 생성해보자

Dto/LoginDto.java

import lombok.*;

import jakarta.validation.constraints.NotNull;
import jakarta.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;
}

로그인시 사용할 로그인 DTO이다.

Dto/TokenDto.java

import lombok.*;

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

    private String token;
}

토큰 정보를 Response할때 사용할 DTO이다.

Dto/UserDto.java

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

@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;
}

회원가입시에 사용할 User의 DTO이다.

Repository

Repository는 Spring Data JPA를 사용하였다.

Repository/UserRepository.java

import com.example.jwttutorialinflearn.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") //쿼리가 수행될때 Lazy조회가 아닌 Eager조회로 authorities 정보를 같이 가져오게 한다.
    Optional<User> findOneWithAuthoritiesByUsername(String username);
}

JPA를 사용했기 때문에, 기본적인 CRUD 메소드를 사용 가능하고, 위와같이 findOneWithAuthoritiesByUsername() 필요한 메소드를 정의해줄 수 있다. username을 기준으로 User정보를 가져올때 권한 정보도 같이 가져오는 메소드이다.

Service

Service/CustomUserDetailsService.java

import com.example.jwttutorialinflearn.Entity.User;
import com.example.jwttutorialinflearn.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 {
    //Spring Security의 UserDetailsService를 구현한 클래스이다.
    private final UserRepository userRepository;

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

    //로그인 시 DB에서 유저정보와 권한정보를 가져오는 메소드.
    @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());
        //권한정보, username, password로 User 객체를 리턴해줌.
        return new org.springframework.security.core.userdetails.User(user.getUsername(),
                user.getPassword(),
                grantedAuthorities);
    }
}

Spring Security의 중요한 요소인 UserDetailsService를 구현한 클래스이다.
UserDetailsService를 implements하고, loadUserByUsername 메소드를 Override해서 로그인시에 DB에서 유저정보와 권한정보를 가져오도록 해주고, 이 정보를 바탕으로 userdetails.User 객체를 생성해서 return 해준다.

Controller

로그인 API를 위한 Controller를 만들어보자.

Controller/AuthController.java

import com.example.jwttutorialinflearn.Dto.LoginDto;
import com.example.jwttutorialinflearn.Dto.TokenDto;
import com.example.jwttutorialinflearn.Jwt.JwtFilter;
import com.example.jwttutorialinflearn.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 jakarta.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) {
        //LoginDto로 들어오는 입력을 받아서 권한토큰을 생성한다.
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());

        //권한토큰을 이용하여 Authentication 객체를 생성.
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        //SecurityContext에 저장.
        SecurityContextHolder.getContext().setAuthentication(authentication);

        //Authentication 객체를 이용하여 JWT 토큰을 생성.
        String jwt = tokenProvider.createToken(authentication);

        //JWT 토큰을 Response Header에 넣어주고,
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);

        //TokenDto를 이용해서 Response Body에도 넣어줘서 return 한다.
        return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
    }
}

권한토큰을 이용하여 Authentication 객체를 생성하려고 authenticate메소드가 실행될때, CustomUserDetailsService에서 Override 했던 loadUserByUsername 메소드가 실행된다. 이 실행 결과값으로 Authentication 객체가 생성되는 것이다.

로그인 테스트

이제 Postman으로 API를 테스트해보자!

이 admin 계정 정보는 data.sql의 insert문이 서버가 시작될때 자동실행되므로 DB에 저장되어있는 상태이다.

성공적으로 토큰이 반환되었다.

Postman 사용 팁

Postman의 Tests 탭에서 Response의 데이터를 전역변수에 저장해놓으면, 다른 Request에서도 해당 변수로 데이터를 가져다 쓸 수 있다!

회원가입 API, 권한검증 확인

버전 업데이트에 따른 코드 추가

들어가기 전에, 스프링 버전이 업데이트 되면서 추가해야할 코드가 생겼다.
가로줄 표시는 안써도 괜찮음.

Config/CorsConfig.java 생성

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

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

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

Config/SecurityConfig.java에 CorsFilter 추가

import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.CorsFilter;


@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final CorsFilter corsFilter; //<-추가

    //생성자 주입. 만들었던 JWT관련 클래스를 주입해준다.
    public SecurityConfig(
            TokenProvider tokenProvider,
            JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
            JwtAccessDeniedHandler jwtAccessDeniedHandler,
            CorsFilter corsFilter) { //<-추가
        this.tokenProvider = tokenProvider;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
        this.corsFilter = corsFilter; //<-추가
    }

    //PasswordEncoder로는 BCryptPasswordEncoder를 사용한다.
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable) //token을 쓰는 방식이므로 csrf를 disable

                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) //<-추가

                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .accessDeniedHandler(jwtAccessDeniedHandler) //필요한 권한이 존재하지 않는 경우
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint) //유효한 자격증명을 제공하지 않고 접근하려할 경우
                )

                //HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다.
                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                        //(로그인API, 회원가입API)는 토큰이 없는 상태에서 요청이 들어오므로 모두 허용.
                        .requestMatchers("/api/hello", "/api/authenticate", "/api/signup").permitAll()
                        .requestMatchers(PathRequest.toH2Console()).permitAll()
                        .anyRequest().authenticated() //나머지 요청들에 대해서는 인증을 받야아 한다.
                )

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

                // enable h2-console
                .headers(headers ->
                        headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
                )

                //JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig 클래스도 적용해줌.
                .with(new JwtSecurityConfig(tokenProvider), customizer -> {});
        return http.build();
    }
}

Dto/ErrorDto.java 생성

import java.util.ArrayList;
import java.util.List;
import org.springframework.validation.FieldError;

public class ErrorDto {
    private final int status;
    private final String message;
    private List<FieldError> fieldErrors = new ArrayList<>();

    public ErrorDto(int status, String message) {
        this.status = status;
        this.message = message;
    }

    public int getStatus() {
        return status;
    }

    public String getMessage() {
        return message;
    }

    public void addFieldError(String objectName, String path, String message) {
        FieldError error = new FieldError(objectName, path, message);
        fieldErrors.add(error);
    }

    public List<FieldError> getFieldErrors() {
        return fieldErrors;
    }
}

Repository/AuthorityRepository.java 생성

import com.example.jwttutorialinflearn.Entity.Authority;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AuthorityRepository extends JpaRepository<Authority, String> {
}

Dto/AuthorityDto.java 생성

import lombok.*;

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

Dto/UserDto.java 추가

import com.example.jwttutorialinflearn.Entity.User;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;

import jakarta.validation.constraints.NotNull;
import jakarta.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();
    }
}

Exception/DuplicateMemberException.java 생성

public class DuplicateMemberException extends RuntimeException {
    public DuplicateMemberException() {
        super();
    }
    public DuplicateMemberException(String message, Throwable cause) {
        super(message, cause);
    }
    public DuplicateMemberException(String message) {
        super(message);
    }
    public DuplicateMemberException(Throwable cause) {
        super(cause);
    }
}

Exception/NotFoundMemberException.java 생성

public class NotFoundMemberException extends RuntimeException {
    public NotFoundMemberException() {
        super();
    }
    public NotFoundMemberException(String message, Throwable cause) {
        super(message, cause);
    }
    public NotFoundMemberException(String message) {
        super(message);
    }
    public NotFoundMemberException(Throwable cause) {
        super(cause);
    }
}

Util

이제, 간단한 유틸리티성 메소드들을 만들기 위해 SecurityUtil 클래스를 생성한다.

Util/SecurityUtil.java

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

    //username을 반환해주는 메소드
    public static Optional<String> getCurrentUsername() {
        //SecurityContext에서 Authentication 객체를 꺼내온다.
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        //이 Authentication 객체를 이용해서 username을 반환해주는 간단한 유틸리티성 메소드이다.
        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);
    }
}

아까 JwtFilter클래스의 doFilter 메소드에서 SecurityContext에 저장해놓은 Authentication 객체를 가져와서 사용하는 것이다.

Service

Service/UserService.java

import java.util.Collections;

import com.example.jwttutorialinflearn.Dto.UserDto;
import com.example.jwttutorialinflearn.Entity.Authority;
import com.example.jwttutorialinflearn.Entity.User;
import com.example.jwttutorialinflearn.Exception.DuplicateMemberException;
import com.example.jwttutorialinflearn.Exception.NotFoundMemberException;
import com.example.jwttutorialinflearn.Repository.UserRepository;
import com.example.jwttutorialinflearn.Util.SecurityUtil;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@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) {
        //UserDto로 받은 데이터중 username을 기준으로 하여 DB에 이미 있는지 확인.
        if (userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername()).orElse(null) != null) {
            throw new DuplicateMemberException("이미 가입되어 있는 유저입니다.");
        }
        //username이 중복이 없다면 권한정보를 생성
        Authority authority = Authority.builder()
                .authorityName("ROLE_USER") //ROLE_USER라는 권한을 가짐.
                .build();
        //받아온 UserDto의 정보와 생성한 권한정보를 이용하여 Entity.User 객체 생성
        User user = User.builder()
                .username(userDto.getUsername())
                .password(passwordEncoder.encode(userDto.getPassword()))
                .nickname(userDto.getNickname())
                .authorities(Collections.singleton(authority))
                .activated(true)
                .build();
        //DB에 저장.
        return UserDto.from(userRepository.save(user));
    }
    //유저, 권한정보를 가져오는 메소드 2개. 허용권한이 다르므로 권한검증에 대한 테스트로 사용할 것이다.

    //username으로 유저 객체, 권한정보를 가져오는 메소드
    @Transactional(readOnly = true)
    public UserDto getUserWithAuthorities(String username) {
        return UserDto.from(userRepository.findOneWithAuthoritiesByUsername(username).orElse(null));
    }

    //현재 SecurityContext에 저장된 username에 해당하는 유저 객체와 권한정보를 가져오는 메소드
    @Transactional(readOnly = true)
    public UserDto getMyUserWithAuthorities() {
        return UserDto.from(
                SecurityUtil.getCurrentUsername()
                        .flatMap(userRepository::findOneWithAuthoritiesByUsername)
                        .orElseThrow(() -> new NotFoundMemberException("Member not found"))
        );
    }
}

Controller

Controller/UserController.java

import com.example.jwttutorialinflearn.Dto.UserDto;
import com.example.jwttutorialinflearn.Service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

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

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

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

    //회원가입
    @PostMapping("/signup")
    public ResponseEntity<UserDto> signup(
            @Valid @RequestBody UserDto userDto
    ) {
        //UserDto로 받아서 UserService의 signup 메소드 호출
        return ResponseEntity.ok(userService.signup(userDto));
    }

    //===============username을 기준으로 유저 정보와 권한 정보를 리턴하는 API=======================

    @GetMapping("/user")
    @PreAuthorize("hasAnyRole('USER','ADMIN')") //USER, ADMIN 두가지 권한 모두 호출할 수 있는 API
    public ResponseEntity<UserDto> getMyUserInfo(HttpServletRequest request) {
        return ResponseEntity.ok(userService.getMyUserWithAuthorities());
    }

    @GetMapping("/user/{username}")
    @PreAuthorize("hasAnyRole('ADMIN')") //ADMIN 권한만 호출할 수 있는 API
    public ResponseEntity<UserDto> getUserInfo(@PathVariable String username) {
        return ResponseEntity.ok(userService.getUserWithAuthorities(username));
    }
}

회원가입, 권한검증 테스트

회원가입

username, password, nickname을 JSON 값으로 POST 요청한다.

결과

USER 권한을 부여받은 것을 확인할 수 있다.

H2 Console에서 데이터가 잘 들어왔는지 확인해보자.
JRingterm 유저는 USER 권한 하나만을 갖고있고, admin 유저는 ADMIN 권한과 USER 권한을 갖고있다.

이제, 권한이 다른 두 계정을 가지고, 허용 권한이 달랐던 API를 사용해보자.

ADMIN 권한만 허용되는 API 호출

ADMIN 권한일때

아까 ADMIN 권한으로 로그인했을때 발급되었던 권한토큰을 Postman 전역변수에 담아놨었다. 그 변수를 Authorization 탭에 Bearer Token 타입으로해서 입력해주고, API를 호출해보았다.

결과

200 OK 가 반환되는 것을 확인할 수 있다.
무슨 일인지 Body에 아무런 값도 출력되지 않는다. 문제가 뭔지 찾아볼 예정...

이 토큰은 ADMIN 권한의 토큰이기 때문에 Admin 권한만 허용되는 API도 호출이 가능했다!

USER 권한일때

먼저, USER 권한을 갖고있는 hojun 계정으로 로그인해서 권한토큰을 발급받는다. 이 토큰도 마찬가지로 Tests 탭에서 전역변수로 저장했다.

이제 API를 호출해보자.

결과

USER 권한은 이 API를 호출할 수 있는 권한이 없기 때문에, 403 Forbidden 에러가 반환되었다.
우리가 만들어놨던 JwtAccessDeniedHandler가 잘 작동한 것을 알 수 있다.

USER, ADMIN 권한 모두 허용되는 API 호출

이 API는 SecurityContext에 저장되어있는 username을 기준으로 유저 정보와 권한 정보를 가져오는 API이다. 즉, 로그인한 자기자신의 정보를 가져오는 것.

USER 권한으로 API를 호출해보자.

결과

USER 권한토큰으로 이 API는 잘 호출되는 것을 볼 수 있다.

profile
열심히 살자

0개의 댓글