본문 바로가기
Backend/Spring Security

Spring Security + JWT

by 박상윤 2023. 12. 9.

인증

인증(authentication)은 사용자가 누구인지 확인하는 단계를 의미한다. 인증의 대표적인 예로 '로그인'이 있다.

로그인은 데이터베이스에 등록된 아이디와 패스워드를 사용자가 입력한 아이디와 비밀번호와 비교해서 일치 여부를 확인하는 과정이다.

로그인에 성공하면 애플리케이션 서버는 응답으로 사용자에게 토큰을 전달한다.

로그인에 실패한 사용자는 토큰을 전달받지 못해 원하는 리소스에 접근할 수 없다.

 

접근 주체

접근 주체는 말그대로 애플리케이션의 기능을 사용하는 주체를 의미한다. 접근 주체는 사용자가 될 수도 있고, 디바이스, 시스템 등이 될 수도 있다. 애플리케이션은 앞서 소개한 인증 과정을 통해 접근 주체가 신뢰할 수 있는지 확인하고, 인가 과정을 통해 주체에게 부여된 권한을 확인하는 과정 등을 거친다.

 

스프링 시큐리티

애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 하위 프로젝트 중 하나이다. 보안과 관련된 많은 기능을 제공하기 때문에

스프링 시큐리티를 활용하면 더욱 편리하게 원하는 기능을 설계 할 수 있다.

 

스프링 시큐리티의 동작 구조

스프링 시큐리티는 서블릿 필터(Servlet Filter)를 기반으로 동작한다.

DispatcherServlet 앞에 필터가 배치되어있다.

 

필터체인(FilterChain)은 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미한다.

클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URI를 확인해서 필터와 서블릿을 매핑한다.

스프링 시큐리티는 사용하고자 하는 필터체인서블릿 컨테이너의 필터 사이에서 동작시키기 위해 DelegatingFilterProxy를 사용한다.

 

DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨택스트(Application context) 사이에서 다리 역할을 수행하는 필터 구현체이다. 표준 서블릿 필터를 구현하고 있고, 역할을 위임할 필터체인 프록시를 내부에 가지고 있다.

필터체인 프록시는 스프링 부트의 자동 설정에 의해 자동 생성된다.

 

필터체인 프록시는 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter)를 사용할 수 있다. 필터체인 프록시에서 사용할 수 있는 보안 필터 체인은 List 형식으로 담을 수 있게 설정돼 있어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용하게 된다.

 

보안 필터체인은 WebSecurityConfigurerAdapter 클래스를 상속받아 설정할 수 있다. 앞에서 이야기 한 것처럼 필터체인 프록시는 여러 보안 필터체인을 가질 수 있는데, 여러 보안 필터체인을 만들기 위해서는 WebSecurityConfigurerAdapter 클래스를 상속받는 클래스를 여러 개 생성하면 된다. (구 버전)

 

이때 WebSecurityConfigurerAdapter 클래스에는 @Order 어노테이션을 통해 우선순위가 지정돼 있는데, 2개 이상의 클래스를 생성했을 때 똑같은 설정으로 우선순위가 100이 설정돼 있으면 예외가 발생하기 때문에 상속받은 클래스에 @Order 어노테이션을 지정해 순서를 정의하는 것이 중요하다.

 

별도의 설정이 없다면 스프링 시큐리티에서는 SecurityFilterChain에서 사용하는 필터 중 UsernamePasswordAuthenticationFilter를 통해 인증을 처리한다.

 

(1) 클라이언트로부터 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고 그중 UsernamePasswordAuthenticationFilter(그림에서 AuthenticationFilter)에서 인증을 처리한다.

 

(2) AuthenticationFilter는 요청 객체(HttpServletRequest)에서 username과 password를 추출해서 토큰을 생성한다.

 

(3) AuthenticationManager에게 토큰을 전달한다. AuthenticationManager는 인터페이스이며, 일반적으로 사용되는 구현체는 ProviderManager이다.

 

(4) ProvideManager는 인증을 위해 AuthenticationProvider로 토큰을 전달한다.

 

(5) AuthenticationProvider는 토큰의 정보를 UserDetailsService에 전달한다.

 

(6) UserDetailsService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성한다.

 

(7) 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고, 성공하게 되면 ProviderManager로 권한을 담은 토큰을 전달한다.

 

(8) ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다.

 

(9) AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장한다.

 

이러한 과정에서 사용된 UsernamePasswordAuthenticationFilter는 접근 권한을 확인하고, 인증이 실패할 경우 로그인 폼이라는 화면을 보내는 역할을 수행한다. 

 

JWT

JWT(JSON Web Token)는 당사자 간에 정보를 JSON형태로 안전하게 전송하기 위한 토큰이다.

JWT는 URL로 이용할 수 있는 문자열로만 구성돼 있으며, 디지털 서명이 적용돼 있어 신뢰할 수 있다.

JWT는 주로 서버와의 통신에서 권한 인가를 위해 사용된다. URL에서 사용할 수 있는 문자열로만 구성돼 있기 때문에 HTTP 구성요소 어디든 위치할 수 있다.

 

JWT 구조

