JWT

2021. 6. 8. 15:38WEB Dev./JWT

JWT

JWT는 RFC 7519 웹 표준으로 지정되어 있습니다. JSON 객체를 이용해서 토큰 자체에 정보들을 저장하고 있는 Web Token이라 정의할 수 있습니다. JWT를 이용하는 방식은 아주 간편하고 쉽게 적용할 수 있어 사이드 프로젝트 등을 진행할 때 매우 유용합니다.

 

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

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

JWT의 장점

  • 중앙의 인증 서버, 데이터 스토어에 대한 의존성이 없음
  • 시스템 수평 확장 유리
  • Base64 URL Safe Encoding을 이용하므로 URL, Cookie, Header 모두 사용 가능함(범용성)

JWT의 단점

  • Payload의 정보가 많아지면 네트워크 사용량이 증가하게 됨
  • 데이터 설계 고려 필요
  • 토큰이 클라이언트에 저장되기 때문에 서버에서 클라이언트의 토큰을 조작할 수 없음

실습

https://start.spring.io/ 를 통해 설정해줍니다.

initializr로 생성

만약 Eclipse가 아닌 IntelliJ를 사용한다면 Lombok을 사용하기로 했으므로..

Preferences > Annotation Processors > Enable annotation processing Check!

 

이제 기본적인 테스트용 Rest API를 만들어 보겠습니다.

 

controller 패키지 생성.

/controller/HomeController.java (코드 첨부)

@RestController
@RequestMapping("/api")
public class HelloController {

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

Postman으로 확인하게 되면 Unauthorized를 확인하실 수 있습니다. 이를 해결하기 위해 Security 설정과 기본적인 Data 설정이 필요합니다.

 

Security 설정 & Data 설정

  1. 401 Unauthorized 해결을 위한 Security 설정
  2. Datasource, JPA 설정
  3. Entity 설정
  4. H2 Console 결과 확인

1. 401 Unauthorized 해결을 위한 Security 설정

config라는 이름의 패키지를 생성해줍니다. 기본적인 Security 설정을 위해 SecurityConfig 클래스를 만들어줍니다.

@EnableWebSecurity라는 어노테이션을 사용하는데, 기본적인 Web 보안을 활성화해주는 어노테이션입니다.

추가적인 설정을 위해 WebSecurityConfigurer를 implement 하거나 WebSecurityConfigurerAdapter를 extends하는 방법이 있습니다. WebSecurityConfigurerAdapter에 configure 메소드를 오버라이드 하여 사용합니다.

/config/SecurityConfig (코드 첨부)

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/api/hello").permitAll()
                .anyRequest().authenticated();
    }
}
  • authorizeRequests() - HttpServletRequests를 사용하는 요청들에 대한 접근 제한 설정
  • antMatchers(path).permitAll() - path에 대한 요청은 인증없이 접근을 허용
  • anyRequest().authenticated() - 나머지 요청들은 모두 인증되어야 함을 의미

2. Datasource, JPA 설정

application.properties 파일을 Refactor를 이용해 application.yml로 파일명을 변경하겠습니다. (본인의 취향 차이)

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

logging:
  level:
    me.test: DEBUG

테스트용이므로 H2 DB를 사용할 것이고 메모리에 데이터를 저장하는 방식으로 진행하겠습니다.

H2 console을 enable했고 Datasource 설정을 추가했습니다.

create-drop의 의미는 Session-Factory가 시작될 때 Drop, Create, Alter를 실행하고 종료될 때 Drop을 진행하는 의미입니다.

console 창에서 실행되는 SQL들을 보기 좋게 보여주는 설정도 추가했습니다.

 

3. Entity 설정

User, Authority

