1. 기본 환경 세팅

https://coding-mid-life.tistory.com/71 을 기본으로 시작한다

 

 2. OAuth2에 필요한 추가 코드

  1) build.gradle 파일에 코드를 추가한다

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // 추가

 2) application.yml 파일을 변경한다

  • clientID와 clientSecret은 구글 API console에서 발급받은 API Key를 입력한다
spring:
	security:
    oauth2:
      client:
        registration:
          google:
            clientId:
            clientSecret:
            scope:
              - email
              - profile

 3) loginForm.html 파일에 코드를 추가한다

</form>
<a href="/oauth2/authorization/google">구글 로그인</a> <!-- 추가 -->
<a href="join">회원가입</a>

 4) SecurityConfig 클래스에 코드를 추가한다

                .defaultSuccessUrl("/") //추가
                .and() // 추가
                .oauth2Login() // 추가
                .loginPage("/login"); // 추가
        return http.build();

 

 

3. 애플리케이션 실행

 1) 코드를 모두 수정한 후 애플리케이션을 실행한다

  • localhost:8080/login 으로 접속하여 '구글로그인'을 선택한다

계정을 클릭하여 로그인을 진행하면 

 

 

4. 구글 회원 프로필 정보 받기

 1) 로그인 후 필요한 후처리 작업을 해 준다

  • 코드 받기(인증)
  • 액세스 토큰(권한)
  • 사용자 프로필 정보를 가져온다
  • 3번에서 가져온 정보를 토대로 자동으로 회원가입 할 수 있다
  • 위 정보만으로 부족할 경우 추가적인 구성 정보를 기입할 수 있다

 2) config 패키지에 oauth 패키지를 만들고 PrincipalOauth2UserService 클래스를 생성한다

  • 구글 로그인 버튼 클릭 → 구글 로그인 창 → 로그인 완료 → code 리턴(OAuth-Client 라이브러리) → AccessToken 요청 userRequest 정보 → loadUser 함수 호출 → 구글로부터 회원프로필 정보를 받아 온다
  • 로그인 후에 필요한 후처리 작업을 담당한다
  • loadUser 메서드를 Override하는데 이 메서드는 구글로부터 받은 userRequest 데이터에 대한 후처리 함수이다
    - userRequest에 담긴 정보를 확인할 수 있는 메서드
    - userRequest.getClientRegistration()
    - userRequest.getAccessToken().getTokenValue()
    - super.loadUser(userRequest).getAttributest()
package com.memberlogin.loginjoin.config.oauth;

import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        System.out.println("userRequest : " + userRequest);
        return super.loadUser(userRequest);
    }
}

 

 3) SecurityConfig에 위 코드들을 적용해 주는 코드를 추가한다

  @Autowired
    private PrincipalOauth2UserService principalOauth2UserService; // 추가
......
    	.userInfoEndpoint() // 추가
        .userService(principalOauth2UserService); // 추가

 

 4) Member 클래스에 코드를 추가한다

	private String provider;
    private String providerId;

 

 

5. authentication 정보 확인

 1) PrincipalDetails 클래스에 @Data 애너테이션을 추가한다

@Data // 추가

 

 2) IndexController 클래스에 코드를 추가한다

  • Authentication 객체를 의존성 주입을 통해 다운 캐스팅하여 정보를 가져올 수 있다.
  • 회원가입 → 로그인 후 /loginTest url로 접속해서 로그에 Authentication 정보를 확인합니다.
// 추가
		@GetMapping("/loginTest")
    public @ResponseBody String loginTest(Authentication authentication) {
        System.out.println("============/loginTest===========");
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        System.out.println("authentication : " + principalDetails.getMember());
        return "세션 정보 확인";
    }

  • 위와 같은 동작을 애노테이션으로 할 수 있도록 코드를 추가한다
    - 애너테이션을 통해 회원정보를 가져올 수 있다.
// 추가
		@GetMapping("/loginTest2")
    public @ResponseBody String loginTest2(@AuthenticationPrincipal PrincipalDetails principalDetails) {
        System.out.println("============/loginTest2===========");
        System.out.println("userDetails : " + principalDetails.getMember());
        return "세션 정보 확인2";
    }

  • OAuth2 로그인 정보를 가져오기 위한 코드를 추가한다
// 추가
		@GetMapping("/loginTest3")
    public @ResponseBody String loginOAuthTest(
					Authentication authentication,
					@AuthenticationPrincipal OAuth2User oauth) {
        System.out.println("============/loginOAuthTest===========");
				OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
        System.out.println("authenticaion : " + oauth2User.getAttributes());
				System.out.println("oauth2User : " + oauth.getAttributes());
        return "세션 정보 확인3";
    }

 

 

6. 스프링 시큐리티 (세션)

 1) Authentication 객체만 가질 수 있다

  • 일반 로그인 → PrincipalDetails( implements UserDeatils)
  • OAuth 로그인 → OAuth2User
  • 위와 같이 진행될 시 로그인 User 처리가 불편하게 된다.
    - OAuth 유저도 PrincipalDetails로 묶으면 된다.

 

 2) PrincipalDetails 클래스에 코드를 추가한다

  •  PrincipalDetails 클래스에 OAuth2User를 추가로 implements 해 준다
  • getAttributes() 와 getName() 메서드를 오버라이드 해 준다
 @Data
    public class PrincipalDetails implements UserDetails, OAuth2User {	
    
    		...
    		@Override
    	  public Map<String, Object> getAttributes() {
    	      return null;
    	  }
    	
    	  @Override
    	  public String getName() {
    	      return null;
    	  }
    }

 3) 애플리케이션 재실행 후 localhost:8080/login 접속 후 구글 로그인 출력화면이다

 

 

7. PrincipalDetails

 1) PrincipalDetails

  • UserDetails를 구현하는 객체로 사용되고 있다

 2) 스프링 시큐리티 세션

  • 시프링 시큐리티 세션 정보는 단 1가지 타입인 Authentication 객체만 가지고 있을 수 있다

 3) Authentication 객체를 담는 2가지 필드

  • OAuth2User 와 UserDetails가 있다
    - 일반적으로 회원가입을 하면 UserDetails를 통해 처리를 하게 된다
    - OAuth2로 회원가입 및 로그인을 하면 OAuth2User를 통해 처리를 하게 된다
  • 여기서 문제가 발생한다
    - OAuth2User와 UserDetails에는 Member 객체를 포함하고 있지 않다

  4) 해결 방법

  • UserDetails를 PrincipalDetails에 implements 한 후에 Member 객체를 담게 한다
    - Authentication 객체를 갖는 PrincipalDetails가 만들어진다
    - PrincipalDetails 객체에는 Member가 포함되어 있다
  • OAuth2User 또한 같은 문제점을 가지고 있고 일반 회원가입 & 로그인 처리와 OAuth2 처리가 별도로 되어 문제가 발생한다
    - PrincipalDetails implements UserDetails, OAuth2User를 통해 문제를 해결한다

 

8. 회원가입

 1) PrincipalDetails 클래스에 코드를 추가 및 수정한다

@Data
public class PrincipalDetails implements UserDetails, OAuth2User {

    private Member member;
    private Map<String, Object> attributes; // 추가

		// 일반 로그인
		public PrincipalDetails(Member member) {
        this.member = member;
    }

		// 추가 & OAuth 로그인
    public PrincipalDetails(Member member, Map<String, Object> attributes) {
        this.member = member;
        this.attributes = attributes;
    }
		...

		@Override
    public Map<String, Object> getAttributes() {
        return attributes; // 수정
    }
}

  • Member 클래스에 코드를 추가한다
@Entity
@Data
@NoArgsConstructor // 추가
public class Member {
	
		@Builder // 추가
    public Member(String username, String email, String role, String provider, String providerId) {
        this.username = username;
        this.email = email;
        this.role = role;
        this.provider = provider;
        this.providerId = providerId;
    }
		