JWT는 점으로 구분된 세 부분으로 구성된다.

(1) 헤더(Header)

(2) 내용(Payload)

(3) 서명(Signature)

xxxxx.yyyyy.zzzzz
헤더   | 내용 | 서명

 

헤더

JWT의 헤더는 검증과 관련된 내용을 담고 있다.

alg와 typ라는 두가지 정보를 포함하고 있다.

{
    "alg" : "HS256",
    "typ" : "JWT"
}

 

alg 속성에서는 해싱 알고리즘을 지정한다. 해싱 알고리즘은 보통 SHA256 또는 RSA를 사용하며, 토큰을 검증할 때 사용되는 서명 부분에서 사용된다. HS256은 HMACSHA256 알고리즘을 사용한다는 의미이다.

 

typ 속성에는 토큰의 타입을 지정한다.

 

내용

JWT의 내용에는 토큰에 담는 정보를 포함한다.

이곳에 포함된 속성들을 클레임(Claim)이라 하며, 크게 3가지로 분류 된다.

 

(1) 등록된 클레임

(2) 공개 클레임

(3) 비공개 클레임

 

등록된 클레임은 필수는 아니지만 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임을 뜻한다.

(1) iss: JWT의 발급자 주체를 나타낸다. iss의 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열이다.

(2) sub: JWT의 제목이다.

(3) aud: JWT의 수신인이다. JWT를 처리하려는 각 주체는 해당 값으로 자신을 식별해야 한다. 요청을 처리하는 주체가 'aud'값으로 자신을 식별하지 않으면 JWT는 거부된다.

(4) exp: JWT의 만료시간(Expiration)이다. 시간은 NumericDate 형식으로 지정해야 한다.

(5) nbf: Not Before를 의미한다.

(6) iat: JWT가 발급된 시간(issued at)이다.

(7) jti: JWT의 식별자(JWT ID)이다. 주로 중복 처리를 방지하기 위해 사용된다.

 

공개 클레임은 키 값을 마음대로 정의할 수 있다. 다만 충돌이 발생하지 않을 이름으로 설정해야 한다.

비공개 클레임은 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임을 의미한다.

 

JWT 내용 예시

{
  "sub" : "wikibooks payload",
  "exp" : "1602076408",
  "userId" : "wikibooks",
  "username" : "flature"
}

 

이렇게 완선된 내용은 Base64Url 형식으로 인코딩되어 사용된다.

 

서명

JWT의 서명 부분은 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성된다.

예시로, HMAC SHA256 알고리즘을 사용해서 서명을 생성한다면 다음과 같은 방식으로 생성된다.

HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret
)

서명은 토큰의 값들을 포함해서 암호화하기 때문에 메시지가 도중에 변경되지 않았는지 확인할 때 사용된다.

 

JWT 디버거 사용하기

JWT 공식 사이트에서는 더욱 쉽게 JWT를 생성해 볼 수 있다.

 

https://jwt.io/#debugger-io

 

JWT.IO

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

jwt.io

 

 

Encoded와 Decoded로 나눠져 있으며, 양측의 내용이 일치하는지 사이트에서 확인할 수도 있고 Decoded의 내용을 변경하면

Encoded의 콘텐츠가 자동으로 반영된다.

 

Spring Security와 JWT 적용

스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행하도록 구성돼있다.

이 필터에서는 인증이 실패하면 로그인 폼이 화면을 전달하게 된다. 현재는 API를 사용하는 상황이므로, JWT를 사용하는 인증 필터를 구현하고 UsernamePasswordAuthenticationFilter 앞에 인증 필터를 배치해서 인증 주체를 변경하는 작업을 수행하는 방식으로 구성한다.

 

UserDetails와 UserDetailsService 구현

사용자 정보를 담는 Entity 생성

 

package com.chatty.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import java.util.Collection;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "users")
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @NotNull
    @Column(name = "username")
    private String name;

    private String nickName;

    @NotNull
    private String mobileNumber;

    @NotNull
    private String password;


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

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

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

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

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

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

 

public interface UserDetails extends Serializable {

    /**
     * Returns the authorities granted to the user. Cannot return <code>null</code>.
     * @return the authorities, sorted by natural key (never <code>null</code>)
     */
    Collection<? extends GrantedAuthority> getAuthorities();

    /**
     * Returns the password used to authenticate the user.
     * @return the password
     */
    String getPassword();

    /**
     * Returns the username used to authenticate the user. Cannot return
     * <code>null</code>.
     * @return the username (never <code>null</code>)
     */
    String getUsername();

    /**
     * Indicates whether the user's account has expired. An expired account cannot be
     * authenticated.
     * @return <code>true</code> if the user's account is valid (ie non-expired),
     * <code>false</code> if no longer valid (ie expired)
     */
    boolean isAccountNonExpired();

    /**
     * Indicates whether the user is locked or unlocked. A locked user cannot be
     * authenticated.
     * @return <code>true</code> if the user is not locked, <code>false</code> otherwise
     */
    boolean isAccountNonLocked();

    /**
     * Indicates whether the user's credentials (password) has expired. Expired
     * credentials prevent authentication.
     * @return <code>true</code> if the user's credentials are valid (ie non-expired),
     * <code>false</code> if no longer valid (ie expired)
     */
    boolean isCredentialsNonExpired();