/entity/User.java (코드 첨부)

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

    @JsonIgnore
    @Id
    @Column(name = "user_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

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

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

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

    @JsonIgnore
    @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 - 데이터베이스의 테이블과 1:1 매핑되는 객체를 의미
  • @Table - 테이블 명을 user로 지정하기 위해 Table 어노테이션 사용
  • @Getter, @Setter, @Builder, @..Constructor - 롬복 어노테이션으로 Get, Set, Builder, Constructor 관련 코드를 자동으로 생성
  • userId - 자동으로 증가되는 PK
  • username, password, nickname, activated(활성화 여부), authorities(권한) 에 대한 관계들을 설정했습니다.
  • @ManyToMany, @JoinTable - User 객체와 Authority 객체의 다대다 관계를 일대다, 다대일 관계의 조인 테이블로 정의했다는 의미입니다.

application.yml에서 create-drop 설정했듯이 Spring Boot가 켜지고 꺼질때마다 테이블이 삭제되고 생성됩니다. 따라서 초기 데이터를 어느정도 설정해주기 위해 resource 경로 아래에 data.sql 파일을 만들어줍시다. 이후부터는 부트가 시작될 때마다 자동으로 쿼리를 실행한 상태로 작동됩니다.

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_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');

 

4. H2 Console 결과 확인

H2 Console에서 확인하기 위해 Security 설정을 추가해줘야 원활하게 접근할 수 있습니다.

h2-console 하위 모든 요청들과 favicon 관련 요청은 Security에서 로직을 수행하지 않도록 설정해줍니다.

 

설정을 마치고 Run 했을 때 H2 DB의 쿼리 실행에서 만약 오류가 난다면 이는 boot 버젼 충돌에 의한 것입니다.

따라서 application.yaml에 코드를 추가해줍니다. (코드 첨부)

boot 버전이 업데이트되면서 hibernate 초기화 과정보다 data.sql 과정이 먼저 실행되도록 변경된 것으로 보여집니다.


JWT 설정

  1. JWT 설정 추가
  2. JWT 관련 코드 개발
  3. Security 설정 추가

1. JWT 설정 추가

application.yml 파일에 jwt 설정 코드를 추가해줍니다. secret 값은 특정 문자열을 base64로 인코딩한 값을 사용했습니다.

 

spring:

  h2:
    (생략...)
    
  datasource:
    (생략...)
    
  jpa:
    (생략...)
    
jwt:
  header: Authorization
  #HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
  secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
  token-validity-in-seconds: 86400

logging:
  (생략...)

 

다음 build.gradle 파일로 이동하셔서 JWT 관련 라이브러리를 추가해줍니다. (코드 첨부)

 

2. JWT 관련 코드 개발

 

jwt 패키지를 생성하고 토큰의 생성토큰의 유효성을 검증 역할을 담당할 TokenProvider 클래스를 만들어줍니다.

/jwt/TokenProvider.java (코드 첨부)

package me.test.tutorial.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 {

    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;
    }
}
  • InitializingBean을 implements해서 afterPropertiesSet을 Override한 이유는 빈이 생성되고 의존성 주입(생성자)을 받은 후에 주입받은 secret 값을 Base64 Decode해서 key 변수에 할당하기 위함입니다.
  • createToken(authentication) - Authentication 객체의 권한 정보를 이용해 토큰을 생성합니다.  authentication 파라미터를 받아 권한 정보를 설정하고 application.yml에서 설정했던 만료시간을 설정해 토큰을 생성합니다.
  • getAuthentication(token) - 토큰을 파라미터로 받아서 토큰에 담겨있는 권한 정보들을 이용해 authentication 객체를 리턴합니다.
    (토큰 → 클레임 생성 권한 정보 추출 유저 객체 생성(principal) 유저객체 / 토큰 / 권한 정보로 Authentication 객체 리턴)
  • validateToken(token) - 토큰을 파라미터로 받아서 토큰의 유효성 검사를 해주는 메소드입니다. 토큰을 파싱하고 나오는 exception들을 캐치하고 문제가 있으면 false 없으면 true를 리턴해줍니다.

JWT를 위한 커스텀 필터를 만들기 위해 JwtFilter 클래스를 생성합니다./jwt/JwtFilter.java (코드 첨부)

package me.test.tutorial.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;
    }
}

 

  • JwtFilter는 위에서 만든 TokenProvider를 주입받습니다.
  • GenericFilterBean을 extends 해서 GenericFilterBean의 doFilter 메서드를 Override 합니다.
  • doFilter의 역할은 JWT 토큰의 인증 정보를 현재 실행 중인 SecurityContext에 저장하는 역할을 수행합니다.
  • 필터링을 하기위해 토큰 정보가 있어야 하므로 resolveToken 메서드를 추가해줍니다. Request Header에서 토큰 정보를 꺼내오는 내용입니다.
  • doFilter(req, res, filter)
    1. resolveToken으로 Request Header에서 토큰을 받아와 TokenProvider의 메서드인 validateToken 메서드를 통해 토큰 유효성을 확인합니다.
    2. 정상 토큰이면 토큰에서 authentication 객체를 받아와 SecurityContext에 set 해줍니다.

TokenProvider와 JwtFilter를 SecurityConfig에 적용할 때 사용할 JwtSecurityConfig 클래스를 추가해줍니다.

/jwt/JwtSecurityConfig.java (코드 첨부)

package me.test.tutorial.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) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  • SecurityConfigurerAdapter를 extends 받고 TokenProvider를 주입받습니다.
  • configure 메서드를 오버라이드해서 JwtFilter를 Security 로직에 필터를 등록합니다.

유효한 자격 증명을 제공하지 않고 접근하려 할 때 401 Unauthorized 에러를 리턴JwtAuthenticationEntryPoint 클래스를 추가하겠습니다.

