Dev/Spring.SpringBoot

[SpringBoot] [Inflearn - 무료] 스프링부트 시큐리티 & JWT 강의 (Section 3)

pu3vig 2023. 2. 28. 15:28
728x90
  • target:  스프링부트 시큐리티 & JWT 강의 17 ~ 27강 (섹션 3. 스프링 시큐리티 JWT 서버구축)

  • method: 

이전 Section 2. 스프링 시큐리티 웹 보안 이해

https://pu3vig.tistory.com/111


* Section 3. 스프링 시큐리티 JWT 서버구축

★ Section 3. 최신버전 업데이트 github 주소

https://github.com/codingspecialist/Springboot-Security-JWT-Easy/tree/version2

 

GitHub - codingspecialist/Springboot-Security-JWT-Easy

Contribute to codingspecialist/Springboot-Security-JWT-Easy development by creating an account on GitHub.

github.com


17. 스프링부트 시큐리티 17강 - JWT 구조 이해

- JWT(=Json Web Token)

- 개방형 표준(RFC 7519)

- HMAC 알고리즘 사용 or RSA or ECDSA를 사용하는 비대칭 키를 이용한 서명 용도에 중점

- .(=dot)으로 구분된 구성

JWT의 일반적 구성

- Header: HMAC SHA256(=HS256) or RSA의 두 부분으로 구성되고, Base64Url로 인코딩되어 JWT의 첫번째 부분 형성

  ※ Base64는 Decoding 가능

Header 예시 (출처: https://jwt.io/introduction)

- Payload: Registered claims / Public claims / Private claims로 구성되고 Base64Url로 인코딩되어 JWT 두번째 부분 형성

  • Registered claims: 필수는 아니지만 권장되는 미리 정의된 클레임 (iss / exp / sub / aud 등의 기본 정보)
  • Public claims: 충돌 방지를 위해 IANA JWT 레지스트리에 정의하거나 충돌방지 네임스페이스를 포함하는 URI로 정의
  • Private claims: 당사자간에 정보를 공유하기 위해 생성된 사용자 지정 클레임

- Signature: 인코딩 된 Header / Payload / Secret / Header에 지정된 Algorithm 을 가지고 서명

HS256 알고리즘을 사용하려는 경우, 서명 생성 방식 (출처: https://jwt.io/introduction)


HS256 알고리즘을 사용하여 서명한 데이터를 Decode한 결과 (출처: https://jwt.io/introduction)

- JWT 사용 

  17.1) 클라이언트가 로그인 인증 후, JWT 생성 후, 클라이언트에게 전송

  17.2) 클라이언트는 JWT를 웹 브라우저 로컬 스토리지에 저장하고 있다가, 클라이언트가 개인 정보를 요청하는 때에 JWT를 서버에게 같이 전송

  17.3) 서버는 JWT의 유효성을 확인하기 위해서 JWT에 있는 hader, payload를 서버의 secret으로 암호화 하여 JWT의 Signature와 비교하여 적법한 JWT인지 검증

  17.4) 검증 이후, payload의 정보를 토대로 클라이언트의 개인정보를 전송

  ※ HS256 / RSA 차이

  - HS256(대칭키)로 사용자의 인증 정보를 암호화하여 클라이언트에게 전송 후, 클라이언트가 개인정보 요청 시, JWT를 전달 받아서 HS256으로 복호화하여 인증 여부 확인

  - RSA(비대칭키)는 사용자의 인증 정보를 서버의 개인키로 암호화하여 클라이언트에게 전송 후, 클라이언트가 개인정보 요청 시, JWT를 전달 받아서 서버의 공개키로 복호화하여 서버의 개인키로 암호화한 인증 여부 확인


18. 스프링부트 시큐리티 18강 - JWT 프로젝트 세팅

  18.1) com.cos.jwt 프로젝트 생성 (이후 ${baseDir}은 /src/main/java/com/cos/jwt 경로를 의미)

    - Lombok

    - Spring Boot DevTools

    - Spring Web

    - MySQL Driver

    - Spring Data JPA

    - Spring Security

     

  18.2) pom.xml에 https://mvnrepository.com/에서 com.auth0.java-jwt를 찾아서 dependency 추가


19. 스프링부트 시큐리티 19강 - JWT를 위한 yml파일 세팅

  19.1) /src/main/resources/application.yml 생성

# application.yml

server:
  port: 8080
  servlet:
    context-path: /
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/security?serverTimezone=Asia/Seoul
    username: root
    password: root

  jpa:
    hibernate:
      ddl-auto: create #create update none
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    show-sql: true

 

  19.2) ${baseDir}/controller/RestApiController.java 생성