    /**
     * Indicates whether the user is enabled or disabled. A disabled user cannot be
     * authenticated.
     * @return <code>true</code> if the user is enabled, <code>false</code> otherwise
     */
    boolean isEnabled();

}

 

User 엔티티는 UserDetails 인터페이스를 구현하고 있다. UserDetails는 UserDetailsService를 통해 입력된 로그인 정보를 가지고 데이터베이스에서 사용자 정보를 가져오는 역할을 수행한다.

 

각 메서드의 용도

(1) getAuthorities() : 계정이 가지고 있는 권한 목록을 리턴한다.

(2) getPassword() : 계정의 비밀번호를 리턴한다.

(3) getUsername(): 계정의 이름을 리턴한다.

(4) isAccountNonExpired(): 계정이 만료했는지 리턴한다. true는 만료되지 않았다는 뜻이다.

(5) isAccountNonLocked(): 계정이 잠겨있는지 리턴한다.  true는 잠기지 않았다는 의미이다.

(6) isCredentialNonExpired(): 비밀번호가 만료됐는지 리턴한다. true는 만료되지 않았다는 의미이다.

(7) isEnabled(): 계정이 활성화돼 있는지 리턴한다. true는 활성화 상태를 의미한다.

 

리포지토리와 서비스 구현

package com.chatty.repository;

import com.chatty.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<User,Long> {
    User getUserByMobileNumber(String mobileNumber);
}

 

UserRepository를 작성하는 것은 기존에 리포지토리를 작성하던 방법과 동일하다.

JpaRepository를 상속받고 User 엔티티에 대해 설정하면 된다.

 

리포지토리를 통해 User 엔티티의 값을 가져오는 서비스 생성

package com.chatty.service;

import com.chatty.repository.UserRepository;

import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;

@RequiredArgsConstructor
@Service
public class UserDetailService implements UserDetailsService {

    private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsService.class);
    private final UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String userMobileNumber) throws UsernameNotFoundException {
        
        LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. userMobileNumber : {}", userMobileNumber);
        
        return userRepository.getUserByMobileNumber(userMobileNumber);
    }
}

 

UserDetailsService 인터페이스를 구현

UserDetailsService는 loadUserByUsername() 메서드를 구현하도록 정의돼 있다.

 

UserDetails는 스프링 시큐리티에서 제공하는 개념으로, UserDetails의 username은 사용자를 구분할 수 있는 ID를 의미한다.

username을 가지고, UserDetails 객체를 리턴하게끔 정의돼 있는데, UserDetails의 구현체로 User 엔티티를 생성했기 때문에 User 객체를 리턴하게끔 구현한 것이다.

 

JwtTokenProvider 구현

JWT 토큰을 생성하는 데 필요한 정보를 UserDetails에서 가져올 수 있기 때문에 JWT 토큰을 생성하는 JwtTokenProvider를 생성한다.

import com.chatty.domain.Roles;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Date;
import javax.crypto.SecretKey;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final UserDetailsService userDetailsService;

    private SecretKey secretKey;
    private final long tokenValidMillisecond = 1000L * 60 * 60;

    @PostConstruct
    protected void init(@Value("${jwt-secret-key}") String secretKey){
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }

    public String createToken(String userMobileNumber, Roles role){
        LOGGER.info("[createToken] 토큰 생성 시작");

        Claims claims = Jwts.claims().subject(userMobileNumber).build();
        claims.put("roles",role);

        Date now = new Date();

        String token = Jwts
                .builder()
                .claims(claims)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + tokenValidMillisecond))
                .compact();

        LOGGER.info("[createToken] 토큰 생성 완료");
        return token;
    }

    public Authentication getAuthentication(String token) {
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
        UserDetails userDetails = userDetailsService.loadUserByUsername(getUserMobileNumber(token));
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료 UserDetails UserName : {}", userDetails.getUsername());

        return new UsernamePasswordAuthenticationToken(userDetails,"",userDetails.getAuthorities());
    }

    public String getUserMobileNumber(String token){

        LOGGER.info("[getAuthentication] 토큰 기반 회원 정보 추출");
        String info = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getSubject();
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}",info);
        return info;
    }

    public String resolveToken(HttpServletRequest request) {
        LOGGER.info("[resolveToken] HTTP 헤더에서 Token값 추출");
        return request.getHeader("X-AUTH-TOKEN");
    }

    public boolean validateToken(String token){
        LOGGER.info("[validationToken] 토큰 유효 체크 시작");
        try{
            Jws<Claims> claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);

            return !claims.getPayload().getExpiration().before(new Date());
        } catch(Exception e){
            LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }
    }
}

 

 

토큰을 생성하기 위해서는 secretKey가 필요하므로 secretKey값을 정의한다. @Value의 값은 application.properties 파일에서 정의할 수 있다.

springboot.jwt.secret=flature!@#

 

 

init() 메서드

@PostConstruct
protected void init(@Value("${jwt-secret-key}") String secretKey){
    LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
    this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
    LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}

 