/jwt/JwtAuthenticationEntryPoint.java (코드 첨부)

package me.test.tutorial.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);
    }
}

필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 리턴하기 위해 JwtAccessDeniedHandler를 만들겠습니다.

/jwt/JwtAccessDeniedHandler.java (코드 참고)

package me.test.tutorial.jwt;

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

위에서 만든 5개의 클래스(TokenProvider, JwtFilter, JwtSecurityConfig, JwtAuthenticationEntryPoint, JwtAccessDeniedHandler)SecurityConfig추가하겠습니다.

/config/SecurityConfig.java (코드 첨부)

package me.test.tutorial.config;

import me.test.tutorial.jwt.JwtAccessDeniedHandler;
import me.test.tutorial.jwt.JwtAuthenticationEntryPoint;
import me.test.tutorial.jwt.JwtSecurityConfig;
import me.test.tutorial.jwt.TokenProvider;
import org.springframework.context.annotation.Bean;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    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();
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                .antMatchers(
                        "/h2-console/**"
                        ,"/favicon.ico"
                        ,"/error"
                );
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // token을 사용하는 방식이기 때문에 csrf를 disable합니다.
                .csrf().disable()

                // Exception 처리
                .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()

                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
    }
}
  • @EnableGlobalMethodSecurity(prePostEnabled = true) - @PreAuthorize 어노테이션을 메서드 단위로 추가하기 위해 적용
  • SecurityConfig 빈은 TokenProvider, JwtAuthenticationEntryPoint, JwtAccessDeniedHandler를 주입받습니다.
  • passwordEncoder는 BCryptPasswordEncoder()를 사용합니다.
  • configure 메서드를 오버라이드한 부분에서..
    우린 토큰 방식을 사용하기 때문에 csrf 설정을 disable 했습니다.
    Exception을 핸들링할 때 앞서 만든 JwtAuthenticationEntryPoint, JwtAccessDeniedHandler를 추가했습니다.
    H2 콘솔을 위한 설정도 추가해줬습니다.
    세션을 사용하지 않기 때문에 세션 설정을 STATELESS로 지정해줬습니다.
    토큰을 받기 위한 api와 회원 가입 api (/api/authenticate, /api/signup)는 토큰이 없는 상태에서 요청이 들어오기 때문에 모두 perimiAll 설정했습니다.
  • JwtFilter를 addFilterBefore 메서드로 등록했던 JwtSecurityConfig 클래스도 적용해줍니다.

DTO & Repository & API

  1. 외부와 통신에 사용할 DTO 클래스 생성
  2. Repository 관련 코드 생성
  3. 로그인 API, 관력 로직 생성

1. 외부와 통신에 사용할 DTO 클래스 생성

 

로그인할 때 필요LoginDto를 만들겠습니다.

/dto/LoginDto.java (코드 첨부)

package me.test.tutorial.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;
}

Lombok 관련 어노테이션과 Valid 관련 어노테이션(@Size)을 추가했습니다.


토큰 정보를 Response 할 때 사용TokenDto를 만들어보겠습니다.

/dto/TokenDto.java (코드 첨부)

package me.test.tutorial.dto;

import lombok.*;

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {
    private String token;
}

회원 가입시에 사용UserDto 클래스도 만들겠습니다.

/dto/UserDto.java (코드 첨부)

package me.test.tutorial.dto;

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

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

 

2. Repository 관련 코드 생성

 

User 엔티티에 매핑되는 UserRepository 인터페이스를 만들겠습니다.

/repository/UserRepository.java (코드 첨부)

package me.test.tutorial.repository;

import me.test.tutorial.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);
}
  • username을 기준으로 User 정보를 가져올 때 권한 정보도 같이 가져오게 됩니다.
  • @EntityGraph 어노테이션은 쿼리가 수행될 때 Lazy 조회가 아니라 Eager 조회로 authorities 정보를 같이 가져오게 됩니다.

SpringSecurity에서 중요한 부분 중 하나인 UserDetailService를 구현CustomUserDetailsService 클래스를 생성하겠습니다.

/service/CustomUserDetailsService.java (코드 첨부)

package me.test.tutorial.service;

import me.test.tutorial.entity.User;
import me.test.tutorial.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()) {  // DB에서 가져온 유저 정보가 활성화 상태라면
            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(),   // 유저 로그인 ID
                user.getPassword(), // 유저 로그인 패스워드
                grantedAuthorities);
    }
}
  • UserDetailsService를 implements 하고 UserRepository를 주입받습니다. loadUserByUsername 메서드를 오버라이드해서 로그인 시에 DB에서 유저 정보와 권한정보를 가져오게 됩니다.
  • 해당 정보를 기반으로 userdetails.User 객체를 생성해서 리턴합니다.

 