// RestApiController.java

package com.cos.jwt.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RestApiController {
    
    @GetMapping("/home")
    public String home() {
        return "<h1>home</h1>";
    }
}

 

  19.3) localhost:8080 접속하여 로그인

 

localhost:8080 접근


springboot 서버를 시작하면 터미널에 나오는 generated security password


user와 security password 입력


로그인 후 localhost:8080/home으로 접근


20. 스프링부트 시큐리티 20강 - JWT를 위한 security 설정

  20.1) ${baseDir}/model/User.java 생성

// User.java

package com.cos.jwt.model;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Data;

@Data
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    private String username;
    private String password;
    private String roles;   // USER,ADMIN

    public List<String> getRoleList() {
        if(this.roles.length() > 0) {
           return Arrays.asList(this.roles.split(","));
        }
        return new ArrayList<>();
    }
}

 

  20.2) DB에 테이블이 생성되었는지 확인

User.java 모델을 통해서 생성

 

  20.3) ${baseDir}/config/SecurityConfig.java 생성

// SecurityConfig.java

package com.cos.jwt.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.filter.CorsFilter;

import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    
    private final CorsFilter corsFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        /* JWT를 위한 기본 설정 */
        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        .addFilter(corsFilter)  // @CrossOrigin 인증을 사용하지 않고, 시큐리티 필터에 등록한 인증을 사용
        .formLogin().disable()
        .httpBasic().disable()
        /* --JWT를 위한 기본 설정 */
        .authorizeHttpRequests()
        .requestMatchers("/api /v1/user/**")
        .access(new WebExpressionAuthorizationManager("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')"))
        .requestMatchers("/api/v1/manager/**")
        .access(new WebExpressionAuthorizationManager("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')"))
        .requestMatchers("/api/v1/admin/**")
        .hasRole("ROLE_ADMIN")
        .anyRequest().permitAll();
        
        return http.build();
    }
}

※ http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

  - JWT 사용을 위해서 STATELESS 서버

※ addFilter(CorsFilter)

  - CrossOrigin 정책을 사용하지 않는(모든 요청을 허용하는) CorsFilter (아래 20.4) 필터 적용

★★스프링부트 버전에 따라서 SecurityConfig.java는 상이할 수 있음 아래 이전 강의에서 2.2) 참고★★

https://pu3vig.tistory.com/109

 

  20.4) ${baseDir}/config/CorsConfig.java 생성

// CorsConfig.java

package com.cos.jwt.config;

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

        /* 
         * wildcard(=*)는 전체를 의미
         * setAllowCredentials(boolean): 내 서버가 응답 시, json을 javascript에서 처리할 수 있게 할지 설정 (false인 경우, javascript로 요청 시, 응답을 보내지 않음)
         * setAllowedOrigin(String): 해당 ip에 응답을 허용
         * setAllowedHeader(String): 해당 header에 응답을 허용
         * setAllowedMethod(String): 해당 method(POST, GET, PUT, DELETE, PATCH) 요청을 허용
         */
        config.setAllowCredentials(true);        
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/api/**", config);

        return new CorsFilter(source);
    }
}

※ Url 기반 필터

※ Cors 요청에 대해서 모두 허용하도록 설정

 

  20.5) 동작확인

localhost:8080의 일반 접근은 허용(페이지만 없어서 404)


localhost:8080/api/v1/user/asdf도 접근은 허용


localhost:8080/api/v1/manager/asdf는 403으로 거부


localhost:8080/api/v1/admin/asdf도 Access Denied


21. 스프링부트 시큐리티 21강 - JWT Bearer 인증 방식

  21.1) SecurityConfig.java에 대한 분석

// SecurityConfig.java

package com.cos.jwt.config;

...
(중략)
...

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    
    private final CorsFilter corsFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        /* JWT를 위한 기본 설정 */
        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        .addFilter(corsFilter)  // @CrossOrigin 인증을 사용하지 않고, 시큐리티 필터에 등록한 인증을 사용
        .formLogin().disable()
        .httpBasic().disable()
        /* --JWT를 위한 기본 설정 */
        
        ...
        (중략)
        ...
        
        return http.build();
    }
}

 

※ 쿠키에 있는 세션의 기본 정책: 동일 도메인에 대해서만 동작

  - 1개의 서버에 도메인 A로 요청하여 인증한 경우, IP로 요청하는 경우, 쿠키 정책에 위배