@PostConstruct 어노테이션은 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드를 가리킨다.

JwtTokenProvider 클래스에는 @Component 어노테이션이 지정돼 있어 애플리케이션이 가동되면서 빈으로 자동 주입된다.

그때 @PostConstruct가 지정돼 있는 init() 메서드가 자동으로 실행된다.

init() 메서드에서는 secretKey를 Base64 형식으로 인코딩한다. (내가 짠 코드와는 다름)

 

// 인코딩 전 원본 문자열
flature!@#

// Base64 인코딩 결과
ZmxhdHVyZSFAIw==

 

createToken() 메서드

public String createToken(String userMobileNumber, Roles role){
        LOGGER.info("[createToken] 토큰 생성 시작");

        Claims claims = Jwts.claims().subject(userMobileNumber).build();
        claims.put("roles",role);

        Date now = new Date();

        String token = Jwts
                .builder()
                .claims(claims)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + tokenValidMillisecond))
                .signWith(secretKey)
                .compact();

        LOGGER.info("[createToken] 토큰 생성 완료");
        return token;
    }

 

JWT 토큰의 내용에 값을 넣기 위해 Claims 객체를 생성한다. subject() 메서드를 통해 sub 속성에 값을 추가하려면 User의 uid값을 사용(내가 짠 코드와는 다르다.)

해당 토큰을 사용하는 사용자의 권한을 확인할 수 있는 role 값을 별개로 추가한다.

Jwts.builder()를 사용해 토큰을 생성한다.

 

getAuthentication() 메서드

public Authentication getAuthentication(String token) {
    LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
    UserDetails userDetails = userDetailsService.loadUserByUsername(getUserMobileNumber(token));
    LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료 UserDetails UserName : {}", userDetails.getUsername());

    return new UsernamePasswordAuthenticationToken(userDetails,"",userDetails.getAuthorities());
}

 

이 메서드는 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는 역할을 한다.

Authentication을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용하는 것이다.

UsernamePasswordAuthenticationToken의 구조는 다음과 같다.

 

 

UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받고 있는데 Abstract AuthenticationToken은 Authentication 인터페이스의 구현체이다.

 

이 토큰 클래스를 사용하려면 초기화를 위한 UserDetails가 필요하다. 이 객체는 UserDetailsService를 통해 가져오게 된다. 이때 사용되는 Username 값은 다음과 같이 구현한다.

 

getUsername()메서드

public String getUserMobileNumber(String token){

    LOGGER.info("[getAuthentication] 토큰 기반 회원 정보 추출");
    String info = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getSubject();
    LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}",info);
    return info;
}

애플리케이션 특성상 전화번호가 계정의 아이디이므로 메서드 이름을 getUserMobileNumber로 수정해주었다.

 

Jwts.parser()를 통해 secretKey를 설정하고, 클레임을 추출해서 토큰을 생성할 때 넣었던 sub값을 추출한다.

그 다음으로 살펴볼 메서드는 resolveToken()이다. resolveToken() 메서드는 다음과 같이 구현되어 있다.

 

resolveToken() 메서드

public String resolveToken(HttpServletRequest request) {
    LOGGER.info("[resolveToken] HTTP 헤더에서 Token값 추출");
    return request.getHeader("X-AUTH-TOKEN");
}

 

이 메서드는 HttpServletRequest를 파라미터로 받아 헤더 값으로 전달된 'X-AUTH-TOKEN' 값을 가져와 리턴한다.

클라이언트가 헤더를 통해 애플리케이션 서버로 JWT 토큰 값을 전달해야 정상적인 추출이 가능하다.

헤더의 이름은 임의로 변경할 수 있다.

 

 마지막으로 볼 메서드는 validationToken()메서드이다.

 

validationToken() 메서드

public boolean validateToken(String token){
    LOGGER.info("[validationToken] 토큰 유효 체크 시작");
    try{
        Jws<Claims> claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);

        return !claims.getPayload().getExpiration().before(new Date());
    } catch(Exception e){
        LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
        return false;
    }
}

 

이 메서드는 토큰을 전달받아 클레임의 유효기간을 체크하고 boolean 타입의 값을 리턴하는 역할을 한다.

 

JwtAuthenticationFilter 구현

JwtAuthenticationFilter는 JWT토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스이다.

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider){
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(request);
        LOGGER.info("[doFilterInternal] token 값 추출 완료. token: {}", token);

        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if(token != null && jwtTokenProvider.validateToken(token)){
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }

        filterChain.doFilter(request,response);
    }
}

 

코드는 비교적 간단하다. 먼저 살펴볼 부분은 OncePerRequestsFilter이다.

스프링 부트에서는 필터를 여러 방법으로 구현할 수 있는데, 가장 편한 구현 방법은 필터를 상속받아 사용하는 것이다.

대표적으로 많이 사용되는 상속 객체는 GenericFilterBean과 OncePerRequestFilter이다.

GenericFilterBean을 상속받아 구현하면 다음과 같이 구현할 수 있다.