		...
}

  • 후처리 작업이 있는 PrincipalOauth2UserService 클래스의 코드를 수정한다
    - memberEntity
      : null 값일 때는 oauth로 처음 로그인 한 것이므로 회원가입 처리를 해 준다
      : null이 아닌 경우에는 기존에 1번이라도 로그인 한 이력이 있기 때문에 별도 처리를 하지 않는다
package com.memberlogin.loginjoin.config.oauth;

import com.memberlogin.loginjoin.model.Member;
import com.memberlogin.loginjoin.repository.MemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    //코드 추가
    @Autowired
    private MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        //코드 추가
        OAuth2User oauth2User = super.loadUser(userRequest);

        String provider = userRequest.getClientRegistration().getClientId();
        String providerId = oauth2User.getAttribute("sub");
        String username = oauth2User.getAttribute("name");
        String email = oauth2User.getAttribute("email");
        String role = "ROSE_USER";

        Member memberEntity = memberRepository.findByUsername(username);

        if(memberEntity == null) {
            // OAuth로 처음 로그인한 유저 - 회원가입 처리
            memberEntity = Member.builder()
                    .username(username)
                    .email(email)
                    .role(role)
                    .provider(provider)
                    .providerId(providerId)
                    .build();
            memberRepository.save(memberEntity);
        }

        return new PrincipalDetails(memberEntity, oauth2User.getAttributes());
    }
//        System.out.println("userRequest : " + userRequest);
//        return super.loadUser(userRequest);
//    }
}

 

 2) 로그인하여 Member 객체 정보 확인

  • IndexController 클래스에서 /user url을 수정한다
    - 일반 회원가입 & 로그인 → /user url 접속
    - 로그아웃 후 구글 로그인 → /user url 접속
public @ResponseBody String user(@AuthenticationPrincipal PrincipalDetails principalDetails) {
        System.out.println(principalDetails.getMember());
        return "user";
    }

  • 일반 회원가입 & 로그인 → /user url 접속

  • 로그아웃 후 구글 로그인 → /user url 접속

  • 일반 회원가입과 OAuth2 로그인으로 회원가입 처리가 정상적으로 되는 것을 확인할 수 있다

 

9. Index 페이지 통한 로그인 정보 확인

  • 지금까지는 로그인이 정상적으로 되었는지 확인하려면 /user (권한이 있는 경우에만 접근 가능)로 접속해서 확인했다
  • 지금부터는 Index 페이지(/)에서 로그인 되었는지 확인할 수 있도록 코드를 수정, 추가해 본다

 1) IndexController 클래스의 코드를 수정 한다

@GetMapping("/")
    public String index(@AuthenticationPrincipal PrincipalDetails principalDetails, Model model) {

        try {
            if(principalDetails.getUsername() != null) {
                model.addAttribute("username", principalDetails.getUsername());
            }
        } catch (NullPointerException e) {}
        return "index";
    }

 2) src > main > resources > templates 패키지에 index.html 파일을 생성한다

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Index Page 입니다.</title>
</head>
<body>
<h1>Index 페이지 입니다.</h1>
{{#username}}
    <h1>{{username}} 사용자입니다.</h1>
    <a href="/user">유저</a>
    <a href="/logout">로그아웃</a>
{{/username}}
{{^username}}
    <h3>로그인되지 않았습니다.</h3>
    <a href="/login">로그인 페이지로 이동</a>
    <a href="/join">회원가입 페이지로 이동</a>
{{/username}}
</body>
</html>

 3) 프로젝트를 재실행 후 애플리케이션에 접속하면 화면 출력이 변경되었음을 확인할 수 있다

로그인하지 않았을 때 로그인 했을 때

 

1. OAuth2 인증

  • 클라이언트 - 서버 - 제 3의 서비스 사이의 인증 방식이다

OAuth 인증 단계

 1) OAuth 용어

  • Resource Owner : 액세스 중인 리소스의 유저를 말한다
  • Client : Resource Owner 대신 보호된 리소스에 액세스하는 응용프로그램이다 (instagram)
  • Resource server : Client의 요청을 수락하고 응답할 수 있는 서버를 말한다 (kakao)
  • Authorization server : Resource server가 토큰을 발급받는 서버를 말한다 (kakao)
  • Authorization grant : Client가 Access token을 발급받기 위하여 사용하는 자격 증명이며, 4가지 종류가 주로 사용된다
    - Authorization Code Grant Type
    - Client Credentials Grant type
    - Implicit Grant Type
    - Resource Owner Credentials Grant Type
  • Authorization code : Access token을 발급받기 전에 필요한 code이다
    - Client ID로 이 Code를 받아온 후, Client Secret과 Code를 이용해 액세스 토큰을 받아올 수 있다
  • Access token : 보호된 리소스에 액세스하기 위하여 사용되는 credentials(자격 증명)이다
  • Scope : 주어진 Access token으로 액세스할 수 있는 리소스의 범위이다

 

2. OAuth2 동작 방식

 1) 권한 부여 방식에 따른 프로토콜 인증방식

  • Authorization Code Grant Type (권한 부여 승인 코드 방식)
    - 권한 부여 승인을 위해 자체 생성한 Authorization Code를 전달하는 방식이다
    - 가장 많이 사용되는 기본 방식이다
    - 리프레시 토큰이 사용 가능하다
    - 권한 부여 승인 요청시 응답 타입(response_type)을 code로 지정하여 요청한다

Authorization Code Grant Type

 

 

  • Client Credentials Grant : 클라이언트 자격 증명 승인 방식
    - 클라이언트 자신이 관리하는 리소스 혹은 권한 서버에 해당 클라이언트를 위한 제한된 리소스 접근 권한이 설정되어 있는 경우 사용이 가능하다
    - 자격 증명을 안전하게 보관할 수 있는 클라이언트에서만 사용되어야 한다
    - 리프레시 토큰의 사용은 불가능하다

Client Credentials Grant

 

  • Implicit Grant : 암묵적 승인 방식
    - 별도의 권한 부여 승인 코드 없이 바로 액세스 토큰을 발급하는 방식이다
    - 자격증명을 안전하게 저장하기 힘든 클라이언트(자바스크립트 등 스크립트 언어를 사용하는 브라우저)에게 최적화된 방식이다
    - 리프레시 토큰 사용이 불가능하다
    - Authorization Server는 client secret을 통해 클라이언트 인증과정을 생략한다
    - 권한 부여 승인 요청시 응답 타입(response_type)을 token으로 지정하여 요청한다

Implicit Grant

  • Resource Owner Password Credential Grant : 자원 소유자 자격 증명 승인 방식
    - 간단하게 로그인시 필요한 정보(username, password)로 액세스 토큰을 발급받는 방식이다
    - 자신의 서비스에서 제공하는 애플리케이션의 경우에만 사용되는 인증 방식이다
    - 리프레시 토큰의 사용도 가능하다
    - 예를 들어 네이버 계정으로 네이버 웹툰 애플리케이션에 로그인, 카카오 계정으로 카카오 지도 애플리케이션에 로그인하는 경우가 Resource Owner Password Credential Grant에 해당한다
    - 권한 서버, 리소스 서버, 클라이언트가 모두 같은 시스템에 속해 있을 때만 사용이 가능하다

 

 