※ 클라이언트측에서 javascript를 통해서 fetch().then()와 같은 방식으로 header에 쿠키를 설정하여 요청을 할 수 있으나 서버 설정에서 http only 설정을 하는 경우, 오로지 http 방식으로 오는 요청에 대해서만 서버에서 쿠키값을 받음 (보안상 http only를 true로 설정)

※ httpBasic().disable()

  - http 요청 시, header의 Authorization에 ID/PW를 가지고 요청하는 방식(=httpBasic)

  - ID/PW가 암호화되지 않기 때문에, 보안상의 이유로 사용하지 않음(disable)

※ http 요청 시, header의 Authorization에 Token을 가지고 요청하는 방식(=Bearer)

  - 여기에 사용되는 Token을 JWT를 사용

※ 따라서 JWT를 사용하기 위해서는

  - 서버의 세션 기본 정책을 STATELESS 서버로 하여 동작

  - httpBasic 인증은 disable 시키고

  - 요청 시, header의 Authorization에 JWT 을 적용하여 요청


22. 스프링부트 시큐리티 22강 - JWT Filter 등록 테스트

★ 아래 코드들은 실제 강의 영상과 일부 다름(필터 중복 등록되는 부분으로 인해 코드 수정)

★ 설명은 23강 내용 전에 [필터가 2번 나오는 이유] 참조★ 22강 이후부터는 필터 관련 코드는 강의 영상과 일부 다름

  22.1) ${baseDir}/filter/MyFilter1.java 생성

// MyFilter1.java

package com.cos.jwt.filter;

import java.io.IOException;

import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public class MyFilter1 extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // TODO Auto-generated method stub
        System.out.println("Filter 1");
        filterChain.doFilter(request, response);  // chain에 필터 등록
    }
}

 

  22.2) SecurityConfig.java에 Filter 적용

// SecurityConfig.java

package com.cos.jwt.config;

...
(중략)
...

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    
    private final CorsFilter corsFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        /* 필터1 추가 */
        /* 
         * MyFilter1 추가
         * addFilter를 쓸 수 없는 이유는 MyFilter1 필터가 security 필터여야 가능
         * 따라서 securityFilter 전(Before)/후(After)에 넣어야함)
         */
        // http.addFilter(new MyFilter1());
        http.addFilterBefore(new MyFilter1(), BasicAuthenticationFilter.class);
        
        ...
        (중략)
        ...
        
    }
}

 

  22.3) 출력 확인

localhost:8080 호출 시 필터 확인

 

  22.4) SecurityConfig.java에 적용한 필터는 주석하고 별도로 ${baseDir}/config/FilterConfig.java  생성하고 Filter1과 동일하게 MyFilter2.java 생성하여 우선순위만 다르게 설정

 

SecurityConfig.java의 필터 모두 주석


// FilterConfig.java

package com.cos.jwt.config;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.cos.jwt.filter.MyFilter1;
import com.cos.jwt.filter.MyFilter2;

@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<MyFilter1> filter1() {
        FilterRegistrationBean<MyFilter1> bean = new FilterRegistrationBean<>(new MyFilter1());
        bean.addUrlPatterns("/*"); // 전체 URL에 대해서
        bean.setOrder(0);   // 0순위
        return bean;
    }

    @Bean
    public FilterRegistrationBean<MyFilter2> filter2() {
        FilterRegistrationBean<MyFilter2> bean = new FilterRegistrationBean<>(new MyFilter2());
        bean.addUrlPatterns("/*"); // 전체 URL에 대해서
        bean.setOrder(1);   // 1순위
        return bean;
    }
}

// MyFilter2.java

package com.cos.jwt.filter;

import java.io.IOException;

import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public class MyFilter2 extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // TODO Auto-generated method stub
        System.out.println("Filter 2");
        filterChain.doFilter(request, response);  // chain에 필터 등록
    }
}

 

  22.5) 출력 확인

MyFilter1, MyFilter2가 정상적으로 출력

 

  22.6) Filter 순서 확인을 위해서 MyFilter3.java, MyFilter4.java를 생성하고 SecurityCofig.java를 아래와 같이 수정

// MyFilter3.java

package com.cos.jwt.filter;

import java.io.IOException;

import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public class MyFilter3 extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // TODO Auto-generated method stub
        System.out.println("Filter 3");
        filterChain.doFilter(request, response);  // chain에 필터 등록
    }
}

// MyFilter4.java

package com.cos.jwt.filter;

import java.io.IOException;

import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public class MyFilter4 extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // TODO Auto-generated method stub
        System.out.println("Filter 4");
        filterChain.doFilter(request, response);  // chain에 필터 등록
    }
}