public class JwtAuthenticationFilter extends GenericFilterBean{
	private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
   	private final JwtTokenProvider jwtTokenProvider;
    
    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider){
    	this.jwtTokenProvider = jwtTokenProvider
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(request);
        LOGGER.info("[doFilterInternal] token 값 추출 완료. token: {}", token);

        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if(token != null && jwtTokenProvider.validateToken(token)){
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }

        filterChain.doFilter(request,response);
    }
}

 

GenericFilterBean은 기존 필터에서 가져올 수 없는 스프링의 설정 정보를 가져올 수 있게 확장된 추상 클래스이다. 다만 서블릿은 사용자의 요청을 받으면 서블릿을 생성해서 메모리에 저장해두고 동일한 클라이언트의 요청을 받으면 재활용하는 구조여서 GenericFilterBean을 상속받으면 RequestDispatcher에 의해 다른 서블릿으로 디스패치되면서 필터가 두 번 실행되는 현상이 발생할 수 있다.
이 같은  문제를 해결하기 위해 등장한 것이 OncePerRequestFilter이며, 이 클래스 역시 GenericFilterBean을 상속받고 있다.
다만 이 클래스를 상속받아 구현한 필터는 매 요청마다 한번만 실행되게끔 구현한다.

 

OncePerRequestFilter로부터 오버라이딩한 doFilterInternal()메서드가 있다. doFilter()메서드는 서블릿을 실행하는 메서드인데, doFilter() 메서드를 기준으로 앞에 작성한 코드는 서블릿이 실행되기 전에 실행되고, 뒤에 작성한 코드는 서블릿이 실행된 후에 실행된다.

 

메서드의 내부 로직을 보면 JwtTokenProvider를 통해 servletRequest에서 토큰을 추출하고, 토큰에 대한 유효성을 검사한다. 토큰이 유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가하는 작업을 수행한다.

 

SecurityConfiguration 구현

지금까지 실습을 통해 스프링 시큐리티를 적용하기 위한 컴포넌트를 구현했다.

이제 스프링 시큐리티와 관련된 설정을 진행하겠다. 스프링 시큐리티를 설정하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현하는 것이다. 

 

import com.chatty.security.JwtAuthenticationFilter;
import com.chatty.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
public class SecurityConfiguration {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .httpBasic(HttpBasicConfigurer::disable)
                .csrf(CsrfConfigurer::disable)
                .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorize ->
                        authorize.requestMatchers(
                                "/sign-api/sign-in",
                                "/sign-api/sign-up",
                                "/sign-api/exception").permitAll()
                                .requestMatchers(HttpMethod.GET, "/product/**").permitAll()
                                .requestMatchers("**exception**").permitAll()
                                .anyRequest().hasRole("ADMIN")
                )
                .exceptionHandling(authenticationManager -> authenticationManager
                        .accessDeniedHandler(new CustomAccessDeniedHandler())
                        .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return httpSecurity.build();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer(WebSecurity webSecurity) throws Exception {
        return (WebSecurityCustomizer) webSecurity.ignoring().requestMatchers("/v2/api-docs", "/swagger-resource/**","/swagger-ui.html","/webjars/**","/swagger/**","/sign-api/exception");
    }
}

 

구조를 살펴보자.

SecurityConfiguration 클래스의 주요 메서드는 두 가지로, WebSecurity 파라미터를 받은 webSecurityCustomizer 메서드와 httpSecurity 파라미터를 받은 filterChain() 메서드이다.

 

먼저 httpSecurity를 설정하는 filterChain() 메서드이다.

스프링 시큐리티의 설정은 대부분 HttpSecurity를 통해 진행한다. 대표적인 기능은 다음과 같다.

(1) 리소스 접근 권한 설정

(2) 인증 실패 시 발생하는 예외 처리

(3) 인증 로직 커스터마이징

(4) csrf, cors등의 스프링 시큐리티 설정

 

filterChain() 메서드에 작성돼 있는 코드를 설정별로 구분해 설명해보겠다. 모든 설정은 전달 받은 HttpSecurity에 설정하게 된다.

 

httpBasic(HttpBasicConfigurer::disable)

UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화 한다.

 

csrf(CsrfConfigurer::disable)

REST API에서는 CSRF 보안이 필요 없기 때문에, 비활성화하는 로직이다. CSRF는 Cross-Site Request Forgery의 줄임말로 '사이트 간 요청 위조를 의미한다. 사이트간 요청 위조란 웹 애플리케이션의 취약점 중 하나로서 사용자가 자신의 의지와 무관하게 웹 애플리케이션을 대상으로 공격자가 의도한 행동을 함으로써 특정 페이지의 보안을 취약하게 한다거나 수정, 삭제 등의 작업을 공격하는 방법이다. 스프링 시큐리티의 csrf() 메서드는 기본적으로 CSRF 토큰을 발급해서 클라이언트로부터 요청을 받을 때마다 토큰을 검증하는 방식으로 동작한다. 브라우저 사용 환경이 아니라면 비활성화해도 크게 문제가 없다.

 

sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

REST API 기반 애플리케이션의 동작 방식을 설정한다. 지금 진행 중인 프로젝트에서는 JWT 토큰으로 인증을 처리하며, 세션은 사용하지 않기 때문에 STATELESS로 설정한다.

 