3. OAuth2 실습

 1) Google API Console

  • 구글 Api Console로 이동한다
    - https://console.cloud.google.com/projectselector2/apis/dashboard?supportedpurview=project

 

  • 새로운 프로젝트를 생성한다
    - 이름은 자유롭게 설정한다
    - 생성한 프로젝트는 만들어지기까지 1분 내외의 시간을 기다려야 한다

  • '사용 설정된 API 및 서비스' 상단에 '프로젝트 선택'을 클릭한다

  • 생성된 프로젝트로 선택 시 API 및 서비스가 나타난다

 

  • 왼쪽 목록에서 OAuth 동의 화면을 클릭한다
    - OAuth 동의 화면에서 User Type은 외부로 한다
    - 앱 등록 수정 페이지에서 앱 이름을 작성한다
    - 본인 이메일을 선택하고 앱 로고를 등록한다
    - 저장 후 계속을 눌러 진행한다
    - 앱 도메인 등 나머지는 별도 설정 해줄 것이 현재 없어서 저장 후 계속을 눌러서 진행한다

  • OAuth 동의 화면이란 무엇인가요?
    - 승인에 OAuth 2.0을 사용하면 앱에서 Google 계정의 여러 액세스 범위에 대한 승인을 요청합니다.
    - Google에서 사용자에게 프로젝트 및 정책 요약과 요청된 액세스 범위가 포함된 동의 화면을 표시합니다.
  • OAuth 동의 범위란 무엇인가요?
    - 범위는 동의 화면에서 사용자를 대신해 액세스 권한을 요청할 사용자 데이터 종류를 정의합니다.
    - 사용 가능한 범위의 전체 목록을 참조하세요.
  • 민감한 범위란 무엇인가요?
    - 민감한 범위는 동의 화면에서 사용자에게 표시되기 전에 Google이 인증을 요청합니다.

  • 저장 후 계속을 클릭하면 '개발자 연락처 정보를 입력하라고 메시지가 표시된다
    - 필수 사항이므로 작성 후 다시 저장 후 계속을 클릭한다

  • 별도의 문제점이 없으면 다시 한번 저장 후 계속을 클릭한다

  • 최종적으로 이상이 없으면 대시보드로 돌아간다

  • 왼쪽 목록에서 사용자 인증 정보를 클릭한다
    - CREATE CREDENTIALS 클릭 후 OAuth 클라이언트 ID를 클릭하여 사용자 인증 정보를 만든다

      - 애플리케이션 유형은 웹 애플리케이션을 선택한다

      - 이름을 입력한다

      - 승인된 리디렉션 URL에 http://localhost:8080/login/oauth2/code/google 를 입력한다

      - 만들기를 클릭한다

  • 구글 로그인이 완료되면 구글 서버 → 애플리케이션 서버로 인증되었다는 코드를 보내준다
    - 코드를 통해서 액세스 토큰을 요청한다
    - 액세스 토큰을 받아 사용자 대신 서버에서 인증 처리를 할 수 있다.

 

  • 만들기 클릭 시 OAuth 클라이언트 ID와 보안 비밀번호가 발급된다
    - 해당 정보를 다른 사람에게 노출/공유해서는 안된다
    - 반드시 정보를 별도로 저장/기록 해 둔다

OAuth 클라이언트 ID/Password 생성
Json 파일

 

  • OAuth 2.0 클라이언트 ID가 생성되었다

1. Filter

  • 스프링 시큐리티는 Servlet Filter를 기반으로 서블릿을 지원한다
  • Filter 는 HTTP 요청과 응답을 변경할 수 있는 재사용 가능한 코드이다
  • 필터는 사용 여부를 지정할 수 있다
    - spring 내부에 보유하고 있는 필터를 자동으로 사용할 수 있다
    - spring 내부에 보유하고 있지만 자동으로 사용하지 않는 필터를 사용하돌록 설정할 수 있다
    - spring 내부에 보유하고 있지 않은 필터를 지정하여 사용할 수 있다
  • @을 사용하거나 .xml 을 사용하여 설정할 수 있다

클라이언트와 서버간 Filter 구조
Spring  Application Server Flow

 

2. FilterChain

  • 여러개의 Filter가 사슬처럼 연결되어 상호 동작하는 것을 의미한다

FilterChain 구조

  • 클라이언트가 앱에 요청을 보내고 컨테이너는 요청 URI의 경로를 기반으로 필터와 서블릿을 적용할지 결정한다
  • 하나의 서블릿은 단일 요청을 처리하지만, 필터는 체인을 형성하여 순서를 지정하며 실제로 요청 자체를 처리하려는 경우 필터가 나머지 체인을 거부 할 수 있다
  • 필터는 다운스트림 필터와 서블릿을 사용해서 요청과 응답을 수정할 수도 있다
  • 필터 체인의 순서는 매우 중요하며 Spring Boot는 두 가지 메커니즘을 통해 이를 관리한다
    - Filter 타입의 @Beans에 @Order를 붙이거나 Orderd를 구현한다
    - API의 일부로 순서를 가지는 FilterRegistrationBean의 일부가 된다
  • 클라이언트는 애플리케이션으로 요청을 전송하고, 컨테이너는 Servlet과 여러 Filter로 구성된 FilterChain을 만들어 요청 URI path 기반으로 HttpServletRequest를 처리한다
  • Filter는 요청이 DispatcherServlet에 의해 다뤄지기 전,후에 동작한다
  • Filter는 FilterChain을 통해 여러 필터가 연쇄적으로(순서) 동작하게 할 수 있다
  • 1개의 Servlet이 HttpServletRequest와 HttpServletResponse를 처리한다
    - Filter는 여러 개를 사용할 수 있다
    - 다운스트림의 Servlet과 Filter의 실행을 막는 경우에는 Filter에서 HttpServletResponse를 작성한다
    - 다운스트림에 있는 Servlet과 여러 Filter로 HttpServletRequest나 HttpServletResponse를 수정한다
  • Filter는 FilterChain 안에 있을 때 효력이 있다

 

3. Filter 인터페이스

  • public void init(FilterConfig filterConfig) throws ServletException
    - 필터를 웹 콘테이너 내에 생성한 후 초기화할 때 호출한다
  • public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws java.io.IOException, ServletException
    - 체인을 따라 다음에 존재하는 필터로 이동한다
    - 체인의 가장 마지막에는 클라이언트가 요청한 최종 resource가 위치한다
  • public void destroy( )
    - 필터가 웹 콘테이너에서 삭제될 때 호출된다
  • 메소드에서 필터의 역할을 하는 메소드가 doFilter() 메소드이다
  • 서블릿 콘테이너는 사용자가 특정 resource를 요청할 경우, resource 사이에 필터가 있으면 필터 객체의 doFilter() 메소드를 호출하며, 호출 시점부터 필터가 작용하기 시작한다

 1) FilterChain에 사용되는 Filter 구현 코드

public class FirstFilter implements Filter {
  
     public void init(FilterConfig filterConfig) throws ServletException {
        // 필터 초기화 작업
     }
     
     public void doFilter(ServletRequest request,
                          ServletResponse response,
                          FilterChain chain)
                          throws IOException, ServletException {
        // 1. request 파리미터를 이용하여 요청의 필터 작업 수행
        // 2. 체인의 다음 필터 처리
        
        chain.doFilter(request, response); 
        // 3. response를 이용하여 응답의 필터링 작업 수행
     }
     
     public void destroy() {
        // 주로 필터가 사용한 자원을 반납
     }
  }

 

4. Filter 실습

 1) 'Spring Security 환경구성 - https://coding-mid-life.tistory.com/71 '에서 작성한 프로젝트를 기본으로 한다

  • Filter 패키지를 만들고 FirstFilter 클래스를 생성한다
    - Filter 인터페이스를 FirstFilter로 구현한다
package com.memberlogin.loginjoin.filter;

import javax.servlet.*;
import java.io.IOException;

public class FirstFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
        System.out.println("FirstFilter 생성됨");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("========First 필터 시작========");
        chain.doFilter(request, response);
        System.out.println("========First 필터 종료========");
    }

    @Override
    public void destroy() {
        System.out.println("FirstFilter 사라짐");
        Filter.super.destroy();
    }
}

 

  • FirstFilter를 적용하기 위한 Config 파일을 작성한다
    - 프로젝트를 재실행하면 init 메서드가 실행되면서 콘솔에 'FirstFilter 생성됨'이 출력된다
    - Controller에 적용된 url로 접속하게 되면 doFilter → controller 동작 → destroy 메서드가 실행되면서  '========First 필터 시작========', '========First 필터 종료========'가 출력된다
package com.memberlogin.loginjoin.filter;

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

@Configuration
public class Config {

