본문 바로가기

JAVA

Spring boot Jwt 개념정리

이글은 단순히 개념을 정리한 글입니다. 

 

JWT를 이용한 인증 과정


기존의 세션기반 인증방식은 사용자가 로그인 되어있는지에 대한 정보를 세션이라는 이름으로 서버에 저장해두었습니다.

보통은 메모리에 세션의 정보를 저장해두며 이로인해 너무 많은 유저의 정보가 저장된다면 메모리에 부담이 된다는 단점을 가지고있습니다. 

이에반해 JWT를 이용한 인증방식은 토큰이라는 것을 이용하여 유저의 정보를 토큰에 저장해두고 클라이언트에게 전송하며 따로 서버에 저장하진 않습니다!

서버에 저장해두지않기때문에 메모리 부담이 적지만 토큰을 탈취당하면 토큰만으로는 부정한 요청인지 알방법이 없습니다.

그래서 토큰의 주기를 짧게 가져가고 다른 토큰을 하나 더 생성하여 긴 주기를 가지게 하여 redis에 토큰의 정보를 저장하는 방법을 많이 택하고 있습니다!

짧게 주기를 가져간 토큰을 Access Token이라고 하면 만료된 Access Token을 재발행(reissue) 하기도 하며

긴 주기를 가지는 토큰을 Refresh Token이라고 한다.

출처: https://onejunu.tistory.com/137

1. 사용자는 URL/auth/loginemail과 passwordpost요청으로 보냅니다.

2. 스프링 시큐리티의 핵심 로직으로 DB로 email과 password를 인증하고 통과하면

Access Token과 Refresh Token을 발급합니다.

3. 사용자는 일반 데이터요청을 Access Token과 함께 보냅니다.

4. 서버는 Access Token을 검증하고 통과하면 데이터 응답을 보냅니다.

5. 사용자가 만료된 Access Token을 이용해 요청을 보내면 서버는 재발행(reissue)  요청을 합니다.

6. 재발행 요청은 만료된 Access Token과 유효한 Refresh Token값을 URL/auth/reissuepost 요청을 보냅니다.

7. 서버에서는 Refresh Token을 검증하고 다시 Access Token과 Refresh Token값을 사용자에게 넘겨줍니다. 

출처: https://onejunu.tistory.com/137

 

 

SpringSecurity 아키텍쳐


Authentication

Spring Security에서 인증에 관한 메인 인터페이스는 AuthenticationManager 입니다. AuthenticationManager에서 authenticate라는 메소드가 1개 존재합니다. 메소드는 3가지 일을 한다고 하는데

먼저 인증에 통과한다면 authenticated라는 필드가 true 인 Authentication을 리턴합니다.

Authentication의 구현체인 UserNamePasswordAuthenticationToken이 있지만

저는 해당 토큰 또한 커스터마이징 해보고 그리고 Principal이 유효하지않다면 AuthenticationException을 던진다.

이러한 경우에도 해당하지않는다면 null을 리턴합니다. 

 

AuthenticationManager의 구현체가 ProviderManager입니다. 또한 ProviderManager 는 AuthenticationProvider의 체인을 갖고있으며 각각의 Authentication Provider는 어떤 Authentication Instance를 support하는지에 대한 support함수를 가지고있고 실제로 Authentication instance를 인증하는 Authencate메서드를 가지고있습니다. 

출처 : https://onejunu.tistory.com/137
출처: https://onejunu.tistory.com/137

ProviderManager를 저렇게 복잡하게 부모 구조를 가질필요있나 싶었지만 왜 필요하냐면

리소스들의 논리적인 그룹 패턴은 URL처럼 생겼다고 하면 각각의 부모자식을 나타낼수있습니다.

예를들어 api/a/b/c의 부모는 api/a/b인것처럼 말입니다. 실제로 ProviderManager의 일부를 보면 부모을 갖고있습니다.

출처: https://onejunu.tistory.com/137

앞으로의 구현에서는 AuthenticationProvider를 implement해서 Authentication instance를

support하는 커스터 마이징 된 AuthenticationProvider를 구현해 보겠습니다.

 

Spring Security는 어떻게 보면 하나의 필터이지만 필터체인이라고 하는것에게 인증을 위임합니다.

DelegatingFilterProxy라고 하는 컨테이너 안에 보통 필터들이 installed되기도 하고 fileterChainProxy에 위임합니다. 

그리고 SpringSecurityFilterChain라고 하는 고정된 이름을 사용합니다.

출처: https://onejunu.tistory.com/137

 

 

출처: https://onejunu.tistory.com/137

위에서 처럼 실제로 보면 Spring Security Filter Chein라는 이름의 확인 할수있습니다.

그리고 FilterChainProxy는 request path에 따라 다양한 필터들을 가질수있습니다. Container인 Delegating FilterProxy 는 알길이없다. 

 

출처: https://onejunu.tistory.com/137

지금까지는  대략적인 아키텍처를 파악하였습니다. 

 

 

 

SpringSecurity를 이용하여 구현 

 


 

 

Member.java

인증에 필요한 멤버 Entity를 만들어 줍니다. Email을 통해 멤버를 찾아와 하므로 unique속성을 True해줍니다.

멤버의 권한들을 Set으로 선언하였으며 @ManyToMany로 매핑을 하였습니다. 

새로운 권한이 생긴다고 하여 멤버에 종속적으로 변화가 일어날거같지는 않아 멤버 Entity안에서 메소드를 통해