authorizeHttpRequests()

애플리케이션에 들어오는 요청에 대한 사용 권한을 체크한다. 이어서 사용한 requestsMathcers() 메서드는 path pattern을 통해 권한을 설정하는 역할을 한다.

- /sign-api/sign-in , /sign-api/sign-up. /sign-api/exception 경로에 대해서는 모두에게 허용한다.

- /product 로 시작하는 경로의 GET 요청은 모두 허용한다.

- exception 단어가 들어간 경로는 모두 허용한다.

- 기타 요청은 인증된 권한을 가진 사용자에게 허용한다.

 

exceptionHandling(authenticationManager -> authenticationManager
                        .accessDeniedHandler(new CustomAccessDeniedHandler()))

권한을 확인하는 과정에서 통과하지 못하는 예외가 발생한 경우 예외를 전달한다.

 

.exceptionHandling(authenticationManager -> authenticationManager
        .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
)

인증 과정에서 예외가 발생할 경우 예외를 전달한다.

 

각 메서드는 CustomAccessDeniedHanlder와 CustomAuthenticationEntryPoint로 예외를 전달한다.

 

스프링 시큐리티는 각각의 역할을 수행하는 필터들이 체인 형태로 구성돼 순서대로 동작한다.

JWT로 인증하는 필터를 생성했고, 이 필터의 등록은 HttpSecurity 설정에서 진행한다.

addFilterBefore()메서드를 사용해 어느 필터 앞에 추가할 것인지 설정할 수 있는데, 현재 구현돼 있는 설정은 스프링 시큐리티에서 인증을 처리하는 필터인 UsernamePasswordAuthenticationFilter 앞에 앞에서 생성한 JwtAuthenticationFilter를 추가하겠다는 의미이다.

추가된 필터에서 인증이 정상정으로 처리되면, UsernamePasswordAuthenticationFilter는 자동으로 통과되기 때문에 위와 같은 구성을 했다.

 

WebSecurity를 사용하는 webSecurityCustomize()메서드를 보자.

WebSecurity는 HttpSecurity 앞단에 적용되며, 전체적으로 스프링 시큐리티의 영향권 밖에 있다.

인증과 인가가 모두 적용되기 전에 동작하는 설정이다. 그렇기 때문에 다양한 곳에서 사용되지 않고, 인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용한다. Swagger에 적용되는 인증과 인가를 피하기 위해 ignoring()메서드를 사용해 Swagger와 관련된 경로에 대한 예외 처리를 수행한다. 의미상 예외 처리라고 표현했지면 정확하게는 인증 인가를 무시하는 경로를 설정한 것이다.

 

커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

인증과 인가의 예외 상황에서 CustomAccessDeniedHandler와 CustomAuthenticationEntryPoint로 예외를 전달하고 있었다.

AccessDeniedHandler 인터페이스의 구현체 클래스를 생성하겠다. 기본적으로 handle()메서드를 오버라이딩해서 구현하게 된다.

 

CustomAccessDeniedHandler 클래스

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

public class CustomAccessDeniedHanlder implements AccessDeniedHandler {
    
    private final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHanlder.class);
    
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {
        LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
        response.sendRedirect("/sign-api/exception");
    }
}

 

AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외이다.

이 예외를 처리하기 위해 AccessDeniedHandler 인터페이스가 사용되며, SecurityConfiguration에도 exceptionHandler() 메서드를 통해 추가했다. AccessDeniedHanlder의 구현 클래스인 CustomAccessDeniedHandler 클래스는 handle()메서드를 오버라이딩 한다. 이 메서드는 HttpServletRequest와 HttpServletResponse, AccessDeniedException을 파라미터로 가져온다.
response에서 리다이렉트하는 sendRedirect()메서드를 활용하는 방식으로 구현했다.

 

인증이 실패한 상황을 처리하는 AuthenticationEntryPoint 인터페이스를 구현한 CustomAuthenticationEntryPoint 클래스이다.

import com.chatty.domain.dto.EntryPointErrorResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        ObjectMapper objectMapper = new ObjectMapper();
        LOGGER.info("[commence] 인증 실패로 response.sendError 발생");

        EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
        entryPointErrorResponse.setMessage("인증이 실패하였습니다.");

        response.setStatus(401);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
    }
}

 

EntryPointErrorResponse

package com.chatty.domain.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
    private String message;

    public void setMessage(String message){
        this.message = message;
    }
}

 

클래스의 구조는 앞에서 본 AccessDeniedHandler와 크게 다르지 않으며, commence() 메서드를 오버라이딩해서 코드를 구현한다.

이 commence() 메서드는 HttpServletRequest, HttpServletResponse, AuthenticationException을 매개변수로 받는데, 예외 처리를 위해 리다이렉트가 아니라 직접 Response를 생성해서 클라이언트에게 응답하는 방식으로 구현돼 있다.

 

컨트롤러에서는 응답을 위한 설저들이 자동으로 구현되기 때문에 별도의 작업이 필요하지 않았지만 여기서는 응답값을 설정할 필요가 있다. 메시지를 담기 위해 EntryPointErrorResponse 객체를 사용해 메시지를 설정하고 response에 상태 코드와 콘텐츠 타입 등을 설정한 후 ObjectMapper를 사용해 EntryPointErrorResponse 객체를 바디 값으로 파싱한다.

 