    @Bean
    public FilterRegistrationBean<FirstFilter> firstFilterRegister()  {
        FilterRegistrationBean<FirstFilter> registrationBean = new FilterRegistrationBean<>(new FirstFilter());
        return registrationBean;
    }
}

  • Filter를 적용시키기 위해 다양한 옵션(addUrlPatterns, setOrder 메서드)들이 있다
    - registrationBean.addUrlPatterns(”/users/*”);
    - registrationBean.setOrder(1);

 

  • 2개 이상 필터를 적용하기 위하여 SeconfFilter 클래스를 생성한다
package com.memberlogin.loginjoin.filter;

import javax.servlet.*;
import java.io.IOException;

public class SecondFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
        System.out.println("SecondFilter가 생성되었습니다.");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("==========Second 필터 시작==========");
        chain.doFilter(request, response);
        System.out.println("==========Second 필터 종료==========");
    }

    @Override
    public void destroy() {
        System.out.println("SecondFilter가 사라집니다.");
        Filter.super.destroy();
    }
}

 

  • Config 클래스의 내용을 수정한다
    - registrationBean.setOrder() 메서드를 통해 순서를 설정할 수 있다
@Configuration
public class Config {

    @Bean
    public FilterRegistrationBean<FirstFilter> firstFilterRegister()  {
        FilterRegistrationBean<FirstFilter> registrationBean = new FilterRegistrationBean<>(new FirstFilter());
        
        //순서 설정 추가
        registrationBean.setOrder(1);
        
        return registrationBean;
    }

    //SecondFilter 설정 추가
    @Bean
    public FilterRegistrationBean<SecondFilter> secondFilterRegister()  {
        FilterRegistrationBean<SecondFilter> registrationBean = new FilterRegistrationBean<>(new SecondFilter());
        registrationBean.setOrder(2);
        return registrationBean;
    }
}

  • 아래는 재실행 후 콘솔에 출력되는 화면이다

  • 애플리케이션에 접속하면 출력되는 콘솔 화면이다
    - Filter는 다운스트림에 있는 나머지 Filter와 Servlet에만 영향을 주기 때문에 순서가 중요하다

 

5. DelegatingFilterProxy

  • 스프링 시큐리티가 모든 애플리케이션의 요청에 보안이 적용되게 하는 서블릿필터이다
  • 스프링 프레임워크 기반의 웹 애플리케이션에서 서블릿 필터 라이프 사이클과 연계해 스프링 빈 의존성을 서블릿 필터에 바인딩하는데 사용한다
  • 스프링 부트는 DelegatingFilterProxy라는 Filter 구현체로 서블릿 컨테이너의 생명주기와 스프링 ApplicationContext를 연결한다
  • 서블릿 컨테이너는 자체 표준을 사용해서 Filter를 등록할 수 있지만 스프링이 정의하는 Bean은 인식하지 못한다
  • DelegatingFilterProxy는 표준 서블릿 컨테이너 메커니즘으로 등록할 수 있지만, 모든 처리를 Filter를 구현한 스프링 빈으로 위임해 준다

  • DelegatingFilterProxy는 ApplicationContext에서 Bean Filter A를 찾아 실행한다
  • Bean Filter A는 FilterChainProxy가 된다

 

6. FilterChainProxy

  • 스프링 시큐리티는 FilterChainProxy 로 서블릿을 지원한다
  • FilterChainProxy 는 스프링 시큐리티가 제공하는 특별한 Filter로 SecurityFilterChain을 통해 여러 Filter 인스턴스로 위임할 수 있다
  • FilterChainProxy는 빈이기 때문에 보통 DelegatingFilterProxy로 감싸져 있다
  • DelegatingFilterProxy는 서블릿 필터이며, Spring IOC 컨테이너가 관리하는 Filter Bean을 갖고 있다
  • Filter Bean은 FilterChainProxy이며 객체안에서 Security와 관련된 일들이 진행된다 

  • 순수한 Servlet Filter는 본래 Spring Container 외부에 존재한다
  • DelegatingFilterProxy 클래스는 Filter를 Spring Bean으로 사용할 수 있도록 한다
  • DelegatingFilterProxy 클래스(Filter Class)는 Servlet Filter 사이에 존재하고 Spring Bean으로 등록된 Filter에게 처리를 위임한다

 

※ 참조 1

  • FilterChain에서 여러 Filter를 적용할 수 있다
  • Filter 실행 중에 DelegatingFilterProxy가 존재할 수 있다
    - DelegatingFilterProxy 내부에 있는 Bean Filter는 FilterChainProxy가 된다
  • SecurityFilterChain은 FilterChainProxy로 등록된다
  • 여러 개의 SecurityFilterChain이 있을 때 어떤 것을 사용할지는 FilterChainProxy가 결정한다
    - 가장 먼저 매칭한 SecurityFilterChain을 실행한다
    - /api/message/ url 요청
      : SecurityFilterChainn(/)도 일치하지만 SecurityFilterChain0(/api/) 패턴과 제일 먼저 매칭되므로 가장 먼저 매칭되는 SecurityFilterChain0만 실행한다
    - /message/ url 요청
      : 가지고 있는 SecurityFilterChain들을 시도해보지만 매칭되는 SecurityFilterChain 인스턴스가 없다면 SecurityFilterChainn(/)을 실행한다
  • Proxy는 “우회” “대신” 등을 의미하고 소프트웨어 디자인 패턴에서의 프록시 패턴에서 사용하는 의미와 비슷하며 네트워크에서의 프록시 개념과도 일맥상통 한다

※ 참조 2

■ Spring Security Filter 종류

  • Security Filter들은 항상 모든 Filter가 수행되지 않고 프로젝트 구성 및 설정에 따라 일부 Filter만 수행된다
  • 직접적으로 개발자가 핸들링할 필요가 없다
  • 예외적으로 개발자가 Custom Filter를 작성하고 등록할때 기존 필터들 사이에서 수행되어야 할 필요가 있는 경우가 있을때에 참고하여 적용한다
  • 각 Filter의 호출 순서는 위에서부터 아래로 진행된다
    ChannelProcessingFilter
    WebAsyncManagerIntegrationFilter
    SecurityContextPersistenceFilter
    HeaderWriterFilter
    CorsFilter
    CsrfFilter
    LogoutFilter
    OAuth2AuthorizationRequestRedirectFilter
    Saml2WebSsoAuthenticationRequestFilter
    X509AuthenticationFilter
    AbstractPreAuthenticatedProcessingFilter
    CasAuthenticationFilter
    OAuth2LoginAuthenticationFilter
    Saml2WebSsoAuthenticationFilter
    UsernamePasswordAuthenticationFilter
    OpenIDAuthenticationFilter
    DefaultLoginPageGeneratingFilter
    DefaultLogoutPageGeneratingFilter
    ConcurrentSessionFilter
    DigestAuthenticationFilter
    BearerTokenAuthenticationFilter
    BasicAuthenticationFilter
    RequestCacheAwareFilter
    SecurityContextHolderAwareRequestFilter
    JaasApiIntegrationFilter
    RememberMeAuthenticationFilter
    AnonymousAuthenticationFilter
    OAuth2AuthorizationCodeGrantFilter
    SessionManagementFilter
    ExceptionTranslationFilter
    FilterSecurityInterceptor
    SwitchUserFilter

1. JWT 로그인 환경 설정

  • 이전에 작성한 token 프로젝트에 로그인 관련 코드를 추가한다

 1) 로그인

  • oauth 패키지를 만들고 PrincipalDetails 클래스를 생성한다
    - UserDetails 인터페이스를 implements 한다
    - Member 객체와 생성자를 추가한다
package com.json.token.oauth;

import com.json.token.model.Member;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@Data
public class PrincipalDetails implements UserDetails {

    private Member member;

    public PrincipalDetails(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        member.getRoleList().forEach(n -> {
            authorities.add(() -> n);
        });
        return authorities;
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

  • repository 패키지를 만들고 인터페이스 MemberRepository 클래스를 생성한다
    - username을 기준으로 검색할 수 있도록 findByUsername()메서드를 생성한다
package com.json.token.repository;

import com.json.token.model.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    public Member findByUsername(String member);
}

 

  • oauth 패키지에 PrincipalDetailsService 클래스를 생성한다
package com.json.token.oauth;

import com.json.token.model.Member;
import com.json.token.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
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;

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member memberEntity = memberRepository.findByUsername(username);
        return new PrincipalDetails(memberEntity);
    }
}

 

  • Jwt로 로그인 처리를 하기 위해 filter 패키지에 JwtAuthenticationFilter 클래스를 생성한다
package com.json.token.filter;


import lombok.RequiredArgsConstructor;
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 javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        System.out.println("login 시도");
        return super.attemptAuthentication(request, response);
    }
}

 

  • JwtAuthenticationFilter 클래스를 실행하기 위해 SecurityConfig.java를 수정한다
    - 이전에는 .addFilter(new JwtAuthenticationFilter(authenticationManager())) 메서드를 통해 쉽게 처리할 수 있었다
    - 하지만 WebSecurityConfigureAdapter가 deprecated되면서 내부에 클래스를 만들어주거나 별도의 처리가 필요하다
    - CustomDsl 내부 클래스를 만들어 .addFilter(new JwtAuthenticationFilter(authenticationManager())) 처리를 통해 해당 필터를 적용한다
	//주석처리  http.addFilterBefore(new FirstFilter(), BasicAuthenticationFilter.class); // 추가
    
    ......
    
    			.and()
                .formLogin().disable()
                .httpBasic().disable()
                .apply(new CustomDsl()) // 추가
                .and()  //추가
                .authorizeRequests()
                
                ......
                
                     return http.build();
    }

	//추가 코드
    public class CustomDsl extends AbstractHttpConfigurer<CustomDsl, HttpSecurity> {

        @Override
        public void configure(HttpSecurity builder) throws Exception {
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
            builder
                    .addFilter(corsFilter)
                    .addFilter(new JwtAuthenticationFilter(authenticationManager));
        }
    }

      

 http.addFilterBefore(new FirstFilter(), BasicAuthenticationFilter.class);
 
 필히 주석처리 한다
 안할 경우 postman에서 결과가 나오지 않는다

 

 2) 회원가입

  • 회원가입 로직 처리를 위해 RestApiController 클래스에 코드를 추가한다
@RestController
@RequiredArgsConstructor // 추가
public class RestApiController {

	// 추가
    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

		...

	// 추가
    @PostMapping("/join")
    public String join(@RequestBody Member member) {
        member.setPassword(bCryptPasswordEncoder.encode(member.getPassword()));
        member.setRoles("ROLE_USER");
        memberRepository.save(member);
        return "회원 가입 완료";
    }
}

 

  •  BCryptPasswordEncoder 빈 등록을 TokenApplication 클래스에 한다
    - 코드를 작성하면 자동으로 두개의 클래스로 분리되어진다
    - BCryptPasswordEncoder 순환 참조를 막기 위해 JwtApplication에 @Bean으로 등록한다
package com.json.token;

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

 

  • 로그인을 진행하기 위하여 postman에서 POST를 통해 회원가입 요청을 보낸다

 

 3) 회원가입 후 로그인 시도

  • 실행 후 postman으로 body에 username, password를 포함하여 /login post 요청을 합니다.

  • 지금은 username과 password 처리를 하지 않았기 때문에 오류가 발생한다
  • intelliJ 로그를 확인해보면 login 시도(jwAuthenticationFilter가 정상적으로 적용) 후에 오류가 발생하는 것을 확인할 수 있다

  • 이를 해결하기 위해 jwtAuthenticationFilter에서 정상적인 로그인 시도를 하고 JWT 토큰을 만들어서 응답해주는 작업을 진행을 해 본다

 

 4) 사용자 정보 확인

  • request 객체에 사용자 정보가 담겨져 있는데 확인해 보도록 한다
    - JwtAuthenticationFilter 클래스의 코드를 추가한다
    - request에 담긴 정보를 가져와서 출력하는 코드를 추가한다
 try {
            BufferedReader br = request.getReader();

            String input = null;
            while((input = br.readLine()) != null) {
                System.out.println(input);
            }
        } catch (IOException e) {
            e.printStackTrace();;
        }

  • 재실행 후 postman으로 /login POST 요청을 하면 login 시도 문구 다음에 입력된 값을 확인할 수 있다
    - 아래에 오류가 뜨는 것은 정상이다

  • postman으로 /login POST 요청을 할 때 Body에 사용자 정보를 raw, JSON 형태로 보내본다
    - 콘솔에 출력되는형태가 변경되었음을 확인할 수 있다

 

 5) 로그인 처리

  • JwtAuthenticationFilter 클래스의 attemptAuthentication 메서드 코드를 수정한다
    -  기존의 코드를 아래의 코드로 대체한다
    - PrincipalDetailsService의 loadUserByUsername() 메서드가 실행된 후 정상 작동된다면 authentication이 return된다
    - 로그인이 정상적으로 된다면 authentication 객체를 session에 저장한다
    - attemptAuthentication 메서드가 정상적으로 작동하게 되면 successfulAuthentication 메서드를 실행한다
      : 해당 메서드에서 JWT 토큰을 만들어서 요청한 사용자에게 JWT 토큰을 응답으로 돌려준다
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            ObjectMapper om = new ObjectMapper();
            Member member = om.readValue(request.getInputStream(), Member.class);

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

            Authentication authentication = authenticationManager.authenticate(authenticationToken);

            PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
            return authentication;
        } catch (IOException e) {
            e.printStackTrace();;
        }
        return null;
    }

      - 재실행 후 postman에서 login을 요청해도 정상 작동하지 않는다

 

 6) JWT 토큰 생성 후 전달

  • JwtAuthenticationFilter 클래스의 successfulAuthentication 메서드를 오버라이드 한다
@Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {

        System.out.println("successfulAuthentication");
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

        String jwtToken = JWT.create()
                .withSubject("cos jwt token")
                .withExpiresAt(new Date(System.currentTimeMillis() + (60 * 1000 * 10)))
                .withClaim("id", principalDetails.getMember().getId())
                .withClaim("username", principalDetails.getMember().getUsername())
                .sign(Algorithm.HMAC512("cos_jwt_token"));
        response.addHeader("Authorization", "Bearer " + jwtToken);
    }

  • 재실행 후 postman에서 join으로 먼저 등록하고 login을 POST로 요청한다
    - join 후 login 시도 시 Body에는 어떤 결과도 반영되지 않는다
    - 하지만, Headers에 값을 확인해보면 Authorization - Bearer 값이 들어와 있는 것을 확인할 수 있다
    - 로그인을 성공 했을 때 JWT 토큰을 생성한다
    - 클라이언트에 JWT 토큰을 응답을 통해 보낸다
    - 요청할 때마다 JWT 토큰을 가지고 요청하게 되고 서버는 JWT 토큰이 유효한지 필터를 통해 판단하기만 하면 된다

 

2. JWT 인증권한 테스트

  • Security filter 에서 권한 및 인증이 필요한 주소를 요청 시 BasicAuthenticationFilter를 반드시 진행하게 되어있다
  • 권한이나 인증이 필요하지 않을 경우 BasicAuthenticationFilter는 적용되지 않는다

 1) 인증 권한 테스트

  • 인증 권한이 필요한 url에 접속할 때 특정 필터가 적용되도록 테스트 해 본다
  • filter 패키지에 JwtAuthorizationFilter 클래스를 생성한다
    - doFilterInternal 메서드는 인증이나 권한이 필요한 주소 요청이 있을 때마다 해당 필터를 통하게 되어 있다
package com.json.token.filter;

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

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

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

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

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("인증이나 권한이 필요한 주소 요청 됨.");
        super.doFilterInternal(request, response, chain);
    }
}
  • SecurityConfig 클래스에 해당 필터를 적용한다
    - 필터가 적용되면 /api/v1/user , /api/v1/admin 등 접속 권한이 필요한 url에는 모두 로그가 출력되는 것을 확인할 수 있다
 //코드 추가
                    .addFilter(new JwtAuthorizationFilter(authenticationManager));

 

 2) Token을 통한 인증 처리

  • JwtAuthorizationFilter 클래스에 코드를 추가 및 수정한다
    - memberRepository가 필요하여 생성자에 추가했으므로 SecurityConfig에도 수정이 필요하다
    - 요청에 Authorization header 값을 가져와서 토큰을 가지고 있는지 체크한다
    - 토큰이 있더라도 verify() 메서드를 통해 username이 있는지 확인하여 서비스에 등록된 유저인지 확인한다
    - username이 정상적으로 확인된다면 UsernamePasswordAuthenticationToken을 통해 authentication을 설정한다
package com.json.token.filter;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.json.token.model.Member;
import com.json.token.oauth.PrincipalDetails;
import com.json.token.repository.MemberRepository;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

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

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    //코드 추가
    private MemberRepository memberRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, /*코드 추가*/MemberRepository memberRepository) {
        super(authenticationManager);

        //코드 추가
        this.memberRepository = memberRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("인증이나 권한이 필요한 주소 요청 됨.");

        //코드 추가
        String jwtHeader = request.getHeader("Authorization");

        if(jwtHeader == null || !jwtHeader.startsWith("Bearer")) {
            chain.doFilter(request, response);
            return;
        }

        String jwtToken = jwtHeader.replace("Bearer ", "");

        String username = JWT.require(Algorithm.HMAC512("cos_jwt_token")).build().verify(jwtToken).getClaim("username").asString();

        if (username != null) {
            Member memberEntity = memberRepository.findByUsername(username);

            PrincipalDetails principalDetails = new PrincipalDetails(memberEntity);
            Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);

            chain.doFilter(request, response);
        }

        super.doFilterInternal(request, response, chain);
    }
}

 

  • SecurityConfig 클래스를 수정한다
    - JwtAuthorizationFilter를 수정한 후 실행 시 에러가 발생하는 부분을 수정한다