// SecurityConfig.java

package com.cos.jwt.config;

...
(중략)
...

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    
    private final CorsFilter corsFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        /* 
         * MyFilter1 추가
         * addFilter를 쓸 수 없는 이유는 MyFilter1 필터가 security 필터여야 가능
         * 따라서 securityFilter 전(Before)/후(After)에 넣어야함)
         */
        // http.addFilter(new MyFilter1());
        // http.addFilterBefore(new MyFilter1(), BasicAuthenticationFilter.class);
        /*
         * 필터3,4 추가
         * SecurityContextPersistenceFilter는 deprecated 됨
         * 필터 순서를 확인하여 원하는 위치에 필터 삽입
         */
        http.addFilterBefore(new MyFilter3(), BasicAuthenticationFilter.class);
        http.addFilterAfter(new MyFilter4(), BasicAuthenticationFilter.class);
        

        ...
        (중략)
        ...
    }
}

※ 필터 순서도

Spring SecurityFilterChain (출처:&nbsp; https://velog.io/@sa833591/Spring-Security-5-Spring-Security-Filter-적용)

★ SecurityContextPersistenceFilter는 스프링 버전에 따라 deprecated

 

  22.7) 출력 확인

SecurityFilter 최초에 3,4가 나오고, Security 필터 적용 후, 필터 1,2가 적용

 

★ 필터가 2번 나오는 이유(강의 영상에서는 해당 현상을 볼 수 없으나, springboot 버전에 따라 다른 것으로 판단됨)

(출처: https://targetcoders.com/필터-중복-적용/)  - JWT 인증과 관련된 필터를 GenericFilterBean을 상속 받아서 구현하고, 의존 관계 주입을 위해 Bean으로 등록한 경우 발생

Springboot Reference Documentation 일부 발췌 (출처: https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/)

  - Springboot는 Bean들 중에 Filter가 있으면 자동으로 Filter Chain에 등록함

  - 서버가 시작될 때, Filter Chain에 한번 등록되고, addFilterBefore 메서드를 통해서 Filter에 한번 또 등록됨

  - 따라서 JwtAuthenticationFilter 구현 시, GenericFilterBean이 아닌 OncePerRequestFilter를 사용받아서 구현할 것

  - GenericFilterBean은 doFilter, OncePerRequestFilter는 doFilterInternal로 구현 메소드와 파라미터가 다름

  - 이에 강의 영상에서는 MyFilter 생성 시, Filter 클래스를 implements 하였으나, 본 게시글에서는 OncePerRequestFilter를 extned 함

// MyFilter1,2,3,4 .java 강의 영상 코드

package com.cos.jwt.filter;

import java.io.IOException;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;

public class MyFilter1 implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
            throws IOException, ServletException {
        // TODO Auto-generated method stub
        System.out.println("Filter 1");
        filterChain.doFilter(request, response);  // chain에 필터 등록
    }
    
}

※ 변경사항

  - implements Filter -> extends OncePerRequestFilter 클래스로 변경

  - doFilter메소드 -> doFilterInternal 메소드로 변경

  - ServletRequest/ServletResponse -> HttpServletRequest/HttpServletResponse 파라미터로 변경


23. 스프링부트 시큐리티 23강 - JWT 임시토큰 만들어서 테스트 해보기

  23.1) RestApiController.java에 PostMapping 추가

// RestApiController.java

package com.cos.jwt.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RestApiController {
    
    ...
    (중략)
    ...

    @PostMapping("token")
    public String token() {
        return "<h1>token</h1>";
    }
}

 

  23.2) MyFilter3.java에 POST 접근 시, request Header에서 "Authorization" 값이 "cos"인 경우에만 정상동작하도록 코드 수정

// MyFilter3.java

package com.cos.jwt.filter;

...
(중략)
...

public class MyFilter3 extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // TODO Auto-generated method stub
 
        /* 
         * 임시토큰 : cos
         * iw/pw가 정상적으로 들어와서 로그인이 완료되면, 토큰 생성 및 응답
         * 요청시마다 header의 Authorization 값 체크해서 서버에서 생성해서 응답한 토큰인지만 체크
         * RSA / HS256
         */
        if("POST".equals(request.getMethod().toUpperCase())) {
            System.out.println("POST Method");
            String headerAuth = request.getHeader("Authorization");
            System.out.println(headerAuth);

            if("cos".equals(headerAuth)) {
                filterChain.doFilter(request, response);
            }
            else {
                PrintWriter pw = response.getWriter();
                pw.println("Not Allowed");
            }
        }
    }
}

 

  23.3) 결과 확인