3. 로그인 API, 관력 로직 생성

 

로그인 API를 추가하기 위해 AuthController를  추가해주겠습니다.

/controller/AuthController.java (코드 첨부)

package me.test.tutorial.controller;

import me.test.tutorial.dto.LoginDto;
import me.test.tutorial.dto.TokenDto;
import me.test.tutorial.jwt.JwtFilter;
import me.test.tutorial.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);
    }
}
  • TokenProvider, AuthenticationManagerBuilder를 주입받습니다.
  • 로그인 API 경로는 /api/authentication이고 Post 요청을 받습니다.
  • LoginDto의 username, password를 파라미터로 받고 UsernamePasswordAuthenticationToken을 생성합니다.
  • authenticationToken을 이용해서 Authentication 객체를 생성하고 authentication 메서드가 실행될 때 loadUserByUsername 메서드가 실행됩니다.
  • Authentication 객체를 생성하고 이를 SecurityContext에 저장합니다. Authentication 객체를 createToken 메서드를 통해 JWT Token을 생성합니다.
  • JWT Token은 Reponse Header에도 넣어주고 TokenDto를 이용해서 ResponseBody에도 넣어서 리턴해줍니다.

회원가입 API 생성

  1. 회원가입 API 생성
  2. 권한검증 확인

1. 회원가입 API 생성

간단한 유틸리티 메소드를 만들기 위해 SecurityUtil 클래스를 만들겠습니다.

/util/SecurityUtil.java (코드 첨부)

package me.test.tutorial.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을 리턴해주는 메서드입니다.
  • SecurityContext에 Authentication 객체가 저장되는 시점은 JwtFilter의 doFilter 메서드에서 Request가 들어올 때 SecurityContext에 Authentication 객체를 저장해서 사용합니다.

회원가입, 유저 정보조회 등의 메서드를 만들기 위해 UserService 클래스를 생성하겠습니다.

/service/UserService.java (코드 첨부)

package me.test.tutorial.service;

import me.test.tutorial.dto.UserDto;
import me.test.tutorial.entity.Authority;
import me.test.tutorial.entity.User;
import me.test.tutorial.repository.UserRepository;
import me.test.tutorial.util.SecurityUtil;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Collections;
import java.util.Optional;

@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 User 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 userRepository.save(user);
    }

    @Transactional(readOnly = true)
    public Optional<User> getUserWithAuthorities(String username) {
        return userRepository.findOneWithAuthoritiesByUsername(username);
    }

    @Transactional(readOnly = true)
    public Optional<User> getMyUserWithAuthorities() {
        return SecurityUtil.getCurrentUsername().flatMap(userRepository::findOneWithAuthoritiesByUsername);
    }
}
  • UserService는 UserRepository, PasswordEncoder를 주입받습니다.
  • signup(userDto) - 회원가입 로직을 수행하는 메서드로써 파라미터로 받은 userDto 안에 username을 기준으로 이 username으로 저장돼 있는 정보가 있는지 확인합니다. 없으면 권한 정보와 유저 정보를 만들어 save 메서드를 통해 DB에 저장합니다.
  • getUserWithAuthorities(username) - 어떠한 유저든 username을 통해 user 객체와 권한정보를 가져올 수 있습니다.
  • getMyUserWithAuthorities() - 현재 SecurityContext에 저장되어 있는 username에 해당하는 user 객체와 권한정보를 가져올 수 있습니다.

UserService의 메소드들을 호출UserController를 생성하겠습니다.

/controller/UserController.java (코드 첨부)

package me.test.tutorial.controller;

import me.test.tutorial.dto.UserDto;
import me.test.tutorial.entity.User;
import me.test.tutorial.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@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("/signup")
    public ResponseEntity<User> signup(@Valid @RequestBody UserDto userDto) {
        return ResponseEntity.ok(userService.signup(userDto));
    }

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

    @GetMapping("/user/{username}")
    @PreAuthorize("hasAnyRole('ADMIN')")
    public ResponseEntity<User> getUserInfo(@PathVariable String username) {
        return ResponseEntity.ok(userService.getUserWithAuthorities(username).get());
    }
}
  • signup 메서드는 UserDto를 파라미터로 받아 UserService의 signup 메서드를 호출합니다.
  • getMyUserInfo 메서드는 @PreAuthorize를 통해 USER, ADMIN 두 가지 권한 모두 허용했습니다.
  • getUserInfo 메서드는 ADMIN 권한만 호출할 수 있도록 설정했습니다.

 

이상 JWT 포스팅을 마치겠습니다.


Reference.

정은구_JWT