private final MemberRepository memberRepository;
.......
.addFilter(new JwtAuthorizationFilter(authenticationManager, memberRepository)); // 수정

 

 3) 로그인 및 권한 테스트

  • 권한을 확인하기 위해 RestApiController 클래스에 url을 추가한다
		// 추가
    @GetMapping("/api/v1/user")
        public String user() {
            return "user";
        }
		// 추가
    @GetMapping("/api/v1/admin")
        public String admin() {
            return "admin";
        }

  • Postman에 post 요청으로 http://localhost:8080/join에 회원가입을 요청한다

  • 회원가입 정보를 /login에 post 요청으로 로그인을 시도한다
    - Body의 내용은 공백이 정상이다
    - Header에 Authorization 값이 생성되었으며 그 값을 복사한다

  • postman에서 http://localhost:8080/api/v1/user/ 등 권한이 필요한 url에 Header에 복사한 Bearer token 값을 넣고 Get 요청을 보낸다
    - 'user'라는 결과를 응답한다
    - 다른 권한 url인 admin, manager 등에서는 403 (접근 권한 없음)이 출력된다

 

1. 인증 방식

 1) Session & Cookie 인증 방식

 

  • 사용자가 로그인 요청을 보내면 사용자를 확인 후 Session ID를 발급한다
  • 발급한 ID를 이용해 다른 요청과 응답을 처리하는 방식이다
  • 쿠키 인증은 쿠키에 사용자 정보를 담아 서버로 보내게 되는데 HTTP 방식 통신을 사용하는 경우 정보가 유출되기 쉽다
  • 세션 인증은 세션 ID를 보내므로 쿠키에 비해 보안성이 높지만 서버에서 처리를 해야하기 때문에 추가적인 데이터베이스 공간이 필요하므로 점점 커지면 부담이 될 수 있다

 

 2) Token 인증 방식

 

  • 저장소의 필요 없이 로그인 시 토큰을 발급한다
  • 데이터 요청 시에 발급받은 토큰을 헤더를 통해 전달하여 응답 받는 방식이다
  • 쿠키나 세션을 이용한 인증보다 보안성이 강하고 효율적인 인증 방법이다
  • 토큰은 데이터가 인코딩 되어 있어 누구나 디코딩하여 데이터가 유출될 수 있지만 서명 필드가 헤더와 페이로드를 통해 만들어져 데이터 변조 후 재전송을 막을 수 있다
  • statelsess 서버를 만들 수 있다
  • 인증정보를 OAuth로 이용할 수 있다
  • 일반적으로 토큰은 요청 헤더의 Authorization 필드에 담겨져 보내지게 된다