Chrome 개발자도구 Console에서 Authorization에 cos값을 추가하여 요청한 경우 정상 회신 확인


인증이 완료된 경우, 정상적으로 Controller의 token 메소드 확인


인증이 완료되지 않은 경우, Response에서 doFilterInternal에서 필터링


로그인 전에 MyFilter3 부터 거쳐서 로그인 인증 후, 나머지 필터를 처리

※ 로그인 인증 후, 토큰 생성하여 클라이언트에 전달한 이후부터는, 클라이언트 요청 시 마다 request header의 Authorization 값을 추출하여 서버에서 생성한 사용자 인증 토큰인지를 검증하는 방식으로 진행

※ 따라서 해당 필터가 적용되는 부분은 22강의 SecurityConfig.java의 맨 처음에 있는 MyFilter3 위치에 적용


24. 스프링부트 시큐리티 24강 - JWT를 위한 로그인 시도

★ 아래 코드는 이전 강의에 나왔던 내용으로 설명이 필요한 경우 아래 게시글 확인

https://pu3vig.tistory.com/109

 

  24.1) ${baseDir}/repository/UserRepository.java 생성

// UserRepository.java

package com.cos.jwt.repository;

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

import com.cos.jwt.model.User;

public interface UserRepository extends JpaRepository<User, Long> {
    public User findByUsername(String username);
}

 

  24.2) ${baseDir}/config/auth/PrincipalDetails.java, PrincipalDetailsService.java 생성

// PrincipalDetails.java

package com.cos.jwt.config.auth;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import com.cos.jwt.model.User;

import lombok.Data;

@Data
public class PrincipalDetails implements UserDetails {
    
    private User user;

    public PrincipalDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // TODO Auto-generated method stub
        Collection<GrantedAuthority> authorities = new ArrayList<>();

        user.getRoleList().forEach(r -> {
            authorities.add(() -> r);
        });
        return authorities;
    }

    @Override
    public String getPassword() {
        // TODO Auto-generated method stub
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        // TODO Auto-generated method stub
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        // TODO Auto-generated method stub
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        // TODO Auto-generated method stub
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        // TODO Auto-generated method stub
        return true;
    }

    @Override
    public boolean isEnabled() {
        // TODO Auto-generated method stub
        return true;
    }
}

// PrincipalDetailsService.java

package com.cos.jwt.config.auth;

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.Service;

import com.cos.jwt.model.User;
import com.cos.jwt.repository.UserRepository;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
 
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // TODO Auto-generated method stub
        User user = userRepository.findByUsername(username);
        return new PrincipalDetails(user);
    }
}

 

  24.3) ${baseDir}/config/JwtAuthenticationFilter.java 생성

// JwtAuthenticationFilter.java

package com.cos.jwt.config;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