권한을 추가하고 제거하는 로직을 넣습니다. 

package com.memo.backend.domain.member;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.memo.backend.domain.Authority.Authority;
import com.memo.backend.domain.Authority.MemberAuth;
import com.memo.backend.dto.member.MemberUpdateDTO;
import lombok.*;
import org.springframework.security.crypto.password.PasswordEncoder;

import javax.persistence.*;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;


@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "member")
@Entity
public class Member {

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

    @Column(name = "username",length = 50,nullable = false)
    private String username;

    // Email 을 토큰의 ID로 관리하기 때문에 unique = True
    @Column(name = "email",unique = true,nullable = false)
    private String email;

    @Column(name = "password",nullable = false)
    private String password;

    @JsonIgnore
    @Column(name = "activated")
    private boolean activated;

    @ManyToMany
    @JoinTable(
            name = "member_authority",
            joinColumns = {@JoinColumn(name="member_id",referencedColumnName = "member_id")},
            inverseJoinColumns = {@JoinColumn(name = "authority_name",referencedColumnName = "authority_name")}
    )
    private Set<Authority> authorities = new HashSet<>();

    @Builder
    public Member(String username, String email, String password, boolean activated,Set<Authority> authorities) {
        this.username = username;
        this.email = email;
        this.password = password;
        this.activated = activated;
        this.authorities = authorities;
    }

    public void addAuthority(Authority authority) {
        this.getAuthorities().add(authority);
    }

    public void removeAuthority(Authority authority) {
        this.getAuthorities().remove(authority);
    }

    public void activate(boolean flag) {
        this.activated = flag;
    }

    public String getAuthoritiesToString() {
        return this.authorities.stream()
                .map(Authority::getAuthorityName)
                .collect(Collectors.joining(","));
    }

    public void updateMember(MemberUpdateDTO dto, PasswordEncoder passwordEncoder) {
        if(dto.getPassword() != null) this.password = passwordEncoder.encode(dto.getPassword());
        if(dto.getUsername() != null) this.username = dto.getUsername();
        if(dto.getAuthorities().size() > 0) {
            this.authorities = dto.getAuthorities().stream()
                    .filter(MemberAuth::containsKey)
                    .map(MemberAuth::get)
                    .map(Authority::new)
                    .collect(Collectors.toSet());
        }
    }
}

  


멤버 정보의 엔터티 입니다.

Authority.java

package com.memo.backend.domain.Authority;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@Table(name = "authority")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Authority {
    @Id
    @Column(name = "authority_name",length = 50)
    @Enumerated(EnumType.STRING)
    private MemberAuth authorityName;

    public String getAuthorityName() {
        return this.authorityName.toString();
    }
}

 


 

 

MemberAuth.java

 

MemberAuth는 멤버들의 권한정보를 String으로 관리하디 보다는 Enum으로 타입을  지정해주고

String 으로 권한을 찾을일이 많을거같아 Map처럼 사용할수있도록 Lookup맵을 생성해서

조회 및 포함여부를 쉽게 알수있도록 작성하였습니다.

package com.memo.backend.domain.Authority;

import java.util.HashMap;
import java.util.Map;

public enum MemberAuth {
    ROLE_USER("ROLE_USER"),
    ROLE_ADMIN("ROLE_ADMIN"),

    ;

    private final String abbreviation;

    private static final Map<String,MemberAuth> lookup = new HashMap<>();

    static {
        for(MemberAuth auth : MemberAuth.values()) {
            lookup.put(auth.abbreviation,auth);
        }
    }

    // private
    MemberAuth(String abbreviation) {
        this.abbreviation = abbreviation;
    }

    public String getAbbreviation() {
        return this.abbreviation;
    }

    public static MemberAuth get(String abbreviation) {
        return lookup.get(abbreviation);
    }

    public static boolean containsKey(String abbreviation) {
        return lookup.containsKey(abbreviation);
    }

}

 

 

 


 

Refresh Token

 

Refresh Token을 RDBMS로 관리하기 위해 엔티티를 생성합니다. Redis가 일반적이지만 편의상 RDBMS로 관리합니다.

package com.memo.backend.domain.jwt;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

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

@Getter
@NoArgsConstructor
@Table(name = "refresh_token")
@Entity
public class RefreshToken {

    @Id
    private String key;

    @Column(nullable = false)
    private String value;

    public void updateValue(String token) {
        this.value = token;
    }

    @Builder
    public RefreshToken(String key, String value) {
        this.key = key;
        this.value = value;
    }
}

 

 

 

 

Member,Authority, RefreshToken은 JpaRepository를 이용합니다.

package com.memo.backend.domain.member;

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

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member,Long> {
    Optional<Member> findByEmail(String email);
    boolean existsByEmail(String email);
}
package com.memo.backend.domain.Authority;

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

import java.util.Optional;

public interface AuthorityRepository extends JpaRepository<Authority,String> {
    Optional<Authority> findByAuthorityName(MemberAuth authorityName);
}
package com.memo.backend.domain.jwt;

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

import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken,Long> {
    Optional<RefreshToken> findByKey(String key);
}

 

이로써 인증을 시험하기 위한 도메인들은 모두 생성하였습니다. 

JWT에서 가장 중요한 Token을 생성하고 Token을 인증하고 Token으로부터 Authentication객체를 가져오도록 도와주는

TokenPrivider객체를 생성합니다.

 

반응형