요청 헤더의 Authorization 필드 구조
Authorization: <type> <credentials>

 

 

2. Bearer 인증 테스트

  • Bearer 인증은 JWT or OAuth에 대한 토큰을 사용한다
  • 기초 환경 설정 코드를 작성한 token 폴더를 오픈한다

 1) Filter 생성

  • filter 패키지를 만들고 FirstFilter 클래스를 생성한다
package com.json.token.filter;

import javax.servlet.*;
import java.io.IOException;

public class FirstFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("FirstFilter");
    }
}

 

 

  • SecurityConfig 클래스에 코드를 추가한다
    - addFilterBefore() 또는 addFilterAfter()를 사용해서 특정 필터 전/후로 적용될 수 있게 한다
    http.addFilterBefore(new FirstFilter(), BasicAuthenticationFilter.class); // 추가

 

 2) Token 적용

  • FirstFilter 클래스에 코드를 추가한다
    - HttpServletRequest
     : ServletRequest를 상속한다
     : Http 프로토콜의 request 정보를 서블릿에 전달하기 위한 목적으로 사용한다
     : Header 정보, Parameter, cookie, URI, URL 등의 정보를 읽어들이는 메서드를 가진 클래스이다
     : Body의 Stream을 읽어들이는 메서드를 가지고 있다

    - HttpServletResponse
     : ServletResponse를 상속한다
     : Servlet이 HttpServletResponse 객체에 Content Type, 응답코드, 응답 메세지 등을 담아서 전송한다

    - HttpServlerRequest, HttpServletResponse 는 http 요청을 할 때 요청 정보가 해당 객체에 있기 때문에 사용한다
package com.json.token.filter;

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

public class FirstFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        //System.out.println("FirstFilter");

        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;

        res.setCharacterEncoding("UTF-8");
        if(req.getMethod().equals("POST")) {
            String headerAuth = req.getHeader("Authorization");

            if(headerAuth.equals("codestates")) {
                chain.doFilter(req, res);
            } else {
                PrintWriter writer = res.getWriter();
                writer.println("인증 실패");
            }
        }
    }
}

 

  • POST 테스트를 위해 RestApiController 클래스에 코드를 추가한다
    @PostMapping("/token")
    public String token() {
        return "<h1>token</h1>";
    }

 

3. 테스트

 1) postman 에 http://localhost:8080/token 으로 POST 요청을 한다

  • FirstFilter 클래스에 작성한 KEY : Authorization 와 VALUE : codestates 를 입력하고 전송한다
  • 'token' 값을 전송받는다

 2) postman 에 http://localhost:8080/token 으로 POST 요청을 한다

  • 임의의 KEY : Authorization 와 VALUE : kim 를 입력하고 전송한다
  • 허용하지 않은 값으로 Authorization이 올 경우에는 '인증 실패' 값을 전송받는다

 

4. 토큰 처리

  • ID, Password가 정상적으로 들어와 로그인이 완료되면 토큰을 만들어주고 응답으로 넘겨 준다
  • 이후 요청이 올 때 header에 Authorization에 있는 토큰 값만 가져와서 확인한다
  • 넘어온 토큰이 우리가 발행한 토큰이 맞는지만 검증하면 된다

 

※ 참조 링크 

▶ 필터 이해하기 : https://atin.tistory.com/590

 

[Spring Security] 필터 Filter, SecurityFilterChain 이해하기