/* 
 * 스프링 시큐리티에서 usernamePasswordAuthenticationFilter가 있음
 * /login 요청해서 username / password를 전송
 * UsernamePasswordAuthenticationFilter가 동작
 * formLogin()을 disable한 경우, .addFilter로 수동으로 추가해야 동작
 */
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
    	super(authenticationManager);
    	this.authenticationManager = authenticationManager;
    }

    /* 
     * /login 요청을 하면 로그인 시도를 위해서 실행되는 함수
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        // TODO Auto-generated method stub
        System.out.println("JwtAuthenticationFilter: Try Login");

        // 1. username / password

        // 2. authenticationManager로 로그인 시도를 하면 PrincipalDetailsService가 호출됨(=loadUserByUsername()이 실행됨)

        // 3. PrincipalDetails를 세션에 담음(권한 관리를 위함, 권한관리 필요 없을 경우 세션에 담을 필요 업승ㅁ)

        // 4. JWT토큰을 만들어서 return        

        return super.attemptAuthentication(request, response);
    }
}

★ UsernamePasswordAuthenticationFilter는 "/login"에서 동작하는데, 현재 springboot의 formLogin()을 disable() 시켰기 때문에 동작하지 않음

★ 따라서 수동으로 24.4)와 같이 Filter 추가를 진행해줘야 함

 

  24.4) SecurityConfig.java에서 JwtAuthenticationFilter 추가

★ WebSecurityConfigurerAdapter가 deprecated돼서 AuthenticationManager를 별도로 @Bean으로 선언 필요

// SecurityConfig.java

package com.cos.jwt.config;

...
(중략)
...

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    
    private final CorsFilter corsFilter;

    private final AuthenticationConfiguration authenticationConfiguration;

    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
    	return authenticationConfiguration.getAuthenticationManager();
    }
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        ...
        (중략)
        ...
        
        .formLogin().disable()
        .httpBasic().disable()
        .addFilter(new JwtAuthenticationFilter(authenticationManager()))   // formLogin을 disable 하였으므로 수동으로 JwtAuthenticationFilter 필터 추가

        ...
        (중략)
        ...
        
        return http.build();
    }
}

 

  24.5) 회원가입을 위한 "join" 메소드 추가 및 비밀번호 암호화 생성

★ BCryptPasswordEncoder는 JwtApplication에 @Bean을 생성(SecurityConfig에서 생성시, 충돌 발생)하고 RestApiController에서 final로 선언

// JwtApplication.java

package com.cos.jwt;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@SpringBootApplication
public class JwtApplication {

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

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

}

// RestApiController.java

package com.cos.jwt.controller;

...
(중략)
...

@RequiredArgsConstructor
@RestController
public class RestApiController {
    
    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    
    ...
    (중략)
    ...

    @PostMapping("join")
    public @ResponseBody String join(@RequestBody User user) {
        user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
        user.setRoles("ROLE_USER");
        userRepository.save(user);
        return "join success!";
    }
}

25. 스프링부트 시큐리티 25강 - JWT를 위한 강제 로그인 진행

  25.1) 로그인을 위해 JwtAuthenticationFilter 수정

// JwtAuthenticationFilter.java

package com.cos.jwt.config;

...
(중략)
...

/* 
 * 스프링 시큐리티에서 usernamePasswordAuthenticationFilter가 있음
 * /login 요청해서 username / password를 전송
 * UsernamePasswordAuthenticationFilter가 동작
 * formLogin()을 disable한 경우, .addFilter로 수동으로 추가해야 동작
 */
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private AuthenticationManager authenticationManager;
    
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        this.authenticationManager = authenticationManager;
    }

    /* 
     * /login 요청을 하면 로그인 시도를 위해서 실행되는 함수
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        // TODO Auto-generated method stub
        System.out.println("JwtAuthenticationFilter: Try Login");

        try {
            // 1. username / password
            User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
            System.out.println(user);

            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
            System.out.println("new UsernamePasswordAuthenticationToken=========================================");
            
            // 2. authenticationManager로 로그인 시도를 하면 PrincipalDetailsService가 호출됨(=loadUserByUsername()이 실행됨)
            // 이때 @Bean으로 생성한 BCryptPasswordEncoder를 사용
            Authentication authentication = authenticationManager.authenticate(authenticationToken);
            System.out.println("authenticationManager.authenticate=========================================");
            
            // 3. PrincipalDetails를 세션에 담음
            // 권한 관리를 위해서 세션에 담기 때문에, 권한관리 필요 없을 경우 세션에 담을 필요 없음
            PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
            System.out.println("authentication.getPrincipal========================================= username: " + principalDetails.getUser().getUsername());

            return authentication;
        }
        catch(IOException e) {
            e.printStackTrace();
        }

        return null;
    }

    /* 
     * attemptAuthentication 실행 후, 인증이 정상적으로 되었으면, successfulAuthentication 메소드 실행
     * JWT 토큰을 만들어서 requestㅇ 요청한 사용자에게 JWT 토큰을 response
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            Authentication authResult) throws IOException, ServletException {
        // TODO Auto-generated method stub
        /* 
         * 이 영역으로 넘어온 경우, 로그인 인증이 정상적으로 성공한 경우
         * JWT 토큰 생성 위치
         */
        System.out.println("successfulAuthentication");
        super.successfulAuthentication(request, response, chain, authResult);
    }
}

 

  25.2) 출력 결과

 

authentication이 정상적으로 동작하여 successfulAuthentication 메소드까지 호출 완료


만약 인증 실패한 경우, 401 에러와 함께 Unauthorized 에러 발생


authenticationManager를 통해 authenticate에서 인증이 되지 않으면 더이상 진행하지 않


26. 스프링부트 시큐리티 26강 - JWT 토큰 만들어서 응답하기

  26.1) ${baseDir}/config/JwtProperties.java 생성

// JwtProperties.java

package com.cos.jwt.config;

public interface JwtProperties {
    String SECRET = "cos";          // jwt.algorithm.secret
    int EXPIRATION_TIME = 60000*10;  // unit: milliseconds
    String TOKEN_PREFIX = "Bearer ";
    String HEADER_STRING = "Authorization";
}

 

  26.2) successfulAuthentication 메소드에서 JWT 토큰 생성