굳이 메시지를 설정할 필요가 없다면 commenc()메서드 내부에 아래와 같이 한줄만 작성하는 식으로 인증 실패 코드만 전달할 수 있다.

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) throws IOException{
	response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}

 

전체적인 코드의 구조가 AccessDeniedHandler와 동일하기 때문에 지금까지 소개한 세 가지 응답을 구성하는 방식을 각 메서드에 혼용할 수도 있다.

 

회원가입과 로그인 구현

인증에서 사용되는 UserDetails 인터페이스의 구현체 클래스로 User 엔티티를 생성했었다.

지금까지는 User 객체를 통해 인증하는 방법을 구현했는데, User객체를 생성하기 위해 회원가입을 구현하고 User객체로 인증을 시도하는 로그인을 구현해보자.

 

회원가입과 로그인의 도메인은 Sign으로 통합해서 표현할 예정이며, 각각 Sign-up, Sign-in으로 구분해서 기능을 구현한다.

먼저 서비스 레이어를 구현한다. 

public interface SignService {
	SignUpResultDto signUp(String id, String password, String name, String role);
    
    	SignInResultDto signIn(String id, String password) throws RuntimeException;
}

 

SignService 인터페이스를 구현한 SignServiceImpl 클래스의 전체 코드는 다음과 같다.


import com.chatty.common.CommonResponse;
import com.chatty.domain.user.User;
import com.chatty.domain.dto.sign.SignInResultDto;
import com.chatty.domain.dto.sign.SignUpResultDto;
import com.chatty.repository.UserRepository;
import com.chatty.security.JwtTokenProvider;
import java.util.Collections;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class SignService implements SignService {

    private final Logger LOGGER = LoggerFactory.getLogger(SignService.class);

    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;
    private final PasswordEncoder passwordEncoder;

    private static final String ROLE_ADMIN = "ROLE_ADMIN";
    private static final String ROLE_USER = "ROLE_USER";

    public SignUpResultDto signUp(String mobileNumber, String name, List<String> role, String nickname) {

        LOGGER.info("[getSignUpResult] 회원 가입 정보 전달");
        User user = checkUser(mobileNumber, name, role, nickname);
        User savedUser = userRepository.save(user);
        LOGGER.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과값 주입");
        SignUpResultDto signUpResultDto = checkResult(new SignInResultDto(), savedUser);
        signUpResultDto.setToken(jwtTokenProvider.createToken(mobileNumber,role));

        return signUpResultDto;
    }

    public SignInResultDto signIn(String mobileNumber, String password) throws RuntimeException {
        LOGGER.info("[getSignInResult] signDataHandler로 회원 정보 요청");
        User user = userRepository.getUserByMobileNumber(mobileNumber);
        LOGGER.info("[getSignInResult] mobileNumber : {}", mobileNumber);

        LOGGER.info("[getSignInResult] 패스워드 비교 수행");
        if(!passwordEncoder.matches(password,user.getPassword())){
            throw new RuntimeException();
        }

        LOGGER.info("[getSignInResult] 패스워드 일치");

        LOGGER.info("[getSignInResult] SignInResultDto 객체 생성");
        SignInResultDto signInResultDto = SignInResultDto.builder()
                .token(jwtTokenProvider.createToken(user.getMobileNumber(),user.getRoles()))
                .build();

        LOGGER.info("[getSignInResult] SignInResultDto 객체에 값 주입");
        return (SignInResultDto) setSuccessResult(signInResultDto);
    }
    private SignUpResultDto checkResult(SignUpResultDto result, User savedUser){
        if(!savedUser.getName().isEmpty()){
            LOGGER.info("[getSignUpResult] 정상 처리 완료");
            return setSuccessResult(result);
        }

        LOGGER.info("[getSignUpResult] 정상 처리 실패");
        return setFailResult(result);
    }

    private SignUpResultDto setSuccessResult(SignUpResultDto result){
        result.setSuccess(true);
        result.setCode(CommonResponse.SUCCESS.getCode());
        result.setMessage(CommonResponse.SUCCESS.getMessage());

        return result;
    }

    private SignUpResultDto setFailResult(SignUpResultDto result){
        result.setSuccess(false);
        result.setCode(CommonResponse.FAIL.getCode());
        result.setMessage(CommonResponse.FAIL.getMessage());

        return result;
    }

    private User checkUser(String mobileNumber, String name, List<String> role, String nickname){
        if(role.contains("ADMIN")){
            return makeUser(mobileNumber,name,ROLE_ADMIN, nickname);
        }

        return makeUser(mobileNumber, name, ROLE_USER, nickname);
    }

    private User makeUser(String mobileNumber, String name, String role, String nickname){
        return User.builder()
                .mobileNumber(mobileNumber)
                .nickName(nickname)
                .name(name)
                .roles(Collections.singletonList(role))
                .build();
    }

}

 

회원가입과 로그인을 구현하기 위해 세 가지 객체에 대한 의존성 주입을 받는다.