Spring Security를 커스터마이징하기 위해서는 그리고 이해하기 위해서는 아래 필터 체인을 이해하는 것이 좋다. 아래 그림은 인터넷에 돌아다니는 Spring Security 호출 그림을 내가 다시 깔끔하게 그

atin.tistory.com

 

1. 토큰(Token)

  • 유저의 정보를 암호화한 상태로 저장한 파일이다
  • 암호화 되어 있어서 클라이언트에 저장하여도 보안이 형성될 수 있다

 1) 토큰기반 사용 목적

  • 세션기반 인증은 서버나 DB에 유저의 정보를 담는 방식이다
  • 정보를 요청할 때마다 세션을 생성해서 DB에 일치 여부를 확인한 후 정보를 전송한다
  • 이러한 불편함을 간소화 하기 위하여 토큰기반 인증을 사용한다
  • 대표적인 토큰기반 인증방식으로는 JWT(JSON Web Token)이 있다

 

2. JWT(JSON Web Token)

 1) JWT 구성 및 진행 절차

  • Json 포맷으로 사용자에 대한 속성을 저장하는 웹 토큰이다
  • Json 객체를 basse64 방식으로 인코딩하면 아래와 같이 토큰이 생성된다
    - Header, Payload, Signature 총 3개의 블록으로 구성되어 있다

JWT 구성

  • HMAC SHA256 알고리즘(암호화 방법 중 하나)을 사용한다면 Signature는 아래와 같은 방식으로 생성된다
HMACSHA256(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret);
  • 토큰기반의 인증 절차
    - 클라이언트가 서버에 아이디/비밀번호를 담아 로그인 요청을 보낸다
    - 서버는 아이디/비밀번호가 일치하는지 확인하고, 클라이언트에게 보낼 암호화 된 토큰을 생성한다
      : 액세스 / 리프레시 토큰을 모두 생성한다
      : 토큰에 담길 정보(payload)는 유저를 식별할 정보, 권한이 부여된 카테고리(사진, 연락처, 기타 등등)가 될 수 있다
      : 두 종류의 토큰이 같은 정보를 담을 필요는 없다
    - 토큰을 클라이언트에게 보내주면, 클라이언트는 토큰을 저장한다
      : 저장하는 위치는 Local Storage, Session Storage, Cookie 등 다양하다
    - 클라이언트가 HTTP 헤더(Authorization 헤더) 또는 쿠키에 토큰을 담아 서버에 보낸다
      : bearer authentication을 이용한다
    - 서버는 토큰을 해독하여 발급해 준 토큰이 맞으면, 클라이언트의 요청을 처리한 후 응답을 보내준다

 

 

 2) JWT 종류

  • 액세스 토큰(Acess Token)
    - 보호된 정보들(유저의 이메일, 연락처, 사진 등)에 접근할 수 있는 권한부여에 사용한다
    - 클라이언트가 처음 인증을 받게 될 때(로그인 시), 액세스 토큰과 리프레시 토큰 두 가지를 모두 받는다
    - 실제로 권한을 얻는 데 사용하는 토큰은 액세스 토큰이다
    - 액세스 토큰은 리프레시 토큰에 비해 탈취에 대비하여 짧은 유효기간을 가지고 있다
    - 유효기간 만료시에는 리프레시 토큰을 이용하여 신규 발급을 받는다
    - 신규 발급 후에는 별도의 로그인은 필요없다
  • 리프레시 토큰(Refresh Token)
    - 클라이언트가 처음 인증을 받게 될 때(로그인 시), 액세스 토큰과 리프레시 토큰 두 가지를 모두 받는다
    - 액세스 토큰에 비해 유효기간이 길다
    - 리프레시 토큰을 사용하지 않는 경우가 많다

 

 3) JWT 기반 인증의 장점

  • Statelessness & Scalability (무상태성 & 확장성)
    - 서버는 토큰의 신뢰성만 확인하면 되고 클라이언트의 정보를 저장하지 않아도 된다
    - 토큰을 헤더에 추가하는 것으로 인증절차를 완료한다
    - 하나의 토큰으로 여러 개의 서버에 사용이 가능하다
  • 안정성
    - 암호화 한 토큰을 사용하므로 안전하다
    - 함호화 키를 노출할 필요가 없다
  • 생성의 자율성
    - 토큰을 생성하는 서버가 토큰을 만들지 않아도 사용이 가능하다
    - 토큰만을 생성하는 서버나 외부 서버에서 생성한 토큰을 사용할 수 있다
  • 권한 부여에 용이
    - 토큰의 payload 안에 접근 가능한 정보의 권한을 지정할 수 있다
    - 연락처만 가능, 갤러리와 포토만 가능 등...

 

 4) JWT 기반 인증의 단점

  • Payload는 해독할 수 있다
    - Payload는 base64로 인코딩 된다
    - 토큰을 탈취하여 Payload를 해독하면 토큰 생성시 저장한 데이터를 확인할 수 있다
    - Payload에는 중요한 정보를 넣지 않도록 한다
  • 토큰의 길이가 길어지면 네트워크에 부하를 줄 수 있다
    - 토큰에 저장하는 정보의 양이 많아질 수록 토큰의 길이는 길어진다
    - 요청할 때마다 길이가 긴 토큰을 함께 전송하면 네트워크에 부하를 줄 수 있다
  • 토큰은 자동으로 삭제되지 않는다
    - JWT는 상태를 저장하지 않기 때문에 한 번 생성된 토큰은 자동으로 삭제되지 않는다
    - 토큰 만료 시간을 반드시 추가해야 한다
    - 토큰이 탈취된 경우 토큰의 기한이 만료될 때까지 대처가 불가능하므로 만료 시간을 너무 길게 설정하지 않는다
  • 토큰은 어딘가에 저장되어야 한다
    - 토큰은 클라이언트가 인증이 필요한 요청을 보낼 경우에 함께 전송할 수 있도록 저장되어 있어야 한다

 

3. JWT 인증 환경 구성

 1) 프로젝트 생성

 

 2) 환경 설정

  • Certificate > token > src 의 buiold.gradle 의 코드 구성을 확인한다
plugins {
	id 'org.springframework.boot' version '2.7.2'
	id 'io.spring.dependency-management' version '1.0.12.RELEASE'
	id 'java'
}

group = 'com.json'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
	useJUnitPlatform()
}
 
 
  • buiold.gradle에 jwt 의존성을 추가해 준다
    - gradle은 추가 작업 후에는 항상 재실행을 해 준다
implementation 'com.auth0:java-jwt:3.19.2'

 

  • Certificate > token > src > main > resources 에 H2 서버 구성을 위하여 application.yml을 추가한다
spring:
  h2:
    console:
      enabled: true
      path: /h2
  datasource:
    url: jdbc:h2:mem:test
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
 
 
  • Certificate > token > src > main > java > com.json.token  아래에 controller 패키지를 만들고 RestApiController 클래스를 생성한다
package com.json.token.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>";
    }
}

 

  • 프로젝트 실행 및 웹 접속을 테스트 한다
localhost:8080/login localhost:8080/home
  
 
 
 3) JWT - security 설정
  • Certificate > token > src > main > java > com.json.token  아래에 model 패키지와 Member.java 를 생성한다
    -
    getRoleList는 역활을 ,로 구분하여 여러개를 넣기 때문에 사용한다
    - Role 모델을 추가하여 getRoleList를 대체할 수 있다
package com.json.token.model;

import lombok.Data;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

@Data
@Entity
public class Member {

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

    public List<String> getRoleList() {
        if(this.roles.length() > 0) {
            return Arrays.asList(this.roles.split(","));
        }
        return new ArrayList<>();
    }
}
 
 
  • Certificate > token > src > main > java > com.json.token  아래에 config 패키지를 만들고 SecurityConfig 클래스를 생성한다
    - JWT를 사용하기 위한 기본 설정
     : JWT는 headers에 Authorization 값에 토큰을 보내는 방식이다. (⇒ Bearer)
     : 토큰 정보는 노출되면 안되지만 노출되게 되더라도 유효 시간을 지정해뒀기 때문에 큰 위험이 없다 : 

    - .http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
     : Web은 기본적으로 stateless인데 seesion이나 cookie를 사용할 수 있다
     : session / cookie를 만들지 않고 STATELESS로 진행하겠다는 의미이다


    - .formLogin().disable()
     : form Login을 사용하지 않는다

    - .httpBasic().disable()
     : http 통신을 할 때 headers에 Authorization 값을 ID, Password를 입력하는 방식이다
     : https를 사용하면 ID와 Password가 암호화되어 전달된다
     : http 로그인 방식을 사용하지 않는다