// JwtAuthenticationFilter.java

package com.cos.jwt.config;

...
(중략)
...

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    ...
    (중략)
    ...

    /* 
     * attemptAuthentication 실행 후, 인증이 정상적으로 되었으면, successfulAuthentication 메소드 실행
     * JWT 토큰을 만들어서 requestㅇ 요청한 사용자에게 JWT 토큰을 response
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            Authentication authResult) throws IOException, ServletException {
        // TODO Auto-generated method stub
        System.out.println("successfulAuthentication=========================================");
        
        /* 
         * 이 영역으로 넘어온 경우, 로그인 인증이 정상적으로 성공한 경우
         * JWT 토큰 생성 위치
         */
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

        // Hash 암호화 방식
        String jwtToken = JWT.create()
                            .withSubject(principalDetails.getUsername())
                            .withExpiresAt(new Date(System.currentTimeMillis()+JwtProperties.EXPIRATION_TIME))     // 만료시간: 단위(ms) 10분
                            .withClaim("id", principalDetails.getUser().getId())
                            .withClaim("username", principalDetails.getUser().getUsername())
                            .sign(Algorithm.HMAC512(JwtProperties.SECRET));

        response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + jwtToken);
        // super.successfulAuthentication(request, response, chain, authResult);
    }
}

 

  26.3) JWT 토큰 확인

Response Headers에 인증완료 후, Authentication에 JWT 토큰이 추가되어있음을 확인


27. 스프링부트 시큐리티 27강 - JWT 토큰 서버 구축 완료

더보기

1. 일반적인 인증

  - 로그인 확인 후, 세션ID 생성

  - 클라이언트 쿠키 세션 ID를 응답

  - 인증이 필요한 요청 시마다, 쿠키값의 세션 ID를 가지고 서버에 요청

  - 서버에서는 session.getAttribute()를 통해서 세션 ID 확인으로 인증

 

2. JWT 인증

  - 로그인 확인 후, JWT 토큰을 생성하여 리턴

  - 클라이언트는 JWT 토큰을 request header에 포함하여 서버에 요청

  - 서버에서는 request header의 JWT 토큰 값이 유효한지를 판단하여 인증


  27.1) JWT 토큰 유효성검증을 위한 JwtAuthorizationFilter.java 생성

// JwtAuthorizationFilter.java

package com.cos.jwt.config;

import java.io.IOException;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/* 
 * 시큐리티의 Filter 중 BasicAuthenticationFilter
 * 권한이나 인증이 필요한 특정 주소를 요청한 경우에만 BasicAuthenticationFilter 적용
 */
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }    

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws IOException, ServletException {
        // TODO Auto-generated method stub
        super.doFilterInternal(request, response, filterChain);
        System.out.println("required authorization");

        String jwtHeader = request.getHeader("Authorization");
        System.out.println("jwtHeader : " + jwtHeader);
    }
}

 

  27.2) JwtAuthorizationFilter 적용

★ MyFilter3는 주석 (BasicAuthenticationFilter 이전에 동작하기 때문에, 테스트 시, request header의 "Authentication"에 "cos"를 넣어주지 않으면 뒤의 필터가 동작하지 않음

★ 더이상 MyFilter는 필요없기에 주석

// SecurityCofig.java

package com.cos.jwt.config;

...
(중략)
...

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    
    ...
    (중략)
    ...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        ...
        (중략)
        ...
        
        /*
         * 필터3,4 추가
         * SecurityContextPersistenceFilter는 deprecated 됨
         * 필터 순서를 확인하여 원하는 위치에 필터 삽입
         */
        // http.addFilterBefore(new MyFilter3(), BasicAuthenticationFilter.class);
        // http.addFilterAfter(new MyFilter4(), BasicAuthenticationFilter.class);

        /* JWT를 위한 기본 설정 */
        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        .addFilter(corsFilter)  // @CrossOrigin 인증을 사용하지 않고, 시큐리티 필터에 등록한 인증을 사용
        .formLogin().disable()
        .httpBasic().disable()
        
        .addFilter(new JwtAuthenticationFilter(authenticationManager()))
        .addFilter(new JwtAuthorizationFilter(authenticationManager()))
        
        ...
        (중략)
        ...
        
        return http.build();
    }
}

 

  27.3) localhost:8080/api/v1/user/1234 인증 요청 확인

403 Access Denied 확인


JwtAuthorizationFilter.doFilterInternal에 걸려서 출력

 

  27.4) 로그인 후, response Header의 authorization 값을 취득 및 /api/v1/user/1234 접근 시 header에 설정하여 확인