회원가입을 구현한다. 현재 애플리케이션에서는 ADMIN과 USER로 권한을 구분하고 있다.

signUp() 메서드는 그에 맞게 전달받은 role 객체를 확인해 User 엔티티의 roles 변수에 추가해서 엔티티를 생성한다.

패스워드는 암호화해서 저장해야 하기 때문에 PasswordEncoder를 활용해 인코딩을 수행한다. PasswordEncoder는 별도의 @Configuration 클래스를 생성하고 @Bean 객체로 등록하도록 구현했다.

 

 

PasswordEncoderConfiguration 클래스

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordEncoderConfiguration {

    @Bean
    public PasswordEncoder passwordEncoder(){
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

 

빈 객체를 등록하기 위해서 생성된 클래스이므로 SecurityConfiguration 클래스 같은 이미 생성된 @Configuration 클래스 내부에 passwordEncoder() 메서드를 정의해도 충분하다.

 

로그인은 미리 저장돼 있는 계정 정보와 요청을 통해 전달된 계정 정보가 일치하는지 확인하는 작업이다.

signIn() 메서드는 아이디와 패스워드를 입력받아 처리하게 된다.

 

(1) id를 기반으로 UserRepository에서 User 엔티티를 가져온다.

(2) PasswordEncoder를 사용해 데이터베이스에 저장돼 있던 패스워드와 입력받은 패스워드가 일치하는지 확인하는 작업을 수행한다.

패스워드가 일치하지 않는 경우 별도의 커스텀 예외를 만들어서 사용해도 된다.

(3) 패스워드가 일치해서 인증을 통과하면 JwtTokenProvider를 통해 id와 role 값을 전달해서 토큰을 생성한 후 Response에 담아 전달한다.

 

setSuccessResult, setFailResult

회원가입과 로그인 메서드에서 사용할 수 있게 설정돼 있으며, 각 메서드는 DTO를 전달받아 값을 설정한다. 

이때 사용된 CommonResponse 클래스는 다음과 같이 작성돼 있다.

public enum CommonResponse {
    SUCCESS(0,"Success"), FAIL(-1,"Fail");

    private int code;
    private String message;

    CommonResponse(int code, String message){
        this.code = code;
        this.message = message;
    }

    public int getCode(){
        return this.code;
    }

    public String getMessage(){
        return this.message;
    }
}

 

회원가입과 로그인을 API로 노출하는 컨트롤러를 생성해야 하는데 사실상 서비스 레이어로 요청을 전달고 응답하는 역할만 수행하기 대문에 코드만 소개

 

import com.chatty.domain.dto.sign.SignInResultDto;
import com.chatty.domain.dto.sign.SignUpResultDto;
import com.chatty.service.sign.SignService;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/sign-api")
@RequiredArgsConstructor
public class SignController {
    private final Logger LOGGER = LoggerFactory.getLogger(SignController.class);
    private final SignService signService;

    @PostMapping(value = "/sign-in")
    public SignInResultDto sigIn(@RequestBody Map<String,String> map) {
        String mobileNumber = map.get("mobileNumber");
        String password = map.get("password");

        LOGGER.info("[siginIn] 로그인을 시도하고 있습니다. mobileNumber : {}, pw : ****", mobileNumber);
        SignInResultDto signInResultDto = signService.signIn(mobileNumber,password);

        if(signInResultDto.getCode() == 0){
            LOGGER.info("[signIn] 정상적으로 로그인되었습니다. mobileNumber : {}, token : {}", mobileNumber, signInResultDto.getToken());
        }

        return signInResultDto;
    }

    @PostMapping(value = "/sign-up")
    public SignUpResultDto signUp(@RequestBody Map<String, String> map){
        String mobileNumber = map.get("mobileNumber");
        String nickname = map.get("nickName");
        String name = map.get("name");
        List<String> role = Arrays.stream(map.get("role").split(",")).toList();

        LOGGER.info("[siginUp] 회원가입을 수행합니다. mobileNumber : {}, pw : ****, name : {}, nickname : {}, role : {}", mobileNumber, name, nickname, role);

        SignUpResultDto signUpResultDto = signService.signUp(mobileNumber,name,role, nickname);

        LOGGER.info("[siginUp] 회원가입을 완료했습니다.");

        return signUpResultDto;
    }

    @GetMapping(value = "/exception")
    public void exceptionTest() throws RuntimeException {
        throw  new RuntimeException("접근 금지");
    }

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String,String>> ExceptionHandler(RuntimeException e){
        HttpHeaders responseHeaders = new HttpHeaders();
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("ExceptionHandler 호출, {},{}", e.getCause(), e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code","400");
        map.put("message","에러 발생");

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }
}

 

SignUpResultDto

package com.chatty.domain.dto.sign;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignUpResultDto {
    private boolean success;
    private int code;
    private String message;
    private String token;
}

 

SignInResultDto

package com.chatty.domain.dto.sign;

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

@Getter
@NoArgsConstructor
@ToString
public class SignInResultDto extends SignUpResultDto{

    @Builder
    public SignInResultDto(boolean success, int code, String msg, String token){
        super(success, code, msg, token);
    }
}