package com.json.token.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;


@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                .httpBasic().disable()
                .authorizeRequests()
                .antMatchers("/api/v1/user/**")
                .access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                .antMatchers("/api/v1/manager/**")
                .access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                .antMatchers("/api/v1/admin/**")
                .access("hasRole('ROLE_ADMIN')")
                .anyRequest().permitAll();
        return http.build();
    }
}
 
 
  •  config 패키지에 CorsConfig 글래스를 생성한다
    - .setAllowCredentials()
     : 서버가 응답할 때 json을 자바스크립트에서 처리할 수 있게 설정해 준다

    - .addAllowedOrigin(”*”)
     : 모든 ip에 응답을 허용한다

    - .addAllowedHeader(”*”)
     : 모든 header에 응답을 허용한다

    - .addAllowedMethod(”*”)
     : 모든 post, get, patch, delete 요청을 허용한다
package com.json.token.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();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/api/**", config);

        return new CorsFilter(source);
    }
}
 
 
  • SecurityConfig 클래스에 코드를 추가한다
@RequiredArgsConstructor //@ 추가

public class SecurityConfig {

    private final CorsFilter corsFilter; //코드 추가

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()

                .addFilter(corsFilter) // 추가
                
                .formLogin().disable()
 
 
  • 프로젝트를 재실행 후 웹에 접속한다
    - localhost:8080 , localhost:8080/login 등... 접속은 404 에러가 발생한다

      - localhost:8080/home 는 로그인 없이 접속된다

      - http://localhost:8080/api/v1/user, http://localhost:8080/api/v1/manager, http://localhost:8080/api/v1/admin 는 403에러가 발생한다

http://localhost:8080/api/v1/user http://localhost:8080/api/v1/manager http://localhost:8080/api/v1/admin

 

 

 

※ 참조 링크

▶ JWT : 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

 Bearer token : https://learning.postman.com/docs/sending-requests/authorization/#bearer-token

 

Authorizing requests | Postman Learning Center

Authorizing requests: documentation for Postman, the collaboration platform for API development. Create better APIs—faster.

learning.postman.com

https://datatracker.ietf.org/doc/html/rfc6750

 

RFC 6750 - The OAuth 2.0 Authorization Framework: Bearer Token Usage

 

datatracker.ietf.org

 

 

1. Spring Security

  • Spring Framework 기반으로 애플리케이션의 인증(Authentication)과 인가(Authorization or 권한 부여) 기능을 가진 프레임워크이다
  • 모든 필수 파일은 애플리케이션에 포함되어 있으며, 애플리케이션이 독립적으로 작동하는 것을 목표로 한다
  • Java Runtime Environment에 특별한 구성 파일을 배치할 필요가 없다
  •  JAAS(Java Authentication and Authorization Service) 정책 파일의 구성이 필요 없다
  • Spring Security를 ​​공통 클래스 경로 위치에 배치하지 않아도 된다
  • 한 시스템에서 다른 시스템으로 대상 아티팩트(JAR, WAR 또는 EAR)를 복사함과 동시에 작동하므로 최대 배포 시간의 유연성을 제공한다
  • Apache 2.0 라이센스 하에 출시된 오픈 소스 소프트웨어이다
  • Java 8 이상의 환경이 필요하다
  • Spring Security는 종속성을 Maven 아티팩트로 배포한다

 

2. Spring Security 용어

  • 주체(Principal)
    - 유저, 기기, 시스템 등이 될 수 있지만, 일반적으로 유저(사용자)를 의미한다
  • 인증(Authentication)
    - 특정 리소스에 접근하려고 하는 사용자가 누구인지 확인하는 경우에 사용한다
    - 주체의 신원(identity)을 증명하는 과정이다
    - 주체는 자신을 인증하기 위해 신원 증명 정보(credential)을 제시한다
    - 주체가 유저일 경우 신원 증명 정보는 패스워드(password)이다
  • 인가(Authorization = 권한 부여)
    - 인증을 마친 유저에게 권한(authority)을 부여하여 애플리케이션의 특정 리소스에 접근할 수 있게 허가하는 과정이다
    - 인가는 반드시 인증 과정 이후 수행되어야 한다
    - 권한은 롤 형태로 부여한다
  • 접근 통제(Access control)
    - 어떤 유저가 애플리케이션 리소스에 접근하도록 허락할지를 제어하는 행위이다
    - 접근 통제 결정(access control decision)이 필요하다
    - 리소스의 접근 속성과 유저에게 부여된 권한 또는 다른 속성들을 결정한다

 

3. Spring Security 제공 기능

  • 모든 요청에 대해서 인증을 요구한다
  • 사용자 이름과 암호를 가진 사용자가 양식 기반으로 인증할 수 있도록 허용한다
  • 사용자의 로그아웃을 허용한다
  • CSRF(Cross-Site Request Forgery) 공격을 방지한다
    - CSRF 공격은 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 공격을 의미한다
  • Session Fixsation(세션 고정 공격)을 보호 한다
    - Session Fixsation은 공격자가 자신의 세션 id를 다른 사용자에게 넘겨주고 공격하게 하는 방법을 의미한다
  • 보안 헤더 통합
    - HSTS 강화
    - X-Content-TypeOptions
    - 캐시 컨트롤(정적 리소스 캐싱)
    - X-XSS-Protection XSS 보안
     : 스크립트 공격 보안
    - 클릭재킹을 방지하는 X-Frame 옵션 통합
     : 클릭재킹이란 웹 사용자가 클릭하고 있는 것에 다른 것을 대체하여 클릭하도록 속이는 기법으로 공격자는 비밀 정보 유출이나 컴퓨터에 대한 제어권을 획득할 수 있게 된다
  • Servlet API 제공

 

 

 

 

※ 참조 링크

Spring Security Tutorial : https://docs.spring.io/spring-security/reference/index.html

 

Spring Security :: Spring Security

If you are ready to start securing an application see the Getting Started sections for servlet and reactive. These sections will walk you through creating your first Spring Security applications. If you want to understand how Spring Security works, you can

docs.spring.io

Spring Security Q&A

 : https://stackoverflow.com/questions/tagged/spring-security

 

Newest 'spring-security' Questions

Stack Overflow | The World’s Largest Online Community for Developers

stackoverflow.com

 : https://github.com/spring-projects/spring-security/issues

 

GitHub - spring-projects/spring-security: Spring Security

Spring Security. Contribute to spring-projects/spring-security development by creating an account on GitHub.

github.com

Spring Security Source Code : https://github.com/spring-projects/spring-security/

 

GitHub - spring-projects/spring-security: Spring Security

Spring Security. Contribute to spring-projects/spring-security development by creating an account on GitHub.

github.com

 HSTS(HTTP Strict Transport Security) 기능

 : https://m.blog.naver.com/PostView.nhn?blogId=aepkoreanet&logNo=221575708943&proxyReferer=https:%2F%2Fwww.google.com%2F 

 

HSTS(HTTP Strict Transport Security) 기능

HSTS(HTTP Strict Transport Security) HSTS(HTTP Strict Transport Security)는, ...

blog.naver.com

클릭재킹 : https://ko.wikipedia.org/wiki/%ED%81%B4%EB%A6%AD%EC%9E%AC%ED%82%B9

 

클릭재킹 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 클릭재킹(Clickjacking, User Interface redress attack, UI redress attack, UI redressing)은 웹 사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 어떤 것을 클릭하게 속이는

ko.wikipedia.org

 

+ Recent posts