사용자 Jwt 획득


JWT 처리 부분이 없어서 여전히 Access Denied, Authorization에는 JWT 적용


서버 측에서 Authorization값 취득

 

  27.5) JWT Authorization을 통해 jwtHeader 인증 및 권한을 SecurityContextHolder의 Authentication 객체를 생성하여 세션 생성

// JwtAuthorizationFilter.java

package com.cos.jwt.config;

...
(중략)
...

/* 
 * 시큐리티의 Filter 중 BasicAuthenticationFilter
 * 권한이나 인증이 필요한 특정 주소를 요청한 경우에만 BasicAuthenticationFilter 적용
 */
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private UserRepository userRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
        super(authenticationManager);
        this.userRepository = userRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws IOException, ServletException {
        // TODO Auto-generated method stub
        System.out.println("required authorization");

        String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
        System.out.println("jwtHeader : " + jwtHeader);

        if(jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
            filterChain.doFilter(request, response);
            return;
        }

        // JWT 검증
        String jwt = jwtHeader.replace(JwtProperties.TOKEN_PREFIX, "");    // "Bearer " 부분 제거
        
        String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(jwt).getClaim("username").asString();
        
        // JWT 인증 완료
        if(username != null) {
            System.out.println("JWT 인증 완료 [username: "+username+"]");

            User userEntity = userRepository.findByUsername(username);
            
            PrincipalDetails principalDetails = new PrincipalDetails(userEntity);

            // JWT 인증 완료된 사용자 이기에, 사용자 정보를 DB에서 조회 후, principalDetails로부터 Authentication 객체 생성
            Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
            
            // 강제로 시큐리티의 세션에 접근하여 Authentication 객체 생성
            SecurityContextHolder.getContext().setAuthentication(authentication);

            System.out.println("SecurityContext에 Authentication 객체 생성");
            filterChain.doFilter(request, response);
        }
    }
}

 

  27.6) 권한 체크를 위해 RestApiController.java 수정

// RestApiController.java

package com.cos.jwt.controller;

...
(중략)
...

@RequiredArgsConstructor
@RestController
public class RestApiController {
    
    ...
    (중략)
    ...

    @GetMapping("/api/v1/user")
    public @ResponseBody String user(Authentication authentication) {
        return "complete login[user]";
    }

    @GetMapping("/api/v1/manager")
    public @ResponseBody String manager() {
        return "complete login[manager]";
    }

    @GetMapping("/api/v1/admin")
    public @ResponseBody String admin() {
        return "complete login[admin]";
    }
}

 

  27.7) 로그인 및 권한 확인

"POST" 로 /login 하여 JWT 취득


"GET"으로 /api/v1/user 로 접근 시, "Authorization"에 취득한 JWT를 적용하여 요청하면 위와 같은 결과 확인


"GET"으로 /api/v1/manager 로 접근 시, 403 Forbidden 에러를 확인

 


  • warning: 

개발/프로그래밍 > 백엔드 > 스프링부트 시큐리티 & JWT 강의

[2023.02.28 기준] - 해당 강의 무료 시청 가능

추후 강의 유/무료가 바뀌거나 강의 내용이 업데이트 될 수 있음


  • source:

https://www.inflearn.com/

 

인프런 - 미래의 동료들과 함께 성장하는 곳 | IT 정보 플랫폼

프로그래밍, 인공지능, 데이터, 마케팅, 디자인, 엑셀 실무 등 입문부터 실전까지 업계 최고 선배들에게 배울 수 있는 곳. 우리는 성장 기회의 평등을 추구합니다.

www.inflearn.com

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

https://velog.io/@sa833591/Spring-Security-5-Spring-Security-Filter-%EC%A0%81%EC%9A%A9

 

Spring Security (5) - Spring Security Filter 적용

Web Security기본 설정시 Spring Security는 일련의 서블릿 필터 체인을 자동으로 구성한다.(web tier에 있는 Spring Security는 Servlet Filter에 기반을 두고 있다.)일반적인 웹 환경에서 브라우저가 서버에게 요

velog.io

https://targetcoders.com/필터-중복-적용/

 

[Spring Boot] 나의 필터가 두 번 적용된 이유 - 타깃코더스

OAuth2.0 구글 로그인 기능을 추가하던 중에 스프링 시큐리티(Spring Security)를 사용하여 JWT 인증을 구현하는 과정에서 한 번 적용되어야 할 필터가 두 번 적용되어 당황했던 경험이 있습니다. 이를

targetcoders.com

 